Merge pull request #70 from tiqi-group/feat/emit_serialized_value_to_frontend

Feat: emit serialized object to frontend
This commit is contained in:
Mose Müller 2023-11-16 09:24:13 +01:00 committed by GitHub
commit 64dc09faf7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 189 additions and 105 deletions

View File

@ -8,96 +8,32 @@ import {
import './App.css'; import './App.css';
import { Notifications } from './components/NotificationsComponent'; import { Notifications } from './components/NotificationsComponent';
import { ConnectionToast } from './components/ConnectionToast'; import { ConnectionToast } from './components/ConnectionToast';
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
type ValueType = boolean | string | number | object;
type State = DataServiceJSON | null;
type Action = type Action =
| { type: 'SET_DATA'; data: DataServiceJSON } | { type: 'SET_DATA'; data: State }
| { type: 'UPDATE_ATTRIBUTE'; parentPath: string; name: string; value: ValueType }; | {
type: 'UPDATE_ATTRIBUTE';
parentPath: string;
name: string;
value: SerializedValue;
};
type UpdateMessage = { type UpdateMessage = {
data: { parent_path: string; name: string; value: object }; data: { parent_path: string; name: string; value: SerializedValue };
}; };
type ExceptionMessage = { type ExceptionMessage = {
data: { exception: string; type: string }; data: { exception: string; type: string };
}; };
/**
* A function to update a specific property in a deeply nested object.
* The property to be updated is specified by a path array.
*
* Each path element can be a regular object key or an array index of the
* form "attribute[index]", where "attribute" is the key of the array in
* the object and "index" is the index of the element in the array.
*
* For array indices, the element at the specified index in the array is
* updated.
*
* If the property to be updated is an object or an array, it is updated
* recursively.
*/
function updateNestedObject(path: Array<string>, obj: object, value: ValueType) {
// Base case: If the path is empty, return the new value.
// This means we've reached the nested property to be updated.
if (path.length === 0) {
return value;
}
// Recursive case: If the path is not empty, split it into the first key and the rest
// of the path.
const [first, ...rest] = path;
// Check if 'first' is an array index.
const indexMatch = first.match(/^(\w+)\[(\d+)\]$/);
// If 'first' is an array index of the form "attribute[index]", then update the
// element at the specified index in the array. Otherwise, update the property
// specified by 'first' in the object.
if (indexMatch) {
const attribute = indexMatch[1];
const index = parseInt(indexMatch[2]);
if (Array.isArray(obj[attribute]?.value)) {
return {
...obj,
[attribute]: {
...obj[attribute],
value: obj[attribute].value.map((item, i) =>
i === index
? {
...item,
value: updateNestedObject(rest, item.value || {}, value)
}
: item
)
}
};
} else {
throw new Error(
`Expected ${attribute}.value to be an array, but received ${typeof obj[
attribute
]?.value}`
);
}
} else {
return {
...obj,
[first]: {
...obj[first],
value: updateNestedObject(rest, obj[first]?.value || {}, value)
}
};
}
}
const reducer = (state: State, action: Action): State => { const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case 'SET_DATA': case 'SET_DATA':
return action.data; return action.data;
case 'UPDATE_ATTRIBUTE': { case 'UPDATE_ATTRIBUTE': {
const path = action.parentPath.split('.').slice(1).concat(action.name); const pathList = action.parentPath.split('.').slice(1).concat(action.name);
const joinedPath = pathList.join('.');
return updateNestedObject(path, state, action.value); return setNestedValueByPath(state, joinedPath, action.value);
} }
default: default:
throw new Error(); throw new Error();
@ -137,7 +73,7 @@ const App = () => {
// Fetch data from the API when the client connects // Fetch data from the API when the client connects
fetch(`http://${hostname}:${port}/service-properties`) fetch(`http://${hostname}:${port}/service-properties`)
.then((response) => response.json()) .then((response) => response.json())
.then((data: DataServiceJSON) => dispatch({ type: 'SET_DATA', data })); .then((data: State) => dispatch({ type: 'SET_DATA', data }));
setConnectionStatus('connected'); setConnectionStatus('connected');
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {

View File

@ -0,0 +1,148 @@
export interface SerializedValue {
type: string;
value: Record<string, unknown> | Array<Record<string, unknown>>;
readonly: boolean;
doc: string | null;
async?: boolean;
parameters?: unknown;
}
export type State = Record<string, SerializedValue> | null;
export function setNestedValueByPath(
serializationDict: Record<string, SerializedValue>,
path: string,
serializedValue: SerializedValue
): Record<string, SerializedValue> {
const parentPathParts = path.split('.').slice(0, -1);
const attrName = path.split('.').pop();
if (!attrName) {
throw new Error('Invalid path');
}
let currentSerializedValue: SerializedValue;
const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
JSON.stringify(serializationDict)
);
let currentDict = newSerializationDict;
try {
for (const pathPart of parentPathParts) {
currentSerializedValue = getNextLevelDictByKey(currentDict, pathPart, false);
// @ts-expect-error The value will be of type SerializedValue as we are still
// looping through the parent parts
currentDict = currentSerializedValue['value'];
}
currentSerializedValue = getNextLevelDictByKey(currentDict, attrName, true);
Object.assign(currentSerializedValue, serializedValue);
return newSerializationDict;
} catch (error) {
console.error(error);
return currentDict;
}
}
function getNextLevelDictByKey(
serializationDict: Record<string, SerializedValue>,
attrName: string,
allowAppend: boolean = false
): SerializedValue {
const [key, index] = parseListAttrAndIndex(attrName);
let currentDict: SerializedValue;
try {
if (index !== null) {
if (!serializationDict[key] || !Array.isArray(serializationDict[key]['value'])) {
throw new Error(`Expected an array at '${key}', but found something else.`);
}
if (index < serializationDict[key]['value'].length) {
currentDict = serializationDict[key]['value'][index];
} else if (allowAppend && index === serializationDict[key]['value'].length) {
// Appending to list
// @ts-expect-error When the index is not null, I expect an array
serializationDict[key]['value'].push({});
currentDict = serializationDict[key]['value'][index];
} else {
throw new Error(`Index out of range for '${key}[${index}]'.`);
}
} else {
if (!serializationDict[key]) {
throw new Error(`Key '${key}' not found.`);
}
currentDict = serializationDict[key];
}
} catch (error) {
throw new Error(`Error occurred trying to access '${attrName}': ${error}`);
}
if (typeof currentDict !== 'object' || currentDict === null) {
throw new Error(
`Expected a dictionary at '${attrName}', but found type '${typeof currentDict}' instead.`
);
}
return currentDict;
}
function parseListAttrAndIndex(attrString: string): [string, number | null] {
let index: number | null = null;
let attrName = attrString;
if (attrString.includes('[') && attrString.endsWith(']')) {
const parts = attrString.split('[');
attrName = parts[0];
const indexPart = parts[1].slice(0, -1); // Removes the closing ']'
if (!isNaN(parseInt(indexPart))) {
index = parseInt(indexPart);
} else {
console.error(`Invalid index format in key: ${attrString}`);
}
}
return [attrName, index];
}
const serializationDict = {
attr_list: {
type: 'list',
value: [
{ type: 'int', value: 1, readonly: false, doc: null },
{ type: 'int', value: 2, readonly: false, doc: null },
{
type: 'Quantity',
value: { magnitude: 1.0, unit: 'ms' },
readonly: false,
doc: null
}
],
readonly: false,
doc: null
},
read_sensor_data: {
type: 'method',
value: null,
readonly: true,
doc: null,
async: true,
parameters: {}
},
readout_wait_time: {
type: 'Quantity',
value: { magnitude: 1.0, unit: 'ms' },
readonly: false,
doc: null
}
};
const attrName: string = 'attr_list[2]'; // example attribute name
try {
const result = getNextLevelDictByKey(serializationDict, attrName);
console.log(result);
} catch (error) {
console.error(error);
}

View File

@ -1,13 +1,13 @@
{ {
"files": { "files": {
"main.css": "/static/css/main.32559665.css", "main.css": "/static/css/main.c0fa0427.css",
"main.js": "/static/js/main.6d4f9d3a.js", "main.js": "/static/js/main.e9762f7d.js",
"index.html": "/index.html", "index.html": "/index.html",
"main.32559665.css.map": "/static/css/main.32559665.css.map", "main.c0fa0427.css.map": "/static/css/main.c0fa0427.css.map",
"main.6d4f9d3a.js.map": "/static/js/main.6d4f9d3a.js.map" "main.e9762f7d.js.map": "/static/js/main.e9762f7d.js.map"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.32559665.css", "static/css/main.c0fa0427.css",
"static/js/main.6d4f9d3a.js" "static/js/main.e9762f7d.js"
] ]
} }

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.6d4f9d3a.js"></script><link href="/static/css/main.32559665.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.e9762f7d.js"></script><link href="/static/css/main.c0fa0427.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,6 @@
http://jedwatson.github.io/classnames http://jedwatson.github.io/classnames
*/ */
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/** /**
* @license React * @license React
* react-dom.production.min.js * react-dom.production.min.js

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@ import os
import signal import signal
import threading import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from enum import Enum from copy import deepcopy
from pathlib import Path from pathlib import Path
from types import FrameType from types import FrameType
from typing import Any, Optional, Protocol, TypedDict from typing import Any, Optional, Protocol, TypedDict
@ -13,9 +13,9 @@ import uvicorn
from rpyc import ForkingServer, ThreadedServer # type: ignore from rpyc import ForkingServer, ThreadedServer # type: ignore
from uvicorn.server import HANDLED_SIGNALS from uvicorn.server import HANDLED_SIGNALS
import pydase.units as u
from pydase import DataService from pydase import DataService
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager
from pydase.utils.serializer import dump, get_nested_dict_by_path
from pydase.version import __version__ from pydase.version import __version__
from .web_server import WebAPI from .web_server import WebAPI
@ -306,11 +306,13 @@ class Server:
# > File "/usr/lib64/python3.11/json/encoder.py", line 180, in default # > File "/usr/lib64/python3.11/json/encoder.py", line 180, in default
# > raise TypeError(f'Object of type {o.__class__.__name__} ' # > raise TypeError(f'Object of type {o.__class__.__name__} '
# > TypeError: Object of type list is not JSON serializable # > TypeError: Object of type list is not JSON serializable
notify_value = value full_access_path = ".".join([*parent_path.split(".")[:-1], name])
if isinstance(value, Enum): cached_value_dict = deepcopy(
notify_value = value.name get_nested_dict_by_path(self._state_manager.cache, full_access_path)
if isinstance(value, u.Quantity): )
notify_value = {"magnitude": value.m, "unit": str(value.u)} serialized_value = dump(value)
cached_value_dict["value"] = serialized_value["value"]
cached_value_dict["type"] = serialized_value["type"]
async def notify() -> None: async def notify() -> None:
try: try:
@ -320,7 +322,7 @@ class Server:
"data": { "data": {
"parent_path": parent_path, "parent_path": parent_path,
"name": name, "name": name,
"value": notify_value, "value": cached_value_dict,
} }
}, },
) )