Compare commits
324 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77dcfdfae | ||
|
|
fe01ada733 | ||
|
|
16c1f966ab | ||
|
|
003ee95272 | ||
|
|
dfbf1c61af | ||
|
|
7233e5933b | ||
|
|
09e66400c3 | ||
|
|
6977b795e5 | ||
|
|
8911b860d7 | ||
|
|
245b1844c9 | ||
|
|
d48ae9f5ad | ||
|
|
cf637d19ae | ||
|
|
edfb7d0341 | ||
|
|
7b06786307 | ||
|
|
5eeaefdd63 | ||
|
|
f65a0e31c3 | ||
|
|
fbada6d818 | ||
|
|
507f286963 | ||
|
|
c148eba5dd | ||
|
|
61c7dc8333 | ||
|
|
a879b09e0b | ||
|
|
bba21e3241 | ||
|
|
16bd17f75c | ||
|
|
ad2800aaf6 | ||
|
|
d792601663 | ||
|
|
166fc57877 | ||
|
|
5b762db535 | ||
|
|
73b2355d35 | ||
|
|
6335ea21ad | ||
|
|
690ecd7317 | ||
|
|
9cb667581a | ||
|
|
5936e7091e | ||
|
|
ad0fd8e833 | ||
|
|
473c6660e6 | ||
|
|
5511ebc808 | ||
|
|
439665177d | ||
|
|
c0b25c0581 | ||
|
|
60a7dda60a | ||
|
|
381d98b078 | ||
|
|
658fb13d9d | ||
|
|
a582dc23ac | ||
|
|
19b24f3060 | ||
|
|
d100bb5fea | ||
|
|
36a70badce | ||
|
|
9916d6df60 | ||
|
|
b4c84da57e | ||
|
|
ecf0e99318 | ||
|
|
10ac007a0c | ||
|
|
900017791a | ||
|
|
edb06b1612 | ||
|
|
bb5205b2e4 | ||
|
|
c02c75aab5 | ||
|
|
cc3fdfbb27 | ||
|
|
7d399df158 | ||
|
|
92e2c0e8ef | ||
|
|
65f63e08ae | ||
|
|
4eddf4b980 | ||
|
|
9d7099f116 | ||
|
|
3f096bda96 | ||
|
|
e56a6e0653 | ||
|
|
e71186dce4 | ||
|
|
d1007fad14 | ||
|
|
6f2c1f8951 | ||
|
|
f18880abd5 | ||
|
|
9851ccfcdf | ||
|
|
f312ec1e51 | ||
|
|
7405d2cafc | ||
|
|
e6251975b8 | ||
|
|
780a2466d3 | ||
|
|
8979a1885e | ||
|
|
fbc4af28ae | ||
|
|
454b0fb7d1 | ||
|
|
9d3264de1f | ||
|
|
2d6c681690 | ||
|
|
612e62d06b | ||
|
|
31f280c9cb | ||
|
|
e4f5374783 | ||
|
|
6397307690 | ||
|
|
2ce4c9ce9b | ||
|
|
15cf0bd414 | ||
|
|
ff3a509132 | ||
|
|
1a01222cb3 | ||
|
|
2eb996b382 | ||
|
|
8addcd26aa | ||
|
|
4db15f2fe8 | ||
|
|
27f22d472d | ||
|
|
c1aa678384 | ||
|
|
11670addc4 | ||
|
|
1c663e9a2e | ||
|
|
ada9dcce4a | ||
|
|
bd5c162148 | ||
|
|
4e1ec90dee | ||
|
|
4406acf4dd | ||
|
|
1ad917a423 | ||
|
|
57e7deb552 | ||
|
|
d9ea33abb6 | ||
|
|
75c5bc6877 | ||
|
|
a606194c48 | ||
|
|
5da7bdea78 | ||
|
|
c6a52914c5 | ||
|
|
ae68a89f48 | ||
|
|
386e69b048 | ||
|
|
8310a51a74 | ||
|
|
2a8cbf7a4a | ||
|
|
857b81d213 | ||
|
|
25834534ad | ||
|
|
4a948f9155 | ||
|
|
df42f41f53 | ||
|
|
b8d421eb90 | ||
|
|
877ab42905 | ||
|
|
51ffd8be4d | ||
|
|
a88a0c6133 | ||
|
|
390a375777 | ||
|
|
4aee899dbe | ||
|
|
c7d452d7db | ||
|
|
b7926b730d | ||
|
|
0c175fc706 | ||
|
|
7d21bca8b1 | ||
|
|
d1628ae8c9 | ||
|
|
441658ebc1 | ||
|
|
99c7ad0ec8 | ||
|
|
24a01c0982 | ||
|
|
b8a52c2e6a | ||
|
|
7aacc21010 | ||
|
|
8787cb0509 | ||
|
|
8971cebfcd | ||
|
|
f2cf0d9c1a | ||
|
|
36c863e845 | ||
|
|
836c1e14df | ||
|
|
dba036c6b3 | ||
|
|
8b1f1ef1b1 | ||
|
|
698db4881b | ||
|
|
d709d43d75 | ||
|
|
691bf809cb | ||
|
|
86ccdd77f1 | ||
|
|
f29fb87054 | ||
|
|
cf5bc1e4e6 | ||
|
|
af36ed6c43 | ||
|
|
853472be94 | ||
|
|
f97a138e65 | ||
|
|
e5d7f4709f | ||
|
|
416ae6f0b4 | ||
|
|
8f0a9ad21a | ||
|
|
6ed6fe5be1 | ||
|
|
9c6323d38f | ||
|
|
5c11202e08 | ||
|
|
e551af68f9 | ||
|
|
e213931cb7 | ||
|
|
fe29530eb6 | ||
|
|
151467b36f | ||
|
|
990add216c | ||
|
|
a05b703bb8 | ||
|
|
9616c57c38 | ||
|
|
a7ce321506 | ||
|
|
a72a551f54 | ||
|
|
26689d8578 | ||
|
|
74fc5d9aab | ||
|
|
da8d07a8b2 | ||
|
|
ca2182c19b | ||
|
|
b2f828ff6f | ||
|
|
affc63219f | ||
|
|
a01cf273fe | ||
|
|
acd0c80316 | ||
|
|
2337aa9d6d | ||
|
|
b6f6b3058e | ||
|
|
d33e9f9dbf | ||
|
|
53676131a6 | ||
|
|
7f407ae6e7 | ||
|
|
3c2f425dee | ||
|
|
ccc53c395e | ||
|
|
c672989768 | ||
|
|
5ff279d5bd | ||
|
|
883ec6d6ae | ||
|
|
22fd2d099d | ||
|
|
f8926ea823 | ||
|
|
ceed62c8f2 | ||
|
|
5313ef6e8c | ||
|
|
2d98ba51f4 | ||
|
|
2f2544b978 | ||
|
|
fffe679bf0 | ||
|
|
2bb02a5558 | ||
|
|
1c029e301b | ||
|
|
f0384b817c | ||
|
|
8042f9b390 | ||
|
|
838145a778 | ||
|
|
7d753b2fc6 | ||
|
|
72f6a8ddee | ||
|
|
dfb6f966aa | ||
|
|
dc42bfaa9b | ||
|
|
c0ba23b0b2 | ||
|
|
bd7a46ddc1 | ||
|
|
5bea0892c7 | ||
|
|
9631a7d467 | ||
|
|
1e8c7bd141 | ||
|
|
10dc1436d0 | ||
|
|
551b8f0158 | ||
|
|
25139b3d4d | ||
|
|
6b1227fcbb | ||
|
|
fd3338f99f | ||
|
|
c23d0372a5 | ||
|
|
b646acc994 | ||
|
|
9b31362f5b | ||
|
|
63edcffe7e | ||
|
|
8c5c6d0f6d | ||
|
|
71b84525dd | ||
|
|
e78dc2defb | ||
|
|
529d61c77d | ||
|
|
c7c88178d4 | ||
|
|
7f082b6f95 | ||
|
|
30138bcb45 | ||
|
|
1318bbc8a8 | ||
|
|
ae9761bd11 | ||
|
|
04d19a853f | ||
|
|
fc28b83bc5 | ||
|
|
f1384b25a1 | ||
|
|
7ef82e61e5 | ||
|
|
6d9191fe18 | ||
|
|
4f71633c5e | ||
|
|
2c95a2496c | ||
|
|
aca5aab1ef | ||
|
|
4f1cc4787d | ||
|
|
8efd67d9f3 | ||
|
|
34fc0f8739 | ||
|
|
e60880fd30 | ||
|
|
036b0c681a | ||
|
|
dd268a4f9b | ||
|
|
e8638f1f3a | ||
|
|
7279fed2aa | ||
|
|
a2518671da | ||
|
|
bcabd2dc48 | ||
|
|
7ac9c557c2 | ||
|
|
656529d1fb | ||
|
|
14601105a7 | ||
|
|
484b5131e9 | ||
|
|
616a5cea21 | ||
|
|
300bd6ca9a | ||
|
|
3e1517e905 | ||
|
|
0ecaeac3fb | ||
|
|
0e9832e2f1 | ||
|
|
0343abd0b0 | ||
|
|
0c149b85b5 | ||
|
|
0e331e58ff | ||
|
|
45135927e6 | ||
|
|
d3866010a8 | ||
|
|
3c0f019af8 | ||
|
|
8aa7fd31f8 | ||
|
|
c9ff3db9e9 | ||
|
|
9e77bae5e7 | ||
|
|
6a6d1b27aa | ||
|
|
2d3e7d8c1b | ||
|
|
c7b039beb7 | ||
|
|
62e647c667 | ||
|
|
6382be5735 | ||
|
|
ea158bf8de | ||
|
|
63ad6d7b93 | ||
|
|
b8e758e479 | ||
|
|
a12a708385 | ||
|
|
edb24f5439 | ||
|
|
2a2b7b800d | ||
|
|
b6b20c21e4 | ||
|
|
53be794a3c | ||
|
|
a303ba7f0b | ||
|
|
2461f85ef0 | ||
|
|
ca41e12014 | ||
|
|
f69723dd58 | ||
|
|
c733026522 | ||
|
|
316ce5c7e7 | ||
|
|
43c3f746fa | ||
|
|
fea96c044c | ||
|
|
6543bc6b39 | ||
|
|
ef36c01407 | ||
|
|
9d90fd2b81 | ||
|
|
9fc6d6f910 | ||
|
|
805e270107 | ||
|
|
8e3a1694ce | ||
|
|
32a1d14a40 | ||
|
|
8940a61d4e | ||
|
|
393bde3280 | ||
|
|
eb2da1c5dc | ||
|
|
e7b73a99da | ||
|
|
392831e0fd | ||
|
|
32bda8d910 | ||
|
|
e106cc4927 | ||
|
|
464478cda9 | ||
|
|
97c026afe0 | ||
|
|
2f5c415cd5 | ||
|
|
728eea09f6 | ||
|
|
e3eaf5ffe2 | ||
|
|
1dc3b62060 | ||
|
|
8214faf5cb | ||
|
|
232eb53249 | ||
|
|
439f514ea5 | ||
|
|
c7d63f5139 | ||
|
|
f64b5c35ab | ||
|
|
bb4de988e9 | ||
|
|
36a8e916f6 | ||
|
|
1a00f37372 | ||
|
|
6630173cec | ||
|
|
08a62b2119 | ||
|
|
37ae34ecc0 | ||
|
|
8b78099178 | ||
|
|
3186e04cc1 | ||
|
|
055acbe591 | ||
|
|
0d08c2ce0d | ||
|
|
68cc5b693e | ||
|
|
4fcd5b4d44 | ||
|
|
9cbc639d0f | ||
|
|
a48cce32e4 | ||
|
|
8c24f5dd67 | ||
|
|
1c4a878aa8 | ||
|
|
31967d0d43 | ||
|
|
b4edc31030 | ||
|
|
ff7c92547e | ||
|
|
fab91f3221 | ||
|
|
bd77995d96 | ||
|
|
729f375901 | ||
|
|
e643dd6f5c | ||
|
|
53f4cf6690 | ||
|
|
c0c8591fc4 | ||
|
|
13fba6d3d6 | ||
|
|
dc4c9ff58f | ||
|
|
83cd07feee | ||
|
|
09f73a2b1d | ||
|
|
88886e3fd6 |
3
.vscode/extensions.json
vendored
@@ -2,6 +2,7 @@
|
||||
"recommendations": [
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance"
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-python.mypy-type-checker"
|
||||
]
|
||||
}
|
||||
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "foo",
|
||||
"justMyCode": true,
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"ENVIRONMENT": "development"
|
||||
}
|
||||
|
||||
6
.vscode/settings.json
vendored
@@ -8,8 +8,8 @@
|
||||
"editor.tabSize": 4,
|
||||
"editor.detectIndentation": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true,
|
||||
"source.fixAll": true
|
||||
"source.organizeImports": "explicit",
|
||||
"source.fixAll": "explicit"
|
||||
}
|
||||
},
|
||||
"[yaml]": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"editor.formatOnType": false,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
}
|
||||
502
README.md
@@ -11,22 +11,32 @@
|
||||
- [Defining a DataService](#defining-a-dataservice)
|
||||
- [Running the Server](#running-the-server)
|
||||
- [Accessing the Web Interface](#accessing-the-web-interface)
|
||||
- [Connecting to the Service using rpyc](#connecting-to-the-service-using-rpyc)
|
||||
- [Connecting to the Service via Python Client](#connecting-to-the-service-via-python-client)
|
||||
- [Tab Completion Support](#tab-completion-support)
|
||||
- [Integration within Another Service](#integration-within-another-service)
|
||||
- [Understanding the Component System](#understanding-the-component-system)
|
||||
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
|
||||
- [Method Components](#method-components)
|
||||
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
|
||||
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
|
||||
- [`DeviceConnection`](#deviceconnection)
|
||||
- [Customizing Connection Logic](#customizing-connection-logic)
|
||||
- [Reconnection Interval](#reconnection-interval)
|
||||
- [`Image`](#image)
|
||||
- [`NumberSlider`](#numberslider)
|
||||
- [`ColouredEnum`](#colouredenum)
|
||||
- [Extending with New Components](#extending-with-new-components)
|
||||
- [Customizing Web Interface Style](#customizing-web-interface-style)
|
||||
- [Understanding Service Persistence](#understanding-service-persistence)
|
||||
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
|
||||
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
|
||||
- [Understanding Units in pydase](#understanding-units-in-pydase)
|
||||
- [Changing the Log Level](#changing-the-log-level)
|
||||
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
|
||||
- [Customizing the Web Interface](#customizing-the-web-interface)
|
||||
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
|
||||
- [Tailoring Frontend Component Layout](#tailoring-frontend-component-layout)
|
||||
- [Specifying a Custom Frontend Source](#specifying-a-custom-frontend-source)
|
||||
- [Logging in pydase](#logging-in-pydase)
|
||||
- [Changing the Log Level](#changing-the-log-level)
|
||||
- [Documentation](#documentation)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
@@ -36,20 +46,19 @@
|
||||
<!-- no toc -->
|
||||
- [Simple data service definition through class-based interface](#defining-a-dataService)
|
||||
- [Integrated web interface for interactive access and control of your data service](#accessing-the-web-interface)
|
||||
- [Support for `rpyc` connections, allowing for programmatic control and interaction with your service](#connecting-to-the-service-using-rpyc)
|
||||
- [Support for programmatic control and interaction with your service](#connecting-to-the-service-via-python-client)
|
||||
- [Component system bridging Python backend with frontend visual representation](#understanding-the-component-system)
|
||||
- [Customizable styling for the web interface through user-defined CSS](#customizing-web-interface-style)
|
||||
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
|
||||
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
|
||||
- [Support for units](#understanding-units-in-pydase)
|
||||
<!-- * Event-based callback functionality for real-time updates
|
||||
- Support for additional servers for specific use-cases -->
|
||||
<!-- Support for additional servers for specific use-cases -->
|
||||
|
||||
## Installation
|
||||
|
||||
<!--installation-start-->
|
||||
|
||||
Install pydase using [`poetry`](https://python-poetry.org/):
|
||||
Install `pydase` using [`poetry`](https://python-poetry.org/):
|
||||
|
||||
```bash
|
||||
poetry add pydase
|
||||
@@ -67,16 +76,17 @@ pip install pydase
|
||||
|
||||
<!--usage-start-->
|
||||
|
||||
Using `pydase` involves three main steps: defining a `DataService` subclass, running the server, and then connecting to the service either programmatically using `rpyc` or through the web interface.
|
||||
Using `pydase` involves three main steps: defining a `DataService` subclass, running the server, and then connecting to the service either programmatically using `pydase.Client` or through the web interface.
|
||||
|
||||
### Defining a DataService
|
||||
|
||||
To use pydase, you'll first need to create a class that inherits from `DataService`. This class represents your custom data service, which will be exposed via RPC (using rpyc) and a web server. Your class can implement class / instance attributes and synchronous and asynchronous tasks.
|
||||
To use pydase, you'll first need to create a class that inherits from `DataService`. This class represents your custom data service, which will be exposed via a web server. Your class can implement class / instance attributes and synchronous and asynchronous tasks.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
from pydase import DataService, Server
|
||||
from pydase.utils.decorators import frontend
|
||||
|
||||
|
||||
class Device(DataService):
|
||||
@@ -114,6 +124,7 @@ class Device(DataService):
|
||||
# run code to set power state
|
||||
self._power = value
|
||||
|
||||
@frontend
|
||||
def reset(self) -> None:
|
||||
self.current = 0.0
|
||||
self.voltage = 0.0
|
||||
@@ -150,23 +161,51 @@ Once the server is running, you can access the web interface in a browser:
|
||||
|
||||
In this interface, you can interact with the properties of your `Device` service.
|
||||
|
||||
### Connecting to the Service using rpyc
|
||||
### Connecting to the Service via Python Client
|
||||
|
||||
You can also connect to the service using `rpyc`. Here's an example on how to establish a connection and interact with the service:
|
||||
You can connect to the service using the `pydase.Client`. Below is an example of how to establish a connection to a service and interact with it:
|
||||
|
||||
```python
|
||||
import rpyc
|
||||
import pydase
|
||||
|
||||
# Connect to the service
|
||||
conn = rpyc.connect("<ip_addr>", 18871)
|
||||
client = conn.root
|
||||
# Replace the hostname and port with the IP address and the port of the machine where
|
||||
# the service is running, respectively
|
||||
client_proxy = pydase.Client(hostname="<ip_addr>", port=8001).proxy
|
||||
|
||||
# Interact with the service
|
||||
client.voltage = 5.0
|
||||
print(client.voltage) # prints 5.0
|
||||
# After the connection, interact with the service attributes as if they were local
|
||||
client_proxy.voltage = 5.0
|
||||
print(client_proxy.voltage) # Expected output: 5.0
|
||||
```
|
||||
|
||||
In this example, replace `<ip_addr>` with the IP address of the machine where the service is running. After establishing a connection, you can interact with the service attributes as if they were local attributes.
|
||||
This example demonstrates setting and retrieving the `voltage` attribute through the client proxy.
|
||||
The proxy acts as a local representative of the remote service, enabling straightforward interaction.
|
||||
|
||||
The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object.
|
||||
|
||||
#### Tab Completion Support
|
||||
|
||||
In interactive environments such as Python interpreters and Jupyter notebooks, the proxy class supports tab completion, which allows users to explore available methods and attributes.
|
||||
|
||||
#### Integration within Another Service
|
||||
|
||||
You can also integrate a client proxy within another service. Here's how you can set it up:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
# Initialize the client without blocking the constructor
|
||||
proxy = pydase.Client(hostname="<ip_addr>", port=8001, block_until_connected=False).proxy
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
# Create a server that exposes this service; adjust the web_port as needed
|
||||
server = pydase.Server(service, web_port=8002). run()
|
||||
```
|
||||
|
||||
In this setup, the `MyService` class has a `proxy` attribute that connects to a `pydase` service located at `<ip_addr>:8001`.
|
||||
The `block_until_connected=False` argument allows the service to start up even if the initial connection attempt fails.
|
||||
This configuration is particularly useful in distributed systems where services may start in any order.
|
||||
|
||||
<!--usage-end-->
|
||||
|
||||
@@ -187,11 +226,35 @@ In `pydase`, components are fundamental building blocks that bridge the Python b
|
||||
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
|
||||
|
||||
### Method Components
|
||||
Within the `DataService` class of `pydase`, only methods devoid of arguments can be represented in the frontend, classified into two distinct categories
|
||||
|
||||
Methods within the `DataService` class have frontend representations:
|
||||
1. [**Tasks**](#understanding-tasks-in-pydase): Argument-free asynchronous functions, identified within `pydase` as tasks, are inherently designed for frontend interaction. These tasks are automatically rendered with a start/stop button, allowing users to initiate or halt the task execution directly from the web interface.
|
||||
2. **Synchronous Methods with `@frontend` Decorator**: Synchronous methods without arguments can also be presented in the frontend. For this, they have to be decorated with the `@frontend` decorator.
|
||||
|
||||
- Regular Methods: These are rendered as a `MethodComponent` in the frontend, allowing users to execute the method via an "execute" button.
|
||||
- Asynchronous Methods: These are manifested as the `AsyncMethodComponent` with "start"/"stop" buttons to manage the execution of [tasks](#understanding-tasks-in-pydase).
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
from pydase.utils.decorators import frontend
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
@frontend
|
||||
def exposed_method(self) -> None:
|
||||
...
|
||||
|
||||
async def my_task(self) -> None:
|
||||
while True:
|
||||
# ...
|
||||
```
|
||||
|
||||

|
||||
|
||||
You can still define synchronous tasks with arguments and call them using a python client. However, decorating them with the `@frontend` decorator will raise a `FunctionDefinitionError`. Defining a task with arguments will raise a `TaskDefinitionError`.
|
||||
I decided against supporting function arguments for functions rendered in the frontend due to the following reasons:
|
||||
|
||||
1. Feature Request Pitfall: supporting function arguments create a bottomless pit of feature requests. As users encounter the limitations of supported types, demands for extending support to more complex types would grow.
|
||||
2. Complexity in Supported Argument Types: while simple types like `int`, `float`, `bool` and `str` could be easily supported, more complicated types are not (representation, (de-)serialization).
|
||||
|
||||
### DataService Instances (Nested Classes)
|
||||
|
||||
@@ -205,9 +268,9 @@ from pydase import DataService, Server
|
||||
|
||||
class Channel(DataService):
|
||||
def __init__(self, channel_id: int) -> None:
|
||||
super().__init__()
|
||||
self._channel_id = channel_id
|
||||
self._current = 0.0
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def current(self) -> float:
|
||||
@@ -223,9 +286,8 @@ class Channel(DataService):
|
||||
|
||||
class Device(DataService):
|
||||
def __init__(self) -> None:
|
||||
self.channels = [Channel(i) for i in range(2)]
|
||||
|
||||
super().__init__()
|
||||
self.channels = [Channel(i) for i in range(2)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -246,6 +308,89 @@ The custom components in `pydase` have two main parts:
|
||||
|
||||
Below are the components available in the `pydase.components` module, accompanied by their Python usage:
|
||||
|
||||
#### `DeviceConnection`
|
||||
|
||||
The `DeviceConnection` component acts as a base class within the `pydase` framework for managing device connections. It provides a structured approach to handle connections by offering a customizable `connect` method and a `connected` property. This setup facilitates the implementation of automatic reconnection logic, which periodically attempts reconnection whenever the connection is lost.
|
||||
|
||||
In the frontend, this class abstracts away the direct interaction with the `connect` method and the `connected` property. Instead, it showcases user-defined attributes, methods, and properties. When the `connected` status is `False`, the frontend displays an overlay that prompts manual reconnection through the `connect()` method. Successful reconnection removes the overlay.
|
||||
|
||||
```python
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
|
||||
|
||||
class Device(pydase.components.DeviceConnection):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._voltage = 10 * u.units.V
|
||||
|
||||
def connect(self) -> None:
|
||||
if not self._connected:
|
||||
self._connected = True
|
||||
|
||||
@property
|
||||
def voltage(self) -> float:
|
||||
return self._voltage
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.device = Device()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||

|
||||
|
||||
##### Customizing Connection Logic
|
||||
|
||||
Users are encouraged to primarily override the `connect` method to tailor the connection process to their specific device. This method should adjust the `self._connected` attribute based on the outcome of the connection attempt:
|
||||
|
||||
```python
|
||||
import pydase.components
|
||||
|
||||
|
||||
class MyDeviceConnection(pydase.components.DeviceConnection):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Add any necessary initialization code here
|
||||
|
||||
def connect(self) -> None:
|
||||
# Implement device-specific connection logic here
|
||||
# Update self._connected to `True` if the connection is successful,
|
||||
# or `False` if unsuccessful
|
||||
...
|
||||
```
|
||||
|
||||
Moreover, if the connection status requires additional logic, users can override the `connected` property:
|
||||
|
||||
```python
|
||||
import pydase.components
|
||||
|
||||
class MyDeviceConnection(pydase.components.DeviceConnection):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# Add any necessary initialization code here
|
||||
|
||||
def connect(self) -> None:
|
||||
# Implement device-specific connection logic here
|
||||
# Ensure self._connected reflects the connection status accurately
|
||||
...
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
# Implement custom logic to accurately report connection status
|
||||
return self._connected
|
||||
```
|
||||
|
||||
##### Reconnection Interval
|
||||
|
||||
The `DeviceConnection` component automatically executes a task that checks for device availability at a default interval of 10 seconds. This interval is adjustable by modifying the `_reconnection_wait_time` attribute on the class instance.
|
||||
|
||||
#### `Image`
|
||||
|
||||
This component provides a versatile interface for displaying images within the application. Users can update and manage images from various sources, including local paths, URLs, and even matplotlib figures.
|
||||
@@ -255,7 +400,6 @@ The component offers methods to load images seamlessly, ensuring that visual con
|
||||
```python
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
import pydase
|
||||
from pydase.components.image import Image
|
||||
|
||||
@@ -286,26 +430,174 @@ if __name__ == "__main__":
|
||||
|
||||
#### `NumberSlider`
|
||||
|
||||
This component provides an interactive slider interface for adjusting numerical values on the frontend. It supports both floats and integers. The values adjusted on the frontend are synchronized with the backend in real-time, ensuring consistent data representation.
|
||||
The `NumberSlider` component in the `pydase` package provides an interactive slider interface for adjusting numerical values on the frontend. It is designed to support both numbers and quantities and ensures that values adjusted on the frontend are synchronized with the backend.
|
||||
|
||||
The slider can be customized with initial values, minimum and maximum limits, and step sizes to fit various use cases.
|
||||
To utilize the `NumberSlider`, users should implement a class that derives from `NumberSlider`. This class can then define the initial values, minimum and maximum limits, step sizes, and additional logic as needed.
|
||||
|
||||
Here's an example of how to implement and use a custom slider:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
from pydase.components import NumberSlider
|
||||
import pydase.components
|
||||
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0.0,
|
||||
min_: float = 0.0,
|
||||
max_: float = 100.0,
|
||||
step_size: float = 1.0,
|
||||
) -> None:
|
||||
super().__init__(value, min_, max_, step_size)
|
||||
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return self._min
|
||||
|
||||
@min.setter
|
||||
def min(self, value: float) -> None:
|
||||
self._min = value
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
|
||||
@max.setter
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
|
||||
@step_size.setter
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
"""Slider value."""
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: float) -> None:
|
||||
if value < self._min or value > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
|
||||
self._value = value
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
slider = NumberSlider(value=3.5, min=0, max=10, step_size=0.1, type="float")
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.voltage = MySlider()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
pydase.Server(service).run()
|
||||
service_instance = MyService()
|
||||
service_instance.voltage.value = 5
|
||||
print(service_instance.voltage.value) # Output: 5
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value` properties. Users can make any of these properties read-only by omitting the corresponding setter method.
|
||||
|
||||

|
||||
|
||||
- Accessing parent class resources in `NumberSlider`
|
||||
|
||||
In scenarios where you need the slider component to interact with or access resources from its parent class, you can achieve this by passing a callback function to it. This method avoids directly passing the entire parent class instance (`self`) and offers a more encapsulated approach. The callback function can be designed to utilize specific attributes or methods of the parent class, allowing the slider to perform actions or retrieve data in response to slider events.
|
||||
|
||||
Here's an illustrative example:
|
||||
|
||||
```python
|
||||
from collections.abc import Callable
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float,
|
||||
on_change: Callable[[float], None],
|
||||
) -> None:
|
||||
super().__init__(value=value)
|
||||
self._on_change = on_change
|
||||
|
||||
# ... other properties ...
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value: float) -> None:
|
||||
if new_value < self._min or new_value > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
self._value = new_value
|
||||
self._on_change(new_value)
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
self.voltage = MySlider(
|
||||
5,
|
||||
on_change=self.handle_voltage_change,
|
||||
)
|
||||
|
||||
def handle_voltage_change(self, new_voltage: float) -> None:
|
||||
print(f"Voltage changed to: {new_voltage}")
|
||||
# Additional logic here
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
my_service.voltage.value = 7 # Output: "Voltage changed to: 7"
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
- Incorporating units in `NumberSlider`
|
||||
|
||||
The `NumberSlider` is capable of [displaying units](#understanding-units-in-pydase) alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.
|
||||
|
||||
Here's how to implement a `NumberSlider` with unit display:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: u.Quantity = 0.0 * u.units.V,
|
||||
) -> None:
|
||||
super().__init__(value)
|
||||
|
||||
@property
|
||||
def value(self) -> u.Quantity:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: u.Quantity) -> None:
|
||||
if value.m < self._min or value.m > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
self._value = value
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.voltage = MySlider()
|
||||
|
||||
if __name__ == "__main__":
|
||||
service_instance = MyService()
|
||||
service_instance.voltage.value = 5 * u.units.V
|
||||
print(service_instance.voltage.value) # Output: 5 V
|
||||
pydase.Server(service_instance).run()
|
||||
```
|
||||
|
||||
#### `ColouredEnum`
|
||||
|
||||
This component provides a way to visually represent different states or categories in a data service using colour-coded options. It behaves similarly to a standard `Enum`, but the values encode colours in a format understood by CSS. The colours can be defined using various methods like Hexadecimal, RGB, HSL, and more.
|
||||
@@ -352,31 +644,6 @@ Users can also extend the library by creating custom components. This involves d
|
||||
|
||||
<!-- Component User Guide End -->
|
||||
|
||||
## Customizing Web Interface Style
|
||||
|
||||
`pydase` allows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
|
||||
|
||||
Here's how you can use this feature:
|
||||
|
||||
1. Prepare your custom CSS file with the desired styles.
|
||||
|
||||
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
|
||||
|
||||
```python
|
||||
from pydase import Server, DataService
|
||||
|
||||
class Device(DataService):
|
||||
# ... your service definition ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
server = Server(service, css="path/to/your/custom.css").run()
|
||||
```
|
||||
|
||||
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
|
||||
|
||||
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
|
||||
|
||||
## Understanding Service Persistence
|
||||
|
||||
`pydase` allows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.
|
||||
@@ -428,9 +695,9 @@ Note: If the service class structure has changed since the last time its state w
|
||||
|
||||
## Understanding Tasks in pydase
|
||||
|
||||
In `pydase`, a task is defined as an asynchronous function contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions.
|
||||
In `pydase`, a task is defined as an asynchronous function without arguments contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions.
|
||||
|
||||
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. The core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and an `rpyc` client, giving you flexible and powerful control over your service's operation.
|
||||
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. One core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and python clients, giving you flexible and powerful control over your service's operation.
|
||||
|
||||
Another powerful feature of `pydase` is its ability to automatically start tasks upon initialization of the service. By specifying the tasks and their arguments in the `_autostart_tasks` dictionary in your service class's `__init__` method, `pydase` will automatically start these tasks when the server is started. Here's an example:
|
||||
|
||||
@@ -439,9 +706,9 @@ from pydase import DataService, Server
|
||||
|
||||
class SensorService(DataService):
|
||||
def __init__(self):
|
||||
self.readout_frequency = 1.0
|
||||
self._autostart_tasks = {"read_sensor_data": ()} # args passed to the function go there
|
||||
super().__init__()
|
||||
self.readout_frequency = 1.0
|
||||
self._autostart_tasks["read_sensor_data"] = ()
|
||||
|
||||
def _process_data(self, data: ...) -> None:
|
||||
...
|
||||
@@ -461,22 +728,22 @@ if __name__ == "__main__":
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. The readout frequency can be updated using the `readout_frequency` attribute.
|
||||
By listing it in the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed.
|
||||
As with all tasks, `pydase` will also generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task.
|
||||
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. By adding it to the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed.
|
||||
As with all tasks, `pydase` will generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task. The readout frequency can be updated using the `readout_frequency` attribute.
|
||||
|
||||
## Understanding Units in pydase
|
||||
|
||||
`pydase` integrates with the [`pint`](https://pint.readthedocs.io/en/stable/) package to allow you to work with physical quantities within your service. This enables you to define attributes with units, making your service more expressive and ensuring consistency in the handling of physical quantities.
|
||||
|
||||
You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality. These quantities can be set and accessed like regular attributes, and `pydase` will automatically handle the conversion between floats and quantities with units.
|
||||
You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
from pydase import DataService, Server
|
||||
|
||||
import pydase.units as u
|
||||
from pydase import DataService, Server
|
||||
|
||||
|
||||
class ServiceClass(DataService):
|
||||
@@ -488,17 +755,15 @@ class ServiceClass(DataService):
|
||||
return self._current
|
||||
|
||||
@current.setter
|
||||
def current(self, value: Any) -> None:
|
||||
def current(self, value: u.Quantity) -> None:
|
||||
self._current = value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = ServiceClass()
|
||||
|
||||
# You can just set floats to the Quantity objects. The DataService __setattr__ will
|
||||
# automatically convert this
|
||||
service.voltage = 10.0
|
||||
service.current = 1.5
|
||||
service.voltage = 10.0 * u.units.V
|
||||
service.current = 1.5 * u.units.mA
|
||||
|
||||
Server(service).run()
|
||||
```
|
||||
@@ -531,6 +796,101 @@ if __name__ == "__main__":
|
||||
|
||||
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
|
||||
|
||||
## Configuring pydase via Environment Variables
|
||||
|
||||
Configuring `pydase` through environment variables enhances flexibility, security, and reusability. This approach allows for easy adaptation of services across different environments without code changes, promoting scalability and maintainability. With that, it simplifies deployment processes and facilitates centralized configuration management. Moreover, environment variables enable separation of configuration from code, aiding in secure and collaborative development.
|
||||
|
||||
`pydase` offers various configurable options:
|
||||
|
||||
- **`ENVIRONMENT`**: Sets the operation mode to either "development" or "production". Affects logging behaviour (see [logging section](#logging-in-pydase)).
|
||||
- **`SERVICE_CONFIG_DIR`**: Specifies the directory for service configuration files, like `web_settings.json`. This directory can also be used to hold user-defined configuration files. Default is the `config` folder in the service root folder. The variable can be accessed through:
|
||||
|
||||
```python
|
||||
import pydase.config
|
||||
pydase.config.ServiceConfig().config_dir
|
||||
```
|
||||
|
||||
- **`SERVICE_WEB_PORT`**: Defines the port number for the web server. This has to be different for each services running on the same host. Default is 8001.
|
||||
- **`SERVICE_RPC_PORT`**: Defines the port number for the rpc server. This has to be different for each services running on the same host. Default is 18871.
|
||||
- **`GENERATE_WEB_SETTINGS`**: When set to true, generates / updates the `web_settings.json` file. If the file already exists, only new entries are appended.
|
||||
|
||||
Some of those settings can also be altered directly in code when initializing the server:
|
||||
|
||||
```python
|
||||
import pathlib
|
||||
|
||||
from pydase import Server
|
||||
from your_service_module import YourService
|
||||
|
||||
|
||||
server = Server(
|
||||
YourService(),
|
||||
web_port=8080,
|
||||
rpc_port=18880,
|
||||
config_dir=pathlib.Path("other_config_dir"), # note that you need to provide an argument of type pathlib.Path
|
||||
generate_web_settings=True
|
||||
).run()
|
||||
```
|
||||
|
||||
## Customizing the Web Interface
|
||||
|
||||
### Enhancing the Web Interface Style with Custom CSS
|
||||
|
||||
`pydase` allows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
|
||||
|
||||
Here's how you can use this feature:
|
||||
|
||||
1. Prepare your custom CSS file with the desired styles.
|
||||
|
||||
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
|
||||
|
||||
```python
|
||||
from pydase import Server, DataService
|
||||
|
||||
class MyService(DataService):
|
||||
# ... your service definition ...
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
server = Server(service, css="path/to/your/custom.css").run()
|
||||
```
|
||||
|
||||
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
|
||||
|
||||
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
|
||||
|
||||
### Tailoring Frontend Component Layout
|
||||
|
||||
`pydase` enables users to customize the frontend layout via the `web_settings.json` file. Each key in the file corresponds to the full access path of public attributes, properties, and methods of the exposed service, using dot-notation.
|
||||
|
||||
- **Custom Display Names**: Modify the `"displayName"` value in the file to change how each component appears in the frontend.
|
||||
- **Control Component Visibility**: Utilize the `"display"` key-value pair to control whether a component is rendered in the frontend. Set the value to `true` to make the component visible or `false` to hide it.
|
||||
<!-- - **Adjustable Component Order**: The `"displayOrder"` values determine the order of components. Alter these values to rearrange the components as desired. -->
|
||||
|
||||
The `web_settings.json` file will be stored in the directory specified by `SERVICE_CONFIG_DIR`. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](#configuring-pydase-via-environment-variables).
|
||||
|
||||
### Specifying a Custom Frontend Source
|
||||
|
||||
To further personalize your web interface, you can provide `pydase` with a custom frontend GUI. To do so, you can use the `frontend_src` keyword in the `pydase.Server`:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
import pydase
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
# Service definition
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
pydase.Server(
|
||||
service,
|
||||
frontend_src=Path("path/to/your/frontend/directory"),
|
||||
).run()
|
||||
```
|
||||
|
||||
## Logging in pydase
|
||||
|
||||
The `pydase` library organizes its loggers on a per-module basis, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library.
|
||||
|
||||
@@ -18,7 +18,7 @@ For example, for a `Image` component, create a file named `image.py`.
|
||||
|
||||
### Step 2: Define the Backend Class
|
||||
|
||||
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain methods which can be used to interact with the component from the backend.
|
||||
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain (public) methods which you can provide for the user to interact with the component from the backend (or python clients).
|
||||
|
||||
For the `Image` component, the class may look like this:
|
||||
|
||||
@@ -31,21 +31,25 @@ from pydase.data_service.data_service import DataService
|
||||
class Image(DataService):
|
||||
def __init__(
|
||||
self,
|
||||
image_representation: bytes = b"",
|
||||
) -> None:
|
||||
self.image_representation = image_representation
|
||||
super().__init__()
|
||||
self._value: str = ""
|
||||
self._format: str = ""
|
||||
|
||||
# need to decode the bytes
|
||||
def __setattr__(self, __name: str, __value: Any) -> None:
|
||||
if __name == "value":
|
||||
if isinstance(__value, bytes):
|
||||
__value = __value.decode()
|
||||
return super().__setattr__(__name, __value)
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self._value
|
||||
|
||||
@property
|
||||
def format(self) -> str:
|
||||
return self._format
|
||||
|
||||
def load_from_path(self, path: Path | str) -> None:
|
||||
# changing self._value and self._format
|
||||
...
|
||||
```
|
||||
|
||||
So, changing the `image_representation` will push the updated value to the browsers connected to the service.
|
||||
So, calling `load_from_path` will push the updated value and format to the browsers clients connected to the service.
|
||||
|
||||
### Step 3: Register the Backend Class
|
||||
|
||||
@@ -85,10 +89,11 @@ def test_Image(capsys: CaptureFixture) -> None:
|
||||
class ServiceClass(DataService):
|
||||
image = Image()
|
||||
|
||||
service = ServiceClass()
|
||||
# ...
|
||||
```
|
||||
service_instance = ServiceClass()
|
||||
|
||||
service_instance.image.load_from_path("<path/to/image>.png")
|
||||
assert service_instance.image.format == "PNG"
|
||||
```
|
||||
|
||||
## Adding a Frontend Component to `pydase`
|
||||
|
||||
@@ -107,34 +112,41 @@ Write the React component code, following the structure and patterns used in exi
|
||||
For example, for the `Image` component, a template could look like this:
|
||||
|
||||
```tsx
|
||||
import { setAttribute, runMethod } from '../socket'; // use this when your component should sets values of attributes
|
||||
// or runs a method, respectively
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Card, Collapse, Image } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface ImageComponentProps {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
type ImageComponentProps = {
|
||||
name: string; // needed to create the fullAccessPath
|
||||
parentPath: string; // needed to create the fullAccessPath
|
||||
readOnly: boolean; // component changable through frontend?
|
||||
docString: string; // contains docstring of your component
|
||||
displayName: string; // name defined in the web_settings.json
|
||||
id: string; // unique identifier - created from fullAccessPath
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
// Define your component specific props here
|
||||
changeCallback?: ( // function used to communicate changes to the backend
|
||||
value: unknown,
|
||||
attributeName?: string,
|
||||
prefix?: string,
|
||||
callback?: (ack: unknown) => void
|
||||
) => void;
|
||||
// component-specific properties
|
||||
value: string;
|
||||
format: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
const { name, parentPath, value, docString, format, addNotification } = props;
|
||||
const { value, docString, format, addNotification, displayName, id } = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
|
||||
const fullAccessPath = parentPath.concat('.' + name);
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const fullAccessPath = [props.parentPath, props.name]
|
||||
.filter((element) => element)
|
||||
.join('.');
|
||||
|
||||
// Your component logic here
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
@@ -142,13 +154,11 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
|
||||
// This will trigger a notification if notifications are enabled.
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [props.value]);
|
||||
|
||||
// Your component logic here
|
||||
|
||||
return (
|
||||
<div className={'imageComponent'} id={id}>
|
||||
<div className="component imageComponent" id={id}>
|
||||
{/* Add the Card and Collapse components here if you want to be able to expand and
|
||||
collapse your component. */}
|
||||
<Card>
|
||||
@@ -156,14 +166,15 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ cursor: 'pointer' }} // Change cursor style on hover
|
||||
>
|
||||
{name} {open ? <ChevronDown /> : <ChevronRight />}
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
{open ? <ChevronDown /> : <ChevronRight />}
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
<Card.Body>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<p>Render count: {renderCount.current}</p>
|
||||
)}
|
||||
<DocStringComponent docString={docString} />
|
||||
{/* Your component TSX here */}
|
||||
</Card.Body>
|
||||
</Collapse>
|
||||
@@ -175,56 +186,98 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
|
||||
### Step 3: Emitting Updates to the Backend
|
||||
|
||||
React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for smooth communication of these changes. To handle updates, we primarily use two events: `setAttribute` for updating attributes, and `runMethod` for executing backend methods. Below is a detailed guide on how to emit these events from your frontend component:
|
||||
React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for communicating these changes.<br>
|
||||
There are two different events a component might want to trigger: updating an attribute or triggering a method. Below is a guide on how to emit these events from your frontend component:
|
||||
|
||||
1. **Setup for emitting events**:
|
||||
First, ensure you've imported the necessary functions from the `socket` module for both updating attributes and executing methods:
|
||||
1. **Updating Attributes**
|
||||
|
||||
Updating the value of an attribute or property in the backend is a very common requirement. However, we want to define components in a reusable way, i.e. they can be linked to the backend but also be used without emitting change events.<br>
|
||||
This is why we pass a `changeCallback` function as a prop to the component which it can use to communicate changes. If no function is passed, the component can be used in forms, for example.
|
||||
|
||||
The `changeCallback` function takes the following arguments:
|
||||
|
||||
- `value`: the new value for the attribute, which must match the backend attribute type.
|
||||
- `attributeName`: the name of the attribute within the `DataService` instance to update. Defaults to the `name` prop of the component.
|
||||
- `prefix`: the access path for the parent object of the attribute to be updated. Defaults to the `parentPath` prop of the component.
|
||||
- `callback`: the function that will be called when the server sends an acknowledgement. Defaults to `undefined`
|
||||
|
||||
For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
|
||||
|
||||
```tsx
|
||||
import { setAttribute, runMethod } from '../socket';
|
||||
```
|
||||
// file: frontend/src/components/ButtonComponent.tsx
|
||||
// ... (import statements)
|
||||
|
||||
2. **Event Parameters**:
|
||||
|
||||
- When using **`setAttribute`**, we send three main pieces of data:
|
||||
- `name`: The name of the attribute within the `DataService` instance to update.
|
||||
- `parentPath`: The access path for the parent object of the attribute to be updated.
|
||||
- `value`: The new value for the attribute, which must match the backend attribute type.
|
||||
- For **`runMethod`**, the parameters are slightly different:
|
||||
- `name`: The name of the method to be executed in the backend.
|
||||
- `parentPath`: Similar to `setAttribute`, it's the access path to the object containing the method.
|
||||
- `kwargs`: A dictionary of keyword arguments that the method requires.
|
||||
|
||||
3. **Implementation**:
|
||||
|
||||
For illustation, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
|
||||
|
||||
```tsx
|
||||
import { setAttribute } from '../socket';
|
||||
// ... (other imports)
|
||||
type ButtonComponentProps = {
|
||||
// ...
|
||||
changeCallback?: (
|
||||
value: unknown,
|
||||
attributeName?: string,
|
||||
prefix?: string,
|
||||
callback?: (ack: unknown) => void
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
const {
|
||||
// ...
|
||||
const { name, parentPath, value } = props;
|
||||
changeCallback = () => {},
|
||||
} = props;
|
||||
|
||||
const setChecked = (checked: boolean) => {
|
||||
setAttribute(name, parentPath, checked);
|
||||
changeCallback(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
checked={value}
|
||||
value={parentPath}
|
||||
// ... other props
|
||||
onChange={(e) => setChecked(e.currentTarget.checked)}>
|
||||
<p>{name}</p>
|
||||
{/* component TSX */}
|
||||
</ToggleButton>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `setAttribute`.
|
||||
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `changeCallback`.
|
||||
|
||||
2. **Triggering Methods**
|
||||
|
||||
To trigger method through your component, you can either use the `MethodComponent` (which will render a button in the frontend), or use the low-level `runMethod` function. Its parameters are slightly different to the `changeCallback` function:
|
||||
|
||||
- `name`: the name of the method to be executed in the backend.
|
||||
- `parentPath`: the access path to the object containing the method.
|
||||
- `kwargs`: a dictionary of keyword arguments that the method requires.
|
||||
|
||||
To see how to use the `MethodComponent` in your component, have a look at the `DeviceConnection.tsx` file. Here is an example that demonstrates the usage of the `runMethod` function (also, have a look at the `MethodComponent.tsx` file):
|
||||
|
||||
```tsx
|
||||
import { runMethod } from '../socket';
|
||||
// ... (other imports)
|
||||
|
||||
type ComponentProps = {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
// ...
|
||||
};
|
||||
|
||||
export const Component = React.memo((props: ComponentProps) => {
|
||||
const {
|
||||
name,
|
||||
parentPath,
|
||||
// ...
|
||||
} = props;
|
||||
|
||||
// ...
|
||||
|
||||
const someFunction = () => {
|
||||
// ...
|
||||
runMethod(name, parentPath, {});
|
||||
};
|
||||
|
||||
return (
|
||||
{/* component TSX */}
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Step 4: Add the New Component to the GenericComponent
|
||||
|
||||
@@ -271,15 +324,17 @@ Inside the `GenericComponent` function, add a new conditional branch to render t
|
||||
<ImageComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
readOnly={attribute.readonly}
|
||||
docString={attribute.doc}
|
||||
docString={attribute.value['value'].doc}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
// Add any other specific props for the ImageComponent here
|
||||
value={attribute.value['value']['value'] as string}
|
||||
format={attribute.value['format']['value'] as string}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
} else if (...) {
|
||||
// other code
|
||||
```
|
||||
|
||||
@@ -294,12 +349,14 @@ For example, updating an `Image` component corresponds to setting a very long st
|
||||
To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this:
|
||||
|
||||
```tsx
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed.`);
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [props.value]);
|
||||
```
|
||||
|
||||
However, you might want to use the `addNotification` at different places. For an example, see the [MethodComponent](../../frontend/src/components/MethodComponent.tsx).
|
||||
However, you might want to use the `addNotification` at different places. For an example, see the `MethodComponent`.
|
||||
**Note**: you can specify the notification level by passing a string of type `LevelName` (one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'). The default value is 'DEBUG'.
|
||||
|
||||
### Step 6: Write Tests for the Component (TODO)
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 11 KiB |
BIN
docs/images/DeviceConnection_component.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 13 KiB |
BIN
docs/images/method_components.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -5,11 +5,6 @@ body {
|
||||
input.instantUpdate {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.numberComponentButton {
|
||||
padding: 0.15em 6px !important;
|
||||
font-size: 0.70rem !important;
|
||||
}
|
||||
.navbarOffset {
|
||||
padding-top: 60px !important;
|
||||
}
|
||||
@@ -17,26 +12,41 @@ input.instantUpdate {
|
||||
position: fixed !important;
|
||||
padding: 5px;
|
||||
}
|
||||
.debugToast, .infoToast {
|
||||
.debugToast,
|
||||
.infoToast {
|
||||
background-color: rgba(114, 214, 253, 0.5) !important;
|
||||
}
|
||||
.warningToast {
|
||||
background-color: rgba(255, 181, 44, 0.603) !important;
|
||||
}
|
||||
.errorToast, .criticalToast {
|
||||
.errorToast,
|
||||
.criticalToast {
|
||||
background-color: rgba(216, 41, 18, 0.678) !important;
|
||||
}
|
||||
.buttonComponent {
|
||||
.component {
|
||||
position: relative;
|
||||
float: left !important;
|
||||
margin-right: 10px !important;
|
||||
padding: 5px !important;
|
||||
z-index: 1;
|
||||
}
|
||||
.stringComponent {
|
||||
.dataServiceComponent {
|
||||
width: 100%;
|
||||
}
|
||||
.deviceConnectionComponent {
|
||||
position: relative;
|
||||
float: left !important;
|
||||
margin-right: 10px !important;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
.numberComponent {
|
||||
float: left !important;
|
||||
margin-right: 10px !important;
|
||||
width: 270px !important;
|
||||
.overlayContent {
|
||||
position: absolute;
|
||||
inset: 5px; /* (see https://developer.mozilla.org/en-US/docs/Web/CSS/inset) */
|
||||
background: rgba(155, 155, 155, 0.75);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column; /* Stack children vertically */
|
||||
border-radius: var(--bs-border-radius);
|
||||
border: var(--bs-border-width) solid var(--bs-border-color-translucent)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
|
||||
import { hostname, port, socket } from './socket';
|
||||
import {
|
||||
DataServiceComponent,
|
||||
DataServiceJSON
|
||||
} from './components/DataServiceComponent';
|
||||
import './App.css';
|
||||
import {
|
||||
Notifications,
|
||||
@@ -12,7 +8,9 @@ import {
|
||||
LevelName
|
||||
} from './components/NotificationsComponent';
|
||||
import { ConnectionToast } from './components/ConnectionToast';
|
||||
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
|
||||
import { setNestedValueByPath, State } from './utils/stateUtils';
|
||||
import { WebSettingsContext, WebSetting } from './WebSettings';
|
||||
import { SerializedValue, GenericComponent } from './components/GenericComponent';
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_DATA'; data: State }
|
||||
@@ -34,7 +32,13 @@ const reducer = (state: State, action: Action): State => {
|
||||
case 'SET_DATA':
|
||||
return action.data;
|
||||
case 'UPDATE_ATTRIBUTE': {
|
||||
return setNestedValueByPath(state, action.fullAccessPath, action.newValue);
|
||||
if (state === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error();
|
||||
@@ -42,18 +46,13 @@ const reducer = (state: State, action: Action): State => {
|
||||
};
|
||||
const App = () => {
|
||||
const [state, dispatch] = useReducer(reducer, null);
|
||||
const stateRef = useRef(state); // Declare a reference to hold the current state
|
||||
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [connectionStatus, setConnectionStatus] = useState('connecting');
|
||||
|
||||
// Keep the state reference up to date
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
// Allow the user to add a custom css file
|
||||
fetch(`http://${hostname}:${port}/custom.css`)
|
||||
@@ -74,6 +73,9 @@ const App = () => {
|
||||
fetch(`http://${hostname}:${port}/service-properties`)
|
||||
.then((response) => response.json())
|
||||
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
|
||||
fetch(`http://${hostname}:${port}/web-settings`)
|
||||
.then((response) => response.json())
|
||||
.then((data: Record<string, WebSetting>) => setWebSettings(data));
|
||||
setConnectionStatus('connected');
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
@@ -184,12 +186,13 @@ const App = () => {
|
||||
</Offcanvas>
|
||||
|
||||
<div className="App navbarOffset">
|
||||
<DataServiceComponent
|
||||
name={''}
|
||||
props={state as DataServiceJSON}
|
||||
<WebSettingsContext.Provider value={webSettings}>
|
||||
<GenericComponent
|
||||
attribute={state as SerializedValue}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
</WebSettingsContext.Provider>
|
||||
</div>
|
||||
<ConnectionToast connectionStatus={connectionStatus} />
|
||||
</>
|
||||
|
||||
9
frontend/src/WebSettings.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const WebSettingsContext = createContext<Record<string, WebSetting>>({});
|
||||
|
||||
export type WebSetting = {
|
||||
displayName: string;
|
||||
display: boolean;
|
||||
index: number;
|
||||
};
|
||||
@@ -1,55 +1,48 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { InputGroup, Form, Button } from 'react-bootstrap';
|
||||
import { Form, Button, InputGroup } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface AsyncMethodProps {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
parameters: Record<string, string>;
|
||||
value: Record<string, string>;
|
||||
type AsyncMethodProps = {
|
||||
fullAccessPath: string;
|
||||
value: 'RUNNING' | null;
|
||||
docString?: string;
|
||||
hideOutput?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
|
||||
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
const { name, parentPath, docString, value: runningTask, addNotification } = props;
|
||||
const {
|
||||
fullAccessPath,
|
||||
docString,
|
||||
value: runningTask,
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
|
||||
// Conditional rendering based on the 'render' prop.
|
||||
if (!props.render) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const formRef = useRef(null);
|
||||
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
|
||||
const name = fullAccessPath.split('.').at(-1);
|
||||
const parentPath = fullAccessPath.slice(0, -(name.length + 1));
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
|
||||
// updates the value of each form control that has a matching name in the
|
||||
// runningTask object
|
||||
if (runningTask) {
|
||||
const formElement = formRef.current;
|
||||
if (formElement) {
|
||||
Object.entries(runningTask).forEach(([name, value]) => {
|
||||
const inputElement = formElement.elements.namedItem(name);
|
||||
if (inputElement) {
|
||||
inputElement.value = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [runningTask]);
|
||||
|
||||
useEffect(() => {
|
||||
let message: string;
|
||||
|
||||
if (runningTask === null) {
|
||||
message = `${parentPath}.${name} task was stopped.`;
|
||||
message = `${fullAccessPath} task was stopped.`;
|
||||
} else {
|
||||
const runningTaskEntries = Object.entries(runningTask)
|
||||
.map(([key, value]) => `${key}: "${value}"`)
|
||||
.join(', ');
|
||||
|
||||
message = `${parentPath}.${name} was started with parameters { ${runningTaskEntries} }.`;
|
||||
message = `${fullAccessPath} was started.`;
|
||||
}
|
||||
addNotification(message);
|
||||
}, [props.value]);
|
||||
@@ -57,52 +50,32 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
const execute = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
let method_name: string;
|
||||
const kwargs: Record<string, unknown> = {};
|
||||
|
||||
if (runningTask !== undefined && runningTask !== null) {
|
||||
method_name = `stop_${name}`;
|
||||
} else {
|
||||
Object.keys(props.parameters).forEach(
|
||||
(name) => (kwargs[name] = event.target[name].value)
|
||||
);
|
||||
method_name = `start_${name}`;
|
||||
}
|
||||
|
||||
runMethod(method_name, parentPath, kwargs);
|
||||
const accessPath = [parentPath, method_name].filter((element) => element).join('.');
|
||||
runMethod(accessPath);
|
||||
};
|
||||
|
||||
const args = Object.entries(props.parameters).map(([name, type], index) => {
|
||||
const form_name = `${name} (${type})`;
|
||||
const value = runningTask && runningTask[name];
|
||||
const isRunning = value !== undefined && value !== null;
|
||||
|
||||
return (
|
||||
<InputGroup key={index}>
|
||||
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name={name}
|
||||
defaultValue={isRunning ? value : ''}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="align-items-center asyncMethodComponent" id={id}>
|
||||
<div className="component asyncMethodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<h5>
|
||||
Function: {name}
|
||||
<DocStringComponent docString={docString} />
|
||||
</h5>
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
{args}
|
||||
<Button id={`button-${id}`} name={name} value={parentPath} type="submit">
|
||||
{runningTask ? 'Stop' : 'Start'}
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
<Button id={`button-${id}`} type="submit">
|
||||
{runningTask === 'RUNNING' ? 'Stop ' : 'Start '}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { ToggleButton } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface ButtonComponentProps {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
type ButtonComponentProps = {
|
||||
fullAccessPath: string;
|
||||
value: boolean;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
mapping?: [string, string]; // Enforce a tuple of two strings
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
const { name, parentPath, value, readOnly, docString, mapping, addNotification } =
|
||||
props;
|
||||
const buttonName = mapping ? (value ? mapping[0] : mapping[1]) : name;
|
||||
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
|
||||
const {
|
||||
value,
|
||||
fullAccessPath,
|
||||
readOnly,
|
||||
docString,
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
|
||||
@@ -28,29 +36,29 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const setChecked = (checked: boolean) => {
|
||||
setAttribute(name, parentPath, checked);
|
||||
changeCallback(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'buttonComponent'} id={id}>
|
||||
<div className={'component buttonComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
|
||||
<DocStringComponent docString={docString} />
|
||||
<ToggleButton
|
||||
id={`toggle-check-${id}`}
|
||||
type="checkbox"
|
||||
variant={value ? 'success' : 'secondary'}
|
||||
checked={value}
|
||||
value={parentPath}
|
||||
value={displayName}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setChecked(e.currentTarget.checked)}>
|
||||
{buttonName}
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface ColouredEnumComponentProps {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
value: string;
|
||||
docString?: string;
|
||||
readOnly: boolean;
|
||||
enumDict: Record<string, string>;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
|
||||
export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => {
|
||||
const {
|
||||
name,
|
||||
parentPath: parentPath,
|
||||
value,
|
||||
docString,
|
||||
enumDict,
|
||||
readOnly,
|
||||
addNotification
|
||||
} = props;
|
||||
const renderCount = useRef(0);
|
||||
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setAttribute(name, parentPath, newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<DocStringComponent docString={docString} />
|
||||
<Row>
|
||||
<Col className="d-flex align-items-center">
|
||||
<InputGroup.Text>{name}</InputGroup.Text>
|
||||
{readOnly ? (
|
||||
// Display the Form.Control when readOnly is true
|
||||
<Form.Control
|
||||
value={value}
|
||||
disabled={true}
|
||||
style={{ backgroundColor: enumDict[value] }}
|
||||
/>
|
||||
) : (
|
||||
// Display the Form.Select when readOnly is false
|
||||
<Form.Select
|
||||
aria-label="coloured-enum-select"
|
||||
value={value}
|
||||
style={{ backgroundColor: enumDict[value] }}
|
||||
onChange={(event) => handleValueChange(event.target.value)}>
|
||||
{Object.entries(enumDict).map(([key]) => (
|
||||
<option key={key} value={key}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2,62 +2,58 @@ import { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Card, Collapse } from 'react-bootstrap';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { Attribute, GenericComponent } from './GenericComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
type DataServiceProps = {
|
||||
name: string;
|
||||
props: DataServiceJSON;
|
||||
parentPath?: string;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type DataServiceJSON = Record<string, Attribute>;
|
||||
export type DataServiceJSON = Record<string, SerializedValue>;
|
||||
|
||||
export const DataServiceComponent = React.memo(
|
||||
({
|
||||
name,
|
||||
props,
|
||||
parentPath = 'DataService',
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
}: DataServiceProps) => {
|
||||
({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
let fullAccessPath = parentPath;
|
||||
if (name) {
|
||||
fullAccessPath = parentPath.concat('.' + name);
|
||||
}
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
|
||||
if (displayName !== '') {
|
||||
return (
|
||||
<div className="dataServiceComponent" id={id}>
|
||||
<Card className="mb-3">
|
||||
<Card.Header
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ cursor: 'pointer' }} // Change cursor style on hover
|
||||
>
|
||||
{fullAccessPath} {open ? <ChevronDown /> : <ChevronRight />}
|
||||
<div className="component dataServiceComponent" id={id}>
|
||||
<Card>
|
||||
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
|
||||
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
<Card.Body>
|
||||
{Object.entries(props).map(([key, value]) => {
|
||||
return (
|
||||
{Object.entries(props).map(([key, value]) => (
|
||||
<GenericComponent
|
||||
key={key}
|
||||
attribute={value}
|
||||
name={key}
|
||||
parentPath={fullAccessPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</Card.Body>
|
||||
</Collapse>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="component dataServiceComponent" id={id}>
|
||||
{Object.entries(props).map(([key, value]) => (
|
||||
<GenericComponent
|
||||
key={key}
|
||||
attribute={value}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
54
frontend/src/components/DeviceConnection.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
|
||||
import { MethodComponent } from './MethodComponent';
|
||||
|
||||
type DeviceConnectionProps = {
|
||||
fullAccessPath: string;
|
||||
props: DataServiceJSON;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const DeviceConnectionComponent = React.memo(
|
||||
({
|
||||
fullAccessPath,
|
||||
props,
|
||||
isInstantUpdate,
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
}: DeviceConnectionProps) => {
|
||||
const { connected, connect, ...updatedProps } = props;
|
||||
const connectedVal = connected.value;
|
||||
|
||||
return (
|
||||
<div className="deviceConnectionComponent" id={id}>
|
||||
{!connectedVal && (
|
||||
<div className="overlayContent">
|
||||
<div>
|
||||
{displayName != '' ? displayName : 'Device'} is currently not available!
|
||||
</div>
|
||||
<MethodComponent
|
||||
fullAccessPath={`${fullAccessPath}.connect`}
|
||||
docString={connect.doc}
|
||||
addNotification={addNotification}
|
||||
displayName={'reconnect'}
|
||||
id={id + '-connect'}
|
||||
render={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DataServiceComponent
|
||||
props={updatedProps}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
|
||||
import React from 'react';
|
||||
|
||||
interface DocStringProps {
|
||||
type DocStringProps = {
|
||||
docString?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const DocStringComponent = React.memo((props: DocStringProps) => {
|
||||
const { docString } = props;
|
||||
|
||||
@@ -1,61 +1,107 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface EnumComponentProps {
|
||||
export type EnumSerialization = {
|
||||
type: 'Enum' | 'ColouredEnum';
|
||||
full_access_path: string;
|
||||
name: string;
|
||||
parentPath: string;
|
||||
value: string;
|
||||
docString?: string;
|
||||
enumDict: Record<string, string>;
|
||||
readonly: boolean;
|
||||
doc?: string | null;
|
||||
enum: Record<string, string>;
|
||||
};
|
||||
|
||||
type EnumComponentProps = {
|
||||
attribute: EnumSerialization;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
displayName: string;
|
||||
id: string;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
};
|
||||
|
||||
export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
const { attribute, addNotification, displayName, id } = props;
|
||||
const {
|
||||
name,
|
||||
parentPath: parentPath,
|
||||
full_access_path: fullAccessPath,
|
||||
value,
|
||||
docString,
|
||||
enumDict,
|
||||
addNotification
|
||||
} = props;
|
||||
doc: docString,
|
||||
enum: enumDict,
|
||||
readonly: readOnly
|
||||
} = attribute;
|
||||
|
||||
let { changeCallback } = props;
|
||||
if (changeCallback === undefined) {
|
||||
changeCallback = (value: SerializedValue) => {
|
||||
setEnumValue(() => {
|
||||
return String(value.value);
|
||||
});
|
||||
};
|
||||
}
|
||||
const renderCount = useRef(0);
|
||||
const [enumValue, setEnumValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setAttribute(name, parentPath, newValue);
|
||||
};
|
||||
setEnumValue(() => {
|
||||
return value;
|
||||
});
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={'enumComponent'} id={parentPath.concat('.' + name)}>
|
||||
<div className={'component enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<DocStringComponent docString={docString} />
|
||||
<Row>
|
||||
<Col className="d-flex align-items-center">
|
||||
<InputGroup.Text>{name}</InputGroup.Text>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
|
||||
{readOnly ? (
|
||||
// Display the Form.Control when readOnly is true
|
||||
<Form.Control
|
||||
value={enumDict[enumValue]}
|
||||
name={fullAccessPath}
|
||||
disabled={true}
|
||||
/>
|
||||
) : (
|
||||
// Display the Form.Select when readOnly is false
|
||||
<Form.Select
|
||||
aria-label="Default select example"
|
||||
value={value}
|
||||
onChange={(event) => handleValueChange(event.target.value)}>
|
||||
aria-label="example-select"
|
||||
value={enumValue}
|
||||
name={fullAccessPath}
|
||||
style={
|
||||
attribute.type == 'ColouredEnum'
|
||||
? { backgroundColor: enumDict[enumValue] }
|
||||
: {}
|
||||
}
|
||||
onChange={(event) =>
|
||||
changeCallback({
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
enum: enumDict,
|
||||
value: event.target.value,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: attribute.readonly,
|
||||
doc: attribute.doc
|
||||
})
|
||||
}>
|
||||
{Object.entries(enumDict).map(([key, val]) => (
|
||||
<option key={key} value={key}>
|
||||
{key} - {val}
|
||||
{attribute.type == 'ColouredEnum' ? key : val}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { ButtonComponent } from './ButtonComponent';
|
||||
import { NumberComponent } from './NumberComponent';
|
||||
import { SliderComponent } from './SliderComponent';
|
||||
import { EnumComponent } from './EnumComponent';
|
||||
import { EnumComponent, EnumSerialization } from './EnumComponent';
|
||||
import { MethodComponent } from './MethodComponent';
|
||||
import { AsyncMethodComponent } from './AsyncMethodComponent';
|
||||
import { StringComponent } from './StringComponent';
|
||||
import { ListComponent } from './ListComponent';
|
||||
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
|
||||
import { DeviceConnectionComponent } from './DeviceConnection';
|
||||
import { ImageComponent } from './ImageComponent';
|
||||
import { ColouredEnumComponent } from './ColouredEnumComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import { updateValue } from '../socket';
|
||||
|
||||
type AttributeType =
|
||||
| 'str'
|
||||
@@ -21,185 +24,209 @@ type AttributeType =
|
||||
| 'list'
|
||||
| 'method'
|
||||
| 'DataService'
|
||||
| 'DeviceConnection'
|
||||
| 'Enum'
|
||||
| 'NumberSlider'
|
||||
| 'Image'
|
||||
| 'ColouredEnum';
|
||||
|
||||
type ValueType = boolean | string | number | object;
|
||||
export interface Attribute {
|
||||
type ValueType = boolean | string | number | Record<string, unknown>;
|
||||
export type SerializedValue = {
|
||||
type: AttributeType;
|
||||
full_access_path: string;
|
||||
name?: string;
|
||||
value?: ValueType | ValueType[];
|
||||
readonly: boolean;
|
||||
doc?: string | null;
|
||||
parameters?: Record<string, string>;
|
||||
async?: boolean;
|
||||
frontend_render?: boolean;
|
||||
enum?: Record<string, string>;
|
||||
}
|
||||
};
|
||||
type GenericComponentProps = {
|
||||
attribute: Attribute;
|
||||
name: string;
|
||||
parentPath: string;
|
||||
attribute: SerializedValue;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
};
|
||||
|
||||
export const GenericComponent = React.memo(
|
||||
({
|
||||
attribute,
|
||||
name,
|
||||
parentPath,
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
}: GenericComponentProps) => {
|
||||
({ attribute, isInstantUpdate, addNotification }: GenericComponentProps) => {
|
||||
const { full_access_path: fullAccessPath } = attribute;
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
let displayName = fullAccessPath.split('.').at(-1);
|
||||
|
||||
if (webSettings[fullAccessPath]) {
|
||||
if (webSettings[fullAccessPath].display === false) {
|
||||
return null;
|
||||
}
|
||||
if (webSettings[fullAccessPath].displayName) {
|
||||
displayName = webSettings[fullAccessPath].displayName;
|
||||
}
|
||||
}
|
||||
|
||||
function changeCallback(
|
||||
value: SerializedValue,
|
||||
callback: (ack: unknown) => void = undefined
|
||||
) {
|
||||
updateValue(value, callback);
|
||||
}
|
||||
|
||||
if (attribute.type === 'bool') {
|
||||
return (
|
||||
<ButtonComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Boolean(attribute.value)}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'float' || attribute.type === 'int') {
|
||||
return (
|
||||
<NumberComponent
|
||||
name={name}
|
||||
type={attribute.type}
|
||||
parentPath={parentPath}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Number(attribute.value)}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Quantity') {
|
||||
return (
|
||||
<NumberComponent
|
||||
name={name}
|
||||
type="float"
|
||||
parentPath={parentPath}
|
||||
type="Quantity"
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Number(attribute.value['magnitude'])}
|
||||
unit={attribute.value['unit']}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'NumberSlider') {
|
||||
return (
|
||||
<SliderComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value['value'].doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={attribute.value['value']['value']}
|
||||
min={attribute.value['min']['value']}
|
||||
max={attribute.value['max']['value']}
|
||||
stepSize={attribute.value['step_size']['value']}
|
||||
value={attribute.value['value']}
|
||||
min={attribute.value['min']}
|
||||
max={attribute.value['max']}
|
||||
stepSize={attribute.value['step_size']}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Enum') {
|
||||
} else if (attribute.type === 'Enum' || attribute.type === 'ColouredEnum') {
|
||||
return (
|
||||
<EnumComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
value={String(attribute.value)}
|
||||
enumDict={attribute.enum}
|
||||
attribute={attribute as EnumSerialization}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'method') {
|
||||
if (!attribute.async) {
|
||||
return (
|
||||
<MethodComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
parameters={attribute.parameters}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
render={attribute.frontend_render}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<AsyncMethodComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
parameters={attribute.parameters}
|
||||
value={attribute.value as Record<string, string>}
|
||||
value={attribute.value as 'RUNNING' | null}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
render={attribute.frontend_render}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (attribute.type === 'str') {
|
||||
return (
|
||||
<StringComponent
|
||||
name={name}
|
||||
fullAccessPath={fullAccessPath}
|
||||
value={attribute.value as string}
|
||||
readOnly={attribute.readonly}
|
||||
docString={attribute.doc}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DataService') {
|
||||
return (
|
||||
<DataServiceComponent
|
||||
name={name}
|
||||
props={attribute.value as DataServiceJSON}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DeviceConnection') {
|
||||
return (
|
||||
<DeviceConnectionComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
props={attribute.value as DataServiceJSON}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'list') {
|
||||
return (
|
||||
<ListComponent
|
||||
name={name}
|
||||
value={attribute.value as Attribute[]}
|
||||
value={attribute.value as SerializedValue[]}
|
||||
docString={attribute.doc}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Image') {
|
||||
return (
|
||||
<ImageComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
value={attribute.value['value']['value'] as string}
|
||||
readOnly={attribute.readonly}
|
||||
docString={attribute.doc}
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value['value'].doc}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
addNotification={addNotification}
|
||||
// Add any other specific props for the ImageComponent here
|
||||
value={attribute.value['value']['value'] as string}
|
||||
format={attribute.value['format']['value'] as string}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'ColouredEnum') {
|
||||
console.log(attribute);
|
||||
return (
|
||||
<ColouredEnumComponent
|
||||
name={name}
|
||||
parentPath={parentPath}
|
||||
docString={attribute.doc}
|
||||
value={String(attribute.value)}
|
||||
readOnly={attribute.readonly}
|
||||
enumDict={attribute.enum}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <div key={name}>{name}</div>;
|
||||
return <div key={fullAccessPath}>{fullAccessPath}</div>;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,53 +2,49 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Card, Collapse, Image } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface ImageComponentProps {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
type ImageComponentProps = {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
format: string;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
const { name, parentPath, value, docString, format, addNotification } = props;
|
||||
const { fullAccessPath, value, docString, format, addNotification, displayName, id } =
|
||||
props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const [open, setOpen] = useState(true);
|
||||
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed.`);
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [props.value]);
|
||||
|
||||
return (
|
||||
<div className={'imageComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className="component imageComponent" id={id}>
|
||||
<Card>
|
||||
<Card.Header
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ cursor: 'pointer' }} // Change cursor style on hover
|
||||
>
|
||||
{name} {open ? <ChevronDown /> : <ChevronRight />}
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
{open ? <ChevronDown /> : <ChevronRight />}
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
<Card.Body>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<p>Render count: {renderCount.current}</p>
|
||||
)}
|
||||
<DocStringComponent docString={docString} />
|
||||
{/* Your component JSX here */}
|
||||
{format === '' && value === '' ? (
|
||||
<p>No image set in the backend.</p>
|
||||
) : (
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Attribute, GenericComponent } from './GenericComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface ListComponentProps {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
value: Attribute[];
|
||||
type ListComponentProps = {
|
||||
value: SerializedValue[];
|
||||
docString: string;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
const { name, parentPath, value, docString, isInstantUpdate, addNotification } =
|
||||
props;
|
||||
const { value, docString, isInstantUpdate, addNotification, id } = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
@@ -35,8 +31,6 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
<GenericComponent
|
||||
key={`${name}[${index}]`}
|
||||
attribute={item}
|
||||
name={`${name}[${index}]`}
|
||||
parentPath={parentPath}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
|
||||
@@ -1,110 +1,57 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
|
||||
interface MethodProps {
|
||||
name: string;
|
||||
parentPath: string;
|
||||
parameters: Record<string, string>;
|
||||
type MethodProps = {
|
||||
fullAccessPath: string;
|
||||
docString?: string;
|
||||
hideOutput?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
|
||||
export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
const { name, parentPath, docString, addNotification } = props;
|
||||
const { fullAccessPath, docString, addNotification, displayName, id } = props;
|
||||
|
||||
// Conditional rendering based on the 'render' prop.
|
||||
if (!props.render) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const [hideOutput, setHideOutput] = useState(false);
|
||||
// Add a new state variable to hold the list of function calls
|
||||
const [functionCalls, setFunctionCalls] = useState([]);
|
||||
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
|
||||
const formRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
if (props.hideOutput !== undefined) {
|
||||
setHideOutput(props.hideOutput);
|
||||
}
|
||||
});
|
||||
const triggerNotification = () => {
|
||||
const message = `Method ${fullAccessPath} was triggered.`;
|
||||
|
||||
const triggerNotification = (args: Record<string, string>) => {
|
||||
const argsString = Object.entries(args)
|
||||
.map(([key, value]) => `${key}: "${value}"`)
|
||||
.join(', ');
|
||||
let message = `Method ${parentPath}.${name} was triggered`;
|
||||
|
||||
if (argsString === '') {
|
||||
message += '.';
|
||||
} else {
|
||||
message += ` with arguments {${argsString}}.`;
|
||||
}
|
||||
addNotification(message);
|
||||
};
|
||||
|
||||
const execute = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
runMethod(fullAccessPath);
|
||||
|
||||
const kwargs = {};
|
||||
Object.keys(props.parameters).forEach(
|
||||
(name) => (kwargs[name] = event.target[name].value)
|
||||
);
|
||||
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: kwargs, result: ack }
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
triggerNotification(kwargs);
|
||||
triggerNotification();
|
||||
};
|
||||
|
||||
const args = Object.entries(props.parameters).map(([name, type], index) => {
|
||||
const form_name = `${name} (${type})`;
|
||||
return (
|
||||
<InputGroup key={index}>
|
||||
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
|
||||
<Form.Control type="text" name={name} />
|
||||
</InputGroup>
|
||||
);
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="align-items-center methodComponent" id={id}>
|
||||
<div className="component methodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<h5 onClick={() => setHideOutput(!hideOutput)} style={{ cursor: 'pointer' }}>
|
||||
Function: {name}
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
<Button className="component" variant="primary" type="submit">
|
||||
{`${displayName} `}
|
||||
<DocStringComponent docString={docString} />
|
||||
</h5>
|
||||
<Form onSubmit={execute}>
|
||||
{args}
|
||||
<Button variant="primary" type="submit">
|
||||
Execute
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Collapse in={!hideOutput}>
|
||||
<div id="function-output">
|
||||
{functionCalls.map((call, index) => (
|
||||
<div key={index}>
|
||||
<div style={{ color: 'grey', fontSize: 'small' }}>
|
||||
{Object.entries(call.args)
|
||||
.map(([key, val]) => `${key}=${JSON.stringify(val)}`)
|
||||
.join(', ') +
|
||||
' => ' +
|
||||
JSON.stringify(call.result)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,31 +1,48 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
interface NumberComponentProps {
|
||||
name: string;
|
||||
type: 'float' | 'int';
|
||||
parentPath?: string;
|
||||
export type QuantityObject = {
|
||||
type: 'Quantity';
|
||||
readonly: boolean;
|
||||
value: {
|
||||
magnitude: number;
|
||||
unit: string;
|
||||
};
|
||||
doc?: string;
|
||||
};
|
||||
export type IntObject = {
|
||||
type: 'int';
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
export type FloatObject = {
|
||||
type: 'float';
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
export type NumberObject = IntObject | FloatObject | QuantityObject;
|
||||
|
||||
type NumberComponentProps = {
|
||||
type: 'float' | 'int' | 'Quantity';
|
||||
fullAccessPath: string;
|
||||
value: number;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
isInstantUpdate: boolean;
|
||||
unit?: string;
|
||||
showName?: boolean;
|
||||
customEmitUpdate?: (
|
||||
name: string,
|
||||
parent_path: string,
|
||||
value: number,
|
||||
callback?: (ack: unknown) => void
|
||||
) => void;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
displayName?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
// TODO: highlight the digit that is being changed by setting both selectionStart and
|
||||
// selectionEnd
|
||||
@@ -110,73 +127,15 @@ const handleDeleteKey = (
|
||||
return { value, selectionStart };
|
||||
};
|
||||
|
||||
export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
const {
|
||||
name,
|
||||
parentPath,
|
||||
readOnly,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
unit,
|
||||
addNotification
|
||||
} = props;
|
||||
|
||||
// Whether to show the name infront of the component (false if used with a slider)
|
||||
const showName = props.showName !== undefined ? props.showName : true;
|
||||
// If emitUpdate is passed, use this instead of the setAttribute from the socket
|
||||
// Also used when used with a slider
|
||||
const emitUpdate =
|
||||
props.customEmitUpdate !== undefined ? props.customEmitUpdate : setAttribute;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
// Create a state for the cursor position
|
||||
const [cursorPosition, setCursorPosition] = useState(null);
|
||||
// Create a state for the input string
|
||||
const [inputString, setInputString] = useState(props.value.toString());
|
||||
const fullAccessPath = parentPath.concat('.' + name);
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
|
||||
// Set the cursor position after the component re-renders
|
||||
const inputElement = document.getElementsByName(
|
||||
fullAccessPath
|
||||
)[0] as HTMLInputElement;
|
||||
if (inputElement && cursorPosition !== null) {
|
||||
inputElement.setSelectionRange(cursorPosition, cursorPosition);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Parse the input string to a number for comparison
|
||||
const numericInputString =
|
||||
props.type === 'int' ? parseInt(inputString) : parseFloat(inputString);
|
||||
// Only update the inputString if it's different from the prop value
|
||||
if (props.value !== numericInputString) {
|
||||
setInputString(props.value.toString());
|
||||
}
|
||||
|
||||
// emitting notification
|
||||
let notificationMsg = `${parentPath}.${name} changed to ${props.value}`;
|
||||
if (unit === undefined) {
|
||||
notificationMsg += '.';
|
||||
} else {
|
||||
notificationMsg += ` ${unit}.`;
|
||||
}
|
||||
addNotification(notificationMsg);
|
||||
}, [props.value]);
|
||||
|
||||
const handleNumericKey = (
|
||||
const handleNumericKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
) => {
|
||||
) => {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
if (key === '.' && (value.includes('.') || props.type === 'int')) {
|
||||
if (key === '.' && value.includes('.')) {
|
||||
// Check if value already contains a decimal. If so, ignore input.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Invalid input! Ignoring...');
|
||||
return { value, selectionStart };
|
||||
}
|
||||
@@ -193,7 +152,30 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
}
|
||||
|
||||
return { value: newValue, selectionStart: selectionStart + 1 };
|
||||
};
|
||||
};
|
||||
|
||||
export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
const {
|
||||
fullAccessPath,
|
||||
value,
|
||||
readOnly,
|
||||
type,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
unit,
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
|
||||
// Create a state for the cursor position
|
||||
const [cursorPosition, setCursorPosition] = useState(null);
|
||||
// Create a state for the input string
|
||||
const [inputString, setInputString] = useState(value.toString());
|
||||
const renderCount = useRef(0);
|
||||
const name = fullAccessPath.split('.').at(-1);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const { key, target } = event;
|
||||
if (
|
||||
@@ -236,7 +218,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
));
|
||||
} else if (key === '.') {
|
||||
} else if (key === '.' && (type === 'float' || type === 'Quantity')) {
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
@@ -263,7 +245,20 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
selectionEnd
|
||||
));
|
||||
} else if (key === 'Enter' && !isInstantUpdate) {
|
||||
emitUpdate(name, parentPath, Number(newValue));
|
||||
let updatedValue: number | Record<string, unknown> = Number(newValue);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
console.debug(key);
|
||||
@@ -272,7 +267,20 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
// Update the input value and maintain the cursor position
|
||||
if (isInstantUpdate) {
|
||||
emitUpdate(name, parentPath, Number(newValue));
|
||||
let updatedValue: number | Record<string, unknown> = Number(newValue);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
}
|
||||
|
||||
setInputString(newValue);
|
||||
@@ -284,24 +292,66 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
// If not in "instant update" mode, emit an update when the input field loses focus
|
||||
emitUpdate(name, parentPath, Number(inputString));
|
||||
let updatedValue: number | Record<string, unknown> = Number(inputString);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(inputString),
|
||||
unit: unit
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// Parse the input string to a number for comparison
|
||||
const numericInputString =
|
||||
type === 'int' ? parseInt(inputString) : parseFloat(inputString);
|
||||
// Only update the inputString if it's different from the prop value
|
||||
if (value !== numericInputString) {
|
||||
setInputString(value.toString());
|
||||
}
|
||||
|
||||
// emitting notification
|
||||
let notificationMsg = `${fullAccessPath} changed to ${props.value}`;
|
||||
if (unit === undefined) {
|
||||
notificationMsg += '.';
|
||||
} else {
|
||||
notificationMsg += ` ${unit}.`;
|
||||
}
|
||||
addNotification(notificationMsg);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the cursor position after the component re-renders
|
||||
const inputElement = document.getElementsByName(name)[0] as HTMLInputElement;
|
||||
if (inputElement && cursorPosition !== null) {
|
||||
inputElement.setSelectionRange(cursorPosition, cursorPosition);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="numberComponent" id={id}>
|
||||
<div className="component numberComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<DocStringComponent docString={docString} />
|
||||
<div className="d-flex">
|
||||
<InputGroup>
|
||||
{showName && <InputGroup.Text>{name}</InputGroup.Text>}
|
||||
{displayName && (
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
)}
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
name={fullAccessPath}
|
||||
name={name}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
@@ -309,6 +359,5 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,149 +1,155 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Slider } from '@mui/material';
|
||||
import { NumberComponent } from './NumberComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { NumberComponent, NumberObject } from './NumberComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
|
||||
interface SliderComponentProps {
|
||||
name: string;
|
||||
min: number;
|
||||
max: number;
|
||||
parentPath?: string;
|
||||
value: number;
|
||||
type SliderComponentProps = {
|
||||
fullAccessPath: string;
|
||||
min: NumberObject;
|
||||
max: NumberObject;
|
||||
value: NumberObject;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
stepSize: number;
|
||||
stepSize: NumberObject;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
const renderCount = useRef(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
name,
|
||||
parentPath,
|
||||
fullAccessPath,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
stepSize,
|
||||
readOnly,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
addNotification
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
const fullAccessPath = parentPath.concat('.' + name);
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name} changed to ${value}.`);
|
||||
addNotification(`${fullAccessPath} changed to ${value.value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.min changed to ${min}.`);
|
||||
addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
|
||||
}, [props.min]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.max changed to ${max}.`);
|
||||
addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
|
||||
}, [props.max]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${parentPath}.${name}.stepSize changed to ${stepSize}.`);
|
||||
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
|
||||
}, [props.stepSize]);
|
||||
|
||||
const emitSliderUpdate = (
|
||||
name: string,
|
||||
parentPath: string,
|
||||
value: number,
|
||||
callback?: (ack: unknown) => void,
|
||||
min: number = props.min,
|
||||
max: number = props.max,
|
||||
stepSize: number = props.stepSize
|
||||
) => {
|
||||
setAttribute(
|
||||
name,
|
||||
parentPath,
|
||||
{
|
||||
value: value,
|
||||
min: min,
|
||||
max: max,
|
||||
step_size: stepSize
|
||||
},
|
||||
callback
|
||||
);
|
||||
};
|
||||
const handleOnChange = (event, newNumber: number | number[]) => {
|
||||
// This will never be the case as we do not have a range slider. However, we should
|
||||
// make sure this is properly handled.
|
||||
if (Array.isArray(newNumber)) {
|
||||
newNumber = newNumber[0];
|
||||
}
|
||||
emitSliderUpdate(name, parentPath, newNumber);
|
||||
changeCallback({
|
||||
type: value.type,
|
||||
value: newNumber,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString
|
||||
});
|
||||
};
|
||||
|
||||
const handleValueChange = (newValue: number, valueType: string) => {
|
||||
switch (valueType) {
|
||||
case 'min':
|
||||
emitSliderUpdate(name, parentPath, value, undefined, newValue);
|
||||
break;
|
||||
case 'max':
|
||||
emitSliderUpdate(name, parentPath, value, undefined, min, newValue);
|
||||
break;
|
||||
case 'stepSize':
|
||||
emitSliderUpdate(name, parentPath, value, undefined, min, max, newValue);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const handleValueChange = (
|
||||
newValue: number,
|
||||
name: string,
|
||||
valueObject: NumberObject
|
||||
) => {
|
||||
changeCallback({
|
||||
type: valueObject.type,
|
||||
value: newValue,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly
|
||||
});
|
||||
};
|
||||
|
||||
const deconstructNumberDict = (
|
||||
numberDict: NumberObject
|
||||
): [number, boolean, string | null] => {
|
||||
let numberMagnitude: number;
|
||||
let numberUnit: string | null = null;
|
||||
const numberReadOnly = numberDict.readonly;
|
||||
|
||||
if (numberDict.type === 'int' || numberDict.type === 'float') {
|
||||
numberMagnitude = numberDict.value;
|
||||
} else if (numberDict.type === 'Quantity') {
|
||||
numberMagnitude = numberDict.value.magnitude;
|
||||
numberUnit = numberDict.value.unit;
|
||||
}
|
||||
|
||||
return [numberMagnitude, numberReadOnly, numberUnit];
|
||||
};
|
||||
|
||||
const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value);
|
||||
const [minMagnitude, minReadOnly] = deconstructNumberDict(min);
|
||||
const [maxMagnitude, maxReadOnly] = deconstructNumberDict(max);
|
||||
const [stepSizeMagnitude, stepSizeReadOnly] = deconstructNumberDict(stepSize);
|
||||
|
||||
return (
|
||||
<div className="sliderComponent" id={id}>
|
||||
<div className="component sliderComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
|
||||
<DocStringComponent docString={docString} />
|
||||
<Row>
|
||||
<Col xs="auto" xl="auto">
|
||||
<InputGroup.Text>{name}</InputGroup.Text>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
</Col>
|
||||
<Col xs="5" xl>
|
||||
<Slider
|
||||
style={{ margin: '0px 0px 10px 0px' }}
|
||||
aria-label="Always visible"
|
||||
// valueLabelDisplay="on"
|
||||
disabled={readOnly}
|
||||
value={value}
|
||||
disabled={valueReadOnly}
|
||||
value={valueMagnitude}
|
||||
onChange={(event, newNumber) => handleOnChange(event, newNumber)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={stepSize}
|
||||
min={minMagnitude}
|
||||
max={maxMagnitude}
|
||||
step={stepSizeMagnitude}
|
||||
marks={[
|
||||
{ value: min, label: `${min}` },
|
||||
{ value: max, label: `${max}` }
|
||||
{ value: minMagnitude, label: `${minMagnitude}` },
|
||||
{ value: maxMagnitude, label: `${maxMagnitude}` }
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="3" xl>
|
||||
<NumberComponent
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
parentPath={parentPath}
|
||||
name={name}
|
||||
docString=""
|
||||
readOnly={readOnly}
|
||||
fullAccessPath={`${fullAccessPath}.value`}
|
||||
docString={docString}
|
||||
readOnly={valueReadOnly}
|
||||
type="float"
|
||||
value={value}
|
||||
showName={false}
|
||||
customEmitUpdate={emitSliderUpdate}
|
||||
addNotification={() => null}
|
||||
value={valueMagnitude}
|
||||
unit={valueUnit}
|
||||
addNotification={() => {}}
|
||||
changeCallback={changeCallback}
|
||||
id={id + '-value'}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="auto">
|
||||
@@ -178,8 +184,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
<Form.Label>Min Value</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={min}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'min')}
|
||||
value={minMagnitude}
|
||||
disabled={minReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'min', min)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -187,8 +194,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
<Form.Label>Max Value</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={max}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'max')}
|
||||
value={maxMagnitude}
|
||||
disabled={maxReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'max', max)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -196,8 +204,11 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
<Form.Label>Step Size</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={stepSize}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'stepSize')}
|
||||
value={stepSizeMagnitude}
|
||||
disabled={stepSizeReadOnly}
|
||||
onChange={(e) =>
|
||||
handleValueChange(Number(e.target.value), 'step_size', stepSize)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { setAttribute } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
interface StringComponentProps {
|
||||
name: string;
|
||||
parentPath?: string;
|
||||
type StringComponentProps = {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
const { name, parentPath, readOnly, docString, isInstantUpdate, addNotification } =
|
||||
props;
|
||||
const {
|
||||
fullAccessPath,
|
||||
readOnly,
|
||||
docString,
|
||||
isInstantUpdate,
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
} = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const [inputString, setInputString] = useState(props.value);
|
||||
const fullAccessPath = parentPath.concat('.' + name);
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
@@ -36,41 +43,56 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
if (props.value !== inputString) {
|
||||
setInputString(props.value);
|
||||
}
|
||||
addNotification(`${parentPath}.${name} changed to ${props.value}.`);
|
||||
addNotification(`${fullAccessPath} changed to ${props.value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleChange = (event) => {
|
||||
setInputString(event.target.value);
|
||||
if (isInstantUpdate) {
|
||||
setAttribute(name, parentPath, event.target.value);
|
||||
changeCallback(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !isInstantUpdate) {
|
||||
setAttribute(name, parentPath, inputString);
|
||||
changeCallback({
|
||||
type: 'str',
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
setAttribute(name, parentPath, inputString);
|
||||
changeCallback({
|
||||
type: 'str',
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'stringComponent'} id={id}>
|
||||
<div className="component stringComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<DocStringComponent docString={docString} />
|
||||
<InputGroup>
|
||||
<InputGroup.Text>{name}</InputGroup.Text>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name={fullAccessPath}
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import { SerializedValue } from './components/GenericComponent';
|
||||
import { serializeDict, serializeList } from './utils/serializationUtils';
|
||||
|
||||
export const hostname =
|
||||
process.env.NODE_ENV === 'development' ? `localhost` : window.location.hostname;
|
||||
@@ -9,28 +11,44 @@ console.debug('Websocket: ', URL);
|
||||
|
||||
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
|
||||
|
||||
export const setAttribute = (
|
||||
name: string,
|
||||
parentPath: string,
|
||||
value: unknown,
|
||||
export const updateValue = (
|
||||
serializedObject: SerializedValue,
|
||||
callback?: (ack: unknown) => void
|
||||
) => {
|
||||
if (callback) {
|
||||
socket.emit('set_attribute', { name, parent_path: parentPath, value }, callback);
|
||||
socket.emit(
|
||||
'update_value',
|
||||
{ access_path: serializedObject['full_access_path'], value: serializedObject },
|
||||
callback
|
||||
);
|
||||
} else {
|
||||
socket.emit('set_attribute', { name, parent_path: parentPath, value });
|
||||
socket.emit('update_value', {
|
||||
access_path: serializedObject['full_access_path'],
|
||||
value: serializedObject
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const runMethod = (
|
||||
name: string,
|
||||
parentPath: string,
|
||||
kwargs: Record<string, unknown>,
|
||||
accessPath: string,
|
||||
args: unknown[] = [],
|
||||
kwargs: Record<string, unknown> = {},
|
||||
callback?: (ack: unknown) => void
|
||||
) => {
|
||||
const serializedArgs = serializeList(args);
|
||||
const serializedKwargs = serializeDict(kwargs);
|
||||
|
||||
if (callback) {
|
||||
socket.emit('run_method', { name, parent_path: parentPath, kwargs }, callback);
|
||||
socket.emit(
|
||||
'trigger_method',
|
||||
{ access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs },
|
||||
callback
|
||||
);
|
||||
} else {
|
||||
socket.emit('run_method', { name, parent_path: parentPath, kwargs });
|
||||
socket.emit('trigger_method', {
|
||||
access_path: accessPath,
|
||||
args: serializedArgs,
|
||||
kwargs: serializedKwargs
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
101
frontend/src/utils/serializationUtils.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
const serializePrimitive = (
|
||||
obj: number | boolean | string | null,
|
||||
accessPath: string
|
||||
) => {
|
||||
let type: string;
|
||||
|
||||
if (typeof obj === 'number') {
|
||||
type = Number.isInteger(obj) ? 'int' : 'float';
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
};
|
||||
} else if (typeof obj === 'boolean') {
|
||||
type = 'bool';
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
};
|
||||
} else if (typeof obj === 'string') {
|
||||
type = 'str';
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
};
|
||||
} else if (obj === null) {
|
||||
type = 'NoneType';
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: null
|
||||
};
|
||||
} else {
|
||||
throw new Error('Unsupported type for serialization');
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeList = (obj: unknown[], accessPath: string = '') => {
|
||||
const doc = null;
|
||||
const value = obj.map((item, index) => {
|
||||
if (
|
||||
typeof item === 'number' ||
|
||||
typeof item === 'boolean' ||
|
||||
typeof item === 'string' ||
|
||||
item === null
|
||||
) {
|
||||
serializePrimitive(
|
||||
item as number | boolean | string | null,
|
||||
`${accessPath}[${index}]`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: 'list',
|
||||
value,
|
||||
readonly: false,
|
||||
doc
|
||||
};
|
||||
};
|
||||
export const serializeDict = (
|
||||
obj: Record<string, unknown>,
|
||||
accessPath: string = ''
|
||||
) => {
|
||||
const doc = null;
|
||||
const value = Object.entries(obj).reduce((acc, [key, val]) => {
|
||||
// Construct the new access path for nested properties
|
||||
const newPath = `${accessPath}["${key}"]`;
|
||||
|
||||
// Serialize each value in the dictionary and assign to the accumulator
|
||||
if (
|
||||
typeof val === 'number' ||
|
||||
typeof val === 'boolean' ||
|
||||
typeof val === 'string' ||
|
||||
val === null
|
||||
) {
|
||||
acc[key] = serializePrimitive(val as number | boolean | string | null, newPath);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: 'dict',
|
||||
value,
|
||||
readonly: false,
|
||||
doc
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
export interface SerializedValue {
|
||||
import { SerializedValue } from '../components/GenericComponent';
|
||||
|
||||
export type State = {
|
||||
type: string;
|
||||
value: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||
value: Record<string, SerializedValue> | null;
|
||||
readonly: boolean;
|
||||
doc: string | null;
|
||||
async?: boolean;
|
||||
parameters?: unknown;
|
||||
}
|
||||
export type State = Record<string, SerializedValue> | null;
|
||||
};
|
||||
|
||||
export function setNestedValueByPath(
|
||||
serializationDict: Record<string, SerializedValue>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export function getIdFromFullAccessPath(fullAccessPath: string) {
|
||||
if (fullAccessPath) {
|
||||
// Replace '].' with a single dash
|
||||
let id = fullAccessPath.replace(/\]\./g, '-');
|
||||
|
||||
@@ -9,4 +10,7 @@ export function getIdFromFullAccessPath(fullAccessPath: string) {
|
||||
id = id.replace(/-+$/, '');
|
||||
|
||||
return id;
|
||||
} else {
|
||||
return 'main';
|
||||
}
|
||||
}
|
||||
|
||||
1817
poetry.lock
generated
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "pydase"
|
||||
version = "0.4.0"
|
||||
version = "0.8.0"
|
||||
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
|
||||
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
|
||||
readme = "README.md"
|
||||
@@ -9,15 +9,15 @@ packages = [{ include = "pydase", from = "src" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
rpyc = "^5.3.1"
|
||||
fastapi = "^0.100.0"
|
||||
uvicorn = "^0.22.0"
|
||||
fastapi = "^0.108.0"
|
||||
uvicorn = "^0.27.0"
|
||||
toml = "^0.10.2"
|
||||
python-socketio = "^5.8.0"
|
||||
websockets = "^11.0.3"
|
||||
confz = "^2.0.0"
|
||||
pint = "^0.22"
|
||||
pillow = "^10.0.0"
|
||||
websocket-client = "^1.7.0"
|
||||
aiohttp = "^3.9.3"
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
@@ -30,7 +30,8 @@ mypy = "^1.4.1"
|
||||
matplotlib = "^3.7.2"
|
||||
pyright = "^1.1.323"
|
||||
pytest-mock = "^3.11.1"
|
||||
ruff = "^0.1.5"
|
||||
ruff = "^0.2.0"
|
||||
pytest-asyncio = "^0.23.2"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
@@ -47,6 +48,11 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310" # Always generate Python 3.10-compatible code
|
||||
extend-exclude = [
|
||||
"docs", "frontend"
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"ASYNC", # flake8-async
|
||||
"C4", # flake8-comprehensions
|
||||
@@ -77,13 +83,9 @@ select = [
|
||||
"W", # pycodestyle warnings
|
||||
]
|
||||
ignore = [
|
||||
"E203", # whitespace-before-punctuation
|
||||
"W292", # missing-newline-at-end-of-file
|
||||
"RUF006", # asyncio-dangling-task
|
||||
"PERF203", # try-except-in-loop
|
||||
]
|
||||
extend-exclude = [
|
||||
"docs", "frontend"
|
||||
]
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 7
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from pydase.client.client import Client
|
||||
from pydase.data_service import DataService
|
||||
from pydase.server import Server
|
||||
from pydase.utils.logging import setup_logging
|
||||
@@ -7,4 +8,5 @@ setup_logging()
|
||||
__all__ = [
|
||||
"DataService",
|
||||
"Server",
|
||||
"Client",
|
||||
]
|
||||
|
||||
3
src/pydase/client/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from pydase.client.client import Client
|
||||
|
||||
__all__ = ["Client"]
|
||||
151
src/pydase/client/client.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import TypedDict, cast
|
||||
|
||||
import socketio # type: ignore
|
||||
|
||||
import pydase.components
|
||||
from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader
|
||||
from pydase.utils.serialization.deserializer import loads
|
||||
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotifyDataDict(TypedDict):
|
||||
full_access_path: str
|
||||
value: SerializedObject
|
||||
|
||||
|
||||
class NotifyDict(TypedDict):
|
||||
data: NotifyDataDict
|
||||
|
||||
|
||||
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
|
||||
class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
|
||||
"""
|
||||
A proxy class that serves as the interface for interacting with device connections
|
||||
via a socket.io client in an asyncio environment.
|
||||
|
||||
Args:
|
||||
sio_client (socketio.AsyncClient):
|
||||
The socket.io client instance used for asynchronous communication with the
|
||||
pydase service server.
|
||||
loop (asyncio.AbstractEventLoop):
|
||||
The event loop in which the client operations are managed and executed.
|
||||
|
||||
This class is used to create a proxy object that behaves like a local representation
|
||||
of a remote pydase service, facilitating direct interaction as if it were local
|
||||
while actually communicating over network protocols.
|
||||
It can also be used as an attribute of a pydase service itself, e.g.
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
proxy = pydase.Client(
|
||||
hostname="...", port=8001, block_until_connected=False
|
||||
).proxy
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = MyService()
|
||||
server = pydase.Server(service, web_port=8002).run()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._initialise(sio_client=sio_client, loop=loop)
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
A client for connecting to a remote pydase service using socket.io. This client
|
||||
handles asynchronous communication with a service, manages events such as
|
||||
connection, disconnection, and updates, and ensures that the proxy object is
|
||||
up-to-date with the server state.
|
||||
|
||||
Attributes:
|
||||
proxy (ProxyClass):
|
||||
A proxy object representing the remote service, facilitating interaction as
|
||||
if it were local.
|
||||
|
||||
Args:
|
||||
hostname (str):
|
||||
Hostname of the exposed service this client attempts to connect to.
|
||||
Default is "localhost".
|
||||
port (int):
|
||||
Port of the exposed service this client attempts to connect on.
|
||||
Default is 8001.
|
||||
block_until_connected (bool):
|
||||
If set to True, the constructor will block until the connection to the
|
||||
service has been established. This is useful for ensuring the client is
|
||||
ready to use immediately after instantiation. Default is True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
port: int,
|
||||
block_until_connected: bool = True,
|
||||
):
|
||||
self._hostname = hostname
|
||||
self._port = port
|
||||
self._sio = socketio.AsyncClient()
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop)
|
||||
self._thread = threading.Thread(
|
||||
target=asyncio_loop_thread, args=(self._loop,), daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
connection_future = asyncio.run_coroutine_threadsafe(
|
||||
self._connect(), self._loop
|
||||
)
|
||||
if block_until_connected:
|
||||
connection_future.result()
|
||||
|
||||
async def _connect(self) -> None:
|
||||
logger.debug("Connecting to server '%s:%s' ...", self._hostname, self._port)
|
||||
await self._setup_events()
|
||||
await self._sio.connect(
|
||||
f"ws://{self._hostname}:{self._port}",
|
||||
socketio_path="/ws/socket.io",
|
||||
transports=["websocket"],
|
||||
retry=True,
|
||||
)
|
||||
|
||||
async def _setup_events(self) -> None:
|
||||
self._sio.on("connect", self._handle_connect)
|
||||
self._sio.on("disconnect", self._handle_disconnect)
|
||||
self._sio.on("notify", self._handle_update)
|
||||
|
||||
async def _handle_connect(self) -> None:
|
||||
logger.debug("Connected to '%s:%s' ...", self._hostname, self._port)
|
||||
serialized_object = cast(
|
||||
SerializedDataService, await self._sio.call("service_serialization")
|
||||
)
|
||||
ProxyLoader.update_data_service_proxy(
|
||||
self.proxy, serialized_object=serialized_object
|
||||
)
|
||||
serialized_object["type"] = "DeviceConnection"
|
||||
self.proxy._notify_changed("", loads(serialized_object))
|
||||
self.proxy._connected = True
|
||||
|
||||
async def _handle_disconnect(self) -> None:
|
||||
logger.debug("Disconnected from '%s:%s' ...", self._hostname, self._port)
|
||||
self.proxy._connected = False
|
||||
|
||||
async def _handle_update(self, data: NotifyDict) -> None:
|
||||
self.proxy._notify_changed(
|
||||
data["data"]["full_access_path"],
|
||||
loads(data["data"]["value"]),
|
||||
)
|
||||
368
src/pydase/client/proxy_loader.py
Normal file
@@ -0,0 +1,368 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import socketio # type: ignore
|
||||
from typing_extensions import SupportsIndex
|
||||
|
||||
from pydase.utils.serialization.deserializer import Deserializer, loads
|
||||
from pydase.utils.serialization.serializer import dump
|
||||
from pydase.utils.serialization.types import SerializedObject
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProxyAttributeError(Exception): ...
|
||||
|
||||
|
||||
def trigger_method(
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
access_path: str,
|
||||
args: list[Any],
|
||||
kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
async def async_trigger_method() -> Any:
|
||||
return await sio_client.call(
|
||||
"trigger_method",
|
||||
{
|
||||
"access_path": access_path,
|
||||
"args": dump(args),
|
||||
"kwargs": dump(kwargs),
|
||||
},
|
||||
)
|
||||
|
||||
result: SerializedObject | None = asyncio.run_coroutine_threadsafe(
|
||||
async_trigger_method(),
|
||||
loop=loop,
|
||||
).result()
|
||||
|
||||
if result is not None:
|
||||
return ProxyLoader.loads_proxy(
|
||||
serialized_object=result, sio_client=sio_client, loop=loop
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def update_value(
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
access_path: str,
|
||||
value: Any,
|
||||
) -> Any:
|
||||
async def set_result() -> Any:
|
||||
return await sio_client.call(
|
||||
"update_value",
|
||||
{
|
||||
"access_path": access_path,
|
||||
"value": dump(value),
|
||||
},
|
||||
)
|
||||
|
||||
result: SerializedObject | None = asyncio.run_coroutine_threadsafe(
|
||||
set_result(),
|
||||
loop=loop,
|
||||
).result()
|
||||
if result is not None:
|
||||
ProxyLoader.loads_proxy(
|
||||
serialized_object=result, sio_client=sio_client, loop=loop
|
||||
)
|
||||
|
||||
|
||||
class ProxyList(list[Any]):
|
||||
def __init__(
|
||||
self,
|
||||
original_list: list[Any],
|
||||
parent_path: str,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
super().__init__(original_list)
|
||||
self._parent_path = parent_path
|
||||
self._loop = loop
|
||||
self._sio = sio_client
|
||||
|
||||
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
|
||||
full_access_path = f"{self._parent_path}[{key}]"
|
||||
|
||||
update_value(self._sio, self._loop, full_access_path, value)
|
||||
|
||||
def append(self, __object: Any) -> None:
|
||||
full_access_path = f"{self._parent_path}.append"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__object], {})
|
||||
|
||||
def clear(self) -> None:
|
||||
full_access_path = f"{self._parent_path}.clear"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [], {})
|
||||
|
||||
def extend(self, __iterable: Iterable[Any]) -> None:
|
||||
full_access_path = f"{self._parent_path}.extend"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__iterable], {})
|
||||
|
||||
def insert(self, __index: SupportsIndex, __object: Any) -> None:
|
||||
full_access_path = f"{self._parent_path}.insert"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__index, __object], {})
|
||||
|
||||
def pop(self, __index: SupportsIndex = -1) -> Any:
|
||||
full_access_path = f"{self._parent_path}.pop"
|
||||
|
||||
return trigger_method(self._sio, self._loop, full_access_path, [__index], {})
|
||||
|
||||
def remove(self, __value: Any) -> None:
|
||||
full_access_path = f"{self._parent_path}.remove"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [__value], {})
|
||||
|
||||
|
||||
class ProxyClassMixin:
|
||||
def __init__(self) -> None:
|
||||
# declare before DataService init to avoid warning messaged
|
||||
self._observers: dict[str, Any] = {}
|
||||
|
||||
self._proxy_getters: dict[str, Callable[..., Any]] = {}
|
||||
self._proxy_setters: dict[str, Callable[..., Any]] = {}
|
||||
self._proxy_methods: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
def _initialise(
|
||||
self,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
self._loop = loop
|
||||
self._sio = sio_client
|
||||
|
||||
def __dir__(self) -> list[str]:
|
||||
"""Used to provide tab completion on CLI / notebook"""
|
||||
static_dir = super().__dir__()
|
||||
return sorted({*static_dir, *self._proxy_getters, *self._proxy_methods.keys()})
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
try:
|
||||
if name in super().__getattribute__("_proxy_getters"):
|
||||
return super().__getattribute__("_proxy_getters")[name]()
|
||||
if name in super().__getattribute__("_proxy_methods"):
|
||||
return super().__getattribute__("_proxy_methods")[name]
|
||||
except AttributeError:
|
||||
pass
|
||||
return super().__getattribute__(name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
try:
|
||||
if name in super().__getattribute__("_proxy_setters"):
|
||||
return super().__getattribute__("_proxy_setters")[name](value)
|
||||
if name in super().__getattribute__("_proxy_getters"):
|
||||
raise ProxyAttributeError(
|
||||
f"Proxy attribute {name!r} of {type(self).__name__!r} is readonly!"
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return super().__setattr__(name, value)
|
||||
|
||||
def _handle_serialized_method(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
def add_prefix_to_last_path_element(s: str, prefix: str) -> str:
|
||||
parts = s.split(".")
|
||||
parts[-1] = f"{prefix}_{parts[-1]}"
|
||||
return ".".join(parts)
|
||||
|
||||
if serialized_object["type"] == "method":
|
||||
if serialized_object["async"] is True:
|
||||
start_method = copy(serialized_object)
|
||||
start_method["full_access_path"] = add_prefix_to_last_path_element(
|
||||
start_method["full_access_path"], "start"
|
||||
)
|
||||
stop_method = copy(serialized_object)
|
||||
stop_method["full_access_path"] = add_prefix_to_last_path_element(
|
||||
stop_method["full_access_path"], "stop"
|
||||
)
|
||||
self._add_method_proxy(f"start_{attr_name}", start_method)
|
||||
self._add_method_proxy(f"stop_{attr_name}", stop_method)
|
||||
else:
|
||||
self._add_method_proxy(attr_name, serialized_object)
|
||||
|
||||
def _add_method_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
def method_proxy(*args: Any, **kwargs: Any) -> Any:
|
||||
return trigger_method(
|
||||
self._sio,
|
||||
self._loop,
|
||||
serialized_object["full_access_path"],
|
||||
list(args),
|
||||
kwargs,
|
||||
)
|
||||
|
||||
dict.__setitem__(self._proxy_methods, attr_name, method_proxy)
|
||||
|
||||
def _add_attr_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
self._add_getattr_proxy(attr_name, serialized_object=serialized_object)
|
||||
if not serialized_object["readonly"]:
|
||||
self._add_setattr_proxy(attr_name, serialized_object=serialized_object)
|
||||
|
||||
def _add_setattr_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
self._add_getattr_proxy(attr_name, serialized_object=serialized_object)
|
||||
if not serialized_object["readonly"]:
|
||||
|
||||
def setter_proxy(value: Any) -> None:
|
||||
update_value(
|
||||
self._sio, self._loop, serialized_object["full_access_path"], value
|
||||
)
|
||||
|
||||
dict.__setitem__(self._proxy_setters, attr_name, setter_proxy) # type: ignore
|
||||
|
||||
def _add_getattr_proxy(
|
||||
self, attr_name: str, serialized_object: SerializedObject
|
||||
) -> None:
|
||||
def getter_proxy() -> Any:
|
||||
async def get_result() -> Any:
|
||||
return await self._sio.call(
|
||||
"get_value", serialized_object["full_access_path"]
|
||||
)
|
||||
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
get_result(),
|
||||
loop=self._loop,
|
||||
).result()
|
||||
return ProxyLoader.loads_proxy(result, self._sio, self._loop)
|
||||
|
||||
dict.__setitem__(self._proxy_getters, attr_name, getter_proxy) # type: ignore
|
||||
|
||||
|
||||
class ProxyLoader:
|
||||
@staticmethod
|
||||
def load_list_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
return ProxyList(
|
||||
[
|
||||
ProxyLoader.loads_proxy(item, sio_client, loop)
|
||||
for item in cast(list[SerializedObject], serialized_object["value"])
|
||||
],
|
||||
parent_path=serialized_object["full_access_path"],
|
||||
sio_client=sio_client,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def load_dict_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
return loads(serialized_object)
|
||||
|
||||
@staticmethod
|
||||
def update_data_service_proxy(
|
||||
proxy_class: ProxyClassMixin,
|
||||
serialized_object: SerializedObject,
|
||||
) -> Any:
|
||||
proxy_class._proxy_getters.clear()
|
||||
proxy_class._proxy_setters.clear()
|
||||
proxy_class._proxy_methods.clear()
|
||||
for key, value in cast(
|
||||
dict[str, SerializedObject], serialized_object["value"]
|
||||
).items():
|
||||
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
||||
None: None,
|
||||
"int": proxy_class._add_attr_proxy,
|
||||
"float": proxy_class._add_attr_proxy,
|
||||
"bool": proxy_class._add_attr_proxy,
|
||||
"str": proxy_class._add_attr_proxy,
|
||||
"NoneType": proxy_class._add_attr_proxy,
|
||||
"Quantity": proxy_class._add_attr_proxy,
|
||||
"Enum": proxy_class._add_attr_proxy,
|
||||
"ColouredEnum": proxy_class._add_attr_proxy,
|
||||
"method": proxy_class._handle_serialized_method,
|
||||
"list": proxy_class._add_getattr_proxy,
|
||||
"dict": proxy_class._add_getattr_proxy,
|
||||
}
|
||||
|
||||
# First go through handled types (as ColouredEnum is also within the
|
||||
# components)
|
||||
handler = type_handler.get(value["type"])
|
||||
if handler:
|
||||
handler(key, value)
|
||||
else:
|
||||
proxy_class._add_getattr_proxy(key, value)
|
||||
|
||||
@staticmethod
|
||||
def load_data_service_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
# Custom types like Components or DataService classes
|
||||
component_class = cast(
|
||||
type, Deserializer.get_component_class(serialized_object["type"])
|
||||
)
|
||||
class_bases = (
|
||||
ProxyClassMixin,
|
||||
component_class,
|
||||
)
|
||||
proxy_base_class: type[ProxyClassMixin] = type(
|
||||
serialized_object["name"], # type: ignore
|
||||
class_bases,
|
||||
{},
|
||||
)
|
||||
proxy_class_instance = proxy_base_class()
|
||||
proxy_class_instance._initialise(sio_client=sio_client, loop=loop)
|
||||
ProxyLoader.update_data_service_proxy(
|
||||
proxy_class=proxy_class_instance, serialized_object=serialized_object
|
||||
)
|
||||
return proxy_class_instance
|
||||
|
||||
@staticmethod
|
||||
def load_default(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
return loads(serialized_object)
|
||||
|
||||
@staticmethod
|
||||
def loads_proxy(
|
||||
serialized_object: SerializedObject,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
||||
"int": ProxyLoader.load_default,
|
||||
"float": ProxyLoader.load_default,
|
||||
"bool": ProxyLoader.load_default,
|
||||
"str": ProxyLoader.load_default,
|
||||
"NoneType": ProxyLoader.load_default,
|
||||
"Quantity": ProxyLoader.load_default,
|
||||
"Enum": ProxyLoader.load_default,
|
||||
"ColouredEnum": ProxyLoader.load_default,
|
||||
"Exception": ProxyLoader.load_default,
|
||||
"list": ProxyLoader.load_list_proxy,
|
||||
"dict": ProxyLoader.load_dict_proxy,
|
||||
}
|
||||
|
||||
# First go through handled types (as ColouredEnum is also within the components)
|
||||
handler = type_handler.get(serialized_object["type"])
|
||||
if handler:
|
||||
return handler(
|
||||
serialized_object=serialized_object, sio_client=sio_client, loop=loop
|
||||
)
|
||||
|
||||
return ProxyLoader.load_data_service_proxy(
|
||||
serialized_object=serialized_object, sio_client=sio_client, loop=loop
|
||||
)
|
||||
@@ -28,6 +28,7 @@ print(my_service.voltage.value) # Output: 5
|
||||
"""
|
||||
|
||||
from pydase.components.coloured_enum import ColouredEnum
|
||||
from pydase.components.device_connection import DeviceConnection
|
||||
from pydase.components.image import Image
|
||||
from pydase.components.number_slider import NumberSlider
|
||||
|
||||
@@ -35,4 +36,5 @@ __all__ = [
|
||||
"NumberSlider",
|
||||
"Image",
|
||||
"ColouredEnum",
|
||||
"DeviceConnection",
|
||||
]
|
||||
|
||||
77
src/pydase/components/device_connection.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import asyncio
|
||||
|
||||
import pydase.data_service
|
||||
|
||||
|
||||
class DeviceConnection(pydase.data_service.DataService):
|
||||
"""
|
||||
Base class for device connection management within the pydase framework.
|
||||
|
||||
This class serves as the foundation for subclasses that manage connections to
|
||||
specific devices. It implements automatic reconnection logic that periodically
|
||||
checks the device's availability and attempts to reconnect if the connection is
|
||||
lost. The frequency of these checks is controlled by the `_reconnection_wait_time`
|
||||
attribute.
|
||||
|
||||
Subclassing
|
||||
-----------
|
||||
Users should primarily override the `connect` method to establish a connection
|
||||
to the device. This method should update the `self._connected` attribute to reflect
|
||||
the connection status:
|
||||
|
||||
>>> class MyDeviceConnection(DeviceConnection):
|
||||
... def connect(self) -> None:
|
||||
... # Implementation to connect to the device
|
||||
... # Update self._connected to `True` if connection is successful,
|
||||
... # `False` otherwise
|
||||
... ...
|
||||
|
||||
Optionally, if additional logic is needed to determine the connection status,
|
||||
the `connected` property can also be overridden:
|
||||
|
||||
>>> class MyDeviceConnection(DeviceConnection):
|
||||
... @property
|
||||
... def connected(self) -> bool:
|
||||
... # Custom logic to determine connection status
|
||||
... return some_custom_condition
|
||||
...
|
||||
|
||||
Frontend Representation
|
||||
-----------------------
|
||||
In the frontend, this class is represented without directly exposing the `connect`
|
||||
method and `connected` attribute. Instead, user-defined attributes, methods, and
|
||||
properties are displayed. When `self.connected` is `False`, the frontend component
|
||||
shows an overlay that allows manual triggering of the `connect()` method. This
|
||||
overlay disappears once the connection is successfully re-established.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
self._autostart_tasks["_handle_connection"] = () # type: ignore
|
||||
self._reconnection_wait_time = 10.0
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Tries connecting to the device and changes `self._connected` status
|
||||
accordingly. This method is called every `self._reconnection_wait_time` seconds
|
||||
when `self.connected` is False. Users should override this method to implement
|
||||
device-specific connection logic.
|
||||
"""
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Indicates if the device is currently connected or was recently connected.
|
||||
Users may override this property to incorporate custom logic for determining
|
||||
the connection status.
|
||||
"""
|
||||
return self._connected
|
||||
|
||||
async def _handle_connection(self) -> None:
|
||||
"""Automatically tries reconnecting to the device if it is not connected.
|
||||
This method leverages the `connect` method and the `connected` property to
|
||||
manage the connection status.
|
||||
"""
|
||||
while True:
|
||||
if not self.connected:
|
||||
self.connect()
|
||||
await asyncio.sleep(self._reconnection_wait_time)
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from pydase.data_service.data_service import DataService
|
||||
|
||||
@@ -21,15 +21,60 @@ class NumberSlider(DataService):
|
||||
The maximum value of the slider. Defaults to 100.
|
||||
step_size (float, optional):
|
||||
The increment/decrement step size of the slider. Defaults to 1.0.
|
||||
type (Literal["int", "float"], optional):
|
||||
The type of the slider value. Determines if the value is an integer or float.
|
||||
Defaults to "float".
|
||||
|
||||
Example:
|
||||
--------
|
||||
```python
|
||||
class MyService(DataService):
|
||||
voltage = NumberSlider(1, 0, 10, 0.1, "int")
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0.0,
|
||||
min_: float = 0.0,
|
||||
max_: float = 100.0,
|
||||
step_size: float = 1.0,
|
||||
) -> None:
|
||||
super().__init__(value, min_, max_, step_size)
|
||||
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return self._min
|
||||
|
||||
@min.setter
|
||||
def min(self, value: float) -> None:
|
||||
self._min = value
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
|
||||
@max.setter
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
|
||||
@step_size.setter
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: float) -> None:
|
||||
if value < self._min or value > self._max:
|
||||
raise ValueError(
|
||||
"Value is either below allowed min or above max value."
|
||||
)
|
||||
|
||||
self._value = value
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
self.voltage = MyService()
|
||||
|
||||
# Modifying or accessing the voltage value:
|
||||
my_service = MyService()
|
||||
@@ -38,29 +83,39 @@ class NumberSlider(DataService):
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0,
|
||||
value: Any = 0.0,
|
||||
min_: float = 0.0,
|
||||
max_: float = 100.0,
|
||||
step_size: float = 1.0,
|
||||
type_: Literal["int", "float"] = "float",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
if type_ not in {"float", "int"}:
|
||||
logger.error("Unknown type '%s'. Using 'float'.", type_)
|
||||
type_ = "float"
|
||||
self._step_size = step_size
|
||||
self._value = value
|
||||
self._min = min_
|
||||
self._max = max_
|
||||
|
||||
self._type = type_
|
||||
self.step_size = step_size
|
||||
self.value = value
|
||||
self.min = min_
|
||||
self.max = max_
|
||||
@property
|
||||
def min(self) -> float:
|
||||
"""The min property."""
|
||||
return self._min
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if name in ["value", "step_size"]:
|
||||
value = int(value) if self._type == "int" else float(value)
|
||||
elif not name.startswith("_"):
|
||||
value = float(value)
|
||||
@property
|
||||
def max(self) -> float:
|
||||
"""The min property."""
|
||||
return self._max
|
||||
|
||||
return super().__setattr__(name, value)
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
"""The min property."""
|
||||
return self._step_size
|
||||
|
||||
@property
|
||||
def value(self) -> Any:
|
||||
"""The value property."""
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: Any) -> None:
|
||||
self._value = value
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from confz import BaseConfig, EnvSource
|
||||
@@ -7,3 +8,16 @@ class OperationMode(BaseConfig): # type: ignore[misc]
|
||||
environment: Literal["development", "production"] = "development"
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
|
||||
|
||||
|
||||
class ServiceConfig(BaseConfig): # type: ignore[misc]
|
||||
config_dir: Path = Path("config")
|
||||
web_port: int = 8001
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
|
||||
|
||||
|
||||
class WebServerConfig(BaseConfig): # type: ignore[misc]
|
||||
generate_web_settings: bool = False
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import inspect
|
||||
import logging
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, get_type_hints
|
||||
|
||||
import rpyc # type: ignore[import-untyped]
|
||||
from typing import Any
|
||||
|
||||
import pydase.units as u
|
||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||
@@ -13,56 +10,26 @@ from pydase.observer_pattern.observable.observable import (
|
||||
Observable,
|
||||
)
|
||||
from pydase.utils.helpers import (
|
||||
convert_arguments_to_hinted_types,
|
||||
get_class_and_instance_attributes,
|
||||
get_object_attr_from_path_list,
|
||||
is_property_attribute,
|
||||
parse_list_attr_and_index,
|
||||
update_value_if_changed,
|
||||
)
|
||||
from pydase.utils.serializer import (
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializedObject,
|
||||
Serializer,
|
||||
generate_serialized_data_paths,
|
||||
get_nested_dict_by_path,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def process_callable_attribute(attr: Any, args: dict[str, Any]) -> Any:
|
||||
converted_args_or_error_msg = convert_arguments_to_hinted_types(
|
||||
args, get_type_hints(attr)
|
||||
)
|
||||
return (
|
||||
attr(**converted_args_or_error_msg)
|
||||
if not isinstance(converted_args_or_error_msg, str)
|
||||
else converted_args_or_error_msg
|
||||
)
|
||||
|
||||
|
||||
class DataService(rpyc.Service, AbstractDataService):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
class DataService(AbstractDataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._task_manager = TaskManager(self)
|
||||
|
||||
if not hasattr(self, "_autostart_tasks"):
|
||||
self._autostart_tasks = {}
|
||||
|
||||
filename = kwargs.pop("filename", None)
|
||||
if filename is not None:
|
||||
warnings.warn(
|
||||
"The 'filename' argument is deprecated and will be removed in a future "
|
||||
"version. Please pass the 'filename' argument to `pydase.Server`.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._filename: str | Path = filename
|
||||
|
||||
self.__check_instance_classes()
|
||||
self._initialised = True
|
||||
|
||||
def __setattr__(self, __name: str, __value: Any) -> None:
|
||||
# Check and warn for unexpected type changes in attributes
|
||||
@@ -125,113 +92,7 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
):
|
||||
self.__warn_if_not_observable(attr_value)
|
||||
|
||||
def __set_attribute_based_on_type( # noqa: PLR0913
|
||||
self,
|
||||
target_obj: Any,
|
||||
attr_name: str,
|
||||
attr: Any,
|
||||
value: Any,
|
||||
index: int | None,
|
||||
path_list: list[str],
|
||||
) -> None:
|
||||
if isinstance(attr, Enum):
|
||||
update_value_if_changed(target_obj, attr_name, attr.__class__[value])
|
||||
elif isinstance(attr, list) and index is not None:
|
||||
update_value_if_changed(attr, index, value)
|
||||
elif isinstance(attr, DataService) and isinstance(value, dict):
|
||||
for key, v in value.items():
|
||||
self.update_DataService_attribute([*path_list, attr_name], key, v)
|
||||
elif callable(attr):
|
||||
process_callable_attribute(attr, value["args"])
|
||||
else:
|
||||
update_value_if_changed(target_obj, attr_name, value)
|
||||
|
||||
def _rpyc_getattr(self, name: str) -> Any:
|
||||
if name.startswith("_"):
|
||||
# disallow special and private attributes
|
||||
raise AttributeError("cannot access private/special names")
|
||||
# allow all other attributes
|
||||
return getattr(self, name)
|
||||
|
||||
def _rpyc_setattr(self, name: str, value: Any) -> None:
|
||||
if name.startswith("_"):
|
||||
# disallow special and private attributes
|
||||
raise AttributeError("cannot access private/special names")
|
||||
|
||||
# check if the attribute has a setter method
|
||||
attr = getattr(self, name, None)
|
||||
if isinstance(attr, property) and attr.fset is None:
|
||||
raise AttributeError(f"{name} attribute does not have a setter method")
|
||||
|
||||
# allow all other attributes
|
||||
setattr(self, name, value)
|
||||
|
||||
def write_to_file(self) -> None:
|
||||
"""
|
||||
Serialize the DataService instance and write it to a JSON file.
|
||||
|
||||
This method is deprecated and will be removed in a future version.
|
||||
Service persistence is handled by `pydase.Server` now, instead.
|
||||
"""
|
||||
|
||||
warnings.warn(
|
||||
"'write_to_file' is deprecated and will be removed in a future version. "
|
||||
"Service persistence is handled by `pydase.Server` now, instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if hasattr(self, "_state_manager"):
|
||||
self._state_manager.save_state()
|
||||
|
||||
def load_DataService_from_JSON( # noqa: N802
|
||||
self, json_dict: dict[str, Any]
|
||||
) -> None:
|
||||
warnings.warn(
|
||||
"'load_DataService_from_JSON' is deprecated and will be removed in a "
|
||||
"future version. "
|
||||
"Service persistence is handled by `pydase.Server` now, instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Traverse the serialized representation and set the attributes of the class
|
||||
serialized_class = self.serialize()
|
||||
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(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(
|
||||
"Attribute '%s' is read-only. Ignoring value from JSON "
|
||||
"file...",
|
||||
path,
|
||||
)
|
||||
continue
|
||||
# Split the path into parts
|
||||
parts = path.split(".")
|
||||
attr_name = parts[-1]
|
||||
|
||||
# Convert dictionary into Quantity
|
||||
if class_value_type == "Quantity":
|
||||
value = u.convert_to_quantity(value)
|
||||
|
||||
self.update_DataService_attribute(parts[:-1], attr_name, value)
|
||||
else:
|
||||
logger.info(
|
||||
"Attribute type of '%s' changed from '%s' to "
|
||||
"'%s'. Ignoring value from JSON file...",
|
||||
path,
|
||||
value_type,
|
||||
class_value_type,
|
||||
)
|
||||
|
||||
def serialize(self) -> dict[str, dict[str, Any]]:
|
||||
def serialize(self) -> SerializedObject:
|
||||
"""
|
||||
Serializes the instance into a dictionary, preserving the structure of the
|
||||
instance.
|
||||
@@ -248,38 +109,4 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
Returns:
|
||||
dict: The serialized instance.
|
||||
"""
|
||||
return Serializer.serialize_object(self)["value"]
|
||||
|
||||
def update_DataService_attribute( # noqa: N802
|
||||
self,
|
||||
path_list: list[str],
|
||||
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_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)
|
||||
if is_property_attribute(target_obj, attr_name):
|
||||
setattr(target_obj, attr_name, value)
|
||||
return
|
||||
|
||||
attr = get_object_attr_from_path_list(target_obj, [attr_name])
|
||||
if attr is None:
|
||||
return
|
||||
|
||||
self.__set_attribute_based_on_type(
|
||||
target_obj, attr_name, attr, value, index, path_list
|
||||
)
|
||||
return Serializer.serialize_object(self)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pydase.utils.serializer import (
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializationPathError,
|
||||
SerializationValueError,
|
||||
SerializedObject,
|
||||
get_nested_dict_by_path,
|
||||
set_nested_value_by_path,
|
||||
)
|
||||
@@ -16,12 +17,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class DataServiceCache:
|
||||
def __init__(self, service: "DataService") -> None:
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._cache: SerializedObject
|
||||
self.service = service
|
||||
self._initialize_cache()
|
||||
|
||||
@property
|
||||
def cache(self) -> dict[str, Any]:
|
||||
def cache(self) -> SerializedObject:
|
||||
return self._cache
|
||||
|
||||
def _initialize_cache(self) -> None:
|
||||
@@ -30,10 +31,23 @@ class DataServiceCache:
|
||||
self._cache = self.service.serialize()
|
||||
|
||||
def update_cache(self, full_access_path: str, value: Any) -> None:
|
||||
set_nested_value_by_path(self._cache, full_access_path, value)
|
||||
set_nested_value_by_path(
|
||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
||||
full_access_path,
|
||||
value,
|
||||
)
|
||||
|
||||
def get_value_dict_from_cache(self, full_access_path: str) -> dict[str, Any]:
|
||||
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
|
||||
try:
|
||||
return get_nested_dict_by_path(self._cache, full_access_path)
|
||||
return get_nested_dict_by_path(
|
||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
||||
full_access_path,
|
||||
)
|
||||
except (SerializationPathError, SerializationValueError, KeyError):
|
||||
return {}
|
||||
return {
|
||||
"full_access_path": full_access_path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ from pydase.observer_pattern.observable.observable_object import ObservableObjec
|
||||
from pydase.observer_pattern.observer.property_observer import (
|
||||
PropertyObserver,
|
||||
)
|
||||
from pydase.utils.helpers import get_object_attr_from_path_list
|
||||
from pydase.utils.serializer import dump
|
||||
from pydase.utils.helpers import get_object_attr_from_path
|
||||
from pydase.utils.serialization.serializer import SerializedObject, dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,11 +18,18 @@ class DataServiceObserver(PropertyObserver):
|
||||
def __init__(self, state_manager: StateManager) -> None:
|
||||
self.state_manager = state_manager
|
||||
self._notification_callbacks: list[
|
||||
Callable[[str, Any, dict[str, Any]], None]
|
||||
Callable[[str, Any, SerializedObject], None]
|
||||
] = []
|
||||
super().__init__(state_manager.service)
|
||||
|
||||
def on_change(self, full_access_path: str, value: Any) -> None:
|
||||
if any(
|
||||
full_access_path.startswith(changing_attribute)
|
||||
and full_access_path != changing_attribute
|
||||
for changing_attribute in self.changing_attributes
|
||||
):
|
||||
return
|
||||
|
||||
cached_value_dict = deepcopy(
|
||||
self.state_manager._data_service_cache.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
@@ -37,6 +44,12 @@ class DataServiceObserver(PropertyObserver):
|
||||
|
||||
self._update_cache_value(full_access_path, value, cached_value_dict)
|
||||
|
||||
cached_value_dict = deepcopy(
|
||||
self.state_manager._data_service_cache.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
)
|
||||
)
|
||||
|
||||
for callback in self._notification_callbacks:
|
||||
callback(full_access_path, value, cached_value_dict)
|
||||
|
||||
@@ -46,12 +59,15 @@ class DataServiceObserver(PropertyObserver):
|
||||
self._notify_dependent_property_changes(full_access_path)
|
||||
|
||||
def _update_cache_value(
|
||||
self, full_access_path: str, value: Any, cached_value_dict: dict[str, Any]
|
||||
self,
|
||||
full_access_path: str,
|
||||
value: Any,
|
||||
cached_value_dict: SerializedObject | dict[str, Any],
|
||||
) -> None:
|
||||
value_dict = dump(value)
|
||||
if cached_value_dict != {}:
|
||||
if (
|
||||
cached_value_dict["type"] != "method"
|
||||
cached_value_dict != {}
|
||||
and cached_value_dict["type"] != "method"
|
||||
and cached_value_dict["type"] != value_dict["type"]
|
||||
):
|
||||
logger.warning(
|
||||
@@ -76,11 +92,11 @@ class DataServiceObserver(PropertyObserver):
|
||||
if prop not in self.changing_attributes:
|
||||
self._notify_changed(
|
||||
prop,
|
||||
get_object_attr_from_path_list(self.observable, prop.split(".")),
|
||||
get_object_attr_from_path(self.observable, prop),
|
||||
)
|
||||
|
||||
def add_notification_callback(
|
||||
self, callback: Callable[[str, Any, dict[str, Any]], None]
|
||||
self, callback: Callable[[str, Any, SerializedObject], None]
|
||||
) -> None:
|
||||
"""
|
||||
Registers a callback function to be invoked upon attribute changes in the
|
||||
|
||||
@@ -5,16 +5,19 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, 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,
|
||||
get_object_attr_from_path,
|
||||
is_property_attribute,
|
||||
parse_list_attr_and_index,
|
||||
)
|
||||
from pydase.utils.serializer import (
|
||||
dump,
|
||||
from pydase.utils.serialization.deserializer import loads
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializationPathError,
|
||||
SerializedObject,
|
||||
generate_serialized_data_paths,
|
||||
get_nested_dict_by_path,
|
||||
serialized_dict_is_nested_object,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -112,10 +115,17 @@ class StateManager:
|
||||
self._data_service_cache = DataServiceCache(self.service)
|
||||
|
||||
@property
|
||||
def cache(self) -> dict[str, Any]:
|
||||
def cache(self) -> SerializedObject:
|
||||
"""Returns the cached DataService state."""
|
||||
return self._data_service_cache.cache
|
||||
|
||||
@property
|
||||
def cache_value(self) -> dict[str, SerializedObject]:
|
||||
"""Returns the "value" value of the DataService serialization."""
|
||||
return cast(
|
||||
dict[str, SerializedObject], self._data_service_cache.cache["value"]
|
||||
)
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""
|
||||
Saves the DataService's current state to a JSON file defined by `self.filename`.
|
||||
@@ -124,7 +134,7 @@ class StateManager:
|
||||
|
||||
if self.filename is not None:
|
||||
with open(self.filename, "w") as f:
|
||||
json.dump(self.cache, f, indent=4)
|
||||
json.dump(self.cache_value, f, indent=4)
|
||||
else:
|
||||
logger.info(
|
||||
"State manager was not initialised with a filename. Skipping "
|
||||
@@ -144,15 +154,17 @@ class StateManager:
|
||||
return
|
||||
|
||||
for path in generate_serialized_data_paths(json_dict):
|
||||
if self.__is_loadable_state_attribute(path):
|
||||
nested_json_dict = get_nested_dict_by_path(json_dict, path)
|
||||
nested_class_dict = self._data_service_cache.get_value_dict_from_cache(path)
|
||||
nested_class_dict = self._data_service_cache.get_value_dict_from_cache(
|
||||
path
|
||||
)
|
||||
|
||||
value, value_type = nested_json_dict["value"], nested_json_dict["type"]
|
||||
value_type = nested_json_dict["type"]
|
||||
class_attr_value_type = nested_class_dict.get("type", None)
|
||||
|
||||
if class_attr_value_type == value_type:
|
||||
if self.__is_loadable_state_attribute(path):
|
||||
self.set_service_attribute_value_by_path(path, value)
|
||||
self.set_service_attribute_value_by_path(path, nested_json_dict)
|
||||
else:
|
||||
logger.info(
|
||||
"Attribute type of '%s' changed from '%s' to "
|
||||
@@ -173,7 +185,7 @@ class StateManager:
|
||||
def set_service_attribute_value_by_path(
|
||||
self,
|
||||
path: str,
|
||||
value: Any,
|
||||
serialized_value: SerializedObject,
|
||||
) -> None:
|
||||
"""
|
||||
Sets the value of an attribute in the service managed by the `StateManager`
|
||||
@@ -189,64 +201,81 @@ class StateManager:
|
||||
value: The new value to set for the attribute.
|
||||
"""
|
||||
|
||||
current_value_dict = get_nested_dict_by_path(self.cache, path)
|
||||
current_value_dict = get_nested_dict_by_path(self.cache_value, path)
|
||||
|
||||
# This will also filter out methods as they are 'read-only'
|
||||
if current_value_dict["readonly"]:
|
||||
logger.debug("Attribute '%s' is read-only. Ignoring new value...", path)
|
||||
return
|
||||
|
||||
converted_value = self.__convert_value_if_needed(value, current_value_dict)
|
||||
if "full_access_path" not in serialized_value:
|
||||
# Backwards compatibility for JSON files not containing the
|
||||
# full_access_path
|
||||
logger.warning(
|
||||
"The format of your JSON file is out-of-date. This might lead "
|
||||
"to unexpected errors. Please consider updating it."
|
||||
)
|
||||
serialized_value["full_access_path"] = current_value_dict[
|
||||
"full_access_path"
|
||||
]
|
||||
|
||||
# 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)
|
||||
if self.__attr_value_has_changed(serialized_value, current_value_dict):
|
||||
self.__update_attribute_by_path(path, serialized_value)
|
||||
else:
|
||||
logger.debug("Value of attribute '%s' has not changed...", path)
|
||||
|
||||
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`.
|
||||
def __attr_value_has_changed(
|
||||
self, serialized_new_value: Any, serialized_current_value: Any
|
||||
) -> bool:
|
||||
return not (
|
||||
serialized_new_value["type"] == serialized_current_value["type"]
|
||||
and serialized_new_value["value"] == serialized_current_value["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"])
|
||||
if current_value_dict["type"] == "float" and not isinstance(value, float):
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
def __update_attribute_by_path(self, path: str, value: Any) -> None:
|
||||
parent_path_list, attr_name = path.split(".")[:-1], path.split(".")[-1]
|
||||
def __update_attribute_by_path(
|
||||
self, path: str, serialized_value: SerializedObject
|
||||
) -> None:
|
||||
parent_path, attr_name = ".".join(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])
|
||||
path = f"{parent_path}.{attr_name}" if parent_path != "" else attr_name
|
||||
|
||||
attr_cache_type = get_nested_dict_by_path(self.cache, path)["type"]
|
||||
attr_cache_type = get_nested_dict_by_path(self.cache_value, path)["type"]
|
||||
|
||||
# Traverse the object according to the path parts
|
||||
target_obj = get_object_attr_from_path_list(self.service, parent_path_list)
|
||||
target_obj = get_object_attr_from_path(self.service, parent_path)
|
||||
|
||||
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])
|
||||
enum_attr = get_object_attr_from_path(target_obj, attr_name)
|
||||
# take the value of the existing enum class
|
||||
if serialized_value["type"] in ("ColouredEnum", "Enum"):
|
||||
try:
|
||||
setattr(
|
||||
target_obj,
|
||||
attr_name,
|
||||
enum_attr.__class__[serialized_value["value"]],
|
||||
)
|
||||
return
|
||||
except KeyError:
|
||||
# This error will arise when setting an enum from another enum class
|
||||
# In this case, we resort to loading the enum and setting it
|
||||
# directly
|
||||
pass
|
||||
|
||||
value = loads(serialized_value)
|
||||
|
||||
if attr_cache_type == "list":
|
||||
list_obj = get_object_attr_from_path(target_obj, attr_name)
|
||||
list_obj[index] = value
|
||||
else:
|
||||
setattr(target_obj, attr_name, value)
|
||||
|
||||
def __is_loadable_state_attribute(self, property_path: str) -> bool:
|
||||
def __is_loadable_state_attribute(self, full_access_path: str) -> bool:
|
||||
"""Checks if an attribute defined by a dot-separated path should be loaded from
|
||||
storage.
|
||||
|
||||
@@ -254,14 +283,14 @@ class StateManager:
|
||||
attributes default to being loadable.
|
||||
"""
|
||||
|
||||
parent_object = get_object_attr_from_path_list(
|
||||
self.service, property_path.split(".")[:-1]
|
||||
parent_path, attr_name = (
|
||||
".".join(full_access_path.split(".")[:-1]),
|
||||
full_access_path.split(".")[-1],
|
||||
)
|
||||
attr_name = property_path.split(".")[-1]
|
||||
parent_object = get_object_attr_from_path(self.service, parent_path)
|
||||
|
||||
prop = getattr(type(parent_object), attr_name, None)
|
||||
|
||||
if isinstance(prop, property):
|
||||
if is_property_attribute(parent_object, attr_name):
|
||||
prop = getattr(type(parent_object), attr_name)
|
||||
has_decorator = has_load_state_decorator(prop)
|
||||
if not has_decorator:
|
||||
logger.debug(
|
||||
@@ -270,4 +299,21 @@ class StateManager:
|
||||
attr_name,
|
||||
)
|
||||
return has_decorator
|
||||
return True
|
||||
|
||||
try:
|
||||
cached_serialization_dict = get_nested_dict_by_path(
|
||||
self.cache_value, full_access_path
|
||||
)
|
||||
|
||||
if cached_serialization_dict["value"] == "method":
|
||||
return False
|
||||
|
||||
# nested objects cannot be loaded
|
||||
return not serialized_dict_is_nested_object(cached_serialization_dict)
|
||||
except SerializationPathError:
|
||||
logger.debug(
|
||||
"Path %a could not be loaded. It does not correspond to an attribute of"
|
||||
" the class. Ignoring value from JSON file...",
|
||||
attr_name,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -3,11 +3,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||
from pydase.utils.helpers import get_class_and_instance_attributes
|
||||
from pydase.utils.helpers import (
|
||||
function_has_arguments,
|
||||
get_class_and_instance_attributes,
|
||||
is_property_attribute,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -17,9 +21,12 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskDict(TypedDict):
|
||||
task: asyncio.Task[None]
|
||||
kwargs: dict[str, Any]
|
||||
class TaskDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
RUNNING = "running"
|
||||
|
||||
|
||||
class TaskManager:
|
||||
@@ -78,9 +85,8 @@ class TaskManager:
|
||||
|
||||
def __init__(self, service: DataService) -> None:
|
||||
self.service = service
|
||||
self._loop = asyncio.get_event_loop()
|
||||
|
||||
self.tasks: dict[str, TaskDict] = {}
|
||||
self.tasks: dict[str, asyncio.Task[None]] = {}
|
||||
"""A dictionary to keep track of running tasks. The keys are the names of the
|
||||
tasks and the values are TaskDict instances which include the task itself and
|
||||
its kwargs.
|
||||
@@ -88,13 +94,30 @@ class TaskManager:
|
||||
|
||||
self._set_start_and_stop_for_async_methods()
|
||||
|
||||
@property
|
||||
def _loop(self) -> asyncio.AbstractEventLoop:
|
||||
return asyncio.get_running_loop()
|
||||
|
||||
def _set_start_and_stop_for_async_methods(self) -> None:
|
||||
# inspect the methods of the class
|
||||
for name, method in inspect.getmembers(
|
||||
self.service, predicate=inspect.iscoroutinefunction
|
||||
):
|
||||
for name in dir(self.service):
|
||||
# circumvents calling properties
|
||||
if is_property_attribute(self.service, name):
|
||||
continue
|
||||
|
||||
method = getattr(self.service, name)
|
||||
if inspect.iscoroutinefunction(method):
|
||||
if function_has_arguments(method):
|
||||
raise TaskDefinitionError(
|
||||
"Asynchronous functions (tasks) should be defined without "
|
||||
f"arguments. The task '{method.__name__}' has at least one "
|
||||
"argument. Please remove the argument(s) from this function to "
|
||||
"use it."
|
||||
)
|
||||
|
||||
# create start and stop methods for each coroutine
|
||||
setattr(self.service, f"start_{name}", self._make_start_task(name, method))
|
||||
setattr(
|
||||
self.service, f"start_{name}", self._make_start_task(name, method)
|
||||
)
|
||||
setattr(self.service, f"stop_{name}", self._make_stop_task(name))
|
||||
|
||||
def _initiate_task_startup(self) -> None:
|
||||
@@ -135,7 +158,7 @@ class TaskManager:
|
||||
# cancel the task
|
||||
task = self.tasks.get(name, None)
|
||||
if task is not None:
|
||||
self._loop.call_soon_threadsafe(task["task"].cancel)
|
||||
self._loop.call_soon_threadsafe(task.cancel)
|
||||
|
||||
return stop_task
|
||||
|
||||
@@ -154,8 +177,7 @@ class TaskManager:
|
||||
method (callable): The coroutine to be turned into an asyncio task.
|
||||
"""
|
||||
|
||||
@wraps(method)
|
||||
def start_task(*args: Any, **kwargs: Any) -> None:
|
||||
def start_task() -> None:
|
||||
def task_done_callback(task: asyncio.Task[None], name: str) -> None:
|
||||
"""Handles tasks that have finished.
|
||||
|
||||
@@ -179,36 +201,16 @@ class TaskManager:
|
||||
)
|
||||
raise exception
|
||||
|
||||
async def task(*args: Any, **kwargs: Any) -> None:
|
||||
async def task() -> None:
|
||||
try:
|
||||
await method(*args, **kwargs)
|
||||
await method()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Task '%s' was cancelled", name)
|
||||
|
||||
if not self.tasks.get(name):
|
||||
# Get the signature of the coroutine method to start
|
||||
sig = inspect.signature(method)
|
||||
|
||||
# Create a list of the parameter names from the method signature.
|
||||
parameter_names = list(sig.parameters.keys())
|
||||
|
||||
# Extend the list of positional arguments with None values to match
|
||||
# the length of the parameter names list. This is done to ensure
|
||||
# that zip can pair each parameter name with a corresponding value.
|
||||
args_padded = list(args) + [None] * (len(parameter_names) - len(args))
|
||||
|
||||
# Create a dictionary of keyword arguments by pairing the parameter
|
||||
# names with the values in 'args_padded'. Then merge this dictionary
|
||||
# with the 'kwargs' dictionary. If a parameter is specified in both
|
||||
# 'args_padded' and 'kwargs', the value from 'kwargs' is used.
|
||||
kwargs_updated = {
|
||||
**dict(zip(parameter_names, args_padded, strict=True)),
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
# creating the task and adding the task_done_callback which checks
|
||||
# if an exception has occured during the task execution
|
||||
task_object = self._loop.create_task(task(*args, **kwargs))
|
||||
task_object = self._loop.create_task(task())
|
||||
task_object.add_done_callback(
|
||||
lambda task: task_done_callback(task, name)
|
||||
)
|
||||
@@ -216,13 +218,10 @@ class TaskManager:
|
||||
# Store the task and its arguments in the '__tasks' dictionary. The
|
||||
# key is the name of the method, and the value is a dictionary
|
||||
# containing the task object and the updated keyword arguments.
|
||||
self.tasks[name] = {
|
||||
"task": task_object,
|
||||
"kwargs": kwargs_updated,
|
||||
}
|
||||
self.tasks[name] = task_object
|
||||
|
||||
# emit the notification that the task was started
|
||||
self.service._notify_changed(name, kwargs_updated)
|
||||
self.service._notify_changed(name, TaskStatus.RUNNING)
|
||||
else:
|
||||
logger.error("Task '%s' is already running!", name)
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.2d8458eb.css",
|
||||
"main.js": "/static/js/main.7f907b0f.js",
|
||||
"main.css": "/static/css/main.7ef670d5.css",
|
||||
"main.js": "/static/js/main.9c35da6c.js",
|
||||
"index.html": "/index.html",
|
||||
"main.2d8458eb.css.map": "/static/css/main.2d8458eb.css.map",
|
||||
"main.7f907b0f.js.map": "/static/js/main.7f907b0f.js.map"
|
||||
"main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
|
||||
"main.9c35da6c.js.map": "/static/js/main.9c35da6c.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.2d8458eb.css",
|
||||
"static/js/main.7f907b0f.js"
|
||||
"static/css/main.7ef670d5.css",
|
||||
"static/js/main.9c35da6c.js"
|
||||
]
|
||||
}
|
||||
@@ -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.7f907b0f.js"></script><link href="/static/css/main.2d8458eb.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.9c35da6c.js"></script><link href="/static/css/main.7ef670d5.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||
1
src/pydase/frontend/static/css/main.7ef670d5.css.map
Normal file
1
src/pydase/frontend/static/js/main.9c35da6c.js.map
Normal file
@@ -67,5 +67,9 @@ class Observable(ObservableObject):
|
||||
self, observer_attr_name: str, instance_attr_name: str
|
||||
) -> str:
|
||||
if observer_attr_name != "":
|
||||
return f"{observer_attr_name}.{instance_attr_name}"
|
||||
return (
|
||||
f"{observer_attr_name}.{instance_attr_name}"
|
||||
if instance_attr_name != ""
|
||||
else observer_attr_name
|
||||
)
|
||||
return instance_attr_name
|
||||
|
||||
@@ -148,6 +148,7 @@ class _ObservableList(ObservableObject, list[Any]):
|
||||
self._notify_changed(f"[{key}]", value)
|
||||
|
||||
def append(self, __object: Any) -> None:
|
||||
self._notify_change_start("")
|
||||
self._initialise_new_objects(f"[{len(self)}]", __object)
|
||||
super().append(__object)
|
||||
self._notify_changed("", self)
|
||||
|
||||
@@ -14,11 +14,11 @@ class Observer(ABC):
|
||||
self.changing_attributes: list[str] = []
|
||||
|
||||
def _notify_changed(self, changed_attribute: str, value: Any) -> None:
|
||||
self.on_change(full_access_path=changed_attribute, value=value)
|
||||
|
||||
if changed_attribute in self.changing_attributes:
|
||||
self.changing_attributes.remove(changed_attribute)
|
||||
|
||||
self.on_change(full_access_path=changed_attribute, value=value)
|
||||
|
||||
def _notify_change_start(self, changing_attribute: str) -> None:
|
||||
self.changing_attributes.append(changing_attribute)
|
||||
self.on_change_start(changing_attribute)
|
||||
|
||||
@@ -49,7 +49,7 @@ class PropertyObserver(Observer):
|
||||
def _process_observable_properties(
|
||||
self, obj: Observable, deps: dict[str, Any], prefix: str
|
||||
) -> None:
|
||||
for k, value in vars(type(obj)).items():
|
||||
for k, value in inspect.getmembers(type(obj)):
|
||||
prefix = (
|
||||
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
|
||||
)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
from pydase.server.server import Server
|
||||
from pydase.server.web_server.web_server import WebServer
|
||||
|
||||
__all__ = ["Server"]
|
||||
__all__ = [
|
||||
"Server",
|
||||
"WebServer",
|
||||
]
|
||||
|
||||
@@ -3,21 +3,17 @@ import logging
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import Any, Protocol, TypedDict
|
||||
|
||||
import uvicorn
|
||||
from rpyc import ForkingServer, ThreadedServer # type: ignore[import-untyped]
|
||||
from uvicorn.server import HANDLED_SIGNALS
|
||||
|
||||
from pydase import DataService
|
||||
from pydase.config import ServiceConfig
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.utils.serializer import dump
|
||||
|
||||
from .web_server import WebAPI
|
||||
from pydase.server.web_server import WebServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,38 +27,29 @@ class AdditionalServerProtocol(Protocol):
|
||||
any server implementing it should have an __init__ method for initialization and a
|
||||
serve method for starting the server.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
service: DataService
|
||||
The instance of DataService that the server will use. This could be the main
|
||||
application or a specific service that the server will provide.
|
||||
|
||||
port: int
|
||||
The port number at which the server will be accessible. This should be a valid
|
||||
port number, typically in the range 1024-65535.
|
||||
|
||||
host: str
|
||||
The hostname or IP address at which the server will be hosted. This could be a
|
||||
local address (like '127.0.0.1' for localhost) or a public IP address.
|
||||
|
||||
state_manager: StateManager
|
||||
The state manager managing the state cache and persistence of the exposed
|
||||
service.
|
||||
|
||||
**kwargs: Any
|
||||
Any additional parameters required for initializing the server. These parameters
|
||||
are specific to the server's implementation.
|
||||
Args:
|
||||
data_service_observer:
|
||||
Observer for the DataService, handling state updates and communication to
|
||||
connected clients through injected callbacks. Can be utilized to access the
|
||||
service and state manager, and to add custom state-update callbacks.
|
||||
host:
|
||||
Hostname or IP address where the server is accessible. Commonly '0.0.0.0' to
|
||||
bind to all network interfaces.
|
||||
port:
|
||||
Port number on which the server listens. Typically in the range 1024-65535
|
||||
(non-standard ports).
|
||||
**kwargs:
|
||||
Any additional parameters required for initializing the server. These
|
||||
parameters are specific to the server's implementation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: DataService,
|
||||
port: int,
|
||||
data_service_observer: DataServiceObserver,
|
||||
host: str,
|
||||
state_manager: StateManager,
|
||||
port: int,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
...
|
||||
) -> None: ...
|
||||
|
||||
async def serve(self) -> Any:
|
||||
"""Starts the server. This method should be implemented as an asynchronous
|
||||
@@ -89,73 +76,69 @@ class Server:
|
||||
"""
|
||||
The `Server` class provides a flexible server implementation for the `DataService`.
|
||||
|
||||
Parameters:
|
||||
-----------
|
||||
Args:
|
||||
service: DataService
|
||||
The DataService instance that this server will manage.
|
||||
host: str
|
||||
The host address for the server. Default is '0.0.0.0', which means all available
|
||||
network interfaces.
|
||||
rpc_port: int
|
||||
The port number for the RPC server. Default is 18871.
|
||||
The host address for the server. Default is '0.0.0.0', which means all
|
||||
available network interfaces.
|
||||
web_port: int
|
||||
The port number for the web server. Default is 8001.
|
||||
enable_rpc: bool
|
||||
Whether to enable the RPC server. Default is True.
|
||||
The port number for the web server. Default is
|
||||
`pydase.config.ServiceConfig().web_port`.
|
||||
enable_web: bool
|
||||
Whether to enable the web server. Default is True.
|
||||
filename: str | Path | None
|
||||
Filename of the file managing the service state persistence. Defaults to None.
|
||||
use_forking_server: bool
|
||||
Whether to use ForkingServer for multiprocessing. Default is False.
|
||||
Filename of the file managing the service state persistence.
|
||||
Defaults to None.
|
||||
additional_servers : list[AdditionalServer]
|
||||
A list of additional servers to run alongside the main server. Each entry in the
|
||||
list should be a dictionary with the following structure:
|
||||
|
||||
- server: A class that adheres to the AdditionalServerProtocol. This class
|
||||
should have an `__init__` method that accepts the DataService instance,
|
||||
port, host, and optional keyword arguments, and a `serve` method that is a
|
||||
coroutine responsible for starting the server.
|
||||
A list of additional servers to run alongside the main server. Each entry in
|
||||
the list should be a dictionary with the following structure:
|
||||
- server: A class that adheres to the AdditionalServerProtocol. This
|
||||
class should have an `__init__` method that accepts the DataService
|
||||
instance, port, host, and optional keyword arguments, and a `serve`
|
||||
method that is a coroutine responsible for starting the server.
|
||||
- port: The port on which the additional server will be running.
|
||||
- kwargs: A dictionary containing additional keyword arguments that will be
|
||||
passed to the server's `__init__` method.
|
||||
- kwargs: A dictionary containing additional keyword arguments that will
|
||||
be passed to the server's `__init__` method.
|
||||
|
||||
Here's an example of how you might define an additional server:
|
||||
|
||||
```python
|
||||
class MyCustomServer:
|
||||
def __init__(
|
||||
self,
|
||||
data_service_observer: DataServiceObserver,
|
||||
host: str,
|
||||
port: int,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.observer = data_service_observer
|
||||
self.state_manager = self.observer.state_manager
|
||||
self.service = self.state_manager.service
|
||||
self.port = port
|
||||
self.host = host
|
||||
# handle any additional arguments...
|
||||
|
||||
>>> class MyCustomServer:
|
||||
... def __init__(
|
||||
... self,
|
||||
... service: DataService,
|
||||
... port: int,
|
||||
... host: str,
|
||||
... state_manager: StateManager,
|
||||
... **kwargs: Any
|
||||
... ):
|
||||
... self.service = service
|
||||
... self.state_manager = state_manager
|
||||
... self.port = port
|
||||
... self.host = host
|
||||
... # handle any additional arguments...
|
||||
...
|
||||
... async def serve(self):
|
||||
... # code to start the server...
|
||||
async def serve(self):
|
||||
# code to start the server...
|
||||
```
|
||||
|
||||
And here's how you might add it to the `additional_servers` list when creating a
|
||||
`Server` instance:
|
||||
|
||||
>>> server = Server(
|
||||
... service=my_data_service,
|
||||
... additional_servers=[
|
||||
... {
|
||||
... "server": MyCustomServer,
|
||||
... "port": 12345,
|
||||
... "kwargs": {"some_arg": "some_value"}
|
||||
... }
|
||||
... ],
|
||||
... )
|
||||
... server.run()
|
||||
And here's how you might add it to the `additional_servers` list when
|
||||
creating a `Server` instance:
|
||||
|
||||
```python
|
||||
server = Server(
|
||||
service=my_data_service,
|
||||
additional_servers=[
|
||||
{
|
||||
"server": MyCustomServer,
|
||||
"port": 12345,
|
||||
"kwargs": {"some_arg": "some_value"}
|
||||
}
|
||||
],
|
||||
)
|
||||
server.run()
|
||||
```
|
||||
**kwargs: Any
|
||||
Additional keyword arguments.
|
||||
"""
|
||||
@@ -164,12 +147,9 @@ class Server:
|
||||
self,
|
||||
service: DataService,
|
||||
host: str = "0.0.0.0",
|
||||
rpc_port: int = 18871,
|
||||
web_port: int = 8001,
|
||||
enable_rpc: bool = True,
|
||||
web_port: int = ServiceConfig().web_port,
|
||||
enable_web: bool = True,
|
||||
filename: str | Path | None = None,
|
||||
use_forking_server: bool = False,
|
||||
additional_servers: list[AdditionalServer] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
@@ -177,45 +157,24 @@ class Server:
|
||||
additional_servers = []
|
||||
self._service = service
|
||||
self._host = host
|
||||
self._rpc_port = rpc_port
|
||||
self._web_port = web_port
|
||||
self._enable_rpc = enable_rpc
|
||||
self._enable_web = enable_web
|
||||
self._kwargs = kwargs
|
||||
self._loop: asyncio.AbstractEventLoop
|
||||
self._rpc_server_type = ForkingServer if use_forking_server else ThreadedServer
|
||||
self._additional_servers = additional_servers
|
||||
self.should_exit = False
|
||||
self.servers: dict[str, asyncio.Future[Any]] = {}
|
||||
self.executor: ThreadPoolExecutor | None = None
|
||||
self._state_manager = StateManager(self._service, filename)
|
||||
if getattr(self._service, "_filename", None) is not None:
|
||||
self._service._state_manager = self._state_manager
|
||||
self._state_manager.load_state()
|
||||
self._observer = DataServiceObserver(self._state_manager)
|
||||
self._state_manager.load_state()
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Initializes the asyncio event loop and starts the server.
|
||||
|
||||
This method should be called to start the server after it's been instantiated.
|
||||
|
||||
Raises
|
||||
------
|
||||
Exception
|
||||
If there's an error while running the server, the error will be propagated
|
||||
after the server is shut down.
|
||||
"""
|
||||
try:
|
||||
self._loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
try:
|
||||
self._loop.run_until_complete(self.serve())
|
||||
except Exception:
|
||||
self._loop.run_until_complete(self.shutdown())
|
||||
raise
|
||||
asyncio.run(self.serve())
|
||||
|
||||
async def serve(self) -> None:
|
||||
process_id = os.getpid()
|
||||
@@ -230,32 +189,17 @@ class Server:
|
||||
|
||||
logger.info("Finished server process [%s]", process_id)
|
||||
|
||||
async def startup(self) -> None: # noqa: C901
|
||||
async def startup(self) -> None:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._loop.set_exception_handler(self.custom_exception_handler)
|
||||
self.install_signal_handlers()
|
||||
self._service._task_manager.start_autostart_tasks()
|
||||
|
||||
if self._enable_rpc:
|
||||
self.executor = ThreadPoolExecutor()
|
||||
self._rpc_server = self._rpc_server_type(
|
||||
self._service,
|
||||
port=self._rpc_port,
|
||||
protocol_config={
|
||||
"allow_all_attrs": True,
|
||||
"allow_setattr": True,
|
||||
},
|
||||
)
|
||||
future_or_task = self._loop.run_in_executor(
|
||||
executor=self.executor, func=self._rpc_server.start
|
||||
)
|
||||
self.servers["rpyc"] = future_or_task
|
||||
for server in self._additional_servers:
|
||||
addin_server = server["server"](
|
||||
self._service,
|
||||
port=server["port"],
|
||||
data_service_observer=self._observer,
|
||||
host=self._host,
|
||||
state_manager=self._state_manager,
|
||||
port=server["port"],
|
||||
**server["kwargs"],
|
||||
)
|
||||
|
||||
@@ -266,49 +210,13 @@ class Server:
|
||||
future_or_task = self._loop.create_task(addin_server.serve())
|
||||
self.servers[server_name] = future_or_task
|
||||
if self._enable_web:
|
||||
self._wapi = WebAPI(
|
||||
service=self._service,
|
||||
state_manager=self._state_manager,
|
||||
self._web_server = WebServer(
|
||||
data_service_observer=self._observer,
|
||||
host=self._host,
|
||||
port=self._web_port,
|
||||
**self._kwargs,
|
||||
)
|
||||
web_server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
self._wapi.fastapi_app, host=self._host, port=self._web_port
|
||||
)
|
||||
)
|
||||
|
||||
def sio_callback(
|
||||
full_access_path: str, value: Any, cached_value_dict: dict[str, Any]
|
||||
) -> None:
|
||||
if cached_value_dict != {}:
|
||||
serialized_value = dump(value)
|
||||
if cached_value_dict["type"] != "method":
|
||||
cached_value_dict["type"] = serialized_value["type"]
|
||||
|
||||
cached_value_dict["value"] = serialized_value["value"]
|
||||
|
||||
async def notify() -> None:
|
||||
try:
|
||||
await self._wapi.sio.emit(
|
||||
"notify",
|
||||
{
|
||||
"data": {
|
||||
"full_access_path": full_access_path,
|
||||
"value": cached_value_dict,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send notification: %s", e)
|
||||
|
||||
self._loop.create_task(notify())
|
||||
|
||||
self._observer.add_notification_callback(sio_callback)
|
||||
|
||||
# overwrite uvicorn's signal handlers, otherwise it will bogart SIGINT and
|
||||
# SIGTERM, which makes it impossible to escape out of
|
||||
web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
|
||||
future_or_task = self._loop.create_task(web_server.serve())
|
||||
future_or_task = self._loop.create_task(self._web_server.serve())
|
||||
self.servers["web"] = future_or_task
|
||||
|
||||
async def main_loop(self) -> None:
|
||||
@@ -319,16 +227,11 @@ class Server:
|
||||
logger.info("Shutting down")
|
||||
|
||||
logger.info("Saving data to %s.", self._state_manager.filename)
|
||||
if self._state_manager is not None:
|
||||
self._state_manager.save_state()
|
||||
|
||||
await self.__cancel_servers()
|
||||
await self.__cancel_tasks()
|
||||
|
||||
if hasattr(self, "_rpc_server") and self._enable_rpc:
|
||||
logger.debug("Closing rpyc server.")
|
||||
self._rpc_server.close()
|
||||
|
||||
async def __cancel_servers(self) -> None:
|
||||
for server_name, task in self.servers.items():
|
||||
task.cancel()
|
||||
@@ -382,7 +285,7 @@ class Server:
|
||||
|
||||
async def emit_exception() -> None:
|
||||
try:
|
||||
await self._wapi.sio.emit(
|
||||
await self._web_server._sio.emit(
|
||||
"exception",
|
||||
{
|
||||
"data": {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import socketio # type: ignore[import-untyped]
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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.utils.logging import SocketIOHandler
|
||||
from pydase.version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateDict(TypedDict):
|
||||
"""
|
||||
A TypedDict subclass representing a dictionary used for updating attributes in a
|
||||
DataService.
|
||||
|
||||
Attributes:
|
||||
----------
|
||||
name : str
|
||||
The name of the attribute to be updated in the DataService instance.
|
||||
If the attribute is part of a nested structure, this would be the name of the
|
||||
attribute in the last nested object. For example, for an attribute access path
|
||||
'attr1.list_attr[0].attr2', 'attr2' would be the name.
|
||||
|
||||
parent_path : str
|
||||
The access path for the parent object of the attribute to be updated. This is
|
||||
used to construct the full access path for the attribute. For example, for an
|
||||
attribute access path 'attr1.list_attr[0].attr2', 'attr1.list_attr[0]' would be
|
||||
the parent_path.
|
||||
|
||||
value : Any
|
||||
The new value to be assigned to the attribute. The type of this value should
|
||||
match the type of the attribute to be updated.
|
||||
"""
|
||||
|
||||
name: str
|
||||
parent_path: str
|
||||
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
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
service: DataService,
|
||||
state_manager: StateManager,
|
||||
frontend: str | Path | None = None,
|
||||
css: str | Path | None = None,
|
||||
enable_cors: bool = True,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.service = service
|
||||
self.state_manager = state_manager
|
||||
self.frontend = frontend
|
||||
self.css = css
|
||||
self.enable_cors = enable_cors
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
self.setup_socketio()
|
||||
self.setup_fastapi_app()
|
||||
self.setup_logging_handler()
|
||||
|
||||
def setup_logging_handler(self) -> None:
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(SocketIOHandler(self.__sio))
|
||||
|
||||
def setup_socketio(self) -> None:
|
||||
# the socketio ASGI app, to notify clients when params update
|
||||
if self.enable_cors:
|
||||
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
|
||||
else:
|
||||
sio = socketio.AsyncServer(async_mode="asgi")
|
||||
|
||||
@sio.event
|
||||
def set_attribute(sid: str, data: UpdateDict) -> Any:
|
||||
logger.debug("Received frontend update: %s", data)
|
||||
path_list = [*data["parent_path"].split("."), data["name"]]
|
||||
path_list.remove("DataService") # always at the start, does not do anything
|
||||
path = ".".join(path_list)
|
||||
return self.state_manager.set_service_attribute_value_by_path(
|
||||
path=path, value=data["value"]
|
||||
)
|
||||
|
||||
@sio.event
|
||||
def run_method(sid: str, data: RunMethodDict) -> Any:
|
||||
logger.debug("Running method: %s", 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)
|
||||
|
||||
def setup_fastapi_app(self) -> None:
|
||||
app = FastAPI()
|
||||
|
||||
if self.enable_cors:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_credentials=True,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.mount("/ws", self.__sio_app)
|
||||
|
||||
@app.get("/version")
|
||||
def version() -> str:
|
||||
return __version__
|
||||
|
||||
@app.get("/name")
|
||||
def name() -> str:
|
||||
return self.service.get_service_name()
|
||||
|
||||
@app.get("/service-properties")
|
||||
def service_properties() -> dict[str, Any]:
|
||||
return self.state_manager.cache
|
||||
|
||||
# exposing custom.css file provided by user
|
||||
if self.css is not None:
|
||||
|
||||
@app.get("/custom.css")
|
||||
async def styles() -> FileResponse:
|
||||
return FileResponse(str(self.css))
|
||||
|
||||
app.mount(
|
||||
"/",
|
||||
StaticFiles(
|
||||
directory=Path(__file__).parent.parent / "frontend",
|
||||
html=True,
|
||||
),
|
||||
)
|
||||
|
||||
self.__fastapi_app = app
|
||||
|
||||
@property
|
||||
def sio(self) -> socketio.AsyncServer:
|
||||
return self.__sio
|
||||
|
||||
@property
|
||||
def fastapi_app(self) -> FastAPI:
|
||||
return self.__fastapi_app
|
||||
3
src/pydase/server/web_server/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from pydase.server.web_server.web_server import WebServer
|
||||
|
||||
__all__ = ["WebServer"]
|
||||
172
src/pydase/server/web_server/sio_setup.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import click
|
||||
import socketio # type: ignore[import-untyped]
|
||||
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.utils.helpers import get_object_attr_from_path
|
||||
from pydase.utils.logging import SocketIOHandler
|
||||
from pydase.utils.serialization.deserializer import Deserializer
|
||||
from pydase.utils.serialization.serializer import SerializedObject, dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateDict(TypedDict):
|
||||
"""
|
||||
A TypedDict subclass representing a dictionary used for updating attributes in a
|
||||
DataService.
|
||||
|
||||
Attributes:
|
||||
----------
|
||||
access_path : string
|
||||
The full access path of the attribute to be updated.
|
||||
value : SerializedObject
|
||||
The serialized new value to be assigned to the attribute.
|
||||
"""
|
||||
|
||||
access_path: str
|
||||
value: SerializedObject
|
||||
|
||||
|
||||
class TriggerMethodDict(TypedDict):
|
||||
access_path: str
|
||||
args: SerializedObject
|
||||
kwargs: SerializedObject
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def setup_sio_server(
|
||||
observer: DataServiceObserver,
|
||||
enable_cors: bool,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> socketio.AsyncServer:
|
||||
"""
|
||||
Sets up and configures a Socket.IO asynchronous server.
|
||||
|
||||
Args:
|
||||
observer (DataServiceObserver):
|
||||
The observer managing state updates and communication.
|
||||
enable_cors (bool):
|
||||
Flag indicating whether CORS should be enabled for the server.
|
||||
loop (asyncio.AbstractEventLoop):
|
||||
The event loop in which the server will run.
|
||||
|
||||
Returns:
|
||||
socketio.AsyncServer: The configured Socket.IO asynchronous server.
|
||||
"""
|
||||
|
||||
state_manager = observer.state_manager
|
||||
|
||||
if enable_cors:
|
||||
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
|
||||
else:
|
||||
sio = socketio.AsyncServer(async_mode="asgi")
|
||||
|
||||
setup_sio_events(sio, state_manager)
|
||||
setup_logging_handler(sio)
|
||||
|
||||
# Add notification callback to observer
|
||||
def sio_callback(
|
||||
full_access_path: str, value: Any, cached_value_dict: SerializedObject
|
||||
) -> None:
|
||||
if cached_value_dict != {}:
|
||||
|
||||
async def notify() -> None:
|
||||
try:
|
||||
await sio.emit(
|
||||
"notify",
|
||||
{
|
||||
"data": {
|
||||
"full_access_path": full_access_path,
|
||||
"value": cached_value_dict,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send notification: %s", e)
|
||||
|
||||
loop.create_task(notify())
|
||||
|
||||
observer.add_notification_callback(sio_callback)
|
||||
|
||||
return sio
|
||||
|
||||
|
||||
def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) -> None: # noqa: C901
|
||||
@sio.event # type: ignore
|
||||
async def connect(sid: str, environ: Any) -> None:
|
||||
logging.debug("Client [%s] connected", click.style(str(sid), fg="cyan"))
|
||||
|
||||
@sio.event # type: ignore
|
||||
async def disconnect(sid: str) -> None:
|
||||
logging.debug("Client [%s] disconnected", click.style(str(sid), fg="cyan"))
|
||||
|
||||
@sio.event # type: ignore
|
||||
async def service_serialization(sid: str) -> SerializedObject:
|
||||
logging.debug(
|
||||
"Client [%s] requested service serialization",
|
||||
click.style(str(sid), fg="cyan"),
|
||||
)
|
||||
return state_manager.cache
|
||||
|
||||
@sio.event
|
||||
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None: # type: ignore
|
||||
path = data["access_path"]
|
||||
|
||||
try:
|
||||
state_manager.set_service_attribute_value_by_path(
|
||||
path=path, serialized_value=data["value"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return dump(e)
|
||||
|
||||
@sio.event
|
||||
async def get_value(sid: str, access_path: str) -> SerializedObject:
|
||||
try:
|
||||
return state_manager._data_service_cache.get_value_dict_from_cache(
|
||||
access_path
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return dump(e)
|
||||
|
||||
@sio.event
|
||||
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
|
||||
try:
|
||||
method = get_object_attr_from_path(
|
||||
state_manager.service, data["access_path"]
|
||||
)
|
||||
args = Deserializer.deserialize(data["args"])
|
||||
kwargs: dict[str, Any] = Deserializer.deserialize(data["kwargs"])
|
||||
return dump(method(*args, **kwargs))
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return dump(e)
|
||||
|
||||
|
||||
def setup_logging_handler(sio: socketio.AsyncServer) -> None:
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(SocketIOHandler(sio))
|
||||
190
src/pydase/server/web_server/web_server.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import socketio # type: ignore[import-untyped]
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from pydase.config import ServiceConfig, WebServerConfig
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.server.web_server.sio_setup import (
|
||||
setup_sio_server,
|
||||
)
|
||||
from pydase.utils.serialization.serializer import generate_serialized_data_paths
|
||||
from pydase.version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebServer:
|
||||
"""
|
||||
Represents a web server that adheres to the AdditionalServerProtocol, designed to
|
||||
work with a DataService instance. This server facilitates client-server
|
||||
communication and state management through web protocols and socket connections.
|
||||
|
||||
The WebServer class initializes and manages a web server environment using FastAPI
|
||||
and Socket.IO, allowing for HTTP and WebSocket communications. It incorporates CORS
|
||||
(Cross-Origin Resource Sharing) support, custom CSS, and serves a frontend static
|
||||
files directory. It also initializes web server settings based on configuration
|
||||
files or generates default settings if necessary.
|
||||
|
||||
Configuration for the web server (like service configuration directory and whether
|
||||
to generate new web settings) is determined in the following order of precedence:
|
||||
1. Values provided directly to the constructor.
|
||||
2. Environment variable settings (via configuration classes like
|
||||
`pydase.config.ServiceConfig` and `pydase.config.WebServerConfig`).
|
||||
3. Default values defined in the configuration classes.
|
||||
|
||||
Args:
|
||||
data_service_observer (DataServiceObserver): Observer for the DataService,
|
||||
handling state updates and communication to connected clients.
|
||||
host (str): Hostname or IP address where the server is accessible. Commonly
|
||||
'0.0.0.0' to bind to all network interfaces.
|
||||
port (int): Port number on which the server listens. Typically in the range
|
||||
1024-65535 (non-standard ports).
|
||||
css (str | Path | None, optional): Path to a custom CSS file for styling the
|
||||
frontend. If None, no custom styles are applied. Defaults to None.
|
||||
enable_cors (bool, optional): Flag to enable or disable CORS policy. When True,
|
||||
CORS is enabled, allowing cross-origin requests. Defaults to True.
|
||||
config_dir (Path | None, optional): Path to the configuration
|
||||
directory where the web settings will be stored. Defaults to
|
||||
`pydase.config.ServiceConfig().config_dir`.
|
||||
generate_new_web_settings (bool | None, optional): Flag to enable or disable
|
||||
generation of new web settings if the configuration file is missing. Defaults
|
||||
to `pydase.config.WebServerConfig().generate_new_web_settings`.
|
||||
**kwargs (Any): Additional unused keyword arguments.
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
data_service_observer: DataServiceObserver,
|
||||
host: str,
|
||||
port: int,
|
||||
css: str | Path | None = None,
|
||||
enable_cors: bool = True,
|
||||
config_dir: Path = ServiceConfig().config_dir,
|
||||
generate_web_settings: bool = WebServerConfig().generate_web_settings,
|
||||
frontend_src: Path = Path(__file__).parent.parent.parent / "frontend",
|
||||
) -> None:
|
||||
self.observer = data_service_observer
|
||||
self.state_manager = self.observer.state_manager
|
||||
self.service = self.state_manager.service
|
||||
self.port = port
|
||||
self.host = host
|
||||
self.css = css
|
||||
self.enable_cors = enable_cors
|
||||
self.frontend_src = frontend_src
|
||||
self._service_config_dir = config_dir
|
||||
self._generate_web_settings = generate_web_settings
|
||||
self._loop: asyncio.AbstractEventLoop
|
||||
self._initialise_configuration()
|
||||
|
||||
async def serve(self) -> None:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._setup_socketio()
|
||||
self._setup_fastapi_app()
|
||||
self.web_server = uvicorn.Server(
|
||||
uvicorn.Config(self.__fastapi_app, host=self.host, port=self.port)
|
||||
)
|
||||
# overwrite uvicorn's signal handlers, otherwise it will bogart SIGINT and
|
||||
# SIGTERM, which makes it impossible to escape out of
|
||||
self.web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
|
||||
await self.web_server.serve()
|
||||
|
||||
def _initialise_configuration(self) -> None:
|
||||
logger.debug("Initialising web server configuration...")
|
||||
|
||||
file_path = self._service_config_dir / "web_settings.json"
|
||||
|
||||
if self._generate_web_settings:
|
||||
# File does not exist, create it with default content
|
||||
logger.debug("Generating web settings file...")
|
||||
file_path.parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
) # Ensure directory exists
|
||||
file_path.write_text(json.dumps(self.web_settings, indent=4))
|
||||
|
||||
def _get_web_settings_from_file(self) -> dict[str, dict[str, Any]]:
|
||||
file_path = self._service_config_dir / "web_settings.json"
|
||||
web_settings = {}
|
||||
|
||||
# File exists, read its content
|
||||
if file_path.exists():
|
||||
logger.debug(
|
||||
"Reading configuration from file '%s' ...", file_path.absolute()
|
||||
)
|
||||
|
||||
web_settings = json.loads(file_path.read_text())
|
||||
|
||||
return web_settings
|
||||
|
||||
@property
|
||||
def web_settings(self) -> dict[str, dict[str, Any]]:
|
||||
current_web_settings = self._get_web_settings_from_file()
|
||||
for path in generate_serialized_data_paths(self.state_manager.cache_value):
|
||||
if path in current_web_settings:
|
||||
continue
|
||||
|
||||
current_web_settings[path] = {
|
||||
"displayName": path.split(".")[-1],
|
||||
"display": True,
|
||||
}
|
||||
|
||||
return current_web_settings
|
||||
|
||||
def _setup_socketio(self) -> None:
|
||||
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
|
||||
self.__sio_app = socketio.ASGIApp(self._sio)
|
||||
|
||||
def _setup_fastapi_app(self) -> None: # noqa: C901
|
||||
app = FastAPI()
|
||||
|
||||
if self.enable_cors:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_credentials=True,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.mount("/ws", self.__sio_app)
|
||||
|
||||
@app.get("/version")
|
||||
def version() -> str:
|
||||
return __version__
|
||||
|
||||
@app.get("/name")
|
||||
def name() -> str:
|
||||
return type(self.service).__name__
|
||||
|
||||
@app.get("/service-properties")
|
||||
def service_properties() -> dict[str, Any]:
|
||||
return self.state_manager.cache # type: ignore
|
||||
|
||||
@app.get("/web-settings")
|
||||
def web_settings() -> dict[str, Any]:
|
||||
return self.web_settings
|
||||
|
||||
# exposing custom.css file provided by user
|
||||
@app.get("/custom.css")
|
||||
async def styles() -> Response:
|
||||
if self.css is not None:
|
||||
return FileResponse(str(self.css))
|
||||
|
||||
return Response(content="", media_type="text/css")
|
||||
|
||||
app.mount(
|
||||
"/",
|
||||
StaticFiles(
|
||||
directory=self.frontend_src,
|
||||
html=True,
|
||||
),
|
||||
)
|
||||
|
||||
self.__fastapi_app = app
|
||||
27
src/pydase/utils/decorators.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from pydase.utils.helpers import function_has_arguments
|
||||
|
||||
|
||||
class FunctionDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator to mark a DataService method for frontend rendering. Ensures that the
|
||||
method does not contain arguments, as they are not supported for frontend rendering.
|
||||
"""
|
||||
|
||||
if function_has_arguments(func):
|
||||
raise FunctionDefinitionError(
|
||||
"The @frontend decorator requires functions without arguments. Function "
|
||||
f"'{func.__name__}' has at least one argument. "
|
||||
"Please remove the argument(s) from this function to use it with the "
|
||||
"@frontend decorator."
|
||||
)
|
||||
|
||||
# Mark the function for frontend display.
|
||||
func._display_in_frontend = True # type: ignore
|
||||
return func
|
||||
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
@@ -29,13 +30,13 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
|
||||
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
|
||||
|
||||
|
||||
def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
|
||||
def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
|
||||
"""
|
||||
Traverse the object tree according to the given path.
|
||||
|
||||
Args:
|
||||
target_obj: The root object to start the traversal from.
|
||||
path: A list of attribute names representing the path to traverse.
|
||||
path: Access path of the object.
|
||||
|
||||
Returns:
|
||||
The attribute at the end of the path. If the path includes a list index,
|
||||
@@ -45,7 +46,8 @@ def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
|
||||
Raises:
|
||||
ValueError: If a list index in the path is not a valid integer.
|
||||
"""
|
||||
for part in path:
|
||||
path_list = path.split(".") if path != "" else []
|
||||
for part in path_list:
|
||||
try:
|
||||
# Try to split the part into attribute and index
|
||||
attr, index_str = part.split("[", maxsplit=1)
|
||||
@@ -62,49 +64,6 @@ def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
|
||||
return target_obj
|
||||
|
||||
|
||||
def convert_arguments_to_hinted_types(
|
||||
args: dict[str, Any], type_hints: dict[str, Any]
|
||||
) -> dict[str, Any] | str:
|
||||
"""
|
||||
Convert the given arguments to their types hinted in the type_hints dictionary.
|
||||
|
||||
This function attempts to convert each argument in the args dictionary to the type
|
||||
specified for the argument in the type_hints dictionary. If the conversion is
|
||||
successful, the function replaces the original argument in the args dictionary with
|
||||
the converted argument.
|
||||
|
||||
If a ValueError is raised during the conversion of an argument, the function logs
|
||||
an error message and returns the error message as a string.
|
||||
|
||||
Args:
|
||||
args: A dictionary of arguments to be converted. The keys are argument names
|
||||
and the values are the arguments themselves.
|
||||
type_hints: A dictionary of type hints for the arguments. The keys are
|
||||
argument names and the values are the hinted types.
|
||||
|
||||
Returns:
|
||||
A dictionary of the converted arguments if all conversions are successful,
|
||||
or an error message string if a ValueError is raised during a conversion.
|
||||
"""
|
||||
|
||||
# Convert arguments to their hinted types
|
||||
for arg_name, arg_value in args.items():
|
||||
if arg_name in type_hints:
|
||||
arg_type = type_hints[arg_name]
|
||||
if isinstance(arg_type, type):
|
||||
# Attempt to convert the argument to its hinted type
|
||||
try:
|
||||
args[arg_name] = arg_type(arg_value)
|
||||
except ValueError:
|
||||
msg = (
|
||||
f"Failed to convert argument '{arg_name}' to type "
|
||||
f"{arg_type.__name__}"
|
||||
)
|
||||
logger.error(msg)
|
||||
return msg
|
||||
return args
|
||||
|
||||
|
||||
def update_value_if_changed(
|
||||
target: Any, attr_name_or_index: str | int, new_value: Any
|
||||
) -> None:
|
||||
@@ -177,20 +136,53 @@ def parse_list_attr_and_index(attr_string: str) -> tuple[str, int | None]:
|
||||
return attr_name, index
|
||||
|
||||
|
||||
def get_component_class_names() -> list[str]:
|
||||
def get_component_classes() -> list[type]:
|
||||
"""
|
||||
Returns the names of the component classes in a list.
|
||||
|
||||
It takes the names from the pydase/components/__init__.py file, so this file should
|
||||
always be up-to-date with the currently available components.
|
||||
|
||||
Returns:
|
||||
list[str]: List of component class names
|
||||
Returns references to the component classes in a list.
|
||||
"""
|
||||
import pydase.components
|
||||
|
||||
return pydase.components.__all__
|
||||
return [
|
||||
getattr(pydase.components, cls_name) for cls_name in pydase.components.__all__
|
||||
]
|
||||
|
||||
|
||||
def is_property_attribute(target_obj: Any, attr_name: str) -> bool:
|
||||
def get_data_service_class_reference() -> Any:
|
||||
import pydase.data_service.data_service
|
||||
|
||||
return getattr(pydase.data_service.data_service, "DataService")
|
||||
|
||||
|
||||
def is_property_attribute(target_obj: Any, access_path: str) -> bool:
|
||||
parent_path, attr_name = (
|
||||
".".join(access_path.split(".")[:-1]),
|
||||
access_path.split(".")[-1],
|
||||
)
|
||||
target_obj = get_object_attr_from_path(target_obj, parent_path)
|
||||
return isinstance(getattr(type(target_obj), attr_name, None), property)
|
||||
|
||||
|
||||
def function_has_arguments(func: Callable[..., Any]) -> bool:
|
||||
sig = inspect.signature(func)
|
||||
parameters = dict(sig.parameters)
|
||||
# Remove 'self' parameter for instance methods.
|
||||
parameters.pop("self", None)
|
||||
|
||||
# Check if there are any parameters left which would indicate additional arguments.
|
||||
if len(parameters) > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def render_in_frontend(func: Callable[..., Any]) -> bool:
|
||||
"""Determines if the method should be rendered in the frontend.
|
||||
|
||||
It checks if the "@frontend" decorator was used or the method is a coroutine."""
|
||||
|
||||
if inspect.iscoroutinefunction(func):
|
||||
return True
|
||||
|
||||
try:
|
||||
return func._display_in_frontend # type: ignore
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
0
src/pydase/utils/serialization/__init__.py
Normal file
151
src/pydase/utils/serialization/deserializer.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, NoReturn, cast
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
from pydase.utils.helpers import get_component_classes
|
||||
from pydase.utils.serialization.types import SerializedObject
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Deserializer:
|
||||
@classmethod
|
||||
def deserialize(cls, serialized_object: SerializedObject) -> Any:
|
||||
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
||||
None: None,
|
||||
"int": cls.deserialize_primitive,
|
||||
"float": cls.deserialize_primitive,
|
||||
"bool": cls.deserialize_primitive,
|
||||
"str": cls.deserialize_primitive,
|
||||
"NoneType": cls.deserialize_primitive,
|
||||
"Quantity": cls.deserialize_quantity,
|
||||
"Enum": cls.deserialize_enum,
|
||||
"ColouredEnum": lambda serialized_object: cls.deserialize_enum(
|
||||
serialized_object, enum_class=pydase.components.ColouredEnum
|
||||
),
|
||||
"list": cls.deserialize_list,
|
||||
"dict": cls.deserialize_dict,
|
||||
"method": cls.deserialize_method,
|
||||
"Exception": cls.deserialize_exception,
|
||||
}
|
||||
|
||||
# First go through handled types (as ColouredEnum is also within the components)
|
||||
handler = type_handler.get(serialized_object["type"])
|
||||
if handler:
|
||||
return handler(serialized_object)
|
||||
|
||||
# Custom types like Components or DataService classes
|
||||
component_class = cls.get_component_class(serialized_object["type"])
|
||||
if component_class:
|
||||
return cls.deserialize_component_type(serialized_object, component_class)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def deserialize_primitive(cls, serialized_object: SerializedObject) -> Any:
|
||||
if serialized_object["type"] == "float":
|
||||
return float(serialized_object["value"])
|
||||
return serialized_object["value"]
|
||||
|
||||
@classmethod
|
||||
def deserialize_quantity(cls, serialized_object: SerializedObject) -> Any:
|
||||
return u.convert_to_quantity(serialized_object["value"]) # type: ignore
|
||||
|
||||
@classmethod
|
||||
def deserialize_enum(
|
||||
cls,
|
||||
serialized_object: SerializedObject,
|
||||
enum_class: type[enum.Enum] = enum.Enum,
|
||||
) -> Any:
|
||||
return enum_class(serialized_object["name"], serialized_object["enum"])[ # type: ignore
|
||||
serialized_object["value"]
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def deserialize_list(cls, serialized_object: SerializedObject) -> Any:
|
||||
return [
|
||||
cls.deserialize(item)
|
||||
for item in cast(list[SerializedObject], serialized_object["value"])
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def deserialize_dict(cls, serialized_object: SerializedObject) -> Any:
|
||||
return {
|
||||
key: cls.deserialize(value)
|
||||
for key, value in cast(
|
||||
dict[str, SerializedObject], serialized_object["value"]
|
||||
).items()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def deserialize_method(cls, serialized_object: SerializedObject) -> Any:
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def deserialize_exception(cls, serialized_object: SerializedObject) -> NoReturn:
|
||||
import builtins
|
||||
|
||||
try:
|
||||
exception = getattr(builtins, serialized_object["name"]) # type: ignore
|
||||
except AttributeError:
|
||||
exception = type(serialized_object["name"], (Exception,), {}) # type: ignore
|
||||
raise exception(serialized_object["value"])
|
||||
|
||||
@staticmethod
|
||||
def get_component_class(type_name: str | None) -> type | None:
|
||||
for component_class in get_component_classes():
|
||||
if type_name == component_class.__name__:
|
||||
return component_class
|
||||
if type_name == "DataService":
|
||||
import pydase
|
||||
|
||||
return pydase.DataService
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def create_attr_property(cls, serialized_attr: SerializedObject) -> property:
|
||||
attr_name = serialized_attr["full_access_path"].split(".")[-1]
|
||||
|
||||
def get(self) -> Any: # type: ignore
|
||||
return getattr(self, f"_{attr_name}")
|
||||
|
||||
get.__doc__ = serialized_attr["doc"]
|
||||
|
||||
def set(self, value: Any) -> None: # type: ignore
|
||||
return setattr(self, f"_{attr_name}", value)
|
||||
|
||||
if serialized_attr["readonly"]:
|
||||
return property(get)
|
||||
return property(get, set)
|
||||
|
||||
@classmethod
|
||||
def deserialize_component_type(
|
||||
cls, serialized_object: SerializedObject, base_class: type
|
||||
) -> Any:
|
||||
def create_proxy_class(serialized_object: SerializedObject) -> type:
|
||||
class_bases = (base_class,)
|
||||
class_attrs = {}
|
||||
|
||||
# Process and add properties based on the serialized object
|
||||
for key, value in cast(
|
||||
dict[str, SerializedObject], serialized_object["value"]
|
||||
).items():
|
||||
if value["type"] != "method":
|
||||
class_attrs[key] = cls.create_attr_property(value)
|
||||
# Initialize a placeholder for the attribute to avoid AttributeError
|
||||
class_attrs[f"_{key}"] = cls.deserialize(value)
|
||||
|
||||
# Create the dynamic class with the given name and attributes
|
||||
return type(serialized_object["name"], class_bases, class_attrs) # type: ignore
|
||||
|
||||
return create_proxy_class(serialized_object)()
|
||||
|
||||
|
||||
def loads(serialized_object: SerializedObject) -> Any:
|
||||
return Deserializer.deserialize(serialized_object)
|
||||
508
src/pydase/utils/serialization/serializer.py
Normal file
@@ -0,0 +1,508 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
import pydase.units as u
|
||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||
from pydase.data_service.task_manager import TaskStatus
|
||||
from pydase.utils.helpers import (
|
||||
get_attribute_doc,
|
||||
get_component_classes,
|
||||
get_data_service_class_reference,
|
||||
parse_list_attr_and_index,
|
||||
render_in_frontend,
|
||||
)
|
||||
from pydase.utils.serialization.types import (
|
||||
DataServiceTypes,
|
||||
SerializedBool,
|
||||
SerializedDataService,
|
||||
SerializedDict,
|
||||
SerializedEnum,
|
||||
SerializedException,
|
||||
SerializedFloat,
|
||||
SerializedInteger,
|
||||
SerializedList,
|
||||
SerializedMethod,
|
||||
SerializedNoneType,
|
||||
SerializedObject,
|
||||
SerializedQuantity,
|
||||
SerializedString,
|
||||
SignatureDict,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SerializationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SerializationPathError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SerializationValueError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Serializer:
|
||||
@staticmethod
|
||||
def serialize_object(obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
|
||||
result: SerializedObject
|
||||
|
||||
if isinstance(obj, Exception):
|
||||
result = Serializer._serialize_exception(obj)
|
||||
|
||||
elif isinstance(obj, AbstractDataService):
|
||||
result = Serializer._serialize_data_service(obj, access_path=access_path)
|
||||
|
||||
elif isinstance(obj, list):
|
||||
result = Serializer._serialize_list(obj, access_path=access_path)
|
||||
|
||||
elif isinstance(obj, dict):
|
||||
result = Serializer._serialize_dict(obj, access_path=access_path)
|
||||
|
||||
# Special handling for u.Quantity
|
||||
elif isinstance(obj, u.Quantity):
|
||||
result = Serializer._serialize_quantity(obj, access_path=access_path)
|
||||
|
||||
# Handling for Enums
|
||||
elif isinstance(obj, Enum):
|
||||
result = Serializer._serialize_enum(obj, access_path=access_path)
|
||||
|
||||
# Methods and coroutines
|
||||
elif inspect.isfunction(obj) or inspect.ismethod(obj):
|
||||
result = Serializer._serialize_method(obj, access_path=access_path)
|
||||
|
||||
elif isinstance(obj, int | float | bool | str | None):
|
||||
result = Serializer._serialize_primitive(obj, access_path=access_path)
|
||||
|
||||
try:
|
||||
return result
|
||||
except UnboundLocalError:
|
||||
raise SerializationError(
|
||||
f"Could not serialized object of type {type(obj)}."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_primitive(
|
||||
obj: float | bool | str | None,
|
||||
access_path: str,
|
||||
) -> (
|
||||
SerializedInteger
|
||||
| SerializedFloat
|
||||
| SerializedBool
|
||||
| SerializedString
|
||||
| SerializedNoneType
|
||||
):
|
||||
doc = get_attribute_doc(obj)
|
||||
return { # type: ignore
|
||||
"full_access_path": access_path,
|
||||
"doc": doc,
|
||||
"readonly": False,
|
||||
"type": type(obj).__name__,
|
||||
"value": obj,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_exception(obj: Exception) -> SerializedException:
|
||||
return {
|
||||
"full_access_path": "",
|
||||
"doc": None,
|
||||
"readonly": True,
|
||||
"type": "Exception",
|
||||
"value": obj.args[0],
|
||||
"name": obj.__class__.__name__,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_enum(obj: Enum, access_path: str = "") -> SerializedEnum:
|
||||
import pydase.components.coloured_enum
|
||||
|
||||
value = obj.name
|
||||
doc = obj.__doc__
|
||||
class_name = type(obj).__name__
|
||||
if sys.version_info < (3, 11) and doc == "An enumeration.":
|
||||
doc = None
|
||||
if isinstance(obj, pydase.components.coloured_enum.ColouredEnum):
|
||||
obj_type: Literal["ColouredEnum", "Enum"] = "ColouredEnum"
|
||||
else:
|
||||
obj_type = "Enum"
|
||||
|
||||
return {
|
||||
"full_access_path": access_path,
|
||||
"name": class_name,
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": False,
|
||||
"doc": doc,
|
||||
"enum": {
|
||||
name: member.value for name, member in obj.__class__.__members__.items()
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_quantity(
|
||||
obj: u.Quantity, access_path: str = ""
|
||||
) -> SerializedQuantity:
|
||||
doc = get_attribute_doc(obj)
|
||||
value: u.QuantityDict = {"magnitude": obj.m, "unit": str(obj.u)}
|
||||
return {
|
||||
"full_access_path": access_path,
|
||||
"type": "Quantity",
|
||||
"value": value,
|
||||
"readonly": False,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_dict(obj: dict[str, Any], access_path: str = "") -> SerializedDict:
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
value = {
|
||||
key: Serializer.serialize_object(val, access_path=f'{access_path}["{key}"]')
|
||||
for key, val in obj.items()
|
||||
}
|
||||
return {
|
||||
"full_access_path": access_path,
|
||||
"type": "dict",
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_list(obj: list[Any], access_path: str = "") -> SerializedList:
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
value = [
|
||||
Serializer.serialize_object(o, access_path=f"{access_path}[{i}]")
|
||||
for i, o in enumerate(obj)
|
||||
]
|
||||
return {
|
||||
"full_access_path": access_path,
|
||||
"type": "list",
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_method(
|
||||
obj: Callable[..., Any], access_path: str = ""
|
||||
) -> SerializedMethod:
|
||||
readonly = True
|
||||
doc = get_attribute_doc(obj)
|
||||
frontend_render = render_in_frontend(obj)
|
||||
|
||||
# Store parameters and their anotations in a dictionary
|
||||
sig = inspect.signature(obj)
|
||||
sig.return_annotation
|
||||
|
||||
signature: SignatureDict = {"parameters": {}, "return_annotation": {}}
|
||||
|
||||
for k, v in sig.parameters.items():
|
||||
default_value = cast(
|
||||
dict[str, Any], {} if v.default == inspect._empty else dump(v.default)
|
||||
)
|
||||
default_value.pop("full_access_path", None)
|
||||
signature["parameters"][k] = {
|
||||
"annotation": str(v.annotation),
|
||||
"default": default_value,
|
||||
}
|
||||
|
||||
return {
|
||||
"full_access_path": access_path,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
"async": inspect.iscoroutinefunction(obj),
|
||||
"signature": signature,
|
||||
"frontend_render": frontend_render,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_data_service(
|
||||
obj: AbstractDataService, access_path: str = ""
|
||||
) -> SerializedDataService:
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
obj_type: DataServiceTypes = "DataService"
|
||||
obj_name = obj.__class__.__name__
|
||||
|
||||
# Get component base class if any
|
||||
component_base_cls = next(
|
||||
(cls for cls in get_component_classes() if isinstance(obj, cls)), None
|
||||
)
|
||||
if component_base_cls:
|
||||
obj_type = component_base_cls.__name__ # type: ignore
|
||||
|
||||
# Get the set of DataService class attributes
|
||||
data_service_attr_set = set(dir(get_data_service_class_reference()))
|
||||
# Get the set of the object attributes
|
||||
obj_attr_set = set(dir(obj))
|
||||
# Get the difference between the two sets
|
||||
derived_only_attr_set = obj_attr_set - data_service_attr_set
|
||||
|
||||
value: dict[str, SerializedObject] = {}
|
||||
|
||||
# Iterate over attributes, properties, class attributes, and methods
|
||||
for key in sorted(derived_only_attr_set):
|
||||
if key.startswith("_"):
|
||||
continue # Skip attributes that start with underscore
|
||||
|
||||
# Skip keys that start with "start_" or "stop_" and end with an async
|
||||
# method name
|
||||
if key.startswith(("start_", "stop_")) and key.split("_", 1)[1] in {
|
||||
name
|
||||
for name, _ in inspect.getmembers(
|
||||
obj, predicate=inspect.iscoroutinefunction
|
||||
)
|
||||
}:
|
||||
continue
|
||||
|
||||
val = getattr(obj, key)
|
||||
|
||||
path = f"{access_path}.{key}" if access_path else key
|
||||
serialized_object = Serializer.serialize_object(val, access_path=path)
|
||||
|
||||
# If there's a running task for this method
|
||||
if serialized_object["type"] == "method" and key in obj._task_manager.tasks:
|
||||
serialized_object["value"] = TaskStatus.RUNNING.name
|
||||
|
||||
value[key] = serialized_object
|
||||
|
||||
# If the DataService attribute is a property
|
||||
if isinstance(getattr(obj.__class__, key, None), property):
|
||||
prop: property = getattr(obj.__class__, key)
|
||||
value[key]["readonly"] = prop.fset is None
|
||||
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
|
||||
|
||||
return {
|
||||
"full_access_path": access_path,
|
||||
"name": obj_name,
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
|
||||
def dump(obj: Any) -> SerializedObject:
|
||||
return Serializer.serialize_object(obj)
|
||||
|
||||
|
||||
def set_nested_value_by_path(
|
||||
serialization_dict: dict[str, SerializedObject], path: str, value: Any
|
||||
) -> None:
|
||||
"""
|
||||
Set a value in a nested dictionary structure, which conforms to the serialization
|
||||
format used by `pydase.utils.serializer.Serializer`, using a dot-notation path.
|
||||
|
||||
Args:
|
||||
serialization_dict:
|
||||
The base dictionary representing data serialized with
|
||||
`pydase.utils.serializer.Serializer`.
|
||||
path:
|
||||
The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to
|
||||
set the value.
|
||||
value:
|
||||
The new value to set at the specified path.
|
||||
|
||||
Note:
|
||||
- If the index equals the length of the list, the function will append the
|
||||
serialized representation of the 'value' to the list.
|
||||
"""
|
||||
|
||||
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1]
|
||||
current_dict: dict[str, SerializedObject] = serialization_dict
|
||||
|
||||
try:
|
||||
for path_part in parent_path_parts:
|
||||
next_level_serialized_object = get_next_level_dict_by_key(
|
||||
current_dict, path_part, allow_append=False
|
||||
)
|
||||
current_dict = cast(
|
||||
dict[str, SerializedObject], next_level_serialized_object["value"]
|
||||
)
|
||||
|
||||
next_level_serialized_object = get_next_level_dict_by_key(
|
||||
current_dict, attr_name, allow_append=True
|
||||
)
|
||||
except (SerializationPathError, SerializationValueError, KeyError) as e:
|
||||
logger.error(e)
|
||||
return
|
||||
|
||||
if next_level_serialized_object["type"] == "method": # state change of task
|
||||
next_level_serialized_object["value"] = (
|
||||
"RUNNING" if isinstance(value, TaskStatus) else None
|
||||
)
|
||||
else:
|
||||
serialized_value = Serializer.serialize_object(value, access_path=path)
|
||||
serialized_value["readonly"] = next_level_serialized_object["readonly"]
|
||||
|
||||
keys_to_keep = set(serialized_value.keys())
|
||||
|
||||
next_level_serialized_object.update(serialized_value) # type: ignore
|
||||
|
||||
# removes keys that are not present in the serialized new value
|
||||
for key in list(next_level_serialized_object.keys()):
|
||||
if key not in keys_to_keep:
|
||||
next_level_serialized_object.pop(key, None) # type: ignore
|
||||
|
||||
|
||||
def get_nested_dict_by_path(
|
||||
serialization_dict: dict[str, SerializedObject],
|
||||
path: str,
|
||||
) -> SerializedObject:
|
||||
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1]
|
||||
current_dict: dict[str, SerializedObject] = serialization_dict
|
||||
|
||||
for path_part in parent_path_parts:
|
||||
next_level_serialized_object = get_next_level_dict_by_key(
|
||||
current_dict, path_part, allow_append=False
|
||||
)
|
||||
current_dict = cast(
|
||||
dict[str, SerializedObject], next_level_serialized_object["value"]
|
||||
)
|
||||
return get_next_level_dict_by_key(current_dict, attr_name, allow_append=False)
|
||||
|
||||
|
||||
def get_next_level_dict_by_key(
|
||||
serialization_dict: dict[str, SerializedObject],
|
||||
attr_name: str,
|
||||
*,
|
||||
allow_append: bool = False,
|
||||
) -> SerializedObject:
|
||||
"""
|
||||
Retrieve a nested dictionary entry or list item from a data structure serialized
|
||||
with `pydase.utils.serializer.Serializer`.
|
||||
|
||||
Args:
|
||||
serialization_dict: The base dictionary representing serialized data.
|
||||
attr_name: The key name representing the attribute in the dictionary,
|
||||
e.g. 'list_attr[0]' or 'attr'
|
||||
allow_append: Flag to allow appending a new entry if `index` is out of range by
|
||||
one.
|
||||
|
||||
Returns:
|
||||
The dictionary or list item corresponding to the attribute and index.
|
||||
|
||||
Raises:
|
||||
SerializationPathError: If the path composed of `attr_name` and `index` is
|
||||
invalid or leads to an IndexError or KeyError.
|
||||
SerializationValueError: If the expected nested structure is not a dictionary.
|
||||
"""
|
||||
# Check if the key contains an index part like 'attr_name[<index>]'
|
||||
attr_name, index = parse_list_attr_and_index(attr_name)
|
||||
|
||||
try:
|
||||
if index is not None:
|
||||
next_level_serialized_object = cast(
|
||||
list[SerializedObject], serialization_dict[attr_name]["value"]
|
||||
)[index]
|
||||
else:
|
||||
next_level_serialized_object = serialization_dict[attr_name]
|
||||
except IndexError as e:
|
||||
if (
|
||||
index is not None
|
||||
and allow_append
|
||||
and index
|
||||
== len(cast(list[SerializedObject], serialization_dict[attr_name]["value"]))
|
||||
):
|
||||
# Appending to list
|
||||
cast(list[SerializedObject], serialization_dict[attr_name]["value"]).append(
|
||||
{
|
||||
"full_access_path": "",
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
)
|
||||
next_level_serialized_object = cast(
|
||||
list[SerializedObject], serialization_dict[attr_name]["value"]
|
||||
)[index]
|
||||
else:
|
||||
raise SerializationPathError(
|
||||
f"Error occured trying to change '{attr_name}[{index}]': {e}"
|
||||
)
|
||||
except KeyError:
|
||||
if not allow_append:
|
||||
raise SerializationPathError(
|
||||
f"Error occured trying to access the key '{attr_name}': it is either "
|
||||
"not present in the current dictionary or its value does not contain "
|
||||
"a 'value' key."
|
||||
)
|
||||
serialization_dict[attr_name] = {
|
||||
"full_access_path": "",
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
next_level_serialized_object = serialization_dict[attr_name]
|
||||
|
||||
if not isinstance(next_level_serialized_object, dict):
|
||||
raise SerializationValueError(
|
||||
f"Expected a dictionary at '{attr_name}', but found type "
|
||||
f"'{type(next_level_serialized_object).__name__}' instead."
|
||||
)
|
||||
|
||||
return next_level_serialized_object
|
||||
|
||||
|
||||
def generate_serialized_data_paths(
|
||||
data: dict[str, Any], parent_path: str = ""
|
||||
) -> list[str]:
|
||||
"""
|
||||
Generate a list of access paths for all attributes in a dictionary representing
|
||||
data serialized with `pydase.utils.serializer.Serializer`, excluding those that are
|
||||
methods. This function handles nested structures, including lists, by generating
|
||||
paths for each element in the nested lists.
|
||||
|
||||
Args:
|
||||
data (dict[str, Any]): The dictionary representing serialized data, typically
|
||||
produced by `pydase.utils.serializer.Serializer`.
|
||||
parent_path (str, optional): The base path to prepend to the keys in the `data`
|
||||
dictionary to form the access paths. Defaults to an empty string.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of strings where each string is a dot-notation access path
|
||||
to an attribute in the serialized data. For list elements, the path includes
|
||||
the index in square brackets.
|
||||
"""
|
||||
|
||||
paths: list[str] = []
|
||||
for key, value in data.items():
|
||||
new_path = f"{parent_path}.{key}" if parent_path else key
|
||||
paths.append(new_path)
|
||||
if serialized_dict_is_nested_object(value):
|
||||
if isinstance(value["value"], list):
|
||||
for index, item in enumerate(value["value"]):
|
||||
indexed_key_path = f"{new_path}[{index}]"
|
||||
paths.append(indexed_key_path)
|
||||
if serialized_dict_is_nested_object(item):
|
||||
paths.extend(
|
||||
generate_serialized_data_paths(
|
||||
item["value"], indexed_key_path
|
||||
)
|
||||
)
|
||||
continue
|
||||
paths.extend(generate_serialized_data_paths(value["value"], new_path))
|
||||
return paths
|
||||
|
||||
|
||||
def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool:
|
||||
return (
|
||||
serialized_dict["type"] != "Quantity"
|
||||
and isinstance(serialized_dict["value"], dict)
|
||||
) or isinstance(serialized_dict["value"], list)
|
||||
119
src/pydase/utils/serialization/types.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pydase.units as u
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SignatureDict(TypedDict):
|
||||
parameters: dict[str, dict[str, Any]]
|
||||
return_annotation: dict[str, Any]
|
||||
|
||||
|
||||
class SerializedObjectBase(TypedDict):
|
||||
full_access_path: str
|
||||
doc: str | None
|
||||
readonly: bool
|
||||
|
||||
|
||||
class SerializedInteger(SerializedObjectBase):
|
||||
value: int
|
||||
type: Literal["int"]
|
||||
|
||||
|
||||
class SerializedFloat(SerializedObjectBase):
|
||||
value: float
|
||||
type: Literal["float"]
|
||||
|
||||
|
||||
class SerializedQuantity(SerializedObjectBase):
|
||||
value: u.QuantityDict
|
||||
type: Literal["Quantity"]
|
||||
|
||||
|
||||
class SerializedBool(SerializedObjectBase):
|
||||
value: bool
|
||||
type: Literal["bool"]
|
||||
|
||||
|
||||
class SerializedString(SerializedObjectBase):
|
||||
value: str
|
||||
type: Literal["str"]
|
||||
|
||||
|
||||
class SerializedEnum(SerializedObjectBase):
|
||||
name: str
|
||||
value: str
|
||||
type: Literal["Enum", "ColouredEnum"]
|
||||
enum: dict[str, Any]
|
||||
|
||||
|
||||
class SerializedList(SerializedObjectBase):
|
||||
value: list[SerializedObject]
|
||||
type: Literal["list"]
|
||||
|
||||
|
||||
class SerializedDict(SerializedObjectBase):
|
||||
value: dict[str, SerializedObject]
|
||||
type: Literal["dict"]
|
||||
|
||||
|
||||
class SerializedNoneType(SerializedObjectBase):
|
||||
value: None
|
||||
type: Literal["NoneType"]
|
||||
|
||||
|
||||
class SerializedNoValue(SerializedObjectBase):
|
||||
value: None
|
||||
type: Literal["None"]
|
||||
|
||||
|
||||
SerializedMethod = TypedDict(
|
||||
"SerializedMethod",
|
||||
{
|
||||
"full_access_path": str,
|
||||
"value": Literal["RUNNING"] | None,
|
||||
"type": Literal["method"],
|
||||
"doc": str | None,
|
||||
"readonly": bool,
|
||||
"async": bool,
|
||||
"signature": SignatureDict,
|
||||
"frontend_render": bool,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SerializedException(SerializedObjectBase):
|
||||
name: str
|
||||
value: str
|
||||
type: Literal["Exception"]
|
||||
|
||||
|
||||
DataServiceTypes = Literal["DataService", "Image", "NumberSlider", "DeviceConnection"]
|
||||
|
||||
|
||||
class SerializedDataService(SerializedObjectBase):
|
||||
name: str
|
||||
value: dict[str, SerializedObject]
|
||||
type: DataServiceTypes
|
||||
|
||||
|
||||
SerializedObject = (
|
||||
SerializedBool
|
||||
| SerializedFloat
|
||||
| SerializedInteger
|
||||
| SerializedString
|
||||
| SerializedList
|
||||
| SerializedDict
|
||||
| SerializedNoneType
|
||||
| SerializedMethod
|
||||
| SerializedException
|
||||
| SerializedDataService
|
||||
| SerializedEnum
|
||||
| SerializedQuantity
|
||||
| SerializedNoValue
|
||||
)
|
||||
@@ -1,378 +0,0 @@
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import pydase.units as u
|
||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||
from pydase.utils.helpers import (
|
||||
get_attribute_doc,
|
||||
get_component_class_names,
|
||||
parse_list_attr_and_index,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SerializationPathError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SerializationValueError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Serializer:
|
||||
@staticmethod
|
||||
def serialize_object(obj: Any) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
if isinstance(obj, AbstractDataService):
|
||||
result = Serializer._serialize_data_service(obj)
|
||||
|
||||
elif isinstance(obj, list):
|
||||
result = Serializer._serialize_list(obj)
|
||||
|
||||
elif isinstance(obj, dict):
|
||||
result = Serializer._serialize_dict(obj)
|
||||
|
||||
# Special handling for u.Quantity
|
||||
elif isinstance(obj, u.Quantity):
|
||||
result = Serializer._serialize_quantity(obj)
|
||||
|
||||
# Handling for Enums
|
||||
elif isinstance(obj, Enum):
|
||||
result = Serializer._serialize_enum(obj)
|
||||
|
||||
# Methods and coroutines
|
||||
elif inspect.isfunction(obj) or inspect.ismethod(obj):
|
||||
result = Serializer._serialize_method(obj)
|
||||
|
||||
else:
|
||||
obj_type = type(obj).__name__
|
||||
value = obj
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
result = {
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _serialize_enum(obj: Enum) -> dict[str, Any]:
|
||||
value = obj.name
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
if type(obj).__base__.__name__ == "ColouredEnum":
|
||||
obj_type = "ColouredEnum"
|
||||
else:
|
||||
obj_type = "Enum"
|
||||
|
||||
return {
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
"enum": {
|
||||
name: member.value for name, member in obj.__class__.__members__.items()
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_quantity(obj: u.Quantity) -> dict[str, Any]:
|
||||
obj_type = "Quantity"
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
value = {"magnitude": obj.m, "unit": str(obj.u)}
|
||||
return {
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_dict(obj: dict[str, Any]) -> dict[str, Any]:
|
||||
obj_type = "dict"
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
value = {key: Serializer.serialize_object(val) for key, val in obj.items()}
|
||||
return {
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_list(obj: list[Any]) -> dict[str, Any]:
|
||||
obj_type = "list"
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
value = [Serializer.serialize_object(o) for o in obj]
|
||||
return {
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_method(obj: Callable[..., Any]) -> dict[str, Any]:
|
||||
obj_type = "method"
|
||||
value = None
|
||||
readonly = True
|
||||
doc = get_attribute_doc(obj)
|
||||
|
||||
# Store parameters and their anotations in a dictionary
|
||||
sig = inspect.signature(obj)
|
||||
parameters: dict[str, str | None] = {}
|
||||
|
||||
for k, v in sig.parameters.items():
|
||||
annotation = v.annotation
|
||||
if annotation is not inspect._empty:
|
||||
if isinstance(annotation, type):
|
||||
# Handle regular types
|
||||
parameters[k] = annotation.__name__
|
||||
else:
|
||||
# Union, string annotation, Literal types, ...
|
||||
parameters[k] = str(annotation)
|
||||
else:
|
||||
parameters[k] = None
|
||||
|
||||
return {
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
"async": inspect.iscoroutinefunction(obj),
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_data_service(obj: AbstractDataService) -> dict[str, Any]:
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
obj_type = type(obj).__name__
|
||||
if type(obj).__name__ not in get_component_class_names():
|
||||
obj_type = "DataService"
|
||||
|
||||
# Get the dictionary of the base class
|
||||
base_set = set(type(obj).__base__.__dict__)
|
||||
# Get the dictionary of the derived class
|
||||
derived_set = set(type(obj).__dict__)
|
||||
# Get the difference between the two dictionaries
|
||||
derived_only_set = derived_set - base_set
|
||||
|
||||
instance_dict = set(obj.__dict__)
|
||||
# Merge the class and instance dictionaries
|
||||
merged_set = derived_only_set | instance_dict
|
||||
value = {}
|
||||
|
||||
# Iterate over attributes, properties, class attributes, and methods
|
||||
for key in sorted(merged_set):
|
||||
if key.startswith("_"):
|
||||
continue # Skip attributes that start with underscore
|
||||
|
||||
# Skip keys that start with "start_" or "stop_" and end with an async
|
||||
# method name
|
||||
if key.startswith(("start_", "stop_")) and key.split("_", 1)[1] in {
|
||||
name
|
||||
for name, _ in inspect.getmembers(
|
||||
obj, predicate=inspect.iscoroutinefunction
|
||||
)
|
||||
}:
|
||||
continue
|
||||
|
||||
val = getattr(obj, key)
|
||||
|
||||
value[key] = Serializer.serialize_object(val)
|
||||
|
||||
# If there's a running task for this method
|
||||
if key in obj._task_manager.tasks:
|
||||
task_info = obj._task_manager.tasks[key]
|
||||
value[key]["value"] = task_info["kwargs"]
|
||||
|
||||
# If the DataService attribute is a property
|
||||
if isinstance(getattr(obj.__class__, key, None), property):
|
||||
prop: property = getattr(obj.__class__, key)
|
||||
value[key]["readonly"] = prop.fset is None
|
||||
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
|
||||
|
||||
return {
|
||||
"type": obj_type,
|
||||
"value": value,
|
||||
"readonly": readonly,
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
|
||||
def dump(obj: Any) -> dict[str, Any]:
|
||||
return Serializer.serialize_object(obj)
|
||||
|
||||
|
||||
def set_nested_value_by_path(
|
||||
serialization_dict: dict[str, Any], path: str, value: Any
|
||||
) -> None:
|
||||
"""
|
||||
Set a value in a nested dictionary structure, which conforms to the serialization
|
||||
format used by `pydase.utils.serializer.Serializer`, using a dot-notation path.
|
||||
|
||||
Args:
|
||||
serialization_dict:
|
||||
The base dictionary representing data serialized with
|
||||
`pydase.utils.serializer.Serializer`.
|
||||
path:
|
||||
The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to
|
||||
set the value.
|
||||
value:
|
||||
The new value to set at the specified path.
|
||||
|
||||
Note:
|
||||
- If the index equals the length of the list, the function will append the
|
||||
serialized representation of the 'value' to the list.
|
||||
"""
|
||||
|
||||
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1]
|
||||
current_dict: dict[str, Any] = serialization_dict
|
||||
|
||||
try:
|
||||
for path_part in parent_path_parts:
|
||||
current_dict = get_next_level_dict_by_key(
|
||||
current_dict, path_part, allow_append=False
|
||||
)
|
||||
current_dict = current_dict["value"]
|
||||
|
||||
current_dict = get_next_level_dict_by_key(
|
||||
current_dict, attr_name, allow_append=True
|
||||
)
|
||||
except (SerializationPathError, SerializationValueError, KeyError) as e:
|
||||
logger.error(e)
|
||||
return
|
||||
|
||||
# setting the new value
|
||||
serialized_value = dump(value)
|
||||
if "readonly" in current_dict:
|
||||
if current_dict["type"] != "method":
|
||||
current_dict["type"] = serialized_value["type"]
|
||||
current_dict["value"] = serialized_value["value"]
|
||||
else:
|
||||
current_dict.update(serialized_value)
|
||||
|
||||
|
||||
def get_nested_dict_by_path(
|
||||
serialization_dict: dict[str, Any],
|
||||
path: str,
|
||||
) -> dict[str, Any]:
|
||||
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1]
|
||||
current_dict: dict[str, Any] = serialization_dict
|
||||
|
||||
for path_part in parent_path_parts:
|
||||
current_dict = get_next_level_dict_by_key(
|
||||
current_dict, path_part, allow_append=False
|
||||
)
|
||||
current_dict = current_dict["value"]
|
||||
return get_next_level_dict_by_key(current_dict, attr_name, allow_append=False)
|
||||
|
||||
|
||||
def get_next_level_dict_by_key(
|
||||
serialization_dict: dict[str, Any],
|
||||
attr_name: str,
|
||||
*,
|
||||
allow_append: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Retrieve a nested dictionary entry or list item from a data structure serialized
|
||||
with `pydase.utils.serializer.Serializer`.
|
||||
|
||||
Args:
|
||||
serialization_dict: The base dictionary representing serialized data.
|
||||
attr_name: The key name representing the attribute in the dictionary,
|
||||
e.g. 'list_attr[0]' or 'attr'
|
||||
allow_append: Flag to allow appending a new entry if `index` is out of range by
|
||||
one.
|
||||
|
||||
Returns:
|
||||
The dictionary or list item corresponding to the attribute and index.
|
||||
|
||||
Raises:
|
||||
SerializationPathError: If the path composed of `attr_name` and `index` is
|
||||
invalid or leads to an IndexError or KeyError.
|
||||
SerializationValueError: If the expected nested structure is not a dictionary.
|
||||
"""
|
||||
# Check if the key contains an index part like 'attr_name[<index>]'
|
||||
attr_name, index = parse_list_attr_and_index(attr_name)
|
||||
|
||||
try:
|
||||
if index is not None:
|
||||
serialization_dict = serialization_dict[attr_name]["value"][index]
|
||||
else:
|
||||
serialization_dict = serialization_dict[attr_name]
|
||||
except IndexError as e:
|
||||
if allow_append and index == len(serialization_dict[attr_name]["value"]):
|
||||
# Appending to list
|
||||
serialization_dict[attr_name]["value"].append({})
|
||||
serialization_dict = serialization_dict[attr_name]["value"][index]
|
||||
else:
|
||||
raise SerializationPathError(
|
||||
f"Error occured trying to change '{attr_name}[{index}]': {e}"
|
||||
)
|
||||
except KeyError:
|
||||
raise SerializationPathError(
|
||||
f"Error occured trying to access the key '{attr_name}': it is either "
|
||||
"not present in the current dictionary or its value does not contain "
|
||||
"a 'value' key."
|
||||
)
|
||||
|
||||
if not isinstance(serialization_dict, dict):
|
||||
raise SerializationValueError(
|
||||
f"Expected a dictionary at '{attr_name}', but found type "
|
||||
f"'{type(serialization_dict).__name__}' instead."
|
||||
)
|
||||
|
||||
return serialization_dict
|
||||
|
||||
|
||||
def generate_serialized_data_paths(
|
||||
data: dict[str, Any], parent_path: str = ""
|
||||
) -> list[str]:
|
||||
"""
|
||||
Generate a list of access paths for all attributes in a dictionary representing
|
||||
data serialized with `pydase.utils.serializer.Serializer`, excluding those that are
|
||||
methods.
|
||||
|
||||
Args:
|
||||
data: The dictionary representing serialized data, typically produced by
|
||||
`pydase.utils.serializer.Serializer`.
|
||||
parent_path: The base path to prepend to the keys in the `data` dictionary to
|
||||
form the access paths. Defaults to an empty string.
|
||||
|
||||
Returns:
|
||||
A list of strings where each string is a dot-notation access path to an
|
||||
attribute in the serialized data.
|
||||
"""
|
||||
|
||||
paths: list[str] = []
|
||||
for key, value in data.items():
|
||||
if value["type"] == "method":
|
||||
# ignoring methods
|
||||
continue
|
||||
new_path = f"{parent_path}.{key}" if parent_path else key
|
||||
if isinstance(value["value"], dict) and value["type"] != "Quantity":
|
||||
paths.extend(generate_serialized_data_paths(value["value"], new_path))
|
||||
elif isinstance(value["value"], list):
|
||||
for index, item in enumerate(value["value"]):
|
||||
indexed_key_path = f"{new_path}[{index}]"
|
||||
if isinstance(item["value"], dict):
|
||||
paths.extend(
|
||||
generate_serialized_data_paths(item["value"], indexed_key_path)
|
||||
)
|
||||
else:
|
||||
paths.append(indexed_key_path)
|
||||
else:
|
||||
paths.append(new_path)
|
||||
return paths
|
||||
118
tests/client/test_client.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pytest
|
||||
from pydase.client.proxy_loader import ProxyAttributeError
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pydase_client() -> Generator[pydase.Client, None, Any]:
|
||||
class SubService(pydase.DataService):
|
||||
name = "SubService"
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._name = "MyService"
|
||||
self._my_property = 12.1
|
||||
self.sub_service = SubService()
|
||||
self.list_attr = [1, 2]
|
||||
|
||||
@property
|
||||
def my_property(self) -> float:
|
||||
return self._my_property
|
||||
|
||||
@my_property.setter
|
||||
def my_property(self, value: float) -> None:
|
||||
self._my_property = value
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def my_method(self, input_str: str) -> str:
|
||||
return input_str
|
||||
|
||||
server = pydase.Server(MyService(), web_port=9999)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
client = pydase.Client(hostname="localhost", port=9999)
|
||||
|
||||
yield client
|
||||
|
||||
server.handle_exit()
|
||||
thread.join()
|
||||
|
||||
|
||||
def test_property(pydase_client: pydase.Client) -> None:
|
||||
assert pydase_client.proxy.my_property == 12.1
|
||||
pydase_client.proxy.my_property = 2.1
|
||||
assert pydase_client.proxy.my_property == 2.1
|
||||
|
||||
|
||||
def test_readonly_property(pydase_client: pydase.Client) -> None:
|
||||
assert pydase_client.proxy.name == "MyService"
|
||||
with pytest.raises(ProxyAttributeError):
|
||||
pydase_client.proxy.name = "Hello"
|
||||
|
||||
|
||||
def test_method_execution(pydase_client: pydase.Client) -> None:
|
||||
assert pydase_client.proxy.my_method("My return string") == "My return string"
|
||||
assert (
|
||||
pydase_client.proxy.my_method(input_str="My return string")
|
||||
== "My return string"
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
pydase_client.proxy.my_method("Something", 2)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
pydase_client.proxy.my_method(kwarg="hello")
|
||||
|
||||
|
||||
def test_nested_service(pydase_client: pydase.Client) -> None:
|
||||
assert pydase_client.proxy.sub_service.name == "SubService"
|
||||
pydase_client.proxy.sub_service.name = "New name"
|
||||
assert pydase_client.proxy.sub_service.name == "New name"
|
||||
|
||||
|
||||
def test_list(pydase_client: pydase.Client) -> None:
|
||||
assert pydase_client.proxy.list_attr == [1, 2]
|
||||
|
||||
pydase_client.proxy.list_attr.append(1)
|
||||
assert pydase_client.proxy.list_attr == [1, 2, 1]
|
||||
|
||||
pydase_client.proxy.list_attr.extend([123, 2.1])
|
||||
assert pydase_client.proxy.list_attr == [1, 2, 1, 123, 2.1]
|
||||
|
||||
pydase_client.proxy.list_attr.insert(1, 1.2)
|
||||
assert pydase_client.proxy.list_attr == [1, 1.2, 2, 1, 123, 2.1]
|
||||
|
||||
assert pydase_client.proxy.list_attr.pop() == 2.1
|
||||
assert pydase_client.proxy.list_attr == [1, 1.2, 2, 1, 123]
|
||||
|
||||
pydase_client.proxy.list_attr.remove(1.2)
|
||||
assert pydase_client.proxy.list_attr == [1, 2, 1, 123]
|
||||
|
||||
pydase_client.proxy.list_attr[1] = 1337
|
||||
assert pydase_client.proxy.list_attr == [1, 1337, 1, 123]
|
||||
|
||||
pydase_client.proxy.list_attr.clear()
|
||||
assert pydase_client.proxy.list_attr == []
|
||||
|
||||
|
||||
def test_tab_completion(pydase_client: pydase.Client) -> None:
|
||||
# Tab completion gets its suggestions from the __dir__ class method
|
||||
assert all(
|
||||
x in pydase_client.proxy.__dir__()
|
||||
for x in [
|
||||
"list_attr",
|
||||
"my_method",
|
||||
"my_property",
|
||||
"name",
|
||||
"sub_service",
|
||||
]
|
||||
)
|
||||
31
tests/components/test_device_connection.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pydase
|
||||
import pydase.components.device_connection
|
||||
import pytest
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnection(caplog: LogCaptureFixture) -> None:
|
||||
class MyService(pydase.components.device_connection.DeviceConnection):
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._reconnection_wait_time = 0.01
|
||||
|
||||
def connect(self) -> None:
|
||||
self._connected = True
|
||||
|
||||
service_instance = MyService()
|
||||
|
||||
assert service_instance._connected is False
|
||||
|
||||
service_instance._task_manager.start_autostart_tasks()
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
assert service_instance._connected is True
|
||||
149
tests/components/test_image.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.utils.serialization.serializer import dump
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_image_functions(caplog: LogCaptureFixture) -> None:
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.my_image = pydase.components.Image()
|
||||
|
||||
service_instance = MyService()
|
||||
state_manager = StateManager(service_instance)
|
||||
DataServiceObserver(state_manager)
|
||||
|
||||
service_instance.my_image.load_from_url("https://picsum.photos/200")
|
||||
|
||||
caplog.clear()
|
||||
|
||||
|
||||
def test_image_serialization() -> None:
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.my_image = pydase.components.Image()
|
||||
|
||||
assert dump(MyService()) == {
|
||||
"full_access_path": "",
|
||||
"name": "MyService",
|
||||
"type": "DataService",
|
||||
"value": {
|
||||
"my_image": {
|
||||
"full_access_path": "my_image",
|
||||
"name": "Image",
|
||||
"type": "Image",
|
||||
"value": {
|
||||
"format": {
|
||||
"full_access_path": "my_image.format",
|
||||
"type": "str",
|
||||
"value": "",
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
},
|
||||
"load_from_base64": {
|
||||
"full_access_path": "my_image.load_from_base64",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"value_": {
|
||||
"annotation": "<class 'bytes'>",
|
||||
"default": {},
|
||||
},
|
||||
"format_": {
|
||||
"annotation": "str | None",
|
||||
"default": {
|
||||
"type": "NoneType",
|
||||
"value": None,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": False,
|
||||
},
|
||||
"load_from_matplotlib_figure": {
|
||||
"full_access_path": "my_image.load_from_matplotlib_figure",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"fig": {"annotation": "Figure", "default": {}},
|
||||
"format_": {
|
||||
"annotation": "<class 'str'>",
|
||||
"default": {
|
||||
"type": "str",
|
||||
"value": "png",
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": False,
|
||||
},
|
||||
"load_from_path": {
|
||||
"full_access_path": "my_image.load_from_path",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"path": {
|
||||
"annotation": "pathlib.Path | str",
|
||||
"default": {},
|
||||
}
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": False,
|
||||
},
|
||||
"load_from_url": {
|
||||
"full_access_path": "my_image.load_from_url",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"url": {"annotation": "<class 'str'>", "default": {}}
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": False,
|
||||
},
|
||||
"value": {
|
||||
"full_access_path": "my_image.value",
|
||||
"type": "str",
|
||||
"value": "",
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
},
|
||||
},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
@@ -1,46 +1,82 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from pydase.components.number_slider import NumberSlider
|
||||
from pydase.data_service.data_service import DataService
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_NumberSlider(caplog: LogCaptureFixture) -> None:
|
||||
class ServiceClass(DataService):
|
||||
number_slider = NumberSlider(1, 0, 10, 1)
|
||||
int_number_slider = NumberSlider(1, 0, 10, 1, "int")
|
||||
|
||||
service_instance = ServiceClass()
|
||||
def test_number_slider(caplog: LogCaptureFixture) -> None:
|
||||
class MySlider(NumberSlider):
|
||||
def __init__(
|
||||
self,
|
||||
value: float = 0,
|
||||
min_: float = 0,
|
||||
max_: float = 100,
|
||||
step_size: float = 1,
|
||||
callback: Callable[..., None] = lambda: None,
|
||||
) -> None:
|
||||
super().__init__(value, min_, max_, step_size)
|
||||
self._callback = callback
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, value: float) -> None:
|
||||
self._callback(value)
|
||||
self._value = value
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
|
||||
@max.setter
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
|
||||
@step_size.setter
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
|
||||
class MyService(DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.my_slider = MySlider(callback=self.some_method)
|
||||
|
||||
def some_method(self, slider_value: float) -> None:
|
||||
logger.info("Slider changed to '%s'", slider_value)
|
||||
|
||||
service_instance = MyService()
|
||||
state_manager = StateManager(service_instance)
|
||||
DataServiceObserver(state_manager)
|
||||
|
||||
assert service_instance.number_slider.value == 1
|
||||
assert isinstance(service_instance.number_slider.value, float)
|
||||
assert service_instance.number_slider.min == 0
|
||||
assert isinstance(service_instance.number_slider.min, float)
|
||||
assert service_instance.number_slider.max == 10
|
||||
assert isinstance(service_instance.number_slider.max, float)
|
||||
assert service_instance.number_slider.step_size == 1
|
||||
assert isinstance(service_instance.number_slider.step_size, float)
|
||||
service_instance.my_slider.value = 10.0
|
||||
|
||||
assert service_instance.int_number_slider.value == 1
|
||||
assert isinstance(service_instance.int_number_slider.value, int)
|
||||
assert service_instance.int_number_slider.step_size == 1
|
||||
assert isinstance(service_instance.int_number_slider.step_size, int)
|
||||
|
||||
service_instance.number_slider.value = 10.0
|
||||
service_instance.int_number_slider.value = 10.1
|
||||
|
||||
assert "'number_slider.value' changed to '10.0'" in caplog.text
|
||||
assert "'int_number_slider.value' changed to '10'" in caplog.text
|
||||
assert "'my_slider.value' changed to '10.0'" in caplog.text
|
||||
assert "Slider changed to '10.0'" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
service_instance.number_slider.min = 1.1
|
||||
service_instance.my_slider.max = 12.0
|
||||
|
||||
assert "'number_slider.min' changed to '1.1'" in caplog.text
|
||||
assert "'my_slider.max' changed to '12.0'" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
service_instance.my_slider.step_size = 0.1
|
||||
|
||||
def test_init_error(caplog: LogCaptureFixture) -> None:
|
||||
number_slider = NumberSlider(type_="str") # type: ignore # noqa
|
||||
assert "'my_slider.step_size' changed to '0.1'" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
assert "Unknown type 'str'. Using 'float'" in caplog.text
|
||||
# by overriding the getter only you can make the property read-only
|
||||
with pytest.raises(AttributeError):
|
||||
service_instance.my_slider.min = 1.1 # type: ignore[reportGeneralTypeIssues, misc]
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
from pydase import DataService
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.data_service.task_manager import TaskDefinitionError
|
||||
from pydase.utils.decorators import FunctionDefinitionError, frontend
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
|
||||
@@ -32,8 +37,7 @@ def test_unexpected_type_change_warning(caplog: LogCaptureFixture) -> None:
|
||||
|
||||
|
||||
def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
|
||||
class SubService(DataService):
|
||||
...
|
||||
class SubService(DataService): ...
|
||||
|
||||
class SomeEnum(Enum):
|
||||
HI = 0
|
||||
@@ -53,11 +57,9 @@ def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def some_method(self) -> None:
|
||||
...
|
||||
def some_method(self) -> None: ...
|
||||
|
||||
async def some_task(self) -> None:
|
||||
...
|
||||
async def some_task(self) -> None: ...
|
||||
|
||||
ServiceClass()
|
||||
|
||||
@@ -114,3 +116,31 @@ def test_protected_and_private_attribute_warning(caplog: LogCaptureFixture) -> N
|
||||
"Class 'SubClass' does not inherit from DataService. This may lead to "
|
||||
"unexpected behaviour!"
|
||||
) not in caplog.text
|
||||
|
||||
|
||||
def test_exposing_methods() -> None:
|
||||
class ClassWithTask(pydase.DataService):
|
||||
async def some_task(self, sleep_time: int) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(TaskDefinitionError):
|
||||
ClassWithTask()
|
||||
|
||||
with pytest.raises(FunctionDefinitionError):
|
||||
|
||||
class ClassWithMethod(pydase.DataService):
|
||||
@frontend
|
||||
def some_method(self, *args: Any) -> str:
|
||||
return "some method"
|
||||
|
||||
|
||||
def test_dynamically_added_attribute(caplog: LogCaptureFixture) -> None:
|
||||
class MyService(DataService):
|
||||
pass
|
||||
|
||||
service_instance = MyService()
|
||||
pydase.Server(service_instance)
|
||||
|
||||
service_instance.dynamically_added_attr = 1.0
|
||||
|
||||
assert ("'dynamically_added_attr' changed to '1.0'") in caplog.text
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
import pydase
|
||||
import pytest
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
|
||||
@@ -34,7 +35,8 @@ def test_nested_attributes_cache_callback() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_task_status_update() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_status_update() -> None:
|
||||
class ServiceClass(pydase.DataService):
|
||||
name = "World"
|
||||
|
||||
@@ -65,5 +67,5 @@ def test_task_status_update() -> None:
|
||||
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[
|
||||
"value"
|
||||
]
|
||||
== {}
|
||||
== "RUNNING"
|
||||
)
|
||||
|
||||
@@ -94,3 +94,31 @@ def test_protected_or_private_change_logs(caplog: pytest.LogCaptureFixture) -> N
|
||||
|
||||
service.subclass._name = "Hello"
|
||||
assert "'subclass._name' changed to 'Hello'" not in caplog.text
|
||||
|
||||
|
||||
def test_dynamic_list_entry_with_property(caplog: pytest.LogCaptureFixture) -> None:
|
||||
class PropertyClass(pydase.DataService):
|
||||
_name = "Hello"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""The name property."""
|
||||
return self._name
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.list_attr = []
|
||||
|
||||
def toggle_high_voltage(self) -> None:
|
||||
self.list_attr = []
|
||||
self.list_attr.append(PropertyClass())
|
||||
self.list_attr[0]._name = "Hoooo"
|
||||
|
||||
service = MyService()
|
||||
state_manager = StateManager(service)
|
||||
DataServiceObserver(state_manager)
|
||||
service.toggle_high_voltage()
|
||||
|
||||
assert "'list_attr[0].name' changed to 'Hello'" not in caplog.text
|
||||
assert "'list_attr[0].name' changed to 'Hoooo'" in caplog.text
|
||||
|
||||
@@ -3,9 +3,8 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
from pydase.components.coloured_enum import ColouredEnum
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import (
|
||||
StateManager,
|
||||
@@ -19,12 +18,53 @@ class SubService(pydase.DataService):
|
||||
name = "SubService"
|
||||
|
||||
|
||||
class State(ColouredEnum):
|
||||
class State(pydase.components.ColouredEnum):
|
||||
RUNNING = "#0000FF80"
|
||||
COMPLETED = "hsl(120, 100%, 50%)"
|
||||
FAILED = "hsla(0, 100%, 50%, 0.7)"
|
||||
|
||||
|
||||
class MySlider(pydase.components.NumberSlider):
|
||||
@property
|
||||
def min(self) -> float:
|
||||
return self._min
|
||||
|
||||
@min.setter
|
||||
@load_state
|
||||
def min(self, value: float) -> None:
|
||||
self._min = value
|
||||
|
||||
@property
|
||||
def max(self) -> float:
|
||||
return self._max
|
||||
|
||||
@max.setter
|
||||
@load_state
|
||||
def max(self, value: float) -> None:
|
||||
self._max = value
|
||||
|
||||
@property
|
||||
def step_size(self) -> float:
|
||||
return self._step_size
|
||||
|
||||
@step_size.setter
|
||||
@load_state
|
||||
def step_size(self, value: float) -> None:
|
||||
self._step_size = value
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
@load_state
|
||||
def value(self, value: float) -> None:
|
||||
if value < self._min or value > self._max:
|
||||
raise ValueError("Value is either below allowed min or above max value.")
|
||||
|
||||
self._value = value
|
||||
|
||||
|
||||
class Service(pydase.DataService):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
@@ -35,6 +75,7 @@ class Service(pydase.DataService):
|
||||
self._property_attr = 1337.0
|
||||
self._name = "Service"
|
||||
self.state = State.RUNNING
|
||||
self.my_slider = MySlider()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -49,7 +90,7 @@ class Service(pydase.DataService):
|
||||
self._property_attr = value
|
||||
|
||||
|
||||
CURRENT_STATE = Service().serialize()
|
||||
CURRENT_STATE = Service().serialize()["value"]
|
||||
|
||||
LOAD_STATE = {
|
||||
"list_attr": {
|
||||
@@ -61,6 +102,37 @@ LOAD_STATE = {
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
"my_slider": {
|
||||
"type": "NumberSlider",
|
||||
"value": {
|
||||
"max": {
|
||||
"type": "float",
|
||||
"value": 101.0,
|
||||
"readonly": False,
|
||||
"doc": "The min property.",
|
||||
},
|
||||
"min": {
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
"readonly": False,
|
||||
"doc": "The min property.",
|
||||
},
|
||||
"step_size": {
|
||||
"type": "float",
|
||||
"value": 2.0,
|
||||
"readonly": False,
|
||||
"doc": "The min property.",
|
||||
},
|
||||
"value": {
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
"readonly": False,
|
||||
"doc": "The value property.",
|
||||
},
|
||||
},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
"name": {
|
||||
"type": "str",
|
||||
"value": "Another name",
|
||||
@@ -153,6 +225,10 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
|
||||
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.my_slider.value == 1.0 # changed
|
||||
assert service.my_slider.min == 1.0 # changed
|
||||
assert service.my_slider.max == 101.0 # changed
|
||||
assert service.my_slider.step_size == 2.0 # changed
|
||||
|
||||
assert "'some_unit' changed to '12.0 A'" in caplog.text
|
||||
assert (
|
||||
@@ -164,20 +240,14 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
|
||||
"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
|
||||
"Path 'removed_attr' could not be loaded. It does not correspond to an "
|
||||
"attribute of the class. 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) -> None:
|
||||
file = tmp_path / "test_state.json"
|
||||
|
||||
with pytest.warns(DeprecationWarning):
|
||||
service = Service(filename=str(file))
|
||||
StateManager(service=service, filename=str(file))
|
||||
|
||||
assert f"Overwriting filename {str(file)!r} with {str(file)!r}." in caplog.text
|
||||
assert "'my_slider.value' changed to '1.0'" in caplog.text
|
||||
assert "'my_slider.min' changed to '1.0'" in caplog.text
|
||||
assert "'my_slider.max' changed to '101.0'" in caplog.text
|
||||
assert "'my_slider.step_size' changed to '2.0'" in caplog.text
|
||||
|
||||
|
||||
def test_filename_error(caplog: LogCaptureFixture) -> None:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pydase
|
||||
import pytest
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pytest import LogCaptureFixture
|
||||
@@ -8,13 +10,14 @@ from pytest import LogCaptureFixture
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._autostart_tasks = { # type: ignore
|
||||
"my_task": (),
|
||||
"my_other_task": (),
|
||||
"my_task": (), # type: ignore
|
||||
"my_other_task": (), # type: ignore
|
||||
}
|
||||
|
||||
async def my_task(self) -> None:
|
||||
@@ -23,16 +26,18 @@ def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
|
||||
async def my_other_task(self) -> None:
|
||||
logger.info("Triggered other task.")
|
||||
|
||||
# Your test code here
|
||||
service_instance = MyService()
|
||||
state_manager = StateManager(service_instance)
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance._task_manager.start_autostart_tasks()
|
||||
|
||||
assert "'my_task' changed to '{}'" in caplog.text
|
||||
assert "'my_other_task' changed to '{}'" in caplog.text
|
||||
assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
assert "'my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
|
||||
|
||||
def test_DataService_subclass_autostart_task_callback(
|
||||
@pytest.mark.asyncio
|
||||
async def test_DataService_subclass_autostart_task_callback(
|
||||
caplog: LogCaptureFixture,
|
||||
) -> None:
|
||||
class MySubService(pydase.DataService):
|
||||
@@ -57,11 +62,12 @@ def test_DataService_subclass_autostart_task_callback(
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance._task_manager.start_autostart_tasks()
|
||||
|
||||
assert "'sub_service.my_task' changed to '{}'" in caplog.text
|
||||
assert "'sub_service.my_other_task' changed to '{}'" in caplog.text
|
||||
assert "'sub_service.my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
assert "'sub_service.my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
|
||||
|
||||
def test_DataService_subclass_list_autostart_task_callback(
|
||||
@pytest.mark.asyncio
|
||||
async def test_DataService_subclass_list_autostart_task_callback(
|
||||
caplog: LogCaptureFixture,
|
||||
) -> None:
|
||||
class MySubService(pydase.DataService):
|
||||
@@ -86,7 +92,44 @@ def test_DataService_subclass_list_autostart_task_callback(
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance._task_manager.start_autostart_tasks()
|
||||
|
||||
assert "'sub_services_list[0].my_task' changed to '{}'" in caplog.text
|
||||
assert "'sub_services_list[0].my_other_task' changed to '{}'" in caplog.text
|
||||
assert "'sub_services_list[1].my_task' changed to '{}'" in caplog.text
|
||||
assert "'sub_services_list[1].my_other_task' changed to '{}'" in caplog.text
|
||||
assert (
|
||||
"'sub_services_list[0].my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
)
|
||||
assert (
|
||||
"'sub_services_list[0].my_other_task' changed to 'TaskStatus.RUNNING'"
|
||||
in caplog.text
|
||||
)
|
||||
assert (
|
||||
"'sub_services_list[1].my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
)
|
||||
assert (
|
||||
"'sub_services_list[1].my_other_task' changed to 'TaskStatus.RUNNING'"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_and_stop_task_methods(caplog: LogCaptureFixture) -> None:
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
async def my_task(self) -> None:
|
||||
while True:
|
||||
logger.debug("Logging message")
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Your test code here
|
||||
service_instance = MyService()
|
||||
state_manager = StateManager(service_instance)
|
||||
DataServiceObserver(state_manager)
|
||||
service_instance.start_my_task()
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||
assert "Logging message" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
service_instance.stop_my_task()
|
||||
await asyncio.sleep(0.01)
|
||||
assert "Task 'my_task' was cancelled" in caplog.text
|
||||
|
||||
21
tests/observer_pattern/observer/test_property_observer.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from typing import Any
|
||||
|
||||
from pydase.observer_pattern.observable.observable import Observable
|
||||
from pydase.observer_pattern.observer.property_observer import PropertyObserver
|
||||
|
||||
|
||||
def test_inherited_property_dependency_resolution() -> None:
|
||||
class BaseObservable(Observable):
|
||||
_name = "BaseObservable"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
class DerivedObservable(BaseObservable):
|
||||
_name = "DerivedObservable"
|
||||
|
||||
class MyObserver(PropertyObserver):
|
||||
def on_change(self, full_access_path: str, value: Any) -> None: ...
|
||||
|
||||
assert MyObserver(DerivedObservable()).property_deps_dict == {"_name": ["name"]}
|
||||
@@ -1,8 +1,15 @@
|
||||
import json
|
||||
import signal
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
from pydase.data_service.state_manager import load_state
|
||||
from pydase.server.server import Server
|
||||
from pytest import LogCaptureFixture
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
def test_signal_handling(mocker: MockerFixture):
|
||||
@@ -33,3 +40,64 @@ def test_signal_handling(mocker: MockerFixture):
|
||||
# Simulate receiving a SIGINT signal for the second time
|
||||
server.handle_exit(signal.SIGINT, None)
|
||||
mock_exit.assert_called_once_with(1)
|
||||
|
||||
|
||||
class Service(pydase.DataService):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.some_unit: u.Quantity = 1.2 * u.units.A
|
||||
self.some_float = 1.0
|
||||
self._property_attr = 1337.0
|
||||
|
||||
@property
|
||||
def property_attr(self) -> float:
|
||||
return self._property_attr
|
||||
|
||||
@property_attr.setter
|
||||
@load_state
|
||||
def property_attr(self, value: float) -> None:
|
||||
self._property_attr = value
|
||||
|
||||
|
||||
CURRENT_STATE = Service().serialize()
|
||||
|
||||
LOAD_STATE = {
|
||||
"some_float": {
|
||||
"type": "float",
|
||||
"value": 10.0,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
"property_attr": {
|
||||
"type": "float",
|
||||
"value": 1337.1,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
"some_unit": {
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 12.0, "unit": "A"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
|
||||
# Create a StateManager instance with a temporary file
|
||||
file = tmp_path / "test_state.json"
|
||||
|
||||
# Write a temporary JSON file to read back
|
||||
with open(file, "w") as f:
|
||||
json.dump(LOAD_STATE, f, indent=4)
|
||||
|
||||
service = Service()
|
||||
Server(service, filename=str(file))
|
||||
|
||||
assert service.some_unit == u.Quantity(12, "A")
|
||||
assert service.property_attr == 1337.1
|
||||
assert service.some_float == 10.0
|
||||
|
||||
assert "'some_unit' changed to '12.0 A'" in caplog.text
|
||||
assert "'some_float' changed to '10.0'" in caplog.text
|
||||
assert "'property_attr' changed to '1337.1'" in caplog.text
|
||||
|
||||
54
tests/server/web_server/test_web_server.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pydase
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.server.web_server.web_server import WebServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_web_settings() -> None:
|
||||
class SubClass(pydase.DataService):
|
||||
name = "Hello"
|
||||
|
||||
class ServiceClass(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.attr_1 = SubClass()
|
||||
self.added = "added"
|
||||
|
||||
service_instance = ServiceClass()
|
||||
state_manager = StateManager(service_instance)
|
||||
observer = DataServiceObserver(state_manager)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
web_settings = {
|
||||
"attr_1": {"displayName": "Attribute", "display": False},
|
||||
"attr_1.name": {"displayName": "Attribute name", "display": True},
|
||||
}
|
||||
web_settings_file = Path(tmp) / "web_settings.json"
|
||||
|
||||
with web_settings_file.open("w") as file:
|
||||
file.write(json.dumps(web_settings))
|
||||
|
||||
server = WebServer(
|
||||
observer,
|
||||
host="0.0.0.0",
|
||||
port=8001,
|
||||
generate_web_settings=True,
|
||||
config_dir=Path(tmp),
|
||||
)
|
||||
new_web_settings = server.web_settings
|
||||
|
||||
# existing entries are not overwritten, new entries are appended
|
||||
assert new_web_settings == {
|
||||
**web_settings,
|
||||
"added": {"displayName": "added", "display": True},
|
||||
}
|
||||
assert json.loads(web_settings_file.read_text()) == {
|
||||
**web_settings,
|
||||
"added": {"displayName": "added", "display": True},
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
from pydase.data_service.data_service import DataService
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.data_service.state_manager import StateManager, load_state
|
||||
from pydase.utils.serialization.serializer import dump
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
|
||||
@@ -69,21 +71,11 @@ def test_set_service_attribute_value_by_path(caplog: LogCaptureFixture) -> None:
|
||||
DataServiceObserver(state_manager)
|
||||
|
||||
state_manager.set_service_attribute_value_by_path(
|
||||
path="voltage", value=1.0 * u.units.mV
|
||||
path="voltage", serialized_value=dump(1.0 * u.units.mV)
|
||||
)
|
||||
assert "'voltage' changed to '1.0 mV'" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
state_manager.set_service_attribute_value_by_path(path="voltage", value=2)
|
||||
|
||||
assert "'voltage' changed to '2.0 mV'" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
state_manager.set_service_attribute_value_by_path(
|
||||
path="voltage", value={"magnitude": 123, "unit": "kV"}
|
||||
)
|
||||
assert "'voltage' changed to '123.0 kV'" in caplog.text
|
||||
|
||||
|
||||
def test_autoconvert_offset_to_baseunit() -> None:
|
||||
import pint
|
||||
@@ -99,7 +91,10 @@ def test_autoconvert_offset_to_baseunit() -> None:
|
||||
def test_loading_from_json(caplog: LogCaptureFixture) -> None:
|
||||
"""This function tests if the quantity read from the json description is actually
|
||||
passed as a quantity to the property setter."""
|
||||
JSON_DICT = {
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
serialization_dict = {
|
||||
"some_unit": {
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 10.0, "unit": "A"},
|
||||
@@ -118,14 +113,17 @@ def test_loading_from_json(caplog: LogCaptureFixture) -> None:
|
||||
return self._unit
|
||||
|
||||
@some_unit.setter
|
||||
@load_state
|
||||
def some_unit(self, value: u.Quantity) -> None:
|
||||
assert isinstance(value, u.Quantity)
|
||||
self._unit = value
|
||||
|
||||
service_instance = ServiceClass()
|
||||
state_manager = StateManager(service_instance)
|
||||
DataServiceObserver(state_manager)
|
||||
|
||||
service_instance.load_DataService_from_JSON(JSON_DICT)
|
||||
fp = tempfile.NamedTemporaryFile("w+")
|
||||
json.dump(serialization_dict, fp)
|
||||
fp.seek(0)
|
||||
|
||||
pydase.Server(service_instance, filename=fp.name)
|
||||
|
||||
assert "'some_unit' changed to '10.0 A'" in caplog.text
|
||||
|
||||
0
tests/utils/serialization/__init__.py
Normal file
143
tests/utils/serialization/test_deserializer.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import enum
|
||||
from typing import Any
|
||||
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
from pydase.utils.serialization.deserializer import loads
|
||||
from pydase.utils.serialization.serializer import dump
|
||||
from pydase.utils.serialization.types import SerializedObject
|
||||
|
||||
|
||||
class MyEnum(enum.Enum):
|
||||
FINISHED = "finished"
|
||||
RUNNING = "running"
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
name = "MyService"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"obj, obj_serialization",
|
||||
[
|
||||
(
|
||||
1,
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "int",
|
||||
"value": 1,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
1.0,
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
True,
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "bool",
|
||||
"value": True,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
u.Quantity(10, "m"),
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 10, "unit": "meter"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
[1.0],
|
||||
{
|
||||
"full_access_path": "",
|
||||
"value": [
|
||||
{
|
||||
"full_access_path": "[0]",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
}
|
||||
],
|
||||
"type": "list",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
},
|
||||
),
|
||||
(
|
||||
{"key": 1.0},
|
||||
{
|
||||
"full_access_path": "",
|
||||
"value": {
|
||||
"key": {
|
||||
"full_access_path": '["key"]',
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
}
|
||||
},
|
||||
"type": "dict",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_loads_primitive_types(obj: Any, obj_serialization: SerializedObject) -> None:
|
||||
assert loads(obj_serialization) == obj
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"obj, obj_serialization",
|
||||
[
|
||||
(
|
||||
MyEnum.RUNNING,
|
||||
{
|
||||
"full_access_path": "",
|
||||
"value": "RUNNING",
|
||||
"type": "Enum",
|
||||
"doc": "MyEnum description",
|
||||
"readonly": False,
|
||||
"name": "MyEnum",
|
||||
"enum": {"RUNNING": "running", "FINISHED": "finished"},
|
||||
},
|
||||
),
|
||||
(
|
||||
MyService(),
|
||||
{
|
||||
"full_access_path": "",
|
||||
"value": {
|
||||
"name": {
|
||||
"full_access_path": "name",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "str",
|
||||
"value": "MyService",
|
||||
}
|
||||
},
|
||||
"type": "DataService",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"name": "MyService",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_loads_advanced_types(obj: Any, obj_serialization: SerializedObject) -> None:
|
||||
assert dump(loads(obj_serialization)) == dump(obj)
|
||||
873
tests/utils/serialization/test_serializer.py
Normal file
@@ -0,0 +1,873 @@
|
||||
import asyncio
|
||||
import enum
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
from pydase.components.coloured_enum import ColouredEnum
|
||||
from pydase.data_service.task_manager import TaskStatus
|
||||
from pydase.utils.decorators import frontend
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializationPathError,
|
||||
SerializedObject,
|
||||
dump,
|
||||
get_nested_dict_by_path,
|
||||
get_next_level_dict_by_key,
|
||||
serialized_dict_is_nested_object,
|
||||
set_nested_value_by_path,
|
||||
)
|
||||
|
||||
|
||||
class MyEnum(enum.Enum):
|
||||
"""MyEnum description"""
|
||||
|
||||
RUNNING = "running"
|
||||
FINISHED = "finished"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_input, expected",
|
||||
[
|
||||
(
|
||||
1,
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "int",
|
||||
"value": 1,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
1.0,
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
True,
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "bool",
|
||||
"value": True,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
u.Quantity(10, "m"),
|
||||
{
|
||||
"full_access_path": "",
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 10, "unit": "meter"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_dump(test_input: Any, expected: dict[str, Any]) -> None:
|
||||
assert dump(test_input) == expected
|
||||
|
||||
|
||||
def test_enum_serialize() -> None:
|
||||
class EnumClass(Enum):
|
||||
FOO = "foo"
|
||||
BAR = "bar"
|
||||
|
||||
class EnumAttribute(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.some_enum = EnumClass.FOO
|
||||
|
||||
class EnumPropertyWithoutSetter(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._some_enum = EnumClass.FOO
|
||||
|
||||
@property
|
||||
def some_enum(self) -> EnumClass:
|
||||
return self._some_enum
|
||||
|
||||
class EnumPropertyWithSetter(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._some_enum = EnumClass.FOO
|
||||
|
||||
@property
|
||||
def some_enum(self) -> EnumClass:
|
||||
return self._some_enum
|
||||
|
||||
@some_enum.setter
|
||||
def some_enum(self, value: EnumClass) -> None:
|
||||
self._some_enum = value
|
||||
|
||||
assert dump(EnumAttribute())["value"] == {
|
||||
"some_enum": {
|
||||
"full_access_path": "some_enum",
|
||||
"type": "Enum",
|
||||
"name": "EnumClass",
|
||||
"value": "FOO",
|
||||
"enum": {"FOO": "foo", "BAR": "bar"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
}
|
||||
assert dump(EnumPropertyWithoutSetter())["value"] == {
|
||||
"some_enum": {
|
||||
"full_access_path": "some_enum",
|
||||
"type": "Enum",
|
||||
"name": "EnumClass",
|
||||
"value": "FOO",
|
||||
"enum": {"FOO": "foo", "BAR": "bar"},
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
}
|
||||
}
|
||||
assert dump(EnumPropertyWithSetter())["value"] == {
|
||||
"some_enum": {
|
||||
"full_access_path": "some_enum",
|
||||
"type": "Enum",
|
||||
"name": "EnumClass",
|
||||
"value": "FOO",
|
||||
"enum": {"FOO": "foo", "BAR": "bar"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_ColouredEnum_serialize() -> None:
|
||||
class Status(ColouredEnum):
|
||||
"""Status description."""
|
||||
|
||||
PENDING = "#FFA500"
|
||||
RUNNING = "#0000FF80"
|
||||
PAUSED = "rgb(169, 169, 169)"
|
||||
RETRYING = "rgba(255, 255, 0, 0.3)"
|
||||
COMPLETED = "hsl(120, 100%, 50%)"
|
||||
FAILED = "hsla(0, 100%, 50%, 0.7)"
|
||||
CANCELLED = "SlateGray"
|
||||
|
||||
assert dump(Status.FAILED) == {
|
||||
"full_access_path": "",
|
||||
"type": "ColouredEnum",
|
||||
"name": "Status",
|
||||
"value": "FAILED",
|
||||
"enum": {
|
||||
"CANCELLED": "SlateGray",
|
||||
"COMPLETED": "hsl(120, 100%, 50%)",
|
||||
"FAILED": "hsla(0, 100%, 50%, 0.7)",
|
||||
"PAUSED": "rgb(169, 169, 169)",
|
||||
"PENDING": "#FFA500",
|
||||
"RETRYING": "rgba(255, 255, 0, 0.3)",
|
||||
"RUNNING": "#0000FF80",
|
||||
},
|
||||
"readonly": False,
|
||||
"doc": "Status description.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_method_serialization() -> None:
|
||||
class ClassWithMethod(pydase.DataService):
|
||||
def some_method(self) -> str:
|
||||
return "some method"
|
||||
|
||||
async def some_task(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
|
||||
instance = ClassWithMethod()
|
||||
instance.start_some_task() # type: ignore
|
||||
|
||||
assert dump(instance)["value"] == {
|
||||
"some_method": {
|
||||
"full_access_path": "some_method",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {"parameters": {}, "return_annotation": {}},
|
||||
"frontend_render": False,
|
||||
},
|
||||
"some_task": {
|
||||
"full_access_path": "some_task",
|
||||
"type": "method",
|
||||
"value": TaskStatus.RUNNING.name,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": True,
|
||||
"signature": {
|
||||
"parameters": {},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_methods_with_type_hints() -> None:
|
||||
def method_without_type_hint(arg_without_type_hint) -> None:
|
||||
pass
|
||||
|
||||
def method_with_type_hint(some_argument: int) -> None:
|
||||
pass
|
||||
|
||||
def method_with_union_type_hint(some_argument: int | float) -> None:
|
||||
pass
|
||||
|
||||
assert dump(method_without_type_hint) == {
|
||||
"full_access_path": "",
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"arg_without_type_hint": {
|
||||
"annotation": "<class 'inspect._empty'>",
|
||||
"default": {},
|
||||
}
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"frontend_render": False,
|
||||
}
|
||||
|
||||
assert dump(method_with_type_hint) == {
|
||||
"full_access_path": "",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"some_argument": {"annotation": "<class 'int'>", "default": {}}
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": False,
|
||||
}
|
||||
assert dump(method_with_union_type_hint) == {
|
||||
"full_access_path": "",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {
|
||||
"parameters": {
|
||||
"some_argument": {"annotation": "int | float", "default": {}}
|
||||
},
|
||||
"return_annotation": {},
|
||||
},
|
||||
"frontend_render": False,
|
||||
}
|
||||
|
||||
|
||||
def test_exposed_function_serialization() -> None:
|
||||
class MyService(pydase.DataService):
|
||||
@frontend
|
||||
def some_method(self) -> None:
|
||||
pass
|
||||
|
||||
@frontend
|
||||
def some_function() -> None:
|
||||
pass
|
||||
|
||||
assert dump(MyService().some_method) == {
|
||||
"full_access_path": "",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {"parameters": {}, "return_annotation": {}},
|
||||
"frontend_render": True,
|
||||
}
|
||||
|
||||
assert dump(some_function) == {
|
||||
"full_access_path": "",
|
||||
"type": "method",
|
||||
"value": None,
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
"async": False,
|
||||
"signature": {"parameters": {}, "return_annotation": {}},
|
||||
"frontend_render": True,
|
||||
}
|
||||
|
||||
|
||||
def test_list_serialization() -> None:
|
||||
class MySubclass(pydase.DataService):
|
||||
_name = "hi"
|
||||
bool_attr = True
|
||||
int_attr = 1
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
class ClassWithListAttribute(pydase.DataService):
|
||||
list_attr = [1, MySubclass()]
|
||||
|
||||
instance = ClassWithListAttribute()
|
||||
|
||||
assert dump(instance)["value"] == {
|
||||
"list_attr": {
|
||||
"full_access_path": "list_attr",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "list",
|
||||
"value": [
|
||||
{
|
||||
"full_access_path": "list_attr[0]",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "int",
|
||||
"value": 1,
|
||||
},
|
||||
{
|
||||
"full_access_path": "list_attr[1]",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "DataService",
|
||||
"name": "MySubclass",
|
||||
"value": {
|
||||
"bool_attr": {
|
||||
"full_access_path": "list_attr[1].bool_attr",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "bool",
|
||||
"value": True,
|
||||
},
|
||||
"int_attr": {
|
||||
"full_access_path": "list_attr[1].int_attr",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "int",
|
||||
"value": 1,
|
||||
},
|
||||
"name": {
|
||||
"full_access_path": "list_attr[1].name",
|
||||
"doc": None,
|
||||
"readonly": True,
|
||||
"type": "str",
|
||||
"value": "hi",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_dict_serialization() -> None:
|
||||
class MyClass(pydase.DataService):
|
||||
name = "my class"
|
||||
|
||||
test_dict = {
|
||||
"int_key": 1,
|
||||
"float_key": 1.0,
|
||||
"bool_key": True,
|
||||
"Quantity_key": 1.0 * u.units.s,
|
||||
"DataService_key": MyClass(),
|
||||
}
|
||||
|
||||
assert dump(test_dict) == {
|
||||
"full_access_path": "",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "dict",
|
||||
"value": {
|
||||
"DataService_key": {
|
||||
"full_access_path": '["DataService_key"]',
|
||||
"name": "MyClass",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "DataService",
|
||||
"value": {
|
||||
"name": {
|
||||
"full_access_path": '["DataService_key"].name',
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "str",
|
||||
"value": "my class",
|
||||
}
|
||||
},
|
||||
},
|
||||
"Quantity_key": {
|
||||
"full_access_path": '["Quantity_key"]',
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 1.0, "unit": "s"},
|
||||
},
|
||||
"bool_key": {
|
||||
"full_access_path": '["bool_key"]',
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "bool",
|
||||
"value": True,
|
||||
},
|
||||
"float_key": {
|
||||
"full_access_path": '["float_key"]',
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
},
|
||||
"int_key": {
|
||||
"full_access_path": '["int_key"]',
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "int",
|
||||
"value": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_derived_data_service_serialization() -> None:
|
||||
class BaseService(pydase.DataService):
|
||||
class_attr = 1337
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._name = "Service"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str) -> None:
|
||||
self._name = value
|
||||
|
||||
class DerivedService(BaseService): ...
|
||||
|
||||
base_service_serialization = dump(BaseService())
|
||||
derived_service_serialization = dump(DerivedService())
|
||||
|
||||
# Names of the classes obviously differ
|
||||
base_service_serialization.pop("name")
|
||||
derived_service_serialization.pop("name")
|
||||
|
||||
assert base_service_serialization == derived_service_serialization
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_dict() -> dict[str, Any]:
|
||||
class MySubclass(pydase.DataService):
|
||||
attr3 = 1.0
|
||||
list_attr = [1.0, 1]
|
||||
|
||||
class ServiceClass(pydase.DataService):
|
||||
attr1 = 1.0
|
||||
attr2 = MySubclass()
|
||||
enum_attr = MyEnum.RUNNING
|
||||
attr_list = [0, 1, MySubclass()]
|
||||
|
||||
def my_task(self) -> None:
|
||||
pass
|
||||
|
||||
return ServiceClass().serialize()["value"] # type: ignore
|
||||
|
||||
|
||||
def test_update_attribute(setup_dict: dict[str, Any]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr1", 15)
|
||||
assert setup_dict["attr1"]["value"] == 15
|
||||
|
||||
|
||||
def test_update_nested_attribute(setup_dict: dict[str, Any]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr2.attr3", 25.0)
|
||||
assert setup_dict["attr2"]["value"]["attr3"]["value"] == 25.0
|
||||
|
||||
|
||||
def test_update_float_attribute_to_enum(setup_dict: dict[str, Any]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr2.attr3", MyEnum.RUNNING)
|
||||
assert setup_dict["attr2"]["value"]["attr3"] == {
|
||||
"full_access_path": "attr2.attr3",
|
||||
"name": "MyEnum",
|
||||
"doc": "MyEnum description",
|
||||
"enum": {"FINISHED": "finished", "RUNNING": "running"},
|
||||
"readonly": False,
|
||||
"type": "Enum",
|
||||
"value": "RUNNING",
|
||||
}
|
||||
|
||||
|
||||
def test_update_enum_attribute_to_float(setup_dict: dict[str, Any]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "enum_attr", 1.01)
|
||||
assert setup_dict["enum_attr"] == {
|
||||
"full_access_path": "enum_attr",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "float",
|
||||
"value": 1.01,
|
||||
}
|
||||
|
||||
|
||||
def test_update_task_state(setup_dict: dict[str, Any]) -> None:
|
||||
assert setup_dict["my_task"] == {
|
||||
"full_access_path": "my_task",
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"frontend_render": False,
|
||||
"readonly": True,
|
||||
"signature": {"parameters": {}, "return_annotation": {}},
|
||||
"type": "method",
|
||||
"value": None,
|
||||
}
|
||||
set_nested_value_by_path(setup_dict, "my_task", TaskStatus.RUNNING)
|
||||
assert setup_dict["my_task"] == {
|
||||
"full_access_path": "my_task",
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"frontend_render": False,
|
||||
"readonly": True,
|
||||
"signature": {"parameters": {}, "return_annotation": {}},
|
||||
"type": "method",
|
||||
"value": "RUNNING",
|
||||
}
|
||||
|
||||
|
||||
def test_update_list_entry(setup_dict: dict[str, SerializedObject]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr_list[1]", 20)
|
||||
assert setup_dict["attr_list"]["value"][1]["value"] == 20 # type: ignore # noqa
|
||||
|
||||
|
||||
def test_update_list_append(setup_dict: dict[str, SerializedObject]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr_list[3]", MyEnum.RUNNING)
|
||||
assert setup_dict["attr_list"]["value"][3] == { # type: ignore
|
||||
"full_access_path": "attr_list[3]",
|
||||
"doc": "MyEnum description",
|
||||
"name": "MyEnum",
|
||||
"enum": {"FINISHED": "finished", "RUNNING": "running"},
|
||||
"readonly": False,
|
||||
"type": "Enum",
|
||||
"value": "RUNNING",
|
||||
}
|
||||
|
||||
|
||||
def test_update_invalid_list_index(
|
||||
setup_dict: dict[str, Any], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr_list[10]", 30)
|
||||
assert (
|
||||
"Error occured trying to change 'attr_list[10]': list index "
|
||||
"out of range" in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_update_list_inside_class(setup_dict: dict[str, Any]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr2.list_attr[1]", 40)
|
||||
assert setup_dict["attr2"]["value"]["list_attr"]["value"][1]["value"] == 40 # noqa
|
||||
|
||||
|
||||
def test_update_class_attribute_inside_list(setup_dict: dict[str, Any]) -> None:
|
||||
set_nested_value_by_path(setup_dict, "attr_list[2].attr3", 50)
|
||||
assert setup_dict["attr_list"]["value"][2]["value"]["attr3"]["value"] == 50 # noqa
|
||||
|
||||
|
||||
def test_get_next_level_attribute_nested_dict(setup_dict: dict[str, Any]) -> None:
|
||||
nested_dict = get_next_level_dict_by_key(setup_dict, "attr1")
|
||||
assert nested_dict == setup_dict["attr1"]
|
||||
|
||||
|
||||
def test_get_next_level_list_entry_nested_dict(setup_dict: dict[str, Any]) -> None:
|
||||
nested_dict = get_next_level_dict_by_key(setup_dict, "attr_list[0]")
|
||||
assert nested_dict == setup_dict["attr_list"]["value"][0]
|
||||
|
||||
|
||||
def test_get_next_level_invalid_path_nested_dict(setup_dict: dict[str, Any]) -> None:
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_next_level_dict_by_key(setup_dict, "invalid_path")
|
||||
|
||||
|
||||
def test_get_next_level_invalid_list_index(setup_dict: dict[str, Any]) -> None:
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_next_level_dict_by_key(setup_dict, "attr_list[10]")
|
||||
|
||||
|
||||
def test_get_attribute(setup_dict: dict[str, Any]) -> None:
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr1")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_nested_attribute(setup_dict: dict[str, Any]) -> None:
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr2.attr3")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_list_entry(setup_dict: dict[str, Any]) -> None:
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[1]")
|
||||
assert nested_dict["value"] == 1
|
||||
|
||||
|
||||
def test_get_list_inside_class(setup_dict: dict[str, Any]) -> None:
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr2.list_attr[1]")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_class_attribute_inside_list(setup_dict: dict[str, Any]) -> None:
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[2].attr3")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_invalid_list_index(setup_dict: dict[str, Any]) -> None:
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_nested_dict_by_path(setup_dict, "attr_list[10]")
|
||||
|
||||
|
||||
def test_get_invalid_path(setup_dict: dict[str, Any]) -> None:
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_nested_dict_by_path(setup_dict, "invalid_path")
|
||||
|
||||
|
||||
def test_serialized_dict_is_nested_object() -> None:
|
||||
serialized_dict = {
|
||||
"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,
|
||||
},
|
||||
"my_slider": {
|
||||
"type": "NumberSlider",
|
||||
"value": {
|
||||
"max": {
|
||||
"type": "float",
|
||||
"value": 101.0,
|
||||
"readonly": False,
|
||||
"doc": "The min property.",
|
||||
},
|
||||
"min": {
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
"readonly": False,
|
||||
"doc": "The min property.",
|
||||
},
|
||||
"step_size": {
|
||||
"type": "float",
|
||||
"value": 2.0,
|
||||
"readonly": False,
|
||||
"doc": "The min property.",
|
||||
},
|
||||
"value": {
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
"readonly": False,
|
||||
"doc": "The value property.",
|
||||
},
|
||||
},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
"string": {
|
||||
"type": "str",
|
||||
"value": "Another name",
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
},
|
||||
"float": {
|
||||
"type": "int",
|
||||
"value": 10,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
"unit": {
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 12.0, "unit": "A"},
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
assert serialized_dict_is_nested_object(serialized_dict["list_attr"])
|
||||
assert serialized_dict_is_nested_object(serialized_dict["my_slider"])
|
||||
assert serialized_dict_is_nested_object(serialized_dict["subservice"])
|
||||
|
||||
assert not serialized_dict_is_nested_object(
|
||||
serialized_dict["list_attr"]["value"][0] # type: ignore[index]
|
||||
)
|
||||
assert not serialized_dict_is_nested_object(serialized_dict["string"])
|
||||
assert not serialized_dict_is_nested_object(serialized_dict["unit"])
|
||||
assert not serialized_dict_is_nested_object(serialized_dict["float"])
|
||||
assert not serialized_dict_is_nested_object(serialized_dict["state"])
|
||||
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
name = "MyService"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_input, expected",
|
||||
[
|
||||
(
|
||||
1,
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"type": "int",
|
||||
"value": 1,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
1.0,
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
True,
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"type": "bool",
|
||||
"value": True,
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
u.Quantity(10, "m"),
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 10, "unit": "meter"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
MyEnum.RUNNING,
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"value": "RUNNING",
|
||||
"type": "Enum",
|
||||
"doc": "MyEnum description",
|
||||
"readonly": False,
|
||||
"name": "MyEnum",
|
||||
"enum": {"RUNNING": "running", "FINISHED": "finished"},
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
[1.0],
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"value": [
|
||||
{
|
||||
"full_access_path": "new_attr[0]",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
}
|
||||
],
|
||||
"type": "list",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
{"key": 1.0},
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"value": {
|
||||
"key": {
|
||||
"full_access_path": 'new_attr["key"]',
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
}
|
||||
},
|
||||
"type": "dict",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
MyService(),
|
||||
{
|
||||
"new_attr": {
|
||||
"full_access_path": "new_attr",
|
||||
"value": {
|
||||
"name": {
|
||||
"full_access_path": "new_attr.name",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "str",
|
||||
"value": "MyService",
|
||||
}
|
||||
},
|
||||
"type": "DataService",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"name": "MyService",
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_dynamically_add_attributes(test_input: Any, expected: dict[str, Any]) -> None:
|
||||
serialized_object: dict[str, SerializedObject] = {}
|
||||
|
||||
set_nested_value_by_path(serialized_object, "new_attr", test_input)
|
||||
assert serialized_object == expected
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from pydase.utils.helpers import is_property_attribute
|
||||
import pytest
|
||||
from pydase.utils.helpers import (
|
||||
is_property_attribute,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
from pydase.utils.logging import setup_logging
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
|
||||
def test_log_error(caplog: LogCaptureFixture):
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import pydase
|
||||
import pydase.units as u
|
||||
import pytest
|
||||
from pydase.components.coloured_enum import ColouredEnum
|
||||
from pydase.utils.serializer import (
|
||||
SerializationPathError,
|
||||
dump,
|
||||
get_nested_dict_by_path,
|
||||
get_next_level_dict_by_key,
|
||||
set_nested_value_by_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"test_input, expected",
|
||||
[
|
||||
(1, {"type": "int", "value": 1, "readonly": False, "doc": None}),
|
||||
(1.0, {"type": "float", "value": 1.0, "readonly": False, "doc": None}),
|
||||
(True, {"type": "bool", "value": True, "readonly": False, "doc": None}),
|
||||
(
|
||||
u.Quantity(10, "m"),
|
||||
{
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 10, "unit": "meter"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_dump(test_input: Any, expected: dict[str, Any]) -> None:
|
||||
assert dump(test_input) == expected
|
||||
|
||||
|
||||
def test_enum_serialize() -> None:
|
||||
class EnumClass(Enum):
|
||||
FOO = "foo"
|
||||
BAR = "bar"
|
||||
|
||||
class EnumAttribute(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.some_enum = EnumClass.FOO
|
||||
|
||||
class EnumPropertyWithoutSetter(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._some_enum = EnumClass.FOO
|
||||
|
||||
@property
|
||||
def some_enum(self) -> EnumClass:
|
||||
return self._some_enum
|
||||
|
||||
class EnumPropertyWithSetter(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._some_enum = EnumClass.FOO
|
||||
|
||||
@property
|
||||
def some_enum(self) -> EnumClass:
|
||||
return self._some_enum
|
||||
|
||||
@some_enum.setter
|
||||
def some_enum(self, value: EnumClass) -> None:
|
||||
self._some_enum = value
|
||||
|
||||
assert dump(EnumAttribute())["value"] == {
|
||||
"some_enum": {
|
||||
"type": "Enum",
|
||||
"value": "FOO",
|
||||
"enum": {"FOO": "foo", "BAR": "bar"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
}
|
||||
assert dump(EnumPropertyWithoutSetter())["value"] == {
|
||||
"some_enum": {
|
||||
"type": "Enum",
|
||||
"value": "FOO",
|
||||
"enum": {"FOO": "foo", "BAR": "bar"},
|
||||
"readonly": True,
|
||||
"doc": None,
|
||||
}
|
||||
}
|
||||
assert dump(EnumPropertyWithSetter())["value"] == {
|
||||
"some_enum": {
|
||||
"type": "Enum",
|
||||
"value": "FOO",
|
||||
"enum": {"FOO": "foo", "BAR": "bar"},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_ColouredEnum_serialize() -> None:
|
||||
class Status(ColouredEnum):
|
||||
PENDING = "#FFA500"
|
||||
RUNNING = "#0000FF80"
|
||||
PAUSED = "rgb(169, 169, 169)"
|
||||
RETRYING = "rgba(255, 255, 0, 0.3)"
|
||||
COMPLETED = "hsl(120, 100%, 50%)"
|
||||
FAILED = "hsla(0, 100%, 50%, 0.7)"
|
||||
CANCELLED = "SlateGray"
|
||||
|
||||
assert dump(Status.FAILED) == {
|
||||
"type": "ColouredEnum",
|
||||
"value": "FAILED",
|
||||
"enum": {
|
||||
"CANCELLED": "SlateGray",
|
||||
"COMPLETED": "hsl(120, 100%, 50%)",
|
||||
"FAILED": "hsla(0, 100%, 50%, 0.7)",
|
||||
"PAUSED": "rgb(169, 169, 169)",
|
||||
"PENDING": "#FFA500",
|
||||
"RETRYING": "rgba(255, 255, 0, 0.3)",
|
||||
"RUNNING": "#0000FF80",
|
||||
},
|
||||
"readonly": False,
|
||||
"doc": None,
|
||||
}
|
||||
|
||||
|
||||
def test_method_serialization() -> None:
|
||||
class ClassWithMethod(pydase.DataService):
|
||||
def some_method(self) -> str:
|
||||
return "some method"
|
||||
|
||||
async def some_task(self, sleep_time: int) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
instance = ClassWithMethod()
|
||||
instance.start_some_task(10) # type: ignore
|
||||
|
||||
assert dump(instance)["value"] == {
|
||||
"some_method": {
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"parameters": {},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
},
|
||||
"some_task": {
|
||||
"async": True,
|
||||
"doc": None,
|
||||
"parameters": {"sleep_time": "int"},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": {"sleep_time": 10},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_methods_with_type_hints() -> None:
|
||||
def method_without_type_hint(arg_without_type_hint) -> None:
|
||||
pass
|
||||
|
||||
def method_with_type_hint(some_argument: int) -> None:
|
||||
pass
|
||||
|
||||
def method_with_union_type_hint(some_argument: int | float) -> None:
|
||||
pass
|
||||
|
||||
assert dump(method_without_type_hint) == {
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"parameters": {"arg_without_type_hint": None},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
}
|
||||
|
||||
assert dump(method_with_type_hint) == {
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"parameters": {"some_argument": "int"},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
}
|
||||
|
||||
assert dump(method_with_union_type_hint) == {
|
||||
"async": False,
|
||||
"doc": None,
|
||||
"parameters": {"some_argument": "int | float"},
|
||||
"readonly": True,
|
||||
"type": "method",
|
||||
"value": None,
|
||||
}
|
||||
|
||||
|
||||
def test_list_serialization() -> None:
|
||||
class MySubclass(pydase.DataService):
|
||||
_name = "hi"
|
||||
bool_attr = True
|
||||
int_attr = 1
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
class ClassWithListAttribute(pydase.DataService):
|
||||
list_attr = [1, MySubclass()]
|
||||
|
||||
instance = ClassWithListAttribute()
|
||||
|
||||
assert dump(instance)["value"] == {
|
||||
"list_attr": {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "list",
|
||||
"value": [
|
||||
{"doc": None, "readonly": False, "type": "int", "value": 1},
|
||||
{
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "DataService",
|
||||
"value": {
|
||||
"bool_attr": {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "bool",
|
||||
"value": True,
|
||||
},
|
||||
"int_attr": {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "int",
|
||||
"value": 1,
|
||||
},
|
||||
"name": {
|
||||
"doc": None,
|
||||
"readonly": True,
|
||||
"type": "str",
|
||||
"value": "hi",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_dict_serialization() -> None:
|
||||
class MyClass(pydase.DataService):
|
||||
name = "my class"
|
||||
|
||||
test_dict = {
|
||||
"int_key": 1,
|
||||
"float_key": 1.0,
|
||||
"bool_key": True,
|
||||
"Quantity_key": 1.0 * u.units.s,
|
||||
"DataService_key": MyClass(),
|
||||
}
|
||||
|
||||
assert dump(test_dict) == {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "dict",
|
||||
"value": {
|
||||
"DataService_key": {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "DataService",
|
||||
"value": {
|
||||
"name": {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "str",
|
||||
"value": "my class",
|
||||
}
|
||||
},
|
||||
},
|
||||
"Quantity_key": {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "Quantity",
|
||||
"value": {"magnitude": 1.0, "unit": "s"},
|
||||
},
|
||||
"bool_key": {"doc": None, "readonly": False, "type": "bool", "value": True},
|
||||
"float_key": {
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
"type": "float",
|
||||
"value": 1.0,
|
||||
},
|
||||
"int_key": {"doc": None, "readonly": False, "type": "int", "value": 1},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_dict():
|
||||
class MySubclass(pydase.DataService):
|
||||
attr3 = 1.0
|
||||
list_attr = [1.0, 1]
|
||||
|
||||
class ServiceClass(pydase.DataService):
|
||||
attr1 = 1.0
|
||||
attr2 = MySubclass()
|
||||
attr_list = [0, 1, MySubclass()]
|
||||
|
||||
return ServiceClass().serialize()
|
||||
|
||||
|
||||
def test_update_attribute(setup_dict):
|
||||
set_nested_value_by_path(setup_dict, "attr1", 15)
|
||||
assert setup_dict["attr1"]["value"] == 15
|
||||
|
||||
|
||||
def test_update_nested_attribute(setup_dict):
|
||||
set_nested_value_by_path(setup_dict, "attr2.attr3", 25.0)
|
||||
assert setup_dict["attr2"]["value"]["attr3"]["value"] == 25.0
|
||||
|
||||
|
||||
def test_update_list_entry(setup_dict):
|
||||
set_nested_value_by_path(setup_dict, "attr_list[1]", 20)
|
||||
assert setup_dict["attr_list"]["value"][1]["value"] == 20
|
||||
|
||||
|
||||
def test_update_list_append(setup_dict):
|
||||
set_nested_value_by_path(setup_dict, "attr_list[3]", 20)
|
||||
assert setup_dict["attr_list"]["value"][3]["value"] == 20
|
||||
|
||||
|
||||
def test_update_invalid_list_index(setup_dict, caplog: pytest.LogCaptureFixture):
|
||||
set_nested_value_by_path(setup_dict, "attr_list[10]", 30)
|
||||
assert (
|
||||
"Error occured trying to change 'attr_list[10]': list index "
|
||||
"out of range" in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_update_invalid_path(setup_dict, caplog: pytest.LogCaptureFixture):
|
||||
set_nested_value_by_path(setup_dict, "invalid_path", 30)
|
||||
assert (
|
||||
"Error occured trying to access the key 'invalid_path': it is either "
|
||||
"not present in the current dictionary or its value does not contain "
|
||||
"a 'value' key." in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_update_list_inside_class(setup_dict):
|
||||
set_nested_value_by_path(setup_dict, "attr2.list_attr[1]", 40)
|
||||
assert setup_dict["attr2"]["value"]["list_attr"]["value"][1]["value"] == 40
|
||||
|
||||
|
||||
def test_update_class_attribute_inside_list(setup_dict):
|
||||
set_nested_value_by_path(setup_dict, "attr_list[2].attr3", 50)
|
||||
assert setup_dict["attr_list"]["value"][2]["value"]["attr3"]["value"] == 50
|
||||
|
||||
|
||||
def test_get_next_level_attribute_nested_dict(setup_dict):
|
||||
nested_dict = get_next_level_dict_by_key(setup_dict, "attr1")
|
||||
assert nested_dict == setup_dict["attr1"]
|
||||
|
||||
|
||||
def test_get_next_level_list_entry_nested_dict(setup_dict):
|
||||
nested_dict = get_next_level_dict_by_key(setup_dict, "attr_list[0]")
|
||||
assert nested_dict == setup_dict["attr_list"]["value"][0]
|
||||
|
||||
|
||||
def test_get_next_level_invalid_path_nested_dict(setup_dict):
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_next_level_dict_by_key(setup_dict, "invalid_path")
|
||||
|
||||
|
||||
def test_get_next_level_invalid_list_index(setup_dict):
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_next_level_dict_by_key(setup_dict, "attr_list[10]")
|
||||
|
||||
|
||||
def test_get_attribute(setup_dict):
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr1")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_nested_attribute(setup_dict):
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr2.attr3")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_list_entry(setup_dict):
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[1]")
|
||||
assert nested_dict["value"] == 1
|
||||
|
||||
|
||||
def test_get_list_inside_class(setup_dict):
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr2.list_attr[1]")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_class_attribute_inside_list(setup_dict):
|
||||
nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[2].attr3")
|
||||
assert nested_dict["value"] == 1.0
|
||||
|
||||
|
||||
def test_get_invalid_list_index(setup_dict, caplog: pytest.LogCaptureFixture):
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_nested_dict_by_path(setup_dict, "attr_list[10]")
|
||||
|
||||
|
||||
def test_get_invalid_path(setup_dict, caplog: pytest.LogCaptureFixture):
|
||||
with pytest.raises(SerializationPathError):
|
||||
get_nested_dict_by_path(setup_dict, "invalid_path")
|
||||