mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-06-07 05:50:41 +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
|
* If the property to be updated is an object or an array, it is updated
|
||||||
* recursively.
|
* 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) {
|
function updateNestedObject(path: Array<string>, obj: object, value: ValueType) {
|
||||||
// Base case: If the path is empty, return the new value.
|
// Base case: If the path is empty, return the new value.
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { emit_update } from '../socket';
|
import { runMethod } from '../socket';
|
||||||
import { InputGroup, Form, Button } from 'react-bootstrap';
|
import { InputGroup, Form, Button } from 'react-bootstrap';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||||
@ -56,18 +56,18 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
|||||||
const execute = async (event: React.FormEvent) => {
|
const execute = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let method_name: string;
|
let method_name: string;
|
||||||
const args = {};
|
const kwargs: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (runningTask !== undefined && runningTask !== null) {
|
if (runningTask !== undefined && runningTask !== null) {
|
||||||
method_name = `stop_${name}`;
|
method_name = `stop_${name}`;
|
||||||
} else {
|
} else {
|
||||||
Object.keys(props.parameters).forEach(
|
Object.keys(props.parameters).forEach(
|
||||||
(name) => (args[name] = event.target[name].value)
|
(name) => (kwargs[name] = event.target[name].value)
|
||||||
);
|
);
|
||||||
method_name = `start_${name}`;
|
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) => {
|
const args = Object.entries(props.parameters).map(([name, type], index) => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { ToggleButton } from 'react-bootstrap';
|
import { ToggleButton } from 'react-bootstrap';
|
||||||
import { emit_update } from '../socket';
|
import { setAttribute } from '../socket';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
|||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
const setChecked = (checked: boolean) => {
|
const setChecked = (checked: boolean) => {
|
||||||
emit_update(name, parentPath, checked);
|
setAttribute(name, parentPath, checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||||
import { emit_update } from '../socket';
|
import { setAttribute } from '../socket';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentPro
|
|||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
const handleValueChange = (newValue: string) => {
|
const handleValueChange = (newValue: string) => {
|
||||||
emit_update(name, parentPath, newValue);
|
setAttribute(name, parentPath, newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||||
import { emit_update } from '../socket';
|
import { setAttribute } from '../socket';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
|
|
||||||
interface EnumComponentProps {
|
interface EnumComponentProps {
|
||||||
@ -33,7 +33,7 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
|||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
const handleValueChange = (newValue: string) => {
|
const handleValueChange = (newValue: string) => {
|
||||||
emit_update(name, parentPath, newValue);
|
setAttribute(name, parentPath, newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
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 { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||||
@ -46,18 +46,21 @@ export const MethodComponent = React.memo((props: MethodProps) => {
|
|||||||
const execute = async (event: React.FormEvent) => {
|
const execute = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const args = {};
|
const kwargs = {};
|
||||||
Object.keys(props.parameters).forEach(
|
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
|
// Update the functionCalls state with the new call if we get an acknowledge msg
|
||||||
if (ack !== undefined) {
|
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) => {
|
const args = Object.entries(props.parameters).map(([name, type], index) => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Form, InputGroup } from 'react-bootstrap';
|
import { Form, InputGroup } from 'react-bootstrap';
|
||||||
import { emit_update } from '../socket';
|
import { setAttribute } from '../socket';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
import '../App.css';
|
import '../App.css';
|
||||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
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
|
// If emitUpdate is passed, use this instead of the emit_update from the socket
|
||||||
// Also used when used with a slider
|
// Also used when used with a slider
|
||||||
const emitUpdate =
|
const emitUpdate =
|
||||||
props.customEmitUpdate !== undefined ? props.customEmitUpdate : emit_update;
|
props.customEmitUpdate !== undefined ? props.customEmitUpdate : setAttribute;
|
||||||
|
|
||||||
const renderCount = useRef(0);
|
const renderCount = useRef(0);
|
||||||
// Create a state for the cursor position
|
// Create a state for the cursor position
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
|
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
|
||||||
import { emit_update } from '../socket';
|
import { setAttribute } from '../socket';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
import { Slider } from '@mui/material';
|
import { Slider } from '@mui/material';
|
||||||
import { NumberComponent } from './NumberComponent';
|
import { NumberComponent } from './NumberComponent';
|
||||||
@ -66,7 +66,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
|||||||
max: number = props.max,
|
max: number = props.max,
|
||||||
stepSize: number = props.stepSize
|
stepSize: number = props.stepSize
|
||||||
) => {
|
) => {
|
||||||
emit_update(
|
setAttribute(
|
||||||
name,
|
name,
|
||||||
parentPath,
|
parentPath,
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Form, InputGroup } from 'react-bootstrap';
|
import { Form, InputGroup } from 'react-bootstrap';
|
||||||
import { emit_update } from '../socket';
|
import { setAttribute } from '../socket';
|
||||||
import { DocStringComponent } from './DocStringComponent';
|
import { DocStringComponent } from './DocStringComponent';
|
||||||
import '../App.css';
|
import '../App.css';
|
||||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||||
@ -41,19 +41,19 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
|||||||
const handleChange = (event) => {
|
const handleChange = (event) => {
|
||||||
setInputString(event.target.value);
|
setInputString(event.target.value);
|
||||||
if (isInstantUpdate) {
|
if (isInstantUpdate) {
|
||||||
emit_update(name, parentPath, event.target.value);
|
setAttribute(name, parentPath, event.target.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
if (event.key === 'Enter' && !isInstantUpdate) {
|
if (event.key === 'Enter' && !isInstantUpdate) {
|
||||||
emit_update(name, parentPath, inputString);
|
setAttribute(name, parentPath, inputString);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
if (!isInstantUpdate) {
|
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 socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
|
||||||
|
|
||||||
export const emit_update = (
|
export const setAttribute = (
|
||||||
name: string,
|
name: string,
|
||||||
parentPath: string,
|
parentPath: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
callback?: (ack: unknown) => void
|
callback?: (ack: unknown) => void
|
||||||
) => {
|
) => {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
socket.emit('frontend_update', { name, parent_path: parentPath, value }, callback);
|
socket.emit('set_attribute', { name, parent_path: parentPath, value }, callback);
|
||||||
} else {
|
} 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 (
|
from pydase.utils.helpers import (
|
||||||
convert_arguments_to_hinted_types,
|
convert_arguments_to_hinted_types,
|
||||||
get_class_and_instance_attributes,
|
get_class_and_instance_attributes,
|
||||||
get_object_attr_from_path,
|
get_object_attr_from_path_list,
|
||||||
is_property_attribute,
|
is_property_attribute,
|
||||||
parse_list_attr_and_index,
|
parse_list_attr_and_index,
|
||||||
update_value_if_changed,
|
update_value_if_changed,
|
||||||
@ -222,10 +222,19 @@ class DataService(rpyc.Service, AbstractDataService):
|
|||||||
attr_name: str,
|
attr_name: str,
|
||||||
value: Any,
|
value: Any,
|
||||||
) -> None:
|
) -> 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
|
# 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)
|
attr_name, index = parse_list_attr_and_index(attr_name)
|
||||||
# Traverse the object according to the path parts
|
# 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
|
# If the attribute is a property, change it using the setter without getting the
|
||||||
# property value (would otherwise be bad for expensive getter methods)
|
# 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)
|
setattr(target_obj, attr_name, value)
|
||||||
return
|
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:
|
if attr is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pydase.units as u
|
||||||
from pydase.utils.warnings import (
|
from pydase.utils.warnings import (
|
||||||
warn_if_instance_class_does_not_inherit_from_DataService,
|
warn_if_instance_class_does_not_inherit_from_DataService,
|
||||||
)
|
)
|
||||||
@ -47,6 +48,14 @@ class DataServiceList(list):
|
|||||||
super().__init__(*args, **kwargs) # type: ignore
|
super().__init__(*args, **kwargs) # type: ignore
|
||||||
|
|
||||||
def __setitem__(self, key: int, value: Any) -> None: # 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
|
super().__setitem__(key, value) # type: ignore
|
||||||
|
|
||||||
for callback in self.callbacks:
|
for callback in self.callbacks:
|
||||||
|
@ -6,7 +6,13 @@ from typing import TYPE_CHECKING, Any, Optional, cast
|
|||||||
|
|
||||||
import pydase.units as u
|
import pydase.units as u
|
||||||
from pydase.data_service.data_service_cache import DataServiceCache
|
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 (
|
from pydase.utils.serializer import (
|
||||||
|
dump,
|
||||||
generate_serialized_data_paths,
|
generate_serialized_data_paths,
|
||||||
get_nested_dict_by_path,
|
get_nested_dict_by_path,
|
||||||
)
|
)
|
||||||
@ -102,35 +108,19 @@ class StateManager:
|
|||||||
logger.debug("Could not load the service state.")
|
logger.debug("Could not load the service state.")
|
||||||
return
|
return
|
||||||
|
|
||||||
serialized_class = self.cache
|
|
||||||
for path in generate_serialized_data_paths(json_dict):
|
for path in generate_serialized_data_paths(json_dict):
|
||||||
nested_json_dict = get_nested_dict_by_path(json_dict, path)
|
nested_json_dict = get_nested_dict_by_path(json_dict, path)
|
||||||
value = nested_json_dict["value"]
|
nested_class_dict = get_nested_dict_by_path(self.cache, path)
|
||||||
value_type = nested_json_dict["type"]
|
|
||||||
|
|
||||||
nested_class_dict = get_nested_dict_by_path(serialized_class, path)
|
value, value_type = nested_json_dict["value"], nested_json_dict["type"]
|
||||||
class_value_type = nested_class_dict.get("type", None)
|
class_attr_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]
|
|
||||||
|
|
||||||
# Convert dictionary into Quantity
|
if class_attr_value_type == value_type:
|
||||||
if class_value_type == "Quantity":
|
self.set_service_attribute_value_by_path(path, value)
|
||||||
value = u.convert_to_quantity(value)
|
|
||||||
|
|
||||||
self.service.update_DataService_attribute(parts[:-1], attr_name, value)
|
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Attribute type of {path!r} changed from {value_type!r} to "
|
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]:
|
def _get_state_dict_from_JSON_file(self) -> dict[str, Any]:
|
||||||
@ -142,3 +132,85 @@ class StateManager:
|
|||||||
# values
|
# values
|
||||||
return cast(dict[str, Any], json.load(f))
|
return cast(dict[str, Any], json.load(f))
|
||||||
return {}
|
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 fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from pydase import DataService
|
from pydase import DataService
|
||||||
|
from pydase.data_service.data_service import process_callable_attribute
|
||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
|
from pydase.utils.helpers import get_object_attr_from_path_list
|
||||||
from pydase.version import __version__
|
from pydase.version import __version__
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -44,6 +46,25 @@ class UpdateDict(TypedDict):
|
|||||||
value: Any
|
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:
|
class WebAPI:
|
||||||
__sio_app: socketio.ASGIApp
|
__sio_app: socketio.ASGIApp
|
||||||
__fastapi_app: FastAPI
|
__fastapi_app: FastAPI
|
||||||
@ -79,14 +100,23 @@ class WebAPI:
|
|||||||
sio = socketio.AsyncServer(async_mode="asgi")
|
sio = socketio.AsyncServer(async_mode="asgi")
|
||||||
|
|
||||||
@sio.event # type: ignore
|
@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}")
|
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
|
path_list.remove("DataService") # always at the start, does not do anything
|
||||||
return self.service.update_DataService_attribute(
|
path = ".".join(path_list)
|
||||||
path_list=path_list, attr_name=attr_name, value=data["value"]
|
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 = sio
|
||||||
self.__sio_app = socketio.ASGIApp(self.__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
|
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.
|
Traverse the object tree according to the given path.
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ from typing import Any
|
|||||||
|
|
||||||
from pytest import LogCaptureFixture
|
from pytest import LogCaptureFixture
|
||||||
|
|
||||||
|
import pydase.units as u
|
||||||
from pydase import DataService
|
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:
|
def test_protected_list_attribute(caplog: LogCaptureFixture) -> None:
|
||||||
"""Changing protected lists should not emit notifications for the lists themselves, but
|
"""Changing protected lists should not emit notifications for the lists themselves,
|
||||||
still for all properties depending on them.
|
but still for all properties depending on them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class ServiceClass(DataService):
|
class ServiceClass(DataService):
|
||||||
@ -99,3 +100,30 @@ def test_protected_list_attribute(caplog: LogCaptureFixture) -> None:
|
|||||||
|
|
||||||
service_instance._attr[0] = 1337
|
service_instance._attr[0] = 1337
|
||||||
assert "ServiceClass.list_dependend_property changed to 1337" in caplog.text
|
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
|
||||||
import pydase.units as u
|
import pydase.units as u
|
||||||
|
from pydase.components.coloured_enum import ColouredEnum
|
||||||
from pydase.data_service.state_manager import StateManager
|
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):
|
class Service(pydase.DataService):
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
self.subservice = SubService()
|
||||||
self.some_unit: u.Quantity = 1.2 * u.units.A
|
self.some_unit: u.Quantity = 1.2 * u.units.A
|
||||||
self.some_float = 1.0
|
self.some_float = 1.0
|
||||||
|
self.list_attr = [1.0, 2.0]
|
||||||
|
self._property_attr = 1337.0
|
||||||
self._name = "Service"
|
self._name = "Service"
|
||||||
|
self._state = State.RUNNING
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def property_attr(self) -> float:
|
||||||
|
return self._property_attr
|
||||||
|
|
||||||
CURRENT_STATE = {
|
@property_attr.setter
|
||||||
"name": {
|
def property_attr(self, value: float) -> None:
|
||||||
"type": "str",
|
self._property_attr = value
|
||||||
"value": "Service",
|
|
||||||
"readonly": True,
|
@property
|
||||||
"doc": None,
|
def state(self) -> State:
|
||||||
},
|
return self._state
|
||||||
"some_float": {
|
|
||||||
"type": "float",
|
@state.setter
|
||||||
"value": 1.0,
|
def state(self, value: State) -> None:
|
||||||
"readonly": False,
|
self._state = value
|
||||||
"doc": None,
|
|
||||||
},
|
|
||||||
"some_unit": {
|
CURRENT_STATE = Service().serialize()
|
||||||
"type": "Quantity",
|
|
||||||
"value": {"magnitude": 1.2, "unit": "A"},
|
|
||||||
"readonly": False,
|
|
||||||
"doc": None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
LOAD_STATE = {
|
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": {
|
"name": {
|
||||||
"type": "str",
|
"type": "str",
|
||||||
"value": "Service",
|
"value": "Another name",
|
||||||
"readonly": True,
|
"readonly": True,
|
||||||
"doc": None,
|
"doc": None,
|
||||||
},
|
},
|
||||||
"some_float": {
|
"some_float": {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"value": 1,
|
"value": 10,
|
||||||
|
"readonly": False,
|
||||||
|
"doc": None,
|
||||||
|
},
|
||||||
|
"property_attr": {
|
||||||
|
"type": "float",
|
||||||
|
"value": 1337.1,
|
||||||
"readonly": False,
|
"readonly": False,
|
||||||
"doc": None,
|
"doc": None,
|
||||||
},
|
},
|
||||||
@ -61,6 +88,36 @@ LOAD_STATE = {
|
|||||||
"readonly": False,
|
"readonly": False,
|
||||||
"doc": None,
|
"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)
|
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
|
# Create a StateManager instance with a temporary file
|
||||||
file = tmp_path / "test_state.json"
|
file = tmp_path / "test_state.json"
|
||||||
|
|
||||||
@ -87,7 +144,27 @@ def test_load_state(tmp_path: Path):
|
|||||||
service = Service()
|
service = Service()
|
||||||
manager = StateManager(service=service, filename=str(file))
|
manager = StateManager(service=service, filename=str(file))
|
||||||
manager.load_state()
|
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):
|
def test_filename_warning(tmp_path: Path, caplog: LogCaptureFixture):
|
||||||
@ -120,9 +197,7 @@ def test_readonly_attribute(tmp_path: Path, caplog: LogCaptureFixture):
|
|||||||
service = Service()
|
service = Service()
|
||||||
manager = StateManager(service=service, filename=str(file))
|
manager = StateManager(service=service, filename=str(file))
|
||||||
manager.load_state()
|
manager.load_state()
|
||||||
assert (
|
assert "Attribute 'name' is read-only. Ignoring new value..." in caplog.text
|
||||||
"Attribute 'name' is read-only. Ignoring value from JSON file..." in caplog.text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture):
|
def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user