mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-12-18 04:01:20 +01:00
Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebdb77433 | ||
|
|
5ce30cfeaa | ||
|
|
82d6a7f895 | ||
|
|
9aad9dfbc6 | ||
|
|
86bac8f9e5 | ||
|
|
348ff092aa | ||
|
|
1ac08bf97d | ||
|
|
42357d7901 | ||
|
|
014a7b9492 | ||
|
|
e0d710644b | ||
|
|
4a9dba30d7 | ||
|
|
9663dea79d | ||
|
|
81e40860df | ||
|
|
9021e3a903 | ||
|
|
2136d1a157 | ||
|
|
c894215ddc | ||
|
|
22d836587e | ||
|
|
9e852c17ac | ||
|
|
bd6220cb9e | ||
|
|
940f7039d3 | ||
|
|
d45d2dba7d | ||
|
|
1fb296c3c1 | ||
|
|
bfe2d82c0b | ||
|
|
5d8471fd47 | ||
|
|
75e355faf9 | ||
|
|
f91be30ad0 | ||
|
|
b148d6919a | ||
|
|
74ebbc6223 | ||
|
|
554d6f7daa | ||
|
|
80243487cb | ||
|
|
aeaf57331e | ||
|
|
baad1268e8 | ||
|
|
9ce0c93954 | ||
|
|
95d29ee4e8 | ||
|
|
6f4fcf52dd | ||
|
|
0e73239d08 | ||
|
|
e659ca9d1c | ||
|
|
eaf76a7211 | ||
|
|
aa55ac772e | ||
|
|
755a303239 | ||
|
|
7e63f34c0a | ||
|
|
2364fc892c | ||
|
|
a1da332dba | ||
|
|
8bf0b771fa | ||
|
|
fd73653433 | ||
|
|
d09675de6a | ||
|
|
36d3a7becc | ||
|
|
d54eed8a58 | ||
|
|
817afc610a | ||
|
|
ad0f9420d9 | ||
|
|
6d786cd0f8 | ||
|
|
de4270daa4 | ||
|
|
7286017715 | ||
|
|
1a23206f42 | ||
|
|
fdb17e44e2 | ||
|
|
fc738e2743 | ||
|
|
9de4071120 | ||
|
|
369d0b1126 | ||
|
|
c396de75fb | ||
|
|
4ed8899708 | ||
|
|
2fa3505310 | ||
|
|
9d387944ef | ||
|
|
0d70b7492d | ||
|
|
7bc12b340f | ||
|
|
e996966388 | ||
|
|
c07efe056b | ||
|
|
9e0adba8dc | ||
|
|
1789a6ad7e | ||
|
|
0e5f1ede20 | ||
|
|
f8cae28128 | ||
|
|
e31af9ae31 | ||
|
|
60c671eb0d | ||
|
|
203059822c | ||
|
|
e7f9ad799c | ||
|
|
3e5a56446f | ||
|
|
cf0780b2ca | ||
|
|
8afee54c51 | ||
|
|
6e4e000c28 | ||
|
|
b719684702 | ||
|
|
7254482b35 | ||
|
|
44d5a98449 | ||
|
|
29558758af | ||
|
|
f9be97a910 | ||
|
|
fa45ee566b | ||
|
|
6e8ad98282 | ||
|
|
c42872aad4 | ||
|
|
34eb4a0e7c | ||
|
|
7d50bd5759 | ||
|
|
c98f191d20 | ||
|
|
b1e6663c66 | ||
|
|
a5a957d290 | ||
|
|
b856ed3a12 | ||
|
|
b83e241b32 | ||
|
|
fb251649a0 | ||
|
|
a2ee8d02d6 | ||
|
|
44d73c3b77 | ||
|
|
cddb83451a | ||
|
|
218dab1ade | ||
|
|
81af62dc6e | ||
|
|
6ffb068f47 | ||
|
|
73a3283a7d | ||
|
|
c0734d58ce | ||
|
|
b5a7d90d81 | ||
|
|
b91eaaaf90 | ||
|
|
4039d29f42 | ||
|
|
e8428e4a31 | ||
|
|
25459949a0 | ||
|
|
9649f914ac | ||
|
|
4ecc44fdd8 | ||
|
|
4cea7eeb59 | ||
|
|
3c48a23277 | ||
|
|
bfcf72fec7 | ||
|
|
639161d373 | ||
|
|
6f3910efd0 | ||
|
|
fe5d0eed2d | ||
|
|
a11ab1520f | ||
|
|
ae79150252 | ||
|
|
7fdd08021a | ||
|
|
00c6d4c068 | ||
|
|
f49cdd87e4 | ||
|
|
052bf79487 | ||
|
|
203cc0f0f5 | ||
|
|
0c54c9d4b7 | ||
|
|
381e73d624 | ||
|
|
9f27f07ccb | ||
|
|
94cef50e03 | ||
|
|
9fa8f06280 | ||
|
|
84abd63d56 | ||
|
|
999a6016ff | ||
|
|
19f91b7cf3 | ||
|
|
a0b7b92898 | ||
|
|
d7e604992d | ||
|
|
2d1d228c78 | ||
|
|
9c3c92361b | ||
|
|
ba9dbc03f1 | ||
|
|
f783d0b25c | ||
|
|
8285a37a4c | ||
|
|
6a894b6154 | ||
|
|
f9a5352efe | ||
|
|
9c5d133d65 | ||
|
|
eacd5bc6b1 | ||
|
|
314e89ba38 | ||
|
|
46868743c7 | ||
|
|
8203e3a498 | ||
|
|
82b9c14af3 | ||
|
|
b209ad75bb | ||
|
|
88a630518b | ||
|
|
ed80c92b1f | ||
|
|
36e30970c5 | ||
|
|
3384d1bebf | ||
|
|
e2f94c8a28 | ||
|
|
4d442cfadc | ||
|
|
2701a995e1 | ||
|
|
47a73ad55f | ||
|
|
ad4f926472 | ||
|
|
208dee2b92 | ||
|
|
02b2d4fb10 | ||
|
|
b2f59dd447 | ||
|
|
33aa8708fd | ||
|
|
37d698a1b2 | ||
|
|
8fa91e8121 | ||
|
|
b9131c9df2 | ||
|
|
1c1584c2cf | ||
|
|
bb3d6fcce1 | ||
|
|
e9a7e785dd | ||
|
|
a214d6d85a | ||
|
|
6eaf1a03d1 | ||
|
|
31f1c9a8ce | ||
|
|
02f1dba0f3 | ||
|
|
dc40fc299f | ||
|
|
348f8aac9b | ||
|
|
b314ae7dec | ||
|
|
25e578fbba | ||
|
|
1ee6a299b2 | ||
|
|
f315cd62d6 | ||
|
|
87d172b94b | ||
|
|
a2c60a9c40 | ||
|
|
66376e2e6c | ||
|
|
d1c00a2612 | ||
|
|
6dd878a062 | ||
|
|
2898b62b9c | ||
|
|
b29c86ac2c | ||
|
|
c75b203c3d | ||
|
|
036e80b920 | ||
|
|
de7badd007 | ||
|
|
7e06944018 | ||
|
|
4e9e1384df | ||
|
|
5f7cc7f671 | ||
|
|
768be76cc8 | ||
|
|
8fd83fbd7d | ||
|
|
564eeeb433 | ||
|
|
216368571a | ||
|
|
2df1a673ac | ||
|
|
d40d9c5e47 | ||
|
|
6cae76bde1 | ||
|
|
32e2a8a4d1 | ||
|
|
0ac4049282 | ||
|
|
d24c66e522 | ||
|
|
9ae6895858 | ||
|
|
2b8e25f5f1 | ||
|
|
9cfcb1ba0c | ||
|
|
a73e721b73 | ||
|
|
503240aeae | ||
|
|
ba24deecb7 | ||
|
|
5333acd583 | ||
|
|
81c05d2e14 | ||
|
|
8832c879a1 | ||
|
|
ec1f68ae4a | ||
|
|
f5e108bbe5 | ||
|
|
dfe543067f |
6
.github/workflows/python-package.yml
vendored
6
.github/workflows/python-package.yml
vendored
@@ -16,15 +16,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: chartboost/ruff-action@v1
|
||||
with:
|
||||
src: "./src"
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -25,7 +25,7 @@
|
||||
"type": "firefox",
|
||||
"request": "launch",
|
||||
"name": "react: firefox",
|
||||
"url": "http://localhost:3000",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}/frontend"
|
||||
}
|
||||
]
|
||||
|
||||
289
README.md
289
README.md
@@ -1,9 +1,9 @@
|
||||
# pydase (Python Data Service) <!-- omit from toc -->
|
||||
# pydase <!-- omit from toc -->
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://pydase.readthedocs.io/en/latest/?badge=latest)
|
||||
|
||||
`pydase` is a Python library for creating data service servers with integrated web and RPC servers. It's designed to handle the management of data structures, automated tasks, and callbacks, and provides built-in functionality for serving data over different protocols.
|
||||
`pydase` is a Python library designed to streamline the creation of services that interface with devices and data. It offers a unified API, simplifying the process of data querying and device interaction. Whether you're managing lab sensors, network devices, or any abstract data entity, `pydase` facilitates rapid service development and deployment.
|
||||
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
@@ -14,6 +14,7 @@
|
||||
- [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)
|
||||
- [RESTful API](#restful-api)
|
||||
- [Understanding the Component System](#understanding-the-component-system)
|
||||
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
|
||||
- [Method Components](#method-components)
|
||||
@@ -30,11 +31,9 @@
|
||||
- [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)
|
||||
- [Using `validate_set` to Validate Property Setters](#using-validate_set-to-validate-property-setters)
|
||||
- [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)
|
||||
@@ -44,14 +43,15 @@
|
||||
## Features
|
||||
|
||||
<!-- 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)
|
||||
- [Simple service definition through class-based interface](#defining-a-dataService)
|
||||
- [Integrated web interface for interactive access and control of your service](#accessing-the-web-interface)
|
||||
- [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)
|
||||
- [Customizable styling for the web interface](#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)
|
||||
- [Validating Property Setters](#using-validate_set-to-validate-property-setters)
|
||||
<!-- Support for additional servers for specific use-cases -->
|
||||
|
||||
## Installation
|
||||
@@ -151,7 +151,7 @@ if __name__ == "__main__":
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||
This will start the server, making your Device service accessible via RPC and a web server at [http://localhost:8001](http://localhost:8001).
|
||||
This will start the server, making your Device service accessible on [http://localhost:8001](http://localhost:8001).
|
||||
|
||||
### Accessing the Web Interface
|
||||
|
||||
@@ -161,7 +161,7 @@ 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 via Python Client
|
||||
### Connecting to the Service via Python RPC Client
|
||||
|
||||
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:
|
||||
|
||||
@@ -170,7 +170,8 @@ import pydase
|
||||
|
||||
# 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
|
||||
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy
|
||||
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy # if your service uses ssl-encryption
|
||||
|
||||
# After the connection, interact with the service attributes as if they were local
|
||||
client_proxy.voltage = 5.0
|
||||
@@ -182,30 +183,25 @@ The proxy acts as a local representative of the remote service, enabling straigh
|
||||
|
||||
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
|
||||
The RPC client also supports tab completion support in the interpreter, can be used as a context manager and integrates very well with other pydase services. For more information, please refer to the [documentation](https://pydase.readthedocs.io/en/latest/user-guide/interaction/main/#python-client).
|
||||
|
||||
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.
|
||||
### RESTful API
|
||||
The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes.
|
||||
|
||||
#### Integration within Another Service
|
||||
|
||||
You can also integrate a client proxy within another service. Here's how you can set it up:
|
||||
For example, you can get a value like this:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import json
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
# Initialize the client without blocking the constructor
|
||||
proxy = pydase.Client(hostname="<ip_addr>", port=8001, block_until_connected=False).proxy
|
||||
import requests
|
||||
|
||||
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()
|
||||
response = requests.get(
|
||||
"http://<hostname>:<port>/api/v1/get_value?access_path=<full_access_path>"
|
||||
)
|
||||
serialized_value = json.loads(response.text)
|
||||
```
|
||||
|
||||
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.
|
||||
For more information, see [here](https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#restful-api).
|
||||
|
||||
<!--usage-end-->
|
||||
|
||||
@@ -223,6 +219,7 @@ In `pydase`, components are fundamental building blocks that bridge the Python b
|
||||
- `int` and `float`: Manifested as the `NumberComponent`.
|
||||
- `bool`: Rendered as a `ButtonComponent`.
|
||||
- `list`: Each item displayed individually, named after the list attribute and its index.
|
||||
- `dict`: Each key-value pair displayed individually, named after the dictionary attribute and its key. **Note** that the dictionary keys must be strings.
|
||||
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
|
||||
|
||||
### Method Components
|
||||
@@ -507,96 +504,96 @@ In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value`
|
||||
|
||||
- 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.
|
||||
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:
|
||||
Here's an illustrative example:
|
||||
|
||||
```python
|
||||
from collections.abc import Callable
|
||||
```python
|
||||
from collections.abc import Callable
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
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
|
||||
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 ...
|
||||
# ... other properties ...
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self._value
|
||||
@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)
|
||||
@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,
|
||||
)
|
||||
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
|
||||
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()
|
||||
```
|
||||
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.
|
||||
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:
|
||||
Here's how to implement a `NumberSlider` with unit display:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
import pydase.components
|
||||
import pydase.units as u
|
||||
```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)
|
||||
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
|
||||
@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
|
||||
@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()
|
||||
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()
|
||||
```
|
||||
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`
|
||||
|
||||
@@ -638,6 +635,9 @@ my_service.status = MyStatus.FAILED
|
||||
|
||||

|
||||
|
||||
**Note** that each enumeration name and value must be unique.
|
||||
This means that you should use different colour formats when you want to use a colour multiple times.
|
||||
|
||||
#### Extending with New Components
|
||||
|
||||
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
|
||||
@@ -796,6 +796,45 @@ 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/).
|
||||
|
||||
## Using `validate_set` to Validate Property Setters
|
||||
|
||||
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
|
||||
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
|
||||
|
||||
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
|
||||
If the value is not within the precision boundary after this time, an exception is raised.
|
||||
The `precision` argument defines the acceptable deviation from the desired value.
|
||||
If `precision` is `None`, the value must be exact.
|
||||
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
|
||||
|
||||
Here’s how to use the `validate_set` decorator in a `DataService` class:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
from pydase.observer_pattern.observable.decorators import validate_set
|
||||
|
||||
|
||||
class Service(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._device = RemoteDevice() # dummy class
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
# Implement how to get the value from the remote device...
|
||||
return self._device.value
|
||||
|
||||
@value.setter
|
||||
@validate_set(timeout=1.0, precision=1e-5)
|
||||
def value(self, value: float) -> None:
|
||||
# Implement how to set the value on the remote device...
|
||||
self._device.value = value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pydase.Server(Service()).run()
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -811,7 +850,6 @@ Configuring `pydase` through environment variables enhances flexibility, securit
|
||||
```
|
||||
|
||||
- **`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:
|
||||
@@ -826,7 +864,6 @@ 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()
|
||||
@@ -834,62 +871,14 @@ server = Server(
|
||||
|
||||
## 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 through
|
||||
|
||||
`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.
|
||||
1. a custom CSS file, and
|
||||
2. tailoring the frontend component layout and display style.
|
||||
|
||||
Here's how you can use this feature:
|
||||
You can also provide a custom frontend source if you need even more flexibility.
|
||||
|
||||
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()
|
||||
```
|
||||
For details, please see [here](https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#customization-options).
|
||||
|
||||
## Logging in pydase
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ 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
|
||||
```ts
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Card, Collapse, Image } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
@@ -203,8 +203,7 @@ There are two different events a component might want to trigger: updating an at
|
||||
|
||||
For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
|
||||
|
||||
```tsx
|
||||
// file: frontend/src/components/ButtonComponent.tsx
|
||||
```ts title="frontend/src/components/ButtonComponent.tsx"
|
||||
// ... (import statements)
|
||||
|
||||
type ButtonComponentProps = {
|
||||
@@ -249,7 +248,7 @@ There are two different events a component might want to trigger: updating an at
|
||||
|
||||
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
|
||||
```ts title="frontend/src/components/_YourComponent_.tsx"
|
||||
import { runMethod } from '../socket';
|
||||
// ... (other imports)
|
||||
|
||||
@@ -287,9 +286,7 @@ The `GenericComponent` is responsible for rendering different types of component
|
||||
|
||||
At the beginning of the `GenericComponent` file, import the newly created `ImageComponent`:
|
||||
|
||||
```tsx
|
||||
// file: frontend/src/components/GenericComponent.tsx
|
||||
|
||||
```ts title="frontend/src/components/GenericComponent.tsx"
|
||||
import { ImageComponent } from './ImageComponent';
|
||||
```
|
||||
|
||||
@@ -299,7 +296,7 @@ Update the `AttributeType` type definition to include the new type for the `Imag
|
||||
|
||||
For example, if the new attribute type is `'Image'` (which should correspond to the name of the backend component class), you can add it to the union:
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
type AttributeType =
|
||||
| 'str'
|
||||
| 'bool'
|
||||
@@ -318,7 +315,7 @@ type AttributeType =
|
||||
|
||||
Inside the `GenericComponent` function, add a new conditional branch to render the `ImageComponent` when the attribute type is `'Image'`:
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
} else if (attribute.type === 'Image') {
|
||||
return (
|
||||
<ImageComponent
|
||||
@@ -348,7 +345,7 @@ 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
|
||||
```ts
|
||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
BIN
docs/images/Tailoring_frontend_component_layout.png
Normal file
BIN
docs/images/Tailoring_frontend_component_layout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -1,20 +1,35 @@
|
||||
babel==2.15.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
beautifulsoup4==4.12.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
certifi==2024.7.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
charset-normalizer==3.3.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
|
||||
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
idna==3.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||
jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markdown==3.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
markupsafe==2.1.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mergedeep==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-autorefs==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-autorefs==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-get-deps==0.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-include-markdown-plugin==3.9.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs==1.5.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-material-extensions==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-material==9.5.30 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs-swagger-ui-tag==0.6.10 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
mkdocstrings==0.22.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
packaging==23.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pathspec==0.11.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pymdown-extensions==10.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
packaging==24.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
paginate==0.5.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
platformdirs==4.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pygments==2.18.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pymdown-extensions==10.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyyaml-env-tag==0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
regex==2024.7.24 ; python_version >= "3.10" and python_version < "4.0"
|
||||
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
watchdog==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||
soupsieve==2.5 ; python_version >= "3.10" and python_version < "4.0"
|
||||
urllib3==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||
watchdog==4.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||
|
||||
167
docs/user-guide/interaction/Auto-generated Frontend.md
Normal file
167
docs/user-guide/interaction/Auto-generated Frontend.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Auto-generated Frontend
|
||||
|
||||
`pydase` automatically generates a frontend interface based on your service definition, representing the current state and controls of the service.
|
||||
It simplifies the process of visualization and control of the data and devices managed by your `pydase` service, making it accessible to both developers and end-users.
|
||||
|
||||
Through the integration of Socket.IO, the frontend provides real-time updates, reflecting changes as they occur and allowing for immediate interaction with the backend.
|
||||
|
||||
|
||||
## Accessing the Frontend
|
||||
|
||||
You can access the auto-generated frontend by navigating to the hostname of the device the service is hosted on, followed by the exposed port:
|
||||
|
||||
```
|
||||
http://<hostname>:<port>/
|
||||
```
|
||||
|
||||
The frontend uses a component-based approach, representing various data types and control mechanisms as distinct UI components. For more information about this, please refer to [Components Guide](../Components.md).
|
||||
|
||||
## Customization Options
|
||||
|
||||
`pydase` allows you to enhance the user experience by customizing the web interface's appearance through
|
||||
|
||||
1. a custom CSS file, and
|
||||
2. tailoring the frontend component layout and display style.
|
||||
|
||||
For more advanced customization, you can provide a completely custom frontend source.
|
||||
|
||||
### Custom CSS Styling
|
||||
|
||||
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
|
||||
|
||||
You can customize the display names, visibility, and order of components 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 value defaults to [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER).
|
||||
|
||||
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).
|
||||
|
||||
For example, styling the following service
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
|
||||
class Device(pydase.DataService):
|
||||
name = "My Device"
|
||||
temperature = 1.0
|
||||
power = 1
|
||||
|
||||
|
||||
class Service(pydase.DataService):
|
||||
device = Device()
|
||||
state = "RUNNING"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pydase.Server(Service()).run()
|
||||
```
|
||||
|
||||
with the following `web_settings.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"device": {
|
||||
"displayName": "My Device",
|
||||
"displayOrder": 1
|
||||
},
|
||||
"device.name": {
|
||||
"display": false
|
||||
},
|
||||
"device.power": {
|
||||
"displayName": "Power",
|
||||
"displayOrder": 1
|
||||
},
|
||||
"device.temperature": {
|
||||
"displayName": "Temperature",
|
||||
"displayOrder": 0
|
||||
},
|
||||
"state": {
|
||||
"displayOrder": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
looks like this:
|
||||
|
||||

|
||||
|
||||
### Specifying a Custom Frontend Source
|
||||
|
||||
To further customize your web interface, you can provide a custom frontend source.
|
||||
By specifying the `frontend_src` parameter when initializing the server, you can host a tailored frontend application:
|
||||
|
||||
```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()
|
||||
```
|
||||
|
||||
`pydase` expects a directory structured as follows:
|
||||
|
||||
```bash title="Frontend directory structure"
|
||||
<your_frontend_directory>
|
||||
├── assets
|
||||
│ └── ...
|
||||
└── index.html
|
||||
```
|
||||
|
||||
Any CSS, js, image or other files need to be put into the assets folder for the web server to be able to provide access to it.
|
||||
|
||||
#### Example: Custom React Frontend
|
||||
|
||||
You can use vite to generate a react app template:
|
||||
|
||||
```bash
|
||||
npm create vite@latest my-react-app -- --template react
|
||||
```
|
||||
|
||||
*TODO: Add some useful information here...*
|
||||
|
||||
To deploy the custom react frontend, build it with
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
and pass the relative path of the output directory to the `frontend_src` parameter of the `pydase.Server`.
|
||||
|
||||
**Note** that you have to make sure that all the generated files (except the `index.html`) are in the `assets` folder. In the react app, you can achieve this by not using the `public` folder, but instead using e.g. `src/assets`.
|
||||
60
docs/user-guide/interaction/Python Client.md
Normal file
60
docs/user-guide/interaction/Python Client.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Python RPC Client
|
||||
|
||||
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 pydase
|
||||
|
||||
# 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(url="ws://<ip_addr>:<service_port>").proxy
|
||||
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy # if your service uses ssl-encryption
|
||||
|
||||
# Interact with the service attributes as if they were local
|
||||
client_proxy.voltage = 5.0
|
||||
print(client_proxy.voltage) # Expected output: 5.0
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Context Manager
|
||||
|
||||
You can also use the client as a context manager which automatically opens and closes the connection again:
|
||||
|
||||
```python
|
||||
import pydase
|
||||
|
||||
|
||||
with pydase.Client(url="ws://localhost:8001") as client:
|
||||
client.proxy.<my_method>()
|
||||
```
|
||||
|
||||
|
||||
## 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 Other Services
|
||||
|
||||
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(url="ws://<ip_addr>:<service_port>", block_until_connected=False).proxy
|
||||
# proxy = pydase.Client(url="wss://your-domain.ch", block_until_connected=False).proxy # communicating with ssl-encrypted service
|
||||
|
||||
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.
|
||||
22
docs/user-guide/interaction/RESTful API.md
Normal file
22
docs/user-guide/interaction/RESTful API.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# RESTful API
|
||||
|
||||
The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes. This is particularly useful for integrating `pydase` services with other applications or for scripting and automation.
|
||||
|
||||
For example, you can get a value like this:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
"http://<hostname>:<port>/api/v1/get_value?access_path=<full_access_path>"
|
||||
)
|
||||
serialized_value = json.loads(response.text)
|
||||
```
|
||||
|
||||
To help developers understand and utilize the API, we provide an OpenAPI specification. This specification describes the available endpoints and corresponding request/response formats.
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
<swagger-ui src="./openapi.yaml"/>
|
||||
81
docs/user-guide/interaction/main.md
Normal file
81
docs/user-guide/interaction/main.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Interacting with `pydase` Services
|
||||
|
||||
`pydase` offers multiple ways for users to interact with the services they create, providing flexibility and convenience for different use cases. This section outlines the primary interaction methods available, including an auto-generated frontend, a RESTful API, and a Python client based on Socket.IO.
|
||||
|
||||
{%
|
||||
include-markdown "./Auto-generated Frontend.md"
|
||||
heading-offset=1
|
||||
%}
|
||||
|
||||
{%
|
||||
include-markdown "./RESTful API.md"
|
||||
heading-offset=1
|
||||
%}
|
||||
|
||||
{%
|
||||
include-markdown "./Python Client.md"
|
||||
heading-offset=1
|
||||
%}
|
||||
|
||||
<!-- ## 2. **Socket.IO for Real-Time Updates** -->
|
||||
<!-- For scenarios requiring real-time data updates, `pydase` includes a Socket.IO server. This feature is ideal for applications where live data tracking is crucial, such as monitoring systems or interactive dashboards. -->
|
||||
<!---->
|
||||
<!-- ### Key Features: -->
|
||||
<!-- - **Live Data Streams**: Receive real-time updates for data changes. -->
|
||||
<!-- - **Event-Driven Communication**: Utilize event-based messaging to push updates and handle client actions. -->
|
||||
<!---->
|
||||
<!-- ### Example Usage: -->
|
||||
<!-- Clients can connect to the Socket.IO server to receive updates: -->
|
||||
<!-- ```javascript -->
|
||||
<!-- var socket = io.connect('http://<hostname>:<port>'); -->
|
||||
<!-- socket.on('<event_name>', function(data) { -->
|
||||
<!-- console.log(data); -->
|
||||
<!-- }); -->
|
||||
<!-- ``` -->
|
||||
<!---->
|
||||
<!-- **Use Cases:** -->
|
||||
<!---->
|
||||
<!-- - Real-time monitoring and alerts -->
|
||||
<!-- - Live data visualization -->
|
||||
<!-- - Collaborative applications -->
|
||||
<!---->
|
||||
<!-- ## 3. **Auto-Generated Frontend** -->
|
||||
<!-- `pydase` automatically generates a web frontend based on the service definitions. This frontend is a convenient interface for interacting with the service, especially for users who prefer a graphical interface over command-line or code-based interactions. -->
|
||||
<!---->
|
||||
<!-- ### Key Features: -->
|
||||
<!-- - **User-Friendly Interface**: Intuitive and easy to use, with real-time interaction capabilities. -->
|
||||
<!-- - **Customizable**: Adjust the frontend's appearance and functionality to suit specific needs. -->
|
||||
<!---->
|
||||
<!-- ### Accessing the Frontend: -->
|
||||
<!-- Once the service is running, access the frontend via a web browser: -->
|
||||
<!-- ``` -->
|
||||
<!-- http://<hostname>:<port> -->
|
||||
<!-- ``` -->
|
||||
<!---->
|
||||
<!-- **Use Cases:** -->
|
||||
<!---->
|
||||
<!-- - End-user interfaces for data control and visualization -->
|
||||
<!-- - Rapid prototyping and testing -->
|
||||
<!-- - Demonstrations and training -->
|
||||
<!---->
|
||||
<!-- ## 4. **Python Client** -->
|
||||
<!-- `pydase` also provides a Python client for programmatic interactions. This client is particularly useful for developers who want to integrate `pydase` services into other Python applications or automate interactions. -->
|
||||
<!---->
|
||||
<!-- ### Key Features: -->
|
||||
<!-- - **Direct Interaction**: Call methods and access properties as if they were local. -->
|
||||
<!-- - **Tab Completion**: Supports tab completion in interactive environments like Jupyter notebooks. -->
|
||||
<!---->
|
||||
<!-- ### Example Usage: -->
|
||||
<!-- ```python -->
|
||||
<!-- import pydase -->
|
||||
<!---->
|
||||
<!-- client = pydase.Client(hostname="<ip_addr>", port=8001) -->
|
||||
<!-- service = client.proxy -->
|
||||
<!-- service.some_method() -->
|
||||
<!-- ``` -->
|
||||
<!---->
|
||||
<!-- **Use Cases:** -->
|
||||
<!---->
|
||||
<!-- - Integrating with other Python applications -->
|
||||
<!-- - Automation and scripting -->
|
||||
<!-- - Data analysis and manipulation -->
|
||||
326
docs/user-guide/interaction/openapi.yaml
Normal file
326
docs/user-guide/interaction/openapi.yaml
Normal file
@@ -0,0 +1,326 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
version: 1.0.0
|
||||
title: pydase API
|
||||
tags:
|
||||
- name: /api/v1
|
||||
description: Version 1
|
||||
paths:
|
||||
/api/v1/get_value:
|
||||
get:
|
||||
tags:
|
||||
- /api/v1
|
||||
summary: Get the value of an existing attribute.
|
||||
description: Get the value of an existing attribute by full access path.
|
||||
operationId: getValue
|
||||
parameters:
|
||||
- in: query
|
||||
name: access_path
|
||||
schema:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
required: true
|
||||
description: Full access path of the service attribute.
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedAttribute'
|
||||
examples:
|
||||
Exists:
|
||||
summary: Attribute exists
|
||||
value:
|
||||
docs: My documentation string.
|
||||
full_access_path: device.channel[0].voltage
|
||||
readonly: false
|
||||
type: float
|
||||
value: 12.1
|
||||
'400':
|
||||
description: Could not get attribute
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedException'
|
||||
examples:
|
||||
Attribute:
|
||||
summary: Attribute does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "'MyService' object has no attribute 'invalid_attribute'"
|
||||
List:
|
||||
summary: List index out of range
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: IndexError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "list index out of range"
|
||||
/api/v1/update_value:
|
||||
put:
|
||||
tags:
|
||||
- /api/v1
|
||||
summary: Update an existing attribute.
|
||||
description: Update an existing attribute by full access path.
|
||||
operationId: updateValue
|
||||
requestBody:
|
||||
description: Update an existent attribute in the service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateValue'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Operation
|
||||
'400':
|
||||
description: Could not Update Attribute
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedException'
|
||||
examples:
|
||||
Attribute:
|
||||
summary: Attribute does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "'MyService' object has no attribute 'invalid_attribute'"
|
||||
ReadOnly:
|
||||
summary: Attribute is read-only
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "property 'readonly_property' of 'MyService' object has no setter"
|
||||
List:
|
||||
summary: List index out of range
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: IndexError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "list index out of range"
|
||||
/api/v1/trigger_method:
|
||||
put:
|
||||
tags:
|
||||
- /api/v1
|
||||
summary: Trigger method.
|
||||
description: Trigger method with by full access path with provided args and kwargs.
|
||||
operationId: triggerMethod
|
||||
requestBody:
|
||||
description: Update an existent attribute in the service
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerMethod'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedAttribute'
|
||||
examples:
|
||||
NoneReturn:
|
||||
summary: Function returns None
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
readonly: false
|
||||
type: "NoneType"
|
||||
value: null
|
||||
FloatReturn:
|
||||
summary: Function returns float
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
readonly: false
|
||||
type: "float"
|
||||
value: 23.2
|
||||
'400':
|
||||
description: Method does not exist
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SerializedException'
|
||||
examples:
|
||||
Args:
|
||||
summary: Wrong number of arguments
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: TypeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "MyService.some_function() takes 1 positional argument but 2 were given"
|
||||
Attribute:
|
||||
summary: Attribute does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: AttributeError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "'MyService' object has no attribute 'invalid_method'"
|
||||
List:
|
||||
summary: List index out of range
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: IndexError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "list index out of range"
|
||||
Dict:
|
||||
summary: Dictionary key does not exist
|
||||
value:
|
||||
docs: null
|
||||
full_access_path: ""
|
||||
name: KeyError
|
||||
readonly: true
|
||||
type: Exception
|
||||
value: "invalid_key"
|
||||
components:
|
||||
schemas:
|
||||
UpdateValue:
|
||||
required:
|
||||
- access_path
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
access_path:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
value:
|
||||
$ref: '#/components/schemas/SerializedValue'
|
||||
TriggerMethod:
|
||||
required:
|
||||
- access_path
|
||||
type: object
|
||||
properties:
|
||||
access_path:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
args:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
- full_access_path
|
||||
properties:
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- list
|
||||
value:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SerializedValue'
|
||||
kwargs:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
- full_access_path
|
||||
properties:
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- dict
|
||||
value:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/SerializedValue'
|
||||
SerializedValue:
|
||||
required:
|
||||
- full_access_path
|
||||
- type
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
docs:
|
||||
type: string | null
|
||||
example: null
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
readonly:
|
||||
type: boolean
|
||||
example: false
|
||||
type:
|
||||
type: string
|
||||
example: float
|
||||
value:
|
||||
type: any
|
||||
example: 22.0
|
||||
SerializedAttribute:
|
||||
required:
|
||||
- full_access_path
|
||||
- type
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
docs:
|
||||
type: string | null
|
||||
example: My documentation string.
|
||||
full_access_path:
|
||||
type: string
|
||||
example: device.channel[0].voltage
|
||||
readonly:
|
||||
type: boolean
|
||||
example: false
|
||||
type:
|
||||
type: string
|
||||
example: float
|
||||
value:
|
||||
type: any
|
||||
example: 22.0
|
||||
SerializedException:
|
||||
required:
|
||||
- full_access_path
|
||||
- type
|
||||
- value
|
||||
type: object
|
||||
properties:
|
||||
docs:
|
||||
type: string | null
|
||||
example: Raised when the access path does not correspond to a valid attribute.
|
||||
full_access_path:
|
||||
type: string
|
||||
example: ""
|
||||
name:
|
||||
type: string
|
||||
example: SerializationPathError
|
||||
readonly:
|
||||
type: boolean
|
||||
example: true
|
||||
type:
|
||||
type: string
|
||||
example: Exception
|
||||
value:
|
||||
type: string
|
||||
examples:
|
||||
value:
|
||||
"Index '2': list index out of range"
|
||||
some:
|
||||
"Index '2': list index out of range"
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"prettier"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "error"
|
||||
}
|
||||
}
|
||||
38
frontend/.gitignore
vendored
38
frontend/.gitignore
vendored
@@ -1,20 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": true,
|
||||
"endOfLine": "auto",
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"printWidth": 88,
|
||||
"trailingComma": "none"
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": true,
|
||||
"endOfLine": "auto",
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 88
|
||||
}
|
||||
|
||||
@@ -1,70 +1,30 @@
|
||||
# Getting Started with Create React App
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Available Scripts
|
||||
Currently, two official plugins are available:
|
||||
|
||||
In the project directory, you can run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
### `npm start`
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
### `npm test`
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
|
||||
24
frontend/eslint.config.js
Normal file
24
frontend/eslint.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import reactRecommended from "eslint-plugin-react/configs/recommended.js";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.stylistic,
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
...reactRecommended,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended,
|
||||
);
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site displaying a pydase UI." />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
24624
frontend/package-lock.json
generated
24624
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,58 +1,40 @@
|
||||
{
|
||||
"name": "pydase",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fsouza/prettierd": "^0.25.1",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.8.0",
|
||||
"react-bootstrap-icons": "^1.10.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"socket.io-client": "^4.7.1",
|
||||
"web-vitals": "^3.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development react-scripts start",
|
||||
"build": "BUILD_PATH='../src/pydase/frontend' react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.9.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^4.9.0"
|
||||
}
|
||||
"name": "pydase",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build --emptyOutDir",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/material": "^5.14.1",
|
||||
"bootstrap": "^5.3.3",
|
||||
"deep-equal": "^2.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.0",
|
||||
"react-bootstrap-icons": "^1.11.4",
|
||||
"socket.io-client": "^4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.6.0",
|
||||
"@types/deep-equal": "^1.0.4",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.34.3",
|
||||
"prettier": "3.3.2",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^7.15.0",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/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="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>pydase App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,43 +1,49 @@
|
||||
import { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
|
||||
import { hostname, port, socket } from './socket';
|
||||
import './App.css';
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import { Navbar, Form, Offcanvas, Container } from "react-bootstrap";
|
||||
import { hostname, port, socket } from "./socket";
|
||||
import "./App.css";
|
||||
import {
|
||||
Notifications,
|
||||
Notification,
|
||||
LevelName
|
||||
} from './components/NotificationsComponent';
|
||||
import { ConnectionToast } from './components/ConnectionToast';
|
||||
import { setNestedValueByPath, State } from './utils/stateUtils';
|
||||
import { WebSettingsContext, WebSetting } from './WebSettings';
|
||||
import { SerializedValue, GenericComponent } from './components/GenericComponent';
|
||||
LevelName,
|
||||
} from "./components/NotificationsComponent";
|
||||
import { ConnectionToast } from "./components/ConnectionToast";
|
||||
import { setNestedValueByPath, State } from "./utils/stateUtils";
|
||||
import { WebSettingsContext, WebSetting } from "./WebSettings";
|
||||
import { GenericComponent } from "./components/GenericComponent";
|
||||
import { SerializedObject } from "./types/SerializedObject";
|
||||
import useLocalStorage from "./hooks/useLocalStorage";
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_DATA'; data: State }
|
||||
| { type: "SET_DATA"; data: State }
|
||||
| {
|
||||
type: 'UPDATE_ATTRIBUTE';
|
||||
type: "UPDATE_ATTRIBUTE";
|
||||
fullAccessPath: string;
|
||||
newValue: SerializedValue;
|
||||
newValue: SerializedObject;
|
||||
};
|
||||
type UpdateMessage = {
|
||||
data: { full_access_path: string; value: SerializedValue };
|
||||
};
|
||||
type LogMessage = {
|
||||
interface UpdateMessage {
|
||||
data: { full_access_path: string; value: SerializedObject };
|
||||
}
|
||||
interface LogMessage {
|
||||
levelname: LevelName;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
const reducer = (state: State | null, action: Action): State | null => {
|
||||
switch (action.type) {
|
||||
case 'SET_DATA':
|
||||
case "SET_DATA":
|
||||
return action.data;
|
||||
case 'UPDATE_ATTRIBUTE': {
|
||||
case "UPDATE_ATTRIBUTE": {
|
||||
if (state === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
|
||||
value: setNestedValueByPath(
|
||||
state.value as Record<string, SerializedObject>,
|
||||
action.fullAccessPath,
|
||||
action.newValue,
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
@@ -46,12 +52,19 @@ const reducer = (state: State, action: Action): State => {
|
||||
};
|
||||
const App = () => {
|
||||
const [state, dispatch] = useReducer(reducer, null);
|
||||
const [serviceName, setServiceName] = useState<string | null>(null);
|
||||
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useLocalStorage(
|
||||
"isInstantUpdate",
|
||||
false,
|
||||
);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [showNotification, setShowNotification] = useLocalStorage(
|
||||
"showNotification",
|
||||
false,
|
||||
);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [connectionStatus, setConnectionStatus] = useState('connecting');
|
||||
const [connectionStatus, setConnectionStatus] = useState("connecting");
|
||||
|
||||
useEffect(() => {
|
||||
// Allow the user to add a custom css file
|
||||
@@ -59,49 +72,54 @@ const App = () => {
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
// If the file exists, create a link element for the custom CSS
|
||||
const link = document.createElement('link');
|
||||
const link = document.createElement("link");
|
||||
link.href = `http://${hostname}:${port}/custom.css`;
|
||||
link.type = 'text/css';
|
||||
link.rel = 'stylesheet';
|
||||
link.type = "text/css";
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
})
|
||||
.catch(console.error); // Handle the error appropriately
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.on("connect", () => {
|
||||
// Fetch data from the API when the client connects
|
||||
fetch(`http://${hostname}:${port}/service-properties`)
|
||||
.then((response) => response.json())
|
||||
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
|
||||
.then((data: State) => {
|
||||
dispatch({ type: "SET_DATA", data });
|
||||
setServiceName(data.name);
|
||||
|
||||
document.title = data.name; // Setting browser tab title
|
||||
});
|
||||
fetch(`http://${hostname}:${port}/web-settings`)
|
||||
.then((response) => response.json())
|
||||
.then((data: Record<string, WebSetting>) => setWebSettings(data));
|
||||
setConnectionStatus('connected');
|
||||
setConnectionStatus("connected");
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
setConnectionStatus('disconnected');
|
||||
socket.on("disconnect", () => {
|
||||
setConnectionStatus("disconnected");
|
||||
setTimeout(() => {
|
||||
// Only set "reconnecting" is the state is still "disconnected"
|
||||
// E.g. when the client has already reconnected
|
||||
setConnectionStatus((currentState) =>
|
||||
currentState === 'disconnected' ? 'reconnecting' : currentState
|
||||
currentState === "disconnected" ? "reconnecting" : currentState,
|
||||
);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
socket.on('notify', onNotify);
|
||||
socket.on('log', onLogMessage);
|
||||
socket.on("notify", onNotify);
|
||||
socket.on("log", onLogMessage);
|
||||
|
||||
return () => {
|
||||
socket.off('notify', onNotify);
|
||||
socket.off('log', onLogMessage);
|
||||
socket.off("notify", onNotify);
|
||||
socket.off("log", onLogMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Adding useCallback to prevent notify to change causing a re-render of all
|
||||
// components
|
||||
const addNotification = useCallback(
|
||||
(message: string, levelname: LevelName = 'DEBUG') => {
|
||||
(message: string, levelname: LevelName = "DEBUG") => {
|
||||
// Getting the current time in the required format
|
||||
const timeStamp = new Date().toISOString().substring(11, 19);
|
||||
// Adding an id to the notification to provide a way of removing it
|
||||
@@ -110,15 +128,15 @@ const App = () => {
|
||||
// Custom logic for notifications
|
||||
setNotifications((prevNotifications) => [
|
||||
{ levelname, id, message, timeStamp },
|
||||
...prevNotifications
|
||||
...prevNotifications,
|
||||
]);
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const removeNotificationById = (id: number) => {
|
||||
setNotifications((prevNotifications) =>
|
||||
prevNotifications.filter((n) => n.id !== id)
|
||||
prevNotifications.filter((n) => n.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -131,9 +149,9 @@ const App = () => {
|
||||
|
||||
// Dispatching the update to the reducer
|
||||
dispatch({
|
||||
type: 'UPDATE_ATTRIBUTE',
|
||||
type: "UPDATE_ATTRIBUTE",
|
||||
fullAccessPath,
|
||||
newValue
|
||||
newValue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +167,7 @@ const App = () => {
|
||||
<>
|
||||
<Navbar expand={false} bg="primary" variant="dark" fixed="top">
|
||||
<Container fluid>
|
||||
<Navbar.Brand>Data Service App</Navbar.Brand>
|
||||
<Navbar.Brand>{serviceName}</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="offcanvasNavbar" onClick={handleShowSettings} />
|
||||
</Container>
|
||||
</Navbar>
|
||||
@@ -188,7 +206,7 @@ const App = () => {
|
||||
<div className="App navbarOffset">
|
||||
<WebSettingsContext.Provider value={webSettings}>
|
||||
<GenericComponent
|
||||
attribute={state as SerializedValue}
|
||||
attribute={state as SerializedObject}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createContext } from 'react';
|
||||
import { createContext } from "react";
|
||||
|
||||
export const WebSettingsContext = createContext<Record<string, WebSetting>>({});
|
||||
|
||||
export type WebSetting = {
|
||||
export interface WebSetting {
|
||||
displayName: string;
|
||||
display: boolean;
|
||||
index: number;
|
||||
};
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { Form, Button, InputGroup } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { runMethod } from "../socket";
|
||||
import { Form, Button, InputGroup, Spinner } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type AsyncMethodProps = {
|
||||
interface AsyncMethodProps {
|
||||
fullAccessPath: string;
|
||||
value: 'RUNNING' | null;
|
||||
docString?: string;
|
||||
value: "RUNNING" | null;
|
||||
docString: string | null;
|
||||
hideOutput?: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
const {
|
||||
@@ -22,7 +23,7 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
value: runningTask,
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
// Conditional rendering based on the 'render' prop.
|
||||
@@ -30,13 +31,13 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const formRef = useRef(null);
|
||||
const name = fullAccessPath.split('.').at(-1);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const name = fullAccessPath.split(".").at(-1)!;
|
||||
const parentPath = fullAccessPath.slice(0, -(name.length + 1));
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
let message: string;
|
||||
|
||||
if (runningTask === null) {
|
||||
@@ -45,6 +46,7 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
message = `${fullAccessPath} was started.`;
|
||||
}
|
||||
addNotification(message);
|
||||
setSpinning(false);
|
||||
}, [props.value]);
|
||||
|
||||
const execute = async (event: React.FormEvent) => {
|
||||
@@ -57,15 +59,14 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
method_name = `start_${name}`;
|
||||
}
|
||||
|
||||
const accessPath = [parentPath, method_name].filter((element) => element).join('.');
|
||||
const accessPath = [parentPath, method_name].filter((element) => element).join(".");
|
||||
setSpinning(true);
|
||||
runMethod(accessPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="component asyncMethodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
@@ -73,10 +74,18 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
||||
<DocStringComponent docString={docString} />
|
||||
</InputGroup.Text>
|
||||
<Button id={`button-${id}`} type="submit">
|
||||
{runningTask === 'RUNNING' ? 'Stop ' : 'Start '}
|
||||
{spinning ? (
|
||||
<Spinner size="sm" role="status" aria-hidden="true" />
|
||||
) : runningTask === "RUNNING" ? (
|
||||
"Stop "
|
||||
) : (
|
||||
"Start "
|
||||
)}
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AsyncMethodComponent.displayName = "AsyncMethodComponent";
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { ToggleButton } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect } from "react";
|
||||
import { ToggleButton } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type ButtonComponentProps = {
|
||||
interface ButtonComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: boolean;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
mapping?: [string, string]; // Enforce a tuple of two strings
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
const {
|
||||
@@ -25,34 +26,34 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const setChecked = (checked: boolean) => {
|
||||
changeCallback(checked);
|
||||
changeCallback({
|
||||
type: "bool",
|
||||
value: checked,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'component buttonComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"component buttonComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
|
||||
<ToggleButton
|
||||
id={`toggle-check-${id}`}
|
||||
type="checkbox"
|
||||
variant={value ? 'success' : 'secondary'}
|
||||
variant={value ? "success" : "secondary"}
|
||||
checked={value}
|
||||
value={displayName}
|
||||
disabled={readOnly}
|
||||
@@ -63,3 +64,5 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ButtonComponent.displayName = "ButtonComponent";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Toast, Button, ToastContainer } from 'react-bootstrap';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Toast, Button, ToastContainer } from "react-bootstrap";
|
||||
|
||||
type ConnectionToastProps = {
|
||||
interface ConnectionToastProps {
|
||||
connectionStatus: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ConnectionToast Component
|
||||
@@ -36,31 +36,31 @@ export const ConnectionToast = React.memo(
|
||||
delay: number | undefined;
|
||||
} => {
|
||||
switch (connectionStatus) {
|
||||
case 'connecting':
|
||||
case "connecting":
|
||||
return {
|
||||
message: 'Connecting...',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "Connecting...",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
case 'connected':
|
||||
return { message: 'Connected', bg: 'success', delay: 1000 };
|
||||
case 'disconnected':
|
||||
case "connected":
|
||||
return { message: "Connected", bg: "success", delay: 1000 };
|
||||
case "disconnected":
|
||||
return {
|
||||
message: 'Disconnected',
|
||||
bg: 'danger',
|
||||
delay: undefined
|
||||
message: "Disconnected",
|
||||
bg: "danger",
|
||||
delay: undefined,
|
||||
};
|
||||
case 'reconnecting':
|
||||
case "reconnecting":
|
||||
return {
|
||||
message: 'Reconnecting...',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "Reconnecting...",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: '',
|
||||
bg: 'info',
|
||||
delay: undefined
|
||||
message: "",
|
||||
bg: "info",
|
||||
delay: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -82,5 +82,7 @@ export const ConnectionToast = React.memo(
|
||||
</Toast>
|
||||
</ToastContainer>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ConnectionToast.displayName = "ConnectionToast";
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Card, Collapse } from 'react-bootstrap';
|
||||
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React from "react";
|
||||
import { Card, Collapse } from "react-bootstrap";
|
||||
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useLocalStorage from "../hooks/useLocalStorage";
|
||||
import useSortedEntries from "../hooks/useSortedEntries";
|
||||
|
||||
type DataServiceProps = {
|
||||
interface DataServiceProps {
|
||||
props: DataServiceJSON;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type DataServiceJSON = Record<string, SerializedValue>;
|
||||
export type DataServiceJSON = Record<string, SerializedObject>;
|
||||
|
||||
export const DataServiceComponent = React.memo(
|
||||
({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
// Retrieve the initial state from localStorage, default to true if not found
|
||||
const [open, setOpen] = useLocalStorage(`dataServiceComponent-${id}-open`, true);
|
||||
|
||||
if (displayName !== '') {
|
||||
const sortedEntries = useSortedEntries(props);
|
||||
|
||||
if (displayName !== "") {
|
||||
return (
|
||||
<div className="component dataServiceComponent" id={id}>
|
||||
<Card>
|
||||
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
|
||||
<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]) => (
|
||||
{sortedEntries.map((value) => (
|
||||
<GenericComponent
|
||||
key={key}
|
||||
key={value.full_access_path}
|
||||
attribute={value}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
@@ -44,9 +49,9 @@ export const DataServiceComponent = React.memo(
|
||||
} else {
|
||||
return (
|
||||
<div className="component dataServiceComponent" id={id}>
|
||||
{Object.entries(props).map(([key, value]) => (
|
||||
{sortedEntries.map((value) => (
|
||||
<GenericComponent
|
||||
key={key}
|
||||
key={value.full_access_path}
|
||||
attribute={value}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
@@ -55,5 +60,7 @@ export const DataServiceComponent = React.memo(
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DataServiceComponent.displayName = "DataServiceComponent";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from 'react';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
|
||||
import { MethodComponent } from './MethodComponent';
|
||||
import React from "react";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
|
||||
import { MethodComponent } from "./MethodComponent";
|
||||
|
||||
type DeviceConnectionProps = {
|
||||
interface DeviceConnectionProps {
|
||||
fullAccessPath: string;
|
||||
props: DataServiceJSON;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DeviceConnectionComponent = React.memo(
|
||||
({
|
||||
@@ -19,7 +19,7 @@ export const DeviceConnectionComponent = React.memo(
|
||||
isInstantUpdate,
|
||||
addNotification,
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
}: DeviceConnectionProps) => {
|
||||
const { connected, connect, ...updatedProps } = props;
|
||||
const connectedVal = connected.value;
|
||||
@@ -29,14 +29,14 @@ export const DeviceConnectionComponent = React.memo(
|
||||
{!connectedVal && (
|
||||
<div className="overlayContent">
|
||||
<div>
|
||||
{displayName != '' ? displayName : 'Device'} is currently not available!
|
||||
{displayName != "" ? displayName : "Device"} is currently not available!
|
||||
</div>
|
||||
<MethodComponent
|
||||
fullAccessPath={`${fullAccessPath}.connect`}
|
||||
docString={connect.doc}
|
||||
addNotification={addNotification}
|
||||
displayName={'reconnect'}
|
||||
id={id + '-connect'}
|
||||
displayName={"reconnect"}
|
||||
id={id + "-connect"}
|
||||
render={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -50,5 +50,7 @@ export const DeviceConnectionComponent = React.memo(
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DeviceConnectionComponent.displayName = "DeviceConnectionComponent";
|
||||
|
||||
41
frontend/src/components/DictComponent.tsx
Normal file
41
frontend/src/components/DictComponent.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
import useSortedEntries from "../hooks/useSortedEntries";
|
||||
|
||||
interface DictComponentProps {
|
||||
value: Record<string, SerializedObject>;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const DictComponent = React.memo((props: DictComponentProps) => {
|
||||
const { docString, isInstantUpdate, addNotification, id } = props;
|
||||
|
||||
const sortedEntries = useSortedEntries(props.value);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
return (
|
||||
<div className={"listComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<DocStringComponent docString={docString} />
|
||||
{sortedEntries.map((item) => {
|
||||
return (
|
||||
<GenericComponent
|
||||
key={item.full_access_path}
|
||||
attribute={item}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DictComponent.displayName = "DictComponent";
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
|
||||
import React from 'react';
|
||||
import { Badge, Tooltip, OverlayTrigger } from "react-bootstrap";
|
||||
import React from "react";
|
||||
|
||||
type DocStringProps = {
|
||||
docString?: string;
|
||||
};
|
||||
interface DocStringProps {
|
||||
docString?: string | null;
|
||||
}
|
||||
|
||||
export const DocStringComponent = React.memo((props: DocStringProps) => {
|
||||
const { docString } = props;
|
||||
@@ -21,3 +21,5 @@ export const DocStringComponent = React.memo((props: DocStringProps) => {
|
||||
</OverlayTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DocStringComponent.displayName = "DocStringComponent";
|
||||
|
||||
@@ -1,64 +1,40 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect } from "react";
|
||||
import { InputGroup, Form, Row, Col } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject, SerializedEnum } from "../types/SerializedObject";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
export type EnumSerialization = {
|
||||
type: 'Enum' | 'ColouredEnum';
|
||||
full_access_path: string;
|
||||
name: string;
|
||||
value: string;
|
||||
readonly: boolean;
|
||||
doc?: string | null;
|
||||
enum: Record<string, string>;
|
||||
};
|
||||
|
||||
type EnumComponentProps = {
|
||||
attribute: EnumSerialization;
|
||||
interface EnumComponentProps extends SerializedEnum {
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
};
|
||||
changeCallback: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
}
|
||||
|
||||
export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
const { attribute, addNotification, displayName, id } = props;
|
||||
const {
|
||||
full_access_path: fullAccessPath,
|
||||
addNotification,
|
||||
displayName,
|
||||
id,
|
||||
value,
|
||||
doc: docString,
|
||||
full_access_path: fullAccessPath,
|
||||
enum: enumDict,
|
||||
readonly: readOnly
|
||||
} = attribute;
|
||||
doc: docString,
|
||||
readonly: readOnly,
|
||||
changeCallback,
|
||||
} = props;
|
||||
|
||||
let { changeCallback } = props;
|
||||
if (changeCallback === undefined) {
|
||||
changeCallback = (value: SerializedValue) => {
|
||||
setEnumValue(() => {
|
||||
return String(value.value);
|
||||
});
|
||||
};
|
||||
}
|
||||
const renderCount = useRef(0);
|
||||
const [enumValue, setEnumValue] = useState(value);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEnumValue(() => {
|
||||
return value;
|
||||
});
|
||||
addNotification(`${fullAccessPath} changed to ${value}.`);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={'component enumComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"component enumComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Row>
|
||||
<Col className="d-flex align-items-center">
|
||||
<InputGroup.Text>
|
||||
@@ -69,7 +45,10 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
{readOnly ? (
|
||||
// Display the Form.Control when readOnly is true
|
||||
<Form.Control
|
||||
value={enumDict[enumValue]}
|
||||
style={
|
||||
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {}
|
||||
}
|
||||
value={props.type == "ColouredEnum" ? value : enumDict[value]}
|
||||
name={fullAccessPath}
|
||||
disabled={true}
|
||||
/>
|
||||
@@ -77,27 +56,25 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
// Display the Form.Select when readOnly is false
|
||||
<Form.Select
|
||||
aria-label="example-select"
|
||||
value={enumValue}
|
||||
value={value}
|
||||
name={fullAccessPath}
|
||||
style={
|
||||
attribute.type == 'ColouredEnum'
|
||||
? { backgroundColor: enumDict[enumValue] }
|
||||
: {}
|
||||
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {}
|
||||
}
|
||||
onChange={(event) =>
|
||||
changeCallback({
|
||||
type: attribute.type,
|
||||
name: attribute.name,
|
||||
type: props.type,
|
||||
name: props.name,
|
||||
enum: enumDict,
|
||||
value: event.target.value,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: attribute.readonly,
|
||||
doc: attribute.doc
|
||||
readonly: props.readonly,
|
||||
doc: props.doc,
|
||||
})
|
||||
}>
|
||||
{Object.entries(enumDict).map(([key, val]) => (
|
||||
<option key={key} value={key}>
|
||||
{attribute.type == 'ColouredEnum' ? key : val}
|
||||
{props.type == "ColouredEnum" ? key : val}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
@@ -106,4 +83,6 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
EnumComponent.displayName = "EnumComponent";
|
||||
|
||||
@@ -1,59 +1,67 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { ButtonComponent } from './ButtonComponent';
|
||||
import { NumberComponent } from './NumberComponent';
|
||||
import { SliderComponent } from './SliderComponent';
|
||||
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 { LevelName } from './NotificationsComponent';
|
||||
import { getIdFromFullAccessPath } from '../utils/stringUtils';
|
||||
import { WebSettingsContext } from '../WebSettings';
|
||||
import { updateValue } from '../socket';
|
||||
import React, { useContext } from "react";
|
||||
import { ButtonComponent } from "./ButtonComponent";
|
||||
import { NumberComponent, NumberObject } from "./NumberComponent";
|
||||
import { SliderComponent } from "./SliderComponent";
|
||||
import { EnumComponent } 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 { LevelName } from "./NotificationsComponent";
|
||||
import { getIdFromFullAccessPath } from "../utils/stringUtils";
|
||||
import { WebSettingsContext } from "../WebSettings";
|
||||
import { updateValue } from "../socket";
|
||||
import { DictComponent } from "./DictComponent";
|
||||
import { parseFullAccessPath } from "../utils/stateUtils";
|
||||
import { SerializedEnum, SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
type AttributeType =
|
||||
| 'str'
|
||||
| 'bool'
|
||||
| 'float'
|
||||
| 'int'
|
||||
| 'Quantity'
|
||||
| 'list'
|
||||
| 'method'
|
||||
| 'DataService'
|
||||
| 'DeviceConnection'
|
||||
| 'Enum'
|
||||
| 'NumberSlider'
|
||||
| 'Image'
|
||||
| 'ColouredEnum';
|
||||
|
||||
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;
|
||||
async?: boolean;
|
||||
frontend_render?: boolean;
|
||||
enum?: Record<string, string>;
|
||||
};
|
||||
type GenericComponentProps = {
|
||||
attribute: SerializedValue;
|
||||
interface GenericComponentProps {
|
||||
attribute: SerializedObject;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
}
|
||||
|
||||
const getPathFromPathParts = (pathParts: string[]): string => {
|
||||
let path = "";
|
||||
for (const pathPart of pathParts) {
|
||||
if (!pathPart.startsWith("[") && path !== "") {
|
||||
path += ".";
|
||||
}
|
||||
path += pathPart;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const createDisplayNameFromAccessPath = (fullAccessPath: string): string => {
|
||||
const displayNameParts = [];
|
||||
const parsedFullAccessPath = parseFullAccessPath(fullAccessPath);
|
||||
for (let i = parsedFullAccessPath.length - 1; i >= 0; i--) {
|
||||
const item = parsedFullAccessPath[i];
|
||||
displayNameParts.unshift(item);
|
||||
if (!item.startsWith("[")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return getPathFromPathParts(displayNameParts);
|
||||
};
|
||||
|
||||
function changeCallback(
|
||||
value: SerializedObject,
|
||||
callback: (ack: unknown) => void = () => {},
|
||||
) {
|
||||
updateValue(value, callback);
|
||||
}
|
||||
|
||||
export const GenericComponent = React.memo(
|
||||
({ attribute, isInstantUpdate, addNotification }: GenericComponentProps) => {
|
||||
const { full_access_path: fullAccessPath } = attribute;
|
||||
const id = getIdFromFullAccessPath(fullAccessPath);
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
let displayName = fullAccessPath.split('.').at(-1);
|
||||
|
||||
let displayName = createDisplayNameFromAccessPath(fullAccessPath);
|
||||
|
||||
if (webSettings[fullAccessPath]) {
|
||||
if (webSettings[fullAccessPath].display === false) {
|
||||
@@ -64,14 +72,7 @@ export const GenericComponent = React.memo(
|
||||
}
|
||||
}
|
||||
|
||||
function changeCallback(
|
||||
value: SerializedValue,
|
||||
callback: (ack: unknown) => void = undefined
|
||||
) {
|
||||
updateValue(value, callback);
|
||||
}
|
||||
|
||||
if (attribute.type === 'bool') {
|
||||
if (attribute.type === "bool") {
|
||||
return (
|
||||
<ButtonComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
@@ -84,7 +85,7 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'float' || attribute.type === 'int') {
|
||||
} else if (attribute.type === "float" || attribute.type === "int") {
|
||||
return (
|
||||
<NumberComponent
|
||||
type={attribute.type}
|
||||
@@ -99,15 +100,15 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Quantity') {
|
||||
} else if (attribute.type === "Quantity") {
|
||||
return (
|
||||
<NumberComponent
|
||||
type="Quantity"
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={Number(attribute.value['magnitude'])}
|
||||
unit={attribute.value['unit']}
|
||||
value={Number(attribute.value["magnitude"])}
|
||||
unit={attribute.value["unit"]}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
@@ -115,16 +116,16 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'NumberSlider') {
|
||||
} else if (attribute.type === "NumberSlider") {
|
||||
return (
|
||||
<SliderComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value['value'].doc}
|
||||
docString={attribute.value["value"].doc}
|
||||
readOnly={attribute.readonly}
|
||||
value={attribute.value['value']}
|
||||
min={attribute.value['min']}
|
||||
max={attribute.value['max']}
|
||||
stepSize={attribute.value['step_size']}
|
||||
value={attribute.value["value"] as NumberObject}
|
||||
min={attribute.value["min"] as NumberObject}
|
||||
max={attribute.value["max"] as NumberObject}
|
||||
stepSize={attribute.value["step_size"] as NumberObject}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
@@ -132,17 +133,17 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Enum' || attribute.type === 'ColouredEnum') {
|
||||
} else if (attribute.type === "Enum" || attribute.type === "ColouredEnum") {
|
||||
return (
|
||||
<EnumComponent
|
||||
attribute={attribute as EnumSerialization}
|
||||
{...(attribute as SerializedEnum)}
|
||||
addNotification={addNotification}
|
||||
changeCallback={changeCallback}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'method') {
|
||||
} else if (attribute.type === "method") {
|
||||
if (!attribute.async) {
|
||||
return (
|
||||
<MethodComponent
|
||||
@@ -159,7 +160,7 @@ export const GenericComponent = React.memo(
|
||||
<AsyncMethodComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.doc}
|
||||
value={attribute.value as 'RUNNING' | null}
|
||||
value={attribute.value as "RUNNING" | null}
|
||||
addNotification={addNotification}
|
||||
displayName={displayName}
|
||||
id={id}
|
||||
@@ -167,7 +168,7 @@ export const GenericComponent = React.memo(
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (attribute.type === 'str') {
|
||||
} else if (attribute.type === "str") {
|
||||
return (
|
||||
<StringComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
@@ -181,7 +182,7 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DataService') {
|
||||
} else if (attribute.type === "DataService") {
|
||||
return (
|
||||
<DataServiceComponent
|
||||
props={attribute.value as DataServiceJSON}
|
||||
@@ -191,7 +192,7 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'DeviceConnection') {
|
||||
} else if (attribute.type === "DeviceConnection") {
|
||||
return (
|
||||
<DeviceConnectionComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
@@ -202,31 +203,42 @@ export const GenericComponent = React.memo(
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'list') {
|
||||
} else if (attribute.type === "list") {
|
||||
return (
|
||||
<ListComponent
|
||||
value={attribute.value as SerializedValue[]}
|
||||
value={attribute.value}
|
||||
docString={attribute.doc}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === 'Image') {
|
||||
} else if (attribute.type === "dict") {
|
||||
return (
|
||||
<DictComponent
|
||||
value={attribute.value}
|
||||
docString={attribute.doc}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
} else if (attribute.type === "Image") {
|
||||
return (
|
||||
<ImageComponent
|
||||
fullAccessPath={fullAccessPath}
|
||||
docString={attribute.value['value'].doc}
|
||||
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}
|
||||
value={attribute.value["value"]["value"] as string}
|
||||
format={attribute.value["format"]["value"] as string}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <div key={fullAccessPath}>{fullAccessPath}</div>;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
GenericComponent.displayName = "GenericComponent";
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
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 { LevelName } from './NotificationsComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Collapse, Image } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type ImageComponentProps = {
|
||||
interface ImageComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
format: string;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
const { fullAccessPath, value, docString, format, addNotification, displayName, id } =
|
||||
props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath} changed.`);
|
||||
}, [props.value]);
|
||||
@@ -34,7 +31,7 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
<Card>
|
||||
<Card.Header
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{ cursor: 'pointer' }} // Change cursor style on hover
|
||||
style={{ cursor: "pointer" }} // Change cursor style on hover
|
||||
>
|
||||
{displayName}
|
||||
<DocStringComponent docString={docString} />
|
||||
@@ -42,10 +39,10 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
</Card.Header>
|
||||
<Collapse in={open}>
|
||||
<Card.Body>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<p>Render count: {renderCount.current}</p>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<p>Render count: {renderCount}</p>
|
||||
)}
|
||||
{format === '' && value === '' ? (
|
||||
{format === "" && value === "" ? (
|
||||
<p>No image set in the backend.</p>
|
||||
) : (
|
||||
<Image src={`data:image/${format.toLowerCase()};base64,${value}`}></Image>
|
||||
@@ -56,3 +53,5 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ImageComponent.displayName = "ImageComponent";
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { SerializedValue, GenericComponent } from './GenericComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React from "react";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { GenericComponent } from "./GenericComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
import useSortedEntries from "../hooks/useSortedEntries";
|
||||
|
||||
type ListComponentProps = {
|
||||
value: SerializedValue[];
|
||||
docString: string;
|
||||
interface ListComponentProps {
|
||||
value: SerializedObject[];
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
const { value, docString, isInstantUpdate, addNotification, id } = props;
|
||||
const { docString, isInstantUpdate, addNotification, id } = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const sortedEntries = useSortedEntries(props.value);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
}, [props]);
|
||||
const renderCount = useRenderCount();
|
||||
|
||||
return (
|
||||
<div className={'listComponent'} id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
<div className={"listComponent"} id={id}>
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<DocStringComponent docString={docString} />
|
||||
{value.map((item, index) => {
|
||||
{sortedEntries.map((item) => {
|
||||
return (
|
||||
<GenericComponent
|
||||
key={`${name}[${index}]`}
|
||||
key={item.full_access_path}
|
||||
attribute={item}
|
||||
isInstantUpdate={isInstantUpdate}
|
||||
addNotification={addNotification}
|
||||
@@ -39,3 +38,5 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ListComponent.displayName = "ListComponent";
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { runMethod } from '../socket';
|
||||
import { Button, Form } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import React, { useRef } from "react";
|
||||
import { runMethod } from "../socket";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
|
||||
type MethodProps = {
|
||||
interface MethodProps {
|
||||
fullAccessPath: string;
|
||||
docString?: string;
|
||||
docString: string | null;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
render: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
const { fullAccessPath, docString, addNotification, displayName, id } = props;
|
||||
@@ -21,7 +23,7 @@ export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const formRef = useRef(null);
|
||||
|
||||
const triggerNotification = () => {
|
||||
@@ -37,15 +39,9 @@ export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
triggerNotification();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="component methodComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<Form onSubmit={execute} ref={formRef}>
|
||||
<Button className="component" variant="primary" type="submit">
|
||||
{`${displayName} `}
|
||||
@@ -54,4 +50,6 @@ export const MethodComponent = React.memo((props: MethodProps) => {
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
MethodComponent.displayName = "MethodComponent";
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React from 'react';
|
||||
import { ToastContainer, Toast } from 'react-bootstrap';
|
||||
import React from "react";
|
||||
import { ToastContainer, Toast } from "react-bootstrap";
|
||||
|
||||
export type LevelName = 'CRITICAL' | 'ERROR' | 'WARNING' | 'INFO' | 'DEBUG';
|
||||
export type Notification = {
|
||||
export type LevelName = "CRITICAL" | "ERROR" | "WARNING" | "INFO" | "DEBUG";
|
||||
export interface Notification {
|
||||
id: number;
|
||||
timeStamp: string;
|
||||
message: string;
|
||||
levelname: LevelName;
|
||||
};
|
||||
}
|
||||
|
||||
type NotificationProps = {
|
||||
interface NotificationProps {
|
||||
showNotification: boolean;
|
||||
notifications: Notification[];
|
||||
removeNotificationById: (id: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const Notifications = React.memo((props: NotificationProps) => {
|
||||
const { showNotification, notifications, removeNotificationById } = props;
|
||||
@@ -23,10 +23,10 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
{notifications.map((notification) => {
|
||||
// Determine if the toast should be shown
|
||||
const shouldShow =
|
||||
notification.levelname === 'ERROR' ||
|
||||
notification.levelname === 'CRITICAL' ||
|
||||
notification.levelname === "ERROR" ||
|
||||
notification.levelname === "CRITICAL" ||
|
||||
(showNotification &&
|
||||
['WARNING', 'INFO', 'DEBUG'].includes(notification.levelname));
|
||||
["WARNING", "INFO", "DEBUG"].includes(notification.levelname));
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
@@ -34,31 +34,31 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
|
||||
return (
|
||||
<Toast
|
||||
className={notification.levelname.toLowerCase() + 'Toast'}
|
||||
className={notification.levelname.toLowerCase() + "Toast"}
|
||||
key={notification.id}
|
||||
onClose={() => removeNotificationById(notification.id)}
|
||||
onClick={() => removeNotificationById(notification.id)}
|
||||
onMouseLeave={() => {
|
||||
if (notification.levelname !== 'ERROR') {
|
||||
if (notification.levelname !== "ERROR") {
|
||||
removeNotificationById(notification.id);
|
||||
}
|
||||
}}
|
||||
show={true}
|
||||
autohide={
|
||||
notification.levelname === 'WARNING' ||
|
||||
notification.levelname === 'INFO' ||
|
||||
notification.levelname === 'DEBUG'
|
||||
notification.levelname === "WARNING" ||
|
||||
notification.levelname === "INFO" ||
|
||||
notification.levelname === "DEBUG"
|
||||
}
|
||||
delay={
|
||||
notification.levelname === 'WARNING' ||
|
||||
notification.levelname === 'INFO' ||
|
||||
notification.levelname === 'DEBUG'
|
||||
notification.levelname === "WARNING" ||
|
||||
notification.levelname === "INFO" ||
|
||||
notification.levelname === "DEBUG"
|
||||
? 2000
|
||||
: undefined
|
||||
}>
|
||||
<Toast.Header
|
||||
closeButton={false}
|
||||
className={notification.levelname.toLowerCase() + 'Toast text-right'}>
|
||||
className={notification.levelname.toLowerCase() + "Toast text-right"}>
|
||||
<strong className="me-auto">{notification.levelname}</strong>
|
||||
<small>{notification.timeStamp}</small>
|
||||
</Toast.Header>
|
||||
@@ -69,3 +69,5 @@ export const Notifications = React.memo((props: NotificationProps) => {
|
||||
</ToastContainer>
|
||||
);
|
||||
});
|
||||
|
||||
Notifications.displayName = "Notifications";
|
||||
|
||||
@@ -1,59 +1,58 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import "../App.css";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { QuantityMap } from "../types/QuantityMap";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
export type QuantityObject = {
|
||||
type: 'Quantity';
|
||||
export interface QuantityObject {
|
||||
type: "Quantity";
|
||||
readonly: boolean;
|
||||
value: {
|
||||
magnitude: number;
|
||||
unit: string;
|
||||
};
|
||||
doc?: string;
|
||||
};
|
||||
export type IntObject = {
|
||||
type: 'int';
|
||||
value: QuantityMap;
|
||||
doc: string | null;
|
||||
}
|
||||
export interface IntObject {
|
||||
type: "int";
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
export type FloatObject = {
|
||||
type: 'float';
|
||||
doc: string | null;
|
||||
}
|
||||
export interface FloatObject {
|
||||
type: "float";
|
||||
readonly: boolean;
|
||||
value: number;
|
||||
doc?: string;
|
||||
};
|
||||
doc: string | null;
|
||||
}
|
||||
export type NumberObject = IntObject | FloatObject | QuantityObject;
|
||||
|
||||
type NumberComponentProps = {
|
||||
type: 'float' | 'int' | 'Quantity';
|
||||
interface NumberComponentProps {
|
||||
type: "float" | "int" | "Quantity";
|
||||
fullAccessPath: string;
|
||||
value: number;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
unit?: string;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName?: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: highlight the digit that is being changed by setting both selectionStart and
|
||||
// selectionEnd
|
||||
const handleArrowKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number
|
||||
selectionStart: number,
|
||||
// selectionEnd: number
|
||||
) => {
|
||||
// Split the input value into the integer part and decimal part
|
||||
const parts = value.split('.');
|
||||
const parts = value.split(".");
|
||||
const beforeDecimalCount = parts[0].length; // Count digits before the decimal
|
||||
const afterDecimalCount = parts[1] ? parts[1].length : 0; // Count digits after the decimal
|
||||
|
||||
@@ -69,14 +68,14 @@ const handleArrowKey = (
|
||||
|
||||
// Convert the input value to a number, increment or decrement it based on the
|
||||
// arrow key
|
||||
const numValue = parseFloat(value) + (key === 'ArrowUp' ? increment : -increment);
|
||||
const numValue = parseFloat(value) + (key === "ArrowUp" ? increment : -increment);
|
||||
|
||||
// Convert the resulting number to a string, maintaining the same number of digits
|
||||
// after the decimal
|
||||
const newValue = numValue.toFixed(afterDecimalCount);
|
||||
|
||||
// Check if the length of the integer part of the number string has in-/decreased
|
||||
const newBeforeDecimalCount = newValue.split('.')[0].length;
|
||||
const newBeforeDecimalCount = newValue.split(".")[0].length;
|
||||
if (newBeforeDecimalCount > beforeDecimalCount) {
|
||||
// Move the cursor one position to the right
|
||||
selectionStart += 1;
|
||||
@@ -90,18 +89,18 @@ const handleArrowKey = (
|
||||
const handleBackspaceKey = (
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, delete all characters in the selection
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionEnd),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
} else if (selectionStart > 0) {
|
||||
return {
|
||||
value: value.slice(0, selectionStart - 1) + value.slice(selectionStart),
|
||||
selectionStart: selectionStart - 1
|
||||
selectionStart: selectionStart - 1,
|
||||
};
|
||||
}
|
||||
return { value, selectionStart };
|
||||
@@ -110,18 +109,18 @@ const handleBackspaceKey = (
|
||||
const handleDeleteKey = (
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, delete all characters in the selection
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionEnd),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
} else if (selectionStart < value.length) {
|
||||
return {
|
||||
value: value.slice(0, selectionStart) + value.slice(selectionStart + 1),
|
||||
selectionStart
|
||||
selectionStart,
|
||||
};
|
||||
}
|
||||
return { value, selectionStart };
|
||||
@@ -131,12 +130,12 @@ const handleNumericKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
selectionEnd: number,
|
||||
) => {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
if (key === '.' && value.includes('.')) {
|
||||
if (key === "." && value.includes(".")) {
|
||||
// Check if value already contains a decimal. If so, ignore input.
|
||||
console.warn('Invalid input! Ignoring...');
|
||||
console.warn("Invalid input! Ignoring...");
|
||||
return { value, selectionStart };
|
||||
}
|
||||
|
||||
@@ -166,99 +165,110 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
// Create a state for the cursor position
|
||||
const [cursorPosition, setCursorPosition] = useState(null);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(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 renderCount = useRenderCount();
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { key, target } = event;
|
||||
|
||||
// Typecast
|
||||
const inputTarget = target as HTMLInputElement;
|
||||
if (
|
||||
key === 'F1' ||
|
||||
key === 'F5' ||
|
||||
key === 'F12' ||
|
||||
key === 'Tab' ||
|
||||
key === 'ArrowRight' ||
|
||||
key === 'ArrowLeft'
|
||||
key === "F1" ||
|
||||
key === "F5" ||
|
||||
key === "F12" ||
|
||||
key === "Tab" ||
|
||||
key === "ArrowRight" ||
|
||||
key === "ArrowLeft"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
// Get the current input value and cursor position
|
||||
const { value } = target;
|
||||
let { selectionStart } = target;
|
||||
const { selectionEnd } = target;
|
||||
const { value } = inputTarget;
|
||||
const selectionEnd = inputTarget.selectionEnd ?? 0;
|
||||
let selectionStart = inputTarget.selectionStart ?? 0;
|
||||
|
||||
let newValue: string = value;
|
||||
if (event.ctrlKey && key === 'a') {
|
||||
if (event.ctrlKey && key === "a") {
|
||||
// Select everything when pressing Ctrl + a
|
||||
target.setSelectionRange(0, target.value.length);
|
||||
inputTarget.setSelectionRange(0, value.length);
|
||||
return;
|
||||
} else if (key === '-') {
|
||||
if (selectionStart === 0 && !value.startsWith('-')) {
|
||||
newValue = '-' + value;
|
||||
} else if (key === "-") {
|
||||
if (selectionStart === 0 && !value.startsWith("-")) {
|
||||
newValue = "-" + value;
|
||||
selectionStart++;
|
||||
} else if (value.startsWith('-') && selectionStart === 1) {
|
||||
} else if (value.startsWith("-") && selectionStart === 1) {
|
||||
newValue = value.substring(1); // remove minus sign
|
||||
selectionStart--;
|
||||
} else {
|
||||
return; // Ignore "-" pressed in other positions
|
||||
}
|
||||
} else if (!isNaN(key) && key !== ' ') {
|
||||
} else if (key >= "0" && key <= "9") {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === '.' && (type === 'float' || type === 'Quantity')) {
|
||||
} else if (key === "." && (type === "float" || type === "Quantity")) {
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'ArrowUp' || key === 'ArrowDown') {
|
||||
} else if (key === "ArrowUp" || key === "ArrowDown") {
|
||||
({ value: newValue, selectionStart } = handleArrowKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart
|
||||
selectionStart,
|
||||
// selectionEnd
|
||||
));
|
||||
} else if (key === 'Backspace') {
|
||||
} else if (key === "Backspace") {
|
||||
({ value: newValue, selectionStart } = handleBackspaceKey(
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'Delete') {
|
||||
} else if (key === "Delete") {
|
||||
({ value: newValue, selectionStart } = handleDeleteKey(
|
||||
value,
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
selectionEnd,
|
||||
));
|
||||
} else if (key === 'Enter' && !isInstantUpdate) {
|
||||
let updatedValue: number | Record<string, unknown> = Number(newValue);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit
|
||||
} else if (key === "Enter" && !isInstantUpdate) {
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(newValue),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
changeCallback(serializedObject);
|
||||
return;
|
||||
} else {
|
||||
console.debug(key);
|
||||
@@ -267,20 +277,29 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
// Update the input value and maintain the cursor position
|
||||
if (isInstantUpdate) {
|
||||
let updatedValue: number | Record<string, unknown> = Number(newValue);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(newValue),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(newValue),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
changeCallback(serializedObject);
|
||||
}
|
||||
|
||||
setInputString(newValue);
|
||||
@@ -292,26 +311,35 @@ 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
|
||||
let updatedValue: number | Record<string, unknown> = Number(inputString);
|
||||
if (type === 'Quantity') {
|
||||
updatedValue = {
|
||||
magnitude: Number(inputString),
|
||||
unit: unit
|
||||
let serializedObject: SerializedObject;
|
||||
if (type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: Number(inputString),
|
||||
unit: unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: type,
|
||||
value: Number(inputString),
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback({
|
||||
type: type,
|
||||
value: updatedValue,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
changeCallback(serializedObject);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// Parse the input string to a number for comparison
|
||||
const numericInputString =
|
||||
type === 'int' ? parseInt(inputString) : parseFloat(inputString);
|
||||
type === "int" ? parseInt(inputString) : parseFloat(inputString);
|
||||
// Only update the inputString if it's different from the prop value
|
||||
if (value !== numericInputString) {
|
||||
setInputString(value.toString());
|
||||
@@ -320,7 +348,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
// emitting notification
|
||||
let notificationMsg = `${fullAccessPath} changed to ${props.value}`;
|
||||
if (unit === undefined) {
|
||||
notificationMsg += '.';
|
||||
notificationMsg += ".";
|
||||
} else {
|
||||
notificationMsg += ` ${unit}.`;
|
||||
}
|
||||
@@ -329,7 +357,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
// Set the cursor position after the component re-renders
|
||||
const inputElement = document.getElementsByName(name)[0] as HTMLInputElement;
|
||||
const inputElement = document.getElementsByName(id)[0] as HTMLInputElement;
|
||||
if (inputElement && cursorPosition !== null) {
|
||||
inputElement.setSelectionRange(cursorPosition, cursorPosition);
|
||||
}
|
||||
@@ -337,9 +365,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="component numberComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<InputGroup>
|
||||
{displayName && (
|
||||
<InputGroup.Text>
|
||||
@@ -351,13 +377,16 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
type="text"
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
name={name}
|
||||
onChange={() => {}}
|
||||
name={id}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""}
|
||||
/>
|
||||
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NumberComponent.displayName = "NumberComponent";
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Slider } from '@mui/material';
|
||||
import { NumberComponent, NumberObject } from './NumberComponent';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import { Slider } from "@mui/material";
|
||||
import { NumberComponent, NumberObject } from "./NumberComponent";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import { QuantityMap } from "../types/QuantityMap";
|
||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
type SliderComponentProps = {
|
||||
interface SliderComponentProps {
|
||||
fullAccessPath: string;
|
||||
min: NumberObject;
|
||||
max: NumberObject;
|
||||
value: NumberObject;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
stepSize: NumberObject;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const deconstructNumberDict = (
|
||||
numberDict: NumberObject,
|
||||
): [number, boolean, string | undefined] => {
|
||||
let numberMagnitude = 0;
|
||||
let numberUnit: string | undefined = undefined;
|
||||
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];
|
||||
};
|
||||
|
||||
export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
fullAccessPath,
|
||||
@@ -35,72 +55,83 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath} changed to ${value.value}.`);
|
||||
}, [props.value]);
|
||||
}, [props.value.value]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
|
||||
}, [props.min]);
|
||||
}, [props.min.value, props.min.type]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
|
||||
}, [props.max]);
|
||||
}, [props.max.value, props.max.type]);
|
||||
|
||||
useEffect(() => {
|
||||
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
|
||||
}, [props.stepSize]);
|
||||
}, [props.stepSize.value, props.stepSize.type]);
|
||||
|
||||
const handleOnChange = (event, newNumber: number | number[]) => {
|
||||
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];
|
||||
}
|
||||
changeCallback({
|
||||
type: value.type,
|
||||
value: newNumber,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString
|
||||
});
|
||||
|
||||
let serializedObject: SerializedObject;
|
||||
if (value.type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: "Quantity",
|
||||
value: {
|
||||
magnitude: newNumber,
|
||||
unit: value.value.unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: value.type,
|
||||
value: newNumber,
|
||||
full_access_path: `${fullAccessPath}.value`,
|
||||
readonly: value.readonly,
|
||||
doc: docString,
|
||||
};
|
||||
}
|
||||
changeCallback(serializedObject);
|
||||
};
|
||||
|
||||
const handleValueChange = (
|
||||
newValue: number,
|
||||
name: string,
|
||||
valueObject: NumberObject
|
||||
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;
|
||||
let serializedObject: SerializedObject;
|
||||
if (valueObject.type === "Quantity") {
|
||||
serializedObject = {
|
||||
type: valueObject.type,
|
||||
value: {
|
||||
magnitude: newValue,
|
||||
unit: valueObject.value.unit,
|
||||
} as QuantityMap,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly,
|
||||
doc: null,
|
||||
};
|
||||
} else {
|
||||
serializedObject = {
|
||||
type: valueObject.type,
|
||||
value: newValue,
|
||||
full_access_path: `${fullAccessPath}.${name}`,
|
||||
readonly: valueObject.readonly,
|
||||
doc: null,
|
||||
};
|
||||
}
|
||||
|
||||
return [numberMagnitude, numberReadOnly, numberUnit];
|
||||
changeCallback(serializedObject);
|
||||
};
|
||||
|
||||
const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value);
|
||||
@@ -110,9 +141,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
|
||||
return (
|
||||
<div className="component sliderComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
|
||||
<Row>
|
||||
<Col xs="auto" xl="auto">
|
||||
@@ -123,7 +152,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
</Col>
|
||||
<Col xs="5" xl>
|
||||
<Slider
|
||||
style={{ margin: '0px 0px 10px 0px' }}
|
||||
style={{ margin: "0px 0px 10px 0px" }}
|
||||
aria-label="Always visible"
|
||||
// valueLabelDisplay="on"
|
||||
disabled={valueReadOnly}
|
||||
@@ -134,7 +163,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
step={stepSizeMagnitude}
|
||||
marks={[
|
||||
{ value: minMagnitude, label: `${minMagnitude}` },
|
||||
{ value: maxMagnitude, label: `${maxMagnitude}` }
|
||||
{ value: maxMagnitude, label: `${maxMagnitude}` },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
@@ -144,12 +173,12 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
fullAccessPath={`${fullAccessPath}.value`}
|
||||
docString={docString}
|
||||
readOnly={valueReadOnly}
|
||||
type="float"
|
||||
type={value.type}
|
||||
value={valueMagnitude}
|
||||
unit={valueUnit}
|
||||
addNotification={() => {}}
|
||||
changeCallback={changeCallback}
|
||||
id={id + '-value'}
|
||||
id={id + "-value"}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="auto">
|
||||
@@ -179,14 +208,14 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
<Form.Group>
|
||||
<Row
|
||||
className="justify-content-center"
|
||||
style={{ paddingTop: '20px', margin: '10px' }}>
|
||||
style={{ paddingTop: "20px", margin: "10px" }}>
|
||||
<Col xs="auto">
|
||||
<Form.Label>Min Value</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={minMagnitude}
|
||||
disabled={minReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'min', min)}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), "min", min)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -196,7 +225,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
type="number"
|
||||
value={maxMagnitude}
|
||||
disabled={maxReadOnly}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), 'max', max)}
|
||||
onChange={(e) => handleValueChange(Number(e.target.value), "max", max)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -207,7 +236,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
value={stepSizeMagnitude}
|
||||
disabled={stepSizeReadOnly}
|
||||
onChange={(e) =>
|
||||
handleValueChange(Number(e.target.value), 'step_size', stepSize)
|
||||
handleValueChange(Number(e.target.value), "step_size", stepSize)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
@@ -216,4 +245,6 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, propsAreEqual);
|
||||
|
||||
SliderComponent.displayName = "SliderComponent";
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
import { LevelName } from './NotificationsComponent';
|
||||
import { SerializedValue } from './GenericComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Form, InputGroup } from "react-bootstrap";
|
||||
import { DocStringComponent } from "./DocStringComponent";
|
||||
import "../App.css";
|
||||
import { LevelName } from "./NotificationsComponent";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
import useRenderCount from "../hooks/useRenderCount";
|
||||
|
||||
// TODO: add button functionality
|
||||
|
||||
type StringComponentProps = {
|
||||
interface StringComponentProps {
|
||||
fullAccessPath: string;
|
||||
value: string;
|
||||
readOnly: boolean;
|
||||
docString: string;
|
||||
docString: string | null;
|
||||
isInstantUpdate: boolean;
|
||||
addNotification: (message: string, levelname?: LevelName) => void;
|
||||
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
|
||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
||||
displayName: string;
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
const {
|
||||
@@ -28,16 +29,12 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
addNotification,
|
||||
changeCallback = () => {},
|
||||
displayName,
|
||||
id
|
||||
id,
|
||||
} = props;
|
||||
|
||||
const renderCount = useRef(0);
|
||||
const renderCount = useRenderCount();
|
||||
const [inputString, setInputString] = useState(props.value);
|
||||
|
||||
useEffect(() => {
|
||||
renderCount.current++;
|
||||
}, [isInstantUpdate, inputString, renderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only update the inputString if it's different from the prop value
|
||||
if (props.value !== inputString) {
|
||||
@@ -46,21 +43,27 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
addNotification(`${fullAccessPath} changed to ${props.value}.`);
|
||||
}, [props.value]);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputString(event.target.value);
|
||||
if (isInstantUpdate) {
|
||||
changeCallback(event.target.value);
|
||||
changeCallback({
|
||||
type: "str",
|
||||
value: event.target.value,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Enter' && !isInstantUpdate) {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && !isInstantUpdate) {
|
||||
changeCallback({
|
||||
type: 'str',
|
||||
type: "str",
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
doc: docString,
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -69,20 +72,18 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
const handleBlur = () => {
|
||||
if (!isInstantUpdate) {
|
||||
changeCallback({
|
||||
type: 'str',
|
||||
type: "str",
|
||||
value: inputString,
|
||||
full_access_path: fullAccessPath,
|
||||
readonly: readOnly,
|
||||
doc: docString
|
||||
doc: docString,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="component stringComponent" id={id}>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div>Render count: {renderCount.current}</div>
|
||||
)}
|
||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||
<InputGroup>
|
||||
<InputGroup.Text>
|
||||
{displayName}
|
||||
@@ -90,15 +91,17 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
|
||||
</InputGroup.Text>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name={fullAccessPath}
|
||||
name={id}
|
||||
value={inputString}
|
||||
disabled={readOnly}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
|
||||
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StringComponent.displayName = "StringComponent";
|
||||
|
||||
18
frontend/src/hooks/useLocalStorage.ts
Normal file
18
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function useLocalStorage(key: string, defaultValue: unknown) {
|
||||
const [value, setValue] = useState(() => {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
if (storedValue) {
|
||||
return JSON.parse(storedValue);
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined) return;
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}, [value, key]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
11
frontend/src/hooks/useRenderCount.ts
Normal file
11
frontend/src/hooks/useRenderCount.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
export default function useRenderCount() {
|
||||
const count = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
count.current += 1;
|
||||
});
|
||||
|
||||
return count.current;
|
||||
}
|
||||
28
frontend/src/hooks/useSortedEntries.ts
Normal file
28
frontend/src/hooks/useSortedEntries.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useContext } from "react";
|
||||
import { WebSettingsContext } from "../WebSettings";
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
export default function useSortedEntries(
|
||||
props: Record<string, SerializedObject> | SerializedObject[],
|
||||
) {
|
||||
const webSettings = useContext(WebSettingsContext);
|
||||
|
||||
// Get the order for sorting
|
||||
const getOrder = (fullAccessPath: string) => {
|
||||
return webSettings[fullAccessPath]?.displayOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
// Sort entries based on whether props is an array or an object
|
||||
let sortedEntries;
|
||||
if (Array.isArray(props)) {
|
||||
// Need to make copy of array to leave the original array unmodified
|
||||
sortedEntries = [...props].sort((objectA, objectB) => {
|
||||
return getOrder(objectA.full_access_path) - getOrder(objectB.full_access_path);
|
||||
});
|
||||
} else {
|
||||
sortedEntries = Object.values(props).sort((objectA, objectB) => {
|
||||
return getOrder(objectA.full_access_path) - getOrder(objectB.full_access_path);
|
||||
});
|
||||
}
|
||||
return sortedEntries;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import App from './App';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from "./App";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
// Importing the Bootstrap CSS
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
|
||||
// Render the App component into the #root div
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { io } from 'socket.io-client';
|
||||
import { SerializedValue } from './components/GenericComponent';
|
||||
import { serializeDict, serializeList } from './utils/serializationUtils';
|
||||
import { io } from "socket.io-client";
|
||||
import { serializeDict, serializeList } from "./utils/serializationUtils";
|
||||
import { SerializedObject } from "./types/SerializedObject";
|
||||
|
||||
export const hostname =
|
||||
process.env.NODE_ENV === 'development' ? `localhost` : window.location.hostname;
|
||||
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname;
|
||||
export const port =
|
||||
process.env.NODE_ENV === 'development' ? 8001 : window.location.port;
|
||||
process.env.NODE_ENV === "development" ? 8001 : window.location.port;
|
||||
const URL = `ws://${hostname}:${port}/`;
|
||||
console.debug('Websocket: ', URL);
|
||||
console.debug("Websocket: ", URL);
|
||||
|
||||
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
|
||||
export const socket = io(URL, { path: "/ws/socket.io", transports: ["websocket"] });
|
||||
|
||||
export const updateValue = (
|
||||
serializedObject: SerializedValue,
|
||||
callback?: (ack: unknown) => void
|
||||
serializedObject: SerializedObject,
|
||||
callback?: (ack: unknown) => void,
|
||||
) => {
|
||||
if (callback) {
|
||||
socket.emit(
|
||||
'update_value',
|
||||
{ access_path: serializedObject['full_access_path'], value: serializedObject },
|
||||
callback
|
||||
"update_value",
|
||||
{ access_path: serializedObject["full_access_path"], value: serializedObject },
|
||||
callback,
|
||||
);
|
||||
} else {
|
||||
socket.emit('update_value', {
|
||||
access_path: serializedObject['full_access_path'],
|
||||
value: serializedObject
|
||||
socket.emit("update_value", {
|
||||
access_path: serializedObject["full_access_path"],
|
||||
value: serializedObject,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -33,22 +33,22 @@ export const runMethod = (
|
||||
accessPath: string,
|
||||
args: unknown[] = [],
|
||||
kwargs: Record<string, unknown> = {},
|
||||
callback?: (ack: unknown) => void
|
||||
callback?: (ack: unknown) => void,
|
||||
) => {
|
||||
const serializedArgs = serializeList(args);
|
||||
const serializedKwargs = serializeDict(kwargs);
|
||||
|
||||
if (callback) {
|
||||
socket.emit(
|
||||
'trigger_method',
|
||||
"trigger_method",
|
||||
{ access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs },
|
||||
callback
|
||||
callback,
|
||||
);
|
||||
} else {
|
||||
socket.emit('trigger_method', {
|
||||
socket.emit("trigger_method", {
|
||||
access_path: accessPath,
|
||||
args: serializedArgs,
|
||||
kwargs: serializedKwargs
|
||||
kwargs: serializedKwargs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
4
frontend/src/types/QuantityMap.ts
Normal file
4
frontend/src/types/QuantityMap.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface QuantityMap {
|
||||
magnitude: number;
|
||||
unit: string;
|
||||
}
|
||||
101
frontend/src/types/SerializedObject.ts
Normal file
101
frontend/src/types/SerializedObject.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { QuantityMap } from "./QuantityMap";
|
||||
|
||||
interface SignatureDict {
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
return_annotation: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SerializedObjectBase {
|
||||
full_access_path: string;
|
||||
doc: string | null;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
type SerializedInteger = SerializedObjectBase & {
|
||||
value: number;
|
||||
type: "int";
|
||||
};
|
||||
|
||||
type SerializedFloat = SerializedObjectBase & {
|
||||
value: number;
|
||||
type: "float";
|
||||
};
|
||||
|
||||
type SerializedQuantity = SerializedObjectBase & {
|
||||
value: QuantityMap;
|
||||
type: "Quantity";
|
||||
};
|
||||
|
||||
type SerializedBool = SerializedObjectBase & {
|
||||
value: boolean;
|
||||
type: "bool";
|
||||
};
|
||||
|
||||
type SerializedString = SerializedObjectBase & {
|
||||
value: string;
|
||||
type: "str";
|
||||
};
|
||||
|
||||
export type SerializedEnum = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: string;
|
||||
type: "Enum" | "ColouredEnum";
|
||||
enum: Record<string, string>;
|
||||
};
|
||||
|
||||
type SerializedList = SerializedObjectBase & {
|
||||
value: SerializedObject[];
|
||||
type: "list";
|
||||
};
|
||||
|
||||
type SerializedDict = SerializedObjectBase & {
|
||||
value: Record<string, SerializedObject>;
|
||||
type: "dict";
|
||||
};
|
||||
|
||||
type SerializedNoneType = SerializedObjectBase & {
|
||||
value: null;
|
||||
type: "NoneType";
|
||||
};
|
||||
|
||||
type SerializedNoValue = SerializedObjectBase & {
|
||||
value: null;
|
||||
type: "None";
|
||||
};
|
||||
|
||||
type SerializedMethod = SerializedObjectBase & {
|
||||
value: "RUNNING" | null;
|
||||
type: "method";
|
||||
async: boolean;
|
||||
signature: SignatureDict;
|
||||
frontend_render: boolean;
|
||||
};
|
||||
|
||||
type SerializedException = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: string;
|
||||
type: "Exception";
|
||||
};
|
||||
|
||||
type DataServiceTypes = "DataService" | "Image" | "NumberSlider" | "DeviceConnection";
|
||||
|
||||
type SerializedDataService = SerializedObjectBase & {
|
||||
name: string;
|
||||
value: Record<string, SerializedObject>;
|
||||
type: DataServiceTypes;
|
||||
};
|
||||
|
||||
export type SerializedObject =
|
||||
| SerializedBool
|
||||
| SerializedFloat
|
||||
| SerializedInteger
|
||||
| SerializedString
|
||||
| SerializedList
|
||||
| SerializedDict
|
||||
| SerializedNoneType
|
||||
| SerializedMethod
|
||||
| SerializedException
|
||||
| SerializedDataService
|
||||
| SerializedEnum
|
||||
| SerializedQuantity
|
||||
| SerializedNoValue;
|
||||
17
frontend/src/utils/propsAreEqual.ts
Normal file
17
frontend/src/utils/propsAreEqual.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import deepEqual from "deep-equal";
|
||||
|
||||
export const propsAreEqual = <T extends object>(
|
||||
prevProps: T,
|
||||
nextProps: T,
|
||||
): boolean => {
|
||||
for (const key in nextProps) {
|
||||
if (typeof nextProps[key] === "object") {
|
||||
if (!deepEqual(prevProps[key], nextProps[key])) {
|
||||
return false;
|
||||
}
|
||||
} else if (!Object.is(prevProps[key], nextProps[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,101 +1,97 @@
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
const serializePrimitive = (
|
||||
obj: number | boolean | string | null,
|
||||
accessPath: string
|
||||
) => {
|
||||
let type: string;
|
||||
|
||||
if (typeof obj === 'number') {
|
||||
type = Number.isInteger(obj) ? 'int' : 'float';
|
||||
accessPath: string,
|
||||
): SerializedObject => {
|
||||
if (typeof obj === "number") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
type: Number.isInteger(obj) ? "int" : "float",
|
||||
value: obj,
|
||||
};
|
||||
} else if (typeof obj === 'boolean') {
|
||||
type = 'bool';
|
||||
} else if (typeof obj === "boolean") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
type: "bool",
|
||||
value: obj,
|
||||
};
|
||||
} else if (typeof obj === 'string') {
|
||||
type = 'str';
|
||||
} else if (typeof obj === "string") {
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: obj
|
||||
type: "str",
|
||||
value: obj,
|
||||
};
|
||||
} else if (obj === null) {
|
||||
type = 'NoneType';
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
doc: null,
|
||||
readonly: false,
|
||||
type,
|
||||
value: null
|
||||
type: "None",
|
||||
value: null,
|
||||
};
|
||||
} else {
|
||||
throw new Error('Unsupported type for serialization');
|
||||
throw new Error("Unsupported type for serialization");
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeList = (obj: unknown[], accessPath: string = '') => {
|
||||
export const serializeList = (obj: unknown[], accessPath = "") => {
|
||||
const doc = null;
|
||||
const value = obj.map((item, index) => {
|
||||
if (
|
||||
typeof item === 'number' ||
|
||||
typeof item === 'boolean' ||
|
||||
typeof item === 'string' ||
|
||||
typeof item === "number" ||
|
||||
typeof item === "boolean" ||
|
||||
typeof item === "string" ||
|
||||
item === null
|
||||
) {
|
||||
serializePrimitive(
|
||||
item as number | boolean | string | null,
|
||||
`${accessPath}[${index}]`
|
||||
`${accessPath}[${index}]`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: 'list',
|
||||
type: "list",
|
||||
value,
|
||||
readonly: false,
|
||||
doc
|
||||
doc,
|
||||
};
|
||||
};
|
||||
export const serializeDict = (
|
||||
obj: Record<string, unknown>,
|
||||
accessPath: string = ''
|
||||
) => {
|
||||
export const serializeDict = (obj: Record<string, unknown>, accessPath = "") => {
|
||||
const doc = null;
|
||||
const value = Object.entries(obj).reduce((acc, [key, val]) => {
|
||||
// Construct the new access path for nested properties
|
||||
const newPath = `${accessPath}["${key}"]`;
|
||||
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);
|
||||
}
|
||||
// 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 acc;
|
||||
},
|
||||
{} as Record<string, SerializedObject>,
|
||||
);
|
||||
|
||||
return {
|
||||
full_access_path: accessPath,
|
||||
type: 'dict',
|
||||
type: "dict",
|
||||
value,
|
||||
readonly: false,
|
||||
doc
|
||||
doc,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,107 +1,175 @@
|
||||
import { SerializedValue } from '../components/GenericComponent';
|
||||
import { SerializedObject } from "../types/SerializedObject";
|
||||
|
||||
export type State = {
|
||||
export interface State {
|
||||
type: string;
|
||||
value: Record<string, SerializedValue> | null;
|
||||
name: string;
|
||||
value: Record<string, SerializedObject> | null;
|
||||
readonly: boolean;
|
||||
doc: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function setNestedValueByPath(
|
||||
serializationDict: Record<string, SerializedValue>,
|
||||
path: string,
|
||||
serializedValue: SerializedValue
|
||||
): Record<string, SerializedValue> {
|
||||
const parentPathParts = path.split('.').slice(0, -1);
|
||||
const attrName = path.split('.').pop();
|
||||
/**
|
||||
* Splits a full access path into its atomic parts, separating attribute names, numeric
|
||||
* indices (including floating points), and string keys within indices.
|
||||
*
|
||||
* @param path The full access path string to be split into components.
|
||||
* @returns An array of components that make up the path, including attribute names,
|
||||
* numeric indices, and string keys as separate elements.
|
||||
*/
|
||||
export function parseFullAccessPath(path: string): string[] {
|
||||
// The pattern matches:
|
||||
// \w+ - Words
|
||||
// \[\d+\.\d+\] - Floating point numbers inside brackets
|
||||
// \[\d+\] - Integers inside brackets
|
||||
// \["[^"]*"\] - Double-quoted strings inside brackets
|
||||
// \['[^']*'\] - Single-quoted strings inside brackets
|
||||
const pattern = /\w+|\[\d+\.\d+\]|\[\d+\]|\["[^"]*"\]|\['[^']*'\]/g;
|
||||
const matches = path.match(pattern);
|
||||
|
||||
if (!attrName) {
|
||||
throw new Error('Invalid path');
|
||||
return matches ?? []; // Return an empty array if no matches found
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a serialized key and convert it to an appropriate type (number or string).
|
||||
*
|
||||
* @param serializedKey The serialized key, which might be enclosed in brackets and quotes.
|
||||
* @returns The processed key as a number or an unquoted string.
|
||||
*
|
||||
* Examples:
|
||||
* console.log(parseSerializedKey("attr_name")); // Outputs: attr_name (string)
|
||||
* console.log(parseSerializedKey("[123]")); // Outputs: 123 (number)
|
||||
* console.log(parseSerializedKey("[12.3]")); // Outputs: 12.3 (number)
|
||||
* console.log(parseSerializedKey("['hello']")); // Outputs: hello (string)
|
||||
* console.log(parseSerializedKey('["12.34"]')); // Outputs: "12.34" (string)
|
||||
* console.log(parseSerializedKey('["complex"]'));// Outputs: "complex" (string)
|
||||
*/
|
||||
function parseSerializedKey(serializedKey: string): string | number {
|
||||
// Strip outer brackets if present
|
||||
if (serializedKey.startsWith("[") && serializedKey.endsWith("]")) {
|
||||
serializedKey = serializedKey.slice(1, -1);
|
||||
}
|
||||
|
||||
let currentSerializedValue: SerializedValue;
|
||||
const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
|
||||
JSON.stringify(serializationDict)
|
||||
// Strip quotes if the resulting string is quoted
|
||||
if (
|
||||
(serializedKey.startsWith("'") && serializedKey.endsWith("'")) ||
|
||||
(serializedKey.startsWith('"') && serializedKey.endsWith('"'))
|
||||
) {
|
||||
return serializedKey.slice(1, -1);
|
||||
}
|
||||
|
||||
// Try converting to a number if the string is not quoted
|
||||
const parsedNumber = parseFloat(serializedKey);
|
||||
if (!isNaN(parsedNumber)) {
|
||||
return parsedNumber;
|
||||
}
|
||||
|
||||
// Return the original string if it's not a valid number
|
||||
return serializedKey;
|
||||
}
|
||||
|
||||
function getOrCreateItemInContainer(
|
||||
container: Record<string | number, SerializedObject> | SerializedObject[],
|
||||
key: string | number,
|
||||
allowAddKey: boolean,
|
||||
): SerializedObject {
|
||||
// Check if the key exists and return the item if it does
|
||||
if (key in container) {
|
||||
/* @ts-expect-error Key is in the correct form but converted to type any for some reason */
|
||||
return container[key];
|
||||
}
|
||||
|
||||
// Handling the case where the key does not exist
|
||||
if (Array.isArray(container)) {
|
||||
// Handling arrays
|
||||
if (allowAddKey && key === container.length) {
|
||||
container.push(createEmptySerializedObject());
|
||||
return container[key];
|
||||
}
|
||||
throw new Error(`Index out of bounds: ${key}`);
|
||||
} else {
|
||||
// Handling objects
|
||||
if (allowAddKey) {
|
||||
container[key] = createEmptySerializedObject();
|
||||
return container[key];
|
||||
}
|
||||
throw new Error(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an item from a container specified by the passed key. Add an item to the
|
||||
* container if allowAppend is set to True.
|
||||
*
|
||||
* @param container Either a dictionary or list of serialized objects.
|
||||
* @param key The key name or index (as a string) representing the attribute in the container.
|
||||
* @param allowAppend Whether to allow appending a new entry if the specified index is out of range by exactly one position.
|
||||
* @returns The serialized object corresponding to the specified key.
|
||||
* @throws SerializationPathError If the key is invalid or leads to an access error without append permissions.
|
||||
* @throws SerializationValueError If the expected structure is incorrect.
|
||||
*/
|
||||
function getContainerItemByKey(
|
||||
container: Record<string, SerializedObject> | SerializedObject[],
|
||||
key: string,
|
||||
allowAppend = false,
|
||||
): SerializedObject {
|
||||
const processedKey = parseSerializedKey(key);
|
||||
|
||||
try {
|
||||
return getOrCreateItemInContainer(container, processedKey, allowAppend);
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError) {
|
||||
throw new Error(`Index '${processedKey}': ${error.message}`);
|
||||
} else if (error instanceof Error) {
|
||||
throw new Error(`Key '${processedKey}': ${error.message}`);
|
||||
}
|
||||
throw error; // Re-throw if it's not a known error type
|
||||
}
|
||||
}
|
||||
|
||||
export function setNestedValueByPath(
|
||||
serializationDict: Record<string, SerializedObject>,
|
||||
path: string,
|
||||
serializedValue: SerializedObject,
|
||||
): Record<string, SerializedObject> {
|
||||
const pathParts = parseFullAccessPath(path);
|
||||
const newSerializationDict: Record<string, SerializedObject> = JSON.parse(
|
||||
JSON.stringify(serializationDict),
|
||||
);
|
||||
|
||||
let currentDict = newSerializationDict;
|
||||
|
||||
try {
|
||||
for (const pathPart of parentPathParts) {
|
||||
currentSerializedValue = getNextLevelDictByKey(currentDict, pathPart, false);
|
||||
// @ts-expect-error The value will be of type SerializedValue as we are still
|
||||
// looping through the parent parts
|
||||
currentDict = currentSerializedValue['value'];
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const pathPart = pathParts[i];
|
||||
const nextLevelSerializedObject = getContainerItemByKey(
|
||||
currentDict,
|
||||
pathPart,
|
||||
false,
|
||||
);
|
||||
currentDict = nextLevelSerializedObject["value"] as Record<
|
||||
string,
|
||||
SerializedObject
|
||||
>;
|
||||
}
|
||||
|
||||
currentSerializedValue = getNextLevelDictByKey(currentDict, attrName, true);
|
||||
const finalPart = pathParts[pathParts.length - 1];
|
||||
const finalObject = getContainerItemByKey(currentDict, finalPart, true);
|
||||
|
||||
Object.assign(finalObject, serializedValue);
|
||||
|
||||
Object.assign(currentSerializedValue, serializedValue);
|
||||
return newSerializationDict;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return currentDict;
|
||||
console.error(`Error occurred trying to change ${path}: ${error}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getNextLevelDictByKey(
|
||||
serializationDict: Record<string, SerializedValue>,
|
||||
attrName: string,
|
||||
allowAppend: boolean = false
|
||||
): SerializedValue {
|
||||
const [key, index] = parseListAttrAndIndex(attrName);
|
||||
let currentDict: SerializedValue;
|
||||
|
||||
try {
|
||||
if (index !== null) {
|
||||
if (!serializationDict[key] || !Array.isArray(serializationDict[key]['value'])) {
|
||||
throw new Error(`Expected an array at '${key}', but found something else.`);
|
||||
}
|
||||
|
||||
if (index < serializationDict[key]['value'].length) {
|
||||
currentDict = serializationDict[key]['value'][index];
|
||||
} else if (allowAppend && index === serializationDict[key]['value'].length) {
|
||||
// Appending to list
|
||||
// @ts-expect-error When the index is not null, I expect an array
|
||||
serializationDict[key]['value'].push({});
|
||||
currentDict = serializationDict[key]['value'][index];
|
||||
} else {
|
||||
throw new Error(`Index out of range for '${key}[${index}]'.`);
|
||||
}
|
||||
} else {
|
||||
if (!serializationDict[key]) {
|
||||
throw new Error(`Key '${key}' not found.`);
|
||||
}
|
||||
currentDict = serializationDict[key];
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error occurred trying to access '${attrName}': ${error}`);
|
||||
}
|
||||
|
||||
if (typeof currentDict !== 'object' || currentDict === null) {
|
||||
throw new Error(
|
||||
`Expected a dictionary at '${attrName}', but found type '${typeof currentDict}' instead.`
|
||||
);
|
||||
}
|
||||
|
||||
return currentDict;
|
||||
}
|
||||
|
||||
function parseListAttrAndIndex(attrString: string): [string, number | null] {
|
||||
let index: number | null = null;
|
||||
let attrName = attrString;
|
||||
|
||||
if (attrString.includes('[') && attrString.endsWith(']')) {
|
||||
const parts = attrString.split('[');
|
||||
attrName = parts[0];
|
||||
const indexPart = parts[1].slice(0, -1); // Removes the closing ']'
|
||||
|
||||
if (!isNaN(parseInt(indexPart))) {
|
||||
index = parseInt(indexPart);
|
||||
} else {
|
||||
console.error(`Invalid index format in key: ${attrString}`);
|
||||
}
|
||||
}
|
||||
|
||||
return [attrName, index];
|
||||
function createEmptySerializedObject(): SerializedObject {
|
||||
return {
|
||||
full_access_path: "",
|
||||
value: null,
|
||||
type: "None",
|
||||
doc: null,
|
||||
readonly: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export function getIdFromFullAccessPath(fullAccessPath: string) {
|
||||
if (fullAccessPath) {
|
||||
// Replace '].' with a single dash
|
||||
let id = fullAccessPath.replace(/\]\./g, '-');
|
||||
let id = fullAccessPath.replace(/\]\./g, "-");
|
||||
|
||||
// Replace any character that is not a word character or underscore with a dash
|
||||
id = id.replace(/[^\w_]+/g, '-');
|
||||
id = id.replace(/[^\w_]+/g, "-");
|
||||
|
||||
// Remove any trailing dashes
|
||||
id = id.replace(/-+$/, '');
|
||||
id = id.replace(/-+$/, "");
|
||||
|
||||
return id;
|
||||
} else {
|
||||
return 'main';
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
|
||||
31
frontend/tsconfig.app.json
Normal file
31
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
15
frontend/tsconfig.node.json
Normal file
15
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../src/pydase/frontend",
|
||||
},
|
||||
esbuild: {
|
||||
drop: ["console", "debugger"],
|
||||
},
|
||||
});
|
||||
15
mkdocs.yml
15
mkdocs.yml
@@ -4,8 +4,9 @@ edit_uri: blob/docs/docs/
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started: getting-started.md
|
||||
- User Guide:
|
||||
- User Guide:
|
||||
- Components Guide: user-guide/Components.md
|
||||
- Interacting with pydase Services: user-guide/interaction/main.md
|
||||
- Developer Guide:
|
||||
- Developer Guide: dev-guide/README.md
|
||||
- API Reference: dev-guide/api.md
|
||||
@@ -16,7 +17,10 @@ nav:
|
||||
- Contributing: about/contributing.md
|
||||
- License: about/license.md
|
||||
|
||||
theme: readthedocs
|
||||
theme:
|
||||
name: material
|
||||
features:
|
||||
- content.code.copy
|
||||
|
||||
extra_css:
|
||||
- css/extra.css
|
||||
@@ -26,17 +30,20 @@ markdown_extensions:
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.highlight:
|
||||
use_pygments: true
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
- pymdownx.snippets
|
||||
- pymdownx.superfences
|
||||
# - pymdownx.highlight:
|
||||
# - pymdownx.inlinehilite
|
||||
- pymdownx.inlinehilite
|
||||
|
||||
|
||||
plugins:
|
||||
- include-markdown
|
||||
- search
|
||||
- mkdocstrings
|
||||
- swagger-ui-tag
|
||||
|
||||
watch:
|
||||
- src/pydase
|
||||
|
||||
1653
poetry.lock
generated
1653
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "pydase"
|
||||
version = "0.8.0"
|
||||
version = "0.9.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,14 @@ packages = [{ include = "pydase", from = "src" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
fastapi = "^0.108.0"
|
||||
uvicorn = "^0.27.0"
|
||||
toml = "^0.10.2"
|
||||
python-socketio = "^5.8.0"
|
||||
confz = "^2.0.0"
|
||||
pint = "^0.22"
|
||||
pillow = "^10.0.0"
|
||||
pint = "^0.24"
|
||||
websocket-client = "^1.7.0"
|
||||
aiohttp = "^3.9.3"
|
||||
click = "^8.1.7"
|
||||
aiohttp-middlewares = "^2.3.0"
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
@@ -30,17 +29,18 @@ mypy = "^1.4.1"
|
||||
matplotlib = "^3.7.2"
|
||||
pyright = "^1.1.323"
|
||||
pytest-mock = "^3.11.1"
|
||||
ruff = "^0.2.0"
|
||||
ruff = "^0.5.0"
|
||||
pytest-asyncio = "^0.23.2"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
mkdocs = "^1.5.2"
|
||||
mkdocs-material = "^9.5.30"
|
||||
mkdocs-include-markdown-plugin = "^3.9.1"
|
||||
mkdocstrings = "^0.22.0"
|
||||
pymdown-extensions = "^10.1"
|
||||
mkdocs-swagger-ui-tag = "^0.6.10"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from typing import TypedDict, cast
|
||||
|
||||
@@ -10,6 +11,12 @@ from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader
|
||||
from pydase.utils.serialization.deserializer import loads
|
||||
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from typing_extensions import Self
|
||||
else:
|
||||
from typing import Self
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -24,7 +31,10 @@ class NotifyDict(TypedDict):
|
||||
|
||||
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
try:
|
||||
loop.run_forever()
|
||||
except RuntimeError:
|
||||
logger.debug("Tried starting even loop, but it is running already")
|
||||
|
||||
|
||||
class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
|
||||
@@ -80,12 +90,12 @@ class Client:
|
||||
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.
|
||||
url (str):
|
||||
The URL of the pydase Socket.IO server. This should always contain the
|
||||
protocol and the hostname.
|
||||
Examples:
|
||||
- wss://my-service.example.com # for secure connections, use wss
|
||||
- ws://localhost: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
|
||||
@@ -94,12 +104,11 @@ class Client:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
port: int,
|
||||
*,
|
||||
url: str,
|
||||
block_until_connected: bool = True,
|
||||
):
|
||||
self._hostname = hostname
|
||||
self._port = port
|
||||
self._url = url
|
||||
self._sio = socketio.AsyncClient()
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop)
|
||||
@@ -107,29 +116,48 @@ class Client:
|
||||
target=asyncio_loop_thread, args=(self._loop,), daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self.connect(block_until_connected=block_until_connected)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
self.connect(block_until_connected=True)
|
||||
return self
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.disconnect()
|
||||
|
||||
def connect(self, block_until_connected: bool = True) -> None:
|
||||
connection_future = asyncio.run_coroutine_threadsafe(
|
||||
self._connect(), self._loop
|
||||
)
|
||||
if block_until_connected:
|
||||
connection_future.result()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
connection_future = asyncio.run_coroutine_threadsafe(
|
||||
self._disconnect(), self._loop
|
||||
)
|
||||
connection_future.result()
|
||||
|
||||
async def _connect(self) -> None:
|
||||
logger.debug("Connecting to server '%s:%s' ...", self._hostname, self._port)
|
||||
logger.debug("Connecting to server '%s' ...", self._url)
|
||||
await self._setup_events()
|
||||
await self._sio.connect(
|
||||
f"ws://{self._hostname}:{self._port}",
|
||||
self._url,
|
||||
socketio_path="/ws/socket.io",
|
||||
transports=["websocket"],
|
||||
retry=True,
|
||||
)
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
await self._sio.disconnect()
|
||||
|
||||
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)
|
||||
logger.debug("Connected to '%s' ...", self._url)
|
||||
serialized_object = cast(
|
||||
SerializedDataService, await self._sio.call("service_serialization")
|
||||
)
|
||||
@@ -141,7 +169,7 @@ class Client:
|
||||
self.proxy._connected = True
|
||||
|
||||
async def _handle_disconnect(self) -> None:
|
||||
logger.debug("Disconnected from '%s:%s' ...", self._hostname, self._port)
|
||||
logger.debug("Disconnected from '%s' ...", self._url)
|
||||
self.proxy._connected = False
|
||||
|
||||
async def _handle_update(self, data: NotifyDict) -> None:
|
||||
|
||||
@@ -75,6 +75,37 @@ def update_value(
|
||||
)
|
||||
|
||||
|
||||
class ProxyDict(dict[str, Any]):
|
||||
def __init__(
|
||||
self,
|
||||
original_dict: dict[str, Any],
|
||||
parent_path: str,
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
super().__init__(original_dict)
|
||||
self._parent_path = parent_path
|
||||
self._loop = loop
|
||||
self._sio = sio_client
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
observer_key = key
|
||||
if isinstance(key, str):
|
||||
observer_key = f'"{key}"'
|
||||
|
||||
full_access_path = f"{self._parent_path}[{observer_key}]"
|
||||
|
||||
update_value(self._sio, self._loop, full_access_path, value)
|
||||
|
||||
def pop(self, key: str) -> Any: # type: ignore
|
||||
"""Removes the element from the dictionary on the server. It does not return
|
||||
any proxy as the corresponding object on the server does not live anymore."""
|
||||
|
||||
full_access_path = f"{self._parent_path}.pop"
|
||||
|
||||
trigger_method(self._sio, self._loop, full_access_path, [key], {})
|
||||
|
||||
|
||||
class ProxyList(list[Any]):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -266,7 +297,17 @@ class ProxyLoader:
|
||||
sio_client: socketio.AsyncClient,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
return loads(serialized_object)
|
||||
return ProxyDict(
|
||||
{
|
||||
key: ProxyLoader.loads_proxy(value, sio_client, loop)
|
||||
for key, value in cast(
|
||||
dict[str, SerializedObject], serialized_object["value"]
|
||||
).items()
|
||||
},
|
||||
parent_path=serialized_object["full_access_path"],
|
||||
sio_client=sio_client,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_data_service_proxy(
|
||||
|
||||
@@ -56,4 +56,9 @@ class ColouredEnum(Enum):
|
||||
my_service = StatusExample()
|
||||
my_service.status = MyStatus.FAILED
|
||||
```
|
||||
|
||||
Note
|
||||
----
|
||||
Each enumeration name and value must be unique. This means that you should use
|
||||
different colour formats when you want to use a colour multiple times.
|
||||
"""
|
||||
|
||||
@@ -5,8 +5,6 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.request import urlopen
|
||||
|
||||
import PIL.Image # type: ignore[import-untyped]
|
||||
|
||||
from pydase.data_service.data_service import DataService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -16,9 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Image(DataService):
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._value: str = ""
|
||||
self._format: str = ""
|
||||
@@ -32,8 +28,14 @@ class Image(DataService):
|
||||
return self._format
|
||||
|
||||
def load_from_path(self, path: Path | str) -> None:
|
||||
with PIL.Image.open(path) as image:
|
||||
self._load_from_pil(image)
|
||||
with open(path, "rb") as image_file:
|
||||
image_data = image_file.read()
|
||||
format_ = self._get_image_format_from_bytes(image_data)
|
||||
if format_ is None:
|
||||
logger.error("Unsupported image format. Skipping...")
|
||||
return
|
||||
value_ = base64.b64encode(image_data)
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_matplotlib_figure(self, fig: "Figure", format_: str = "png") -> None:
|
||||
buffer = io.BytesIO()
|
||||
@@ -42,12 +44,18 @@ class Image(DataService):
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_url(self, url: str) -> None:
|
||||
image = PIL.Image.open(urlopen(url))
|
||||
self._load_from_pil(image)
|
||||
with urlopen(url) as response:
|
||||
image_data = response.read()
|
||||
format_ = self._get_image_format_from_bytes(image_data)
|
||||
if format_ is None:
|
||||
logger.error("Unsupported image format. Skipping...")
|
||||
return
|
||||
value_ = base64.b64encode(image_data)
|
||||
self._load_from_base64(value_, format_)
|
||||
|
||||
def load_from_base64(self, value_: bytes, format_: str | None = None) -> None:
|
||||
if format_ is None:
|
||||
format_ = self._get_image_format_from_bytes(value_)
|
||||
format_ = self._get_image_format_from_bytes(base64.b64decode(value_))
|
||||
if format_ is None:
|
||||
logger.warning(
|
||||
"Format of passed byte string could not be determined. Skipping..."
|
||||
@@ -60,19 +68,14 @@ class Image(DataService):
|
||||
self._value = value
|
||||
self._format = format_
|
||||
|
||||
def _load_from_pil(self, image: PIL.Image.Image) -> None:
|
||||
if image.format is not None:
|
||||
format_ = image.format
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format=format_)
|
||||
value_ = base64.b64encode(buffer.getvalue())
|
||||
self._load_from_base64(value_, format_)
|
||||
else:
|
||||
logger.error("Image format is 'None'. Skipping...")
|
||||
|
||||
def _get_image_format_from_bytes(self, value_: bytes) -> str | None:
|
||||
image_data = base64.b64decode(value_)
|
||||
# Create a writable memory buffer for the image
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
# Read the image from the buffer and return format
|
||||
return PIL.Image.open(image_buffer).format
|
||||
format_map = {
|
||||
b"\xff\xd8": "JPEG",
|
||||
b"\x89PNG": "PNG",
|
||||
b"GIF": "GIF",
|
||||
b"RIFF": "WEBP",
|
||||
}
|
||||
for signature, format_name in format_map.items():
|
||||
if value_.startswith(signature):
|
||||
return format_name
|
||||
return None
|
||||
|
||||
@@ -73,7 +73,7 @@ class DataService(AbstractDataService):
|
||||
|
||||
if not issubclass(
|
||||
value_class,
|
||||
(int | float | bool | str | list | Enum | u.Quantity | Observable),
|
||||
(int | float | bool | str | list | dict | Enum | u.Quantity | Observable),
|
||||
):
|
||||
logger.warning(
|
||||
"Class '%s' does not inherit from DataService. This may lead to"
|
||||
|
||||
@@ -2,8 +2,6 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializationPathError,
|
||||
SerializationValueError,
|
||||
SerializedObject,
|
||||
get_nested_dict_by_path,
|
||||
set_nested_value_by_path,
|
||||
@@ -38,16 +36,7 @@ class DataServiceCache:
|
||||
)
|
||||
|
||||
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
|
||||
try:
|
||||
return get_nested_dict_by_path(
|
||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
||||
full_access_path,
|
||||
)
|
||||
except (SerializationPathError, SerializationValueError, KeyError):
|
||||
return {
|
||||
"full_access_path": full_access_path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
return get_nested_dict_by_path(
|
||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
||||
full_access_path,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,11 @@ from pydase.observer_pattern.observer.property_observer import (
|
||||
PropertyObserver,
|
||||
)
|
||||
from pydase.utils.helpers import get_object_attr_from_path
|
||||
from pydase.utils.serialization.serializer import SerializedObject, dump
|
||||
from pydase.utils.serialization.serializer import (
|
||||
SerializationPathError,
|
||||
SerializedObject,
|
||||
dump,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,23 +33,34 @@ class DataServiceObserver(PropertyObserver):
|
||||
for changing_attribute in self.changing_attributes
|
||||
):
|
||||
return
|
||||
cached_value_dict: SerializedObject
|
||||
|
||||
cached_value_dict = deepcopy(
|
||||
self.state_manager._data_service_cache.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
try:
|
||||
cached_value_dict = deepcopy(
|
||||
self.state_manager.cache_manager.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
)
|
||||
)
|
||||
)
|
||||
except (SerializationPathError, KeyError):
|
||||
cached_value_dict = {
|
||||
"full_access_path": full_access_path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
cached_value = cached_value_dict.get("value")
|
||||
if cached_value != dump(value)["value"] and all(
|
||||
part[0] != "_" for part in full_access_path.split(".")
|
||||
if (
|
||||
all(part[0] != "_" for part in full_access_path.split("."))
|
||||
and cached_value != value
|
||||
):
|
||||
logger.debug("'%s' changed to '%s'", full_access_path, value)
|
||||
|
||||
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(
|
||||
self.state_manager.cache_manager.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
)
|
||||
)
|
||||
@@ -78,7 +93,7 @@ class DataServiceObserver(PropertyObserver):
|
||||
value_dict["type"],
|
||||
cached_value_dict["type"],
|
||||
)
|
||||
self.state_manager._data_service_cache.update_cache(
|
||||
self.state_manager.cache_manager.update_cache(
|
||||
full_access_path,
|
||||
value,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -7,9 +8,10 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pydase.data_service.data_service_cache import DataServiceCache
|
||||
from pydase.utils.helpers import (
|
||||
get_object_attr_from_path,
|
||||
get_object_by_path_parts,
|
||||
is_property_attribute,
|
||||
parse_list_attr_and_index,
|
||||
parse_full_access_path,
|
||||
parse_serialized_key,
|
||||
)
|
||||
from pydase.utils.serialization.deserializer import loads
|
||||
from pydase.utils.serialization.serializer import (
|
||||
@@ -112,19 +114,12 @@ class StateManager:
|
||||
self.filename = filename
|
||||
|
||||
self.service = service
|
||||
self._data_service_cache = DataServiceCache(self.service)
|
||||
|
||||
@property
|
||||
def cache(self) -> SerializedObject:
|
||||
"""Returns the cached DataService state."""
|
||||
return self._data_service_cache.cache
|
||||
self.cache_manager = DataServiceCache(self.service)
|
||||
|
||||
@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"]
|
||||
)
|
||||
return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""
|
||||
@@ -156,9 +151,18 @@ class StateManager:
|
||||
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
|
||||
)
|
||||
try:
|
||||
nested_class_dict = self.cache_manager.get_value_dict_from_cache(
|
||||
path
|
||||
)
|
||||
except (SerializationPathError, KeyError):
|
||||
nested_class_dict = {
|
||||
"full_access_path": path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
value_type = nested_json_dict["type"]
|
||||
class_attr_value_type = nested_class_dict.get("type", None)
|
||||
@@ -201,12 +205,16 @@ class StateManager:
|
||||
value: The new value to set for the attribute.
|
||||
"""
|
||||
|
||||
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
|
||||
try:
|
||||
current_value_dict = self.cache_manager.get_value_dict_from_cache(path)
|
||||
except (SerializationPathError, KeyError):
|
||||
current_value_dict = {
|
||||
"full_access_path": path,
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
if "full_access_path" not in serialized_value:
|
||||
# Backwards compatibility for JSON files not containing the
|
||||
@@ -236,44 +244,38 @@ class StateManager:
|
||||
def __update_attribute_by_path(
|
||||
self, path: str, serialized_value: SerializedObject
|
||||
) -> None:
|
||||
parent_path, attr_name = ".".join(path.split(".")[:-1]), path.split(".")[-1]
|
||||
is_value_set = False
|
||||
path_parts = parse_full_access_path(path)
|
||||
target_obj = get_object_by_path_parts(self.service, path_parts[:-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 = f"{parent_path}.{attr_name}" if parent_path != "" else attr_name
|
||||
|
||||
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(self.service, parent_path)
|
||||
|
||||
if attr_cache_type in ("ColouredEnum", "Enum"):
|
||||
enum_attr = get_object_attr_from_path(target_obj, attr_name)
|
||||
if self.__cached_value_is_enum(path):
|
||||
enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]])
|
||||
# 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
|
||||
# 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.
|
||||
with contextlib.suppress(KeyError):
|
||||
value = enum_attr.__class__[serialized_value["value"]]
|
||||
is_value_set = True
|
||||
|
||||
value = loads(serialized_value)
|
||||
if not is_value_set:
|
||||
value = loads(serialized_value)
|
||||
|
||||
if attr_cache_type == "list":
|
||||
list_obj = get_object_attr_from_path(target_obj, attr_name)
|
||||
list_obj[index] = value
|
||||
# set the value
|
||||
if isinstance(target_obj, list | dict):
|
||||
processed_key = parse_serialized_key(path_parts[-1])
|
||||
target_obj[processed_key] = value # type: ignore
|
||||
else:
|
||||
setattr(target_obj, attr_name, value)
|
||||
# Don't allow adding attributes to objects through state manager
|
||||
if self.__attr_exists_on_target_obj(
|
||||
target_obj=target_obj, name=path_parts[-1]
|
||||
):
|
||||
raise AttributeError(
|
||||
f"{target_obj.__class__.__name__!r} object has no attribute "
|
||||
f"{path_parts[-1]!r}"
|
||||
)
|
||||
|
||||
setattr(target_obj, path_parts[-1], value)
|
||||
|
||||
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
|
||||
@@ -283,26 +285,23 @@ class StateManager:
|
||||
attributes default to being loadable.
|
||||
"""
|
||||
|
||||
parent_path, attr_name = (
|
||||
".".join(full_access_path.split(".")[:-1]),
|
||||
full_access_path.split(".")[-1],
|
||||
)
|
||||
parent_object = get_object_attr_from_path(self.service, parent_path)
|
||||
path_parts = parse_full_access_path(full_access_path)
|
||||
parent_object = get_object_by_path_parts(self.service, path_parts[:-1])
|
||||
|
||||
if is_property_attribute(parent_object, attr_name):
|
||||
prop = getattr(type(parent_object), attr_name)
|
||||
if is_property_attribute(parent_object, path_parts[-1]):
|
||||
prop = getattr(type(parent_object), path_parts[-1])
|
||||
has_decorator = has_load_state_decorator(prop)
|
||||
if not has_decorator:
|
||||
logger.debug(
|
||||
"Property '%s' has no '@load_state' decorator. "
|
||||
"Ignoring value from JSON file...",
|
||||
attr_name,
|
||||
path_parts[-1],
|
||||
)
|
||||
return has_decorator
|
||||
|
||||
try:
|
||||
cached_serialization_dict = get_nested_dict_by_path(
|
||||
self.cache_value, full_access_path
|
||||
cached_serialization_dict = self.cache_manager.get_value_dict_from_cache(
|
||||
full_access_path
|
||||
)
|
||||
|
||||
if cached_serialization_dict["value"] == "method":
|
||||
@@ -314,6 +313,19 @@ class StateManager:
|
||||
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,
|
||||
path_parts[-1],
|
||||
)
|
||||
return False
|
||||
|
||||
def __cached_value_is_enum(self, path: str) -> bool:
|
||||
try:
|
||||
attr_cache_type = self.cache_manager.get_value_dict_from_cache(path)["type"]
|
||||
|
||||
return attr_cache_type in ("ColouredEnum", "Enum")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def __attr_exists_on_target_obj(self, target_obj: Any, name: str) -> bool:
|
||||
return not is_property_attribute(target_obj, name) and not hasattr(
|
||||
target_obj, name
|
||||
)
|
||||
|
||||
@@ -21,10 +21,6 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
RUNNING = "running"
|
||||
|
||||
@@ -107,12 +103,13 @@ class TaskManager:
|
||||
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."
|
||||
logger.info(
|
||||
"Async function %a is defined with at least one argument. If "
|
||||
"you want to use it as a task, remove the argument(s) from the "
|
||||
"function definition.",
|
||||
method.__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
# create start and stop methods for each coroutine
|
||||
setattr(
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.7ef670d5.css",
|
||||
"main.js": "/static/js/main.9c35da6c.js",
|
||||
"index.html": "/index.html",
|
||||
"main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
|
||||
"main.9c35da6c.js.map": "/static/js/main.9c35da6c.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.7ef670d5.css",
|
||||
"static/js/main.9c35da6c.js"
|
||||
]
|
||||
}
|
||||
5
src/pydase/frontend/assets/index-D2aktF3W.css
Normal file
5
src/pydase/frontend/assets/index-D2aktF3W.css
Normal file
File diff suppressed because one or more lines are too long
62
src/pydase/frontend/assets/index-D7tStNHJ.js
Normal file
62
src/pydase/frontend/assets/index-D7tStNHJ.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1,18 @@
|
||||
<!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>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site displaying a pydase UI." />
|
||||
<script type="module" crossorigin src="/assets/index-D7tStNHJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,45 +0,0 @@
|
||||
/*!
|
||||
Copyright (c) 2018 Jed Watson.
|
||||
Licensed under the MIT License (MIT), see
|
||||
http://jedwatson.github.io/classnames
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
101
src/pydase/observer_pattern/observable/decorators.py
Normal file
101
src/pydase/observer_pattern/observable/decorators.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydase.observer_pattern.observable.observable import Observable
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
def validate_set(
|
||||
*, timeout: float = 0.1, precision: float | None = None
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""
|
||||
Decorator marking a property setter to read back the set value using the property
|
||||
getter and check against the desired value.
|
||||
|
||||
Args:
|
||||
timeout (float):
|
||||
The maximum time (in seconds) to wait for the value to be within the
|
||||
precision boundary.
|
||||
precision (float | None):
|
||||
The acceptable deviation from the desired value. If None, the value must be
|
||||
exact.
|
||||
"""
|
||||
|
||||
def validate_set_decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._validate_kwargs = { # type: ignore
|
||||
"timeout": timeout,
|
||||
"precision": precision,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
|
||||
return validate_set_decorator
|
||||
|
||||
|
||||
def has_validate_set_decorator(prop: property) -> bool:
|
||||
"""
|
||||
Checks if a property setter has been decorated with the `validate_set` decorator.
|
||||
|
||||
Args:
|
||||
prop (property):
|
||||
The property to check.
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
True if the property setter has the `validate_set` decorator, False
|
||||
otherwise.
|
||||
"""
|
||||
|
||||
property_setter = prop.fset
|
||||
return hasattr(property_setter, "_validate_kwargs")
|
||||
|
||||
|
||||
def _validate_value_was_correctly_set(
|
||||
*,
|
||||
obj: "Observable",
|
||||
name: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Validates if the property `name` of `obj` attains the desired `value` within the
|
||||
specified `precision` and time `timeout`.
|
||||
|
||||
Args:
|
||||
obj (Observable):
|
||||
The instance of the class containing the property.
|
||||
name (str):
|
||||
The name of the property to validate.
|
||||
value (Any):
|
||||
The desired value to check against.
|
||||
|
||||
Raises:
|
||||
ValueError:
|
||||
If the property value does not match the desired value within the specified
|
||||
precision and timeout.
|
||||
"""
|
||||
|
||||
prop: property = getattr(type(obj), name)
|
||||
|
||||
timeout = prop.fset._validate_kwargs["timeout"] # type: ignore
|
||||
precision = prop.fset._validate_kwargs["precision"] # type: ignore
|
||||
if precision is None:
|
||||
precision = 0.0
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
current_value = obj.__getattribute__(name)
|
||||
# This check is faster than rounding and comparing to 0
|
||||
if abs(current_value - value) <= precision:
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise ValueError(
|
||||
f"Failed to set value to {value} within {timeout} seconds. Current value: "
|
||||
f"{current_value}."
|
||||
)
|
||||
@@ -1,6 +1,10 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydase.observer_pattern.observable.decorators import (
|
||||
_validate_value_was_correctly_set,
|
||||
has_validate_set_decorator,
|
||||
)
|
||||
from pydase.observer_pattern.observable.observable_object import ObservableObject
|
||||
from pydase.utils.helpers import is_property_attribute
|
||||
|
||||
@@ -15,6 +19,7 @@ class Observable(ObservableObject):
|
||||
for k in set(type(self).__dict__)
|
||||
- set(Observable.__dict__)
|
||||
- set(self.__dict__)
|
||||
- {"__annotations__"}
|
||||
}
|
||||
for name, value in class_attrs.items():
|
||||
if isinstance(value, property) or callable(value):
|
||||
@@ -34,7 +39,12 @@ class Observable(ObservableObject):
|
||||
|
||||
super().__setattr__(name, value)
|
||||
|
||||
self._notify_changed(name, value)
|
||||
if is_property_attribute(self, name) and has_validate_set_decorator(
|
||||
getattr(type(self), name)
|
||||
):
|
||||
_validate_value_was_correctly_set(obj=self, name=name, value=value)
|
||||
else:
|
||||
self._notify_changed(name, value)
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
if is_property_attribute(self, name):
|
||||
|
||||
@@ -1,34 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, SupportsIndex
|
||||
|
||||
from pydase.utils.helpers import parse_serialized_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from pydase.observer_pattern.observer.observer import Observer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObservableObject(ABC):
|
||||
_list_mapping: ClassVar[dict[int, "_ObservableList"]] = {}
|
||||
_dict_mapping: ClassVar[dict[int, "_ObservableDict"]] = {}
|
||||
_list_mapping: ClassVar[dict[int, weakref.ReferenceType[_ObservableList]]] = {}
|
||||
_dict_mapping: ClassVar[dict[int, weakref.ReferenceType[_ObservableDict]]] = {}
|
||||
|
||||
def __init__(self) -> None:
|
||||
if not hasattr(self, "_observers"):
|
||||
self._observers: dict[str, list["ObservableObject | Observer"]] = {}
|
||||
self._observers: dict[str, list[ObservableObject | Observer]] = {}
|
||||
|
||||
def add_observer(
|
||||
self, observer: "ObservableObject | Observer", attr_name: str = ""
|
||||
self, observer: ObservableObject | Observer, attr_name: str = ""
|
||||
) -> None:
|
||||
if attr_name not in self._observers:
|
||||
self._observers[attr_name] = []
|
||||
if observer not in self._observers[attr_name]:
|
||||
self._observers[attr_name].append(observer)
|
||||
|
||||
def _remove_observer(self, observer: "ObservableObject", attribute: str) -> None:
|
||||
def _remove_observer(self, observer: ObservableObject, attribute: str) -> None:
|
||||
if attribute in self._observers:
|
||||
self._observers[attribute].remove(observer)
|
||||
|
||||
# remove attribute key from observers dict if list of observers is empty
|
||||
if not self._observers[attribute]:
|
||||
del self._observers[attribute]
|
||||
|
||||
@abstractmethod
|
||||
def _remove_observer_if_observable(self, name: str) -> None:
|
||||
"""Removes the current object as an observer from an observable attribute.
|
||||
@@ -81,26 +91,30 @@ class ObservableObject(ABC):
|
||||
)
|
||||
observer._notify_change_start(extended_attr_path)
|
||||
|
||||
def _initialise_new_objects(self, attr_name_or_key: Any, value: Any) -> Any:
|
||||
def _initialise_new_objects(self, attr_name_or_key: str, value: Any) -> Any:
|
||||
new_value = value
|
||||
if isinstance(value, list):
|
||||
if id(value) in self._list_mapping:
|
||||
# If the list `value` was already referenced somewhere else
|
||||
new_value = self._list_mapping[id(value)]
|
||||
new_value = self._list_mapping[id(value)]()
|
||||
else:
|
||||
# convert the builtin list into a ObservableList
|
||||
new_value = _ObservableList(original_list=value)
|
||||
self._list_mapping[id(value)] = new_value
|
||||
|
||||
# Use weakref to allow the GC to collect unused objects
|
||||
self._list_mapping[id(value)] = weakref.ref(new_value)
|
||||
elif isinstance(value, dict):
|
||||
if id(value) in self._dict_mapping:
|
||||
# If the list `value` was already referenced somewhere else
|
||||
new_value = self._dict_mapping[id(value)]
|
||||
# If the dict `value` was already referenced somewhere else
|
||||
new_value = self._dict_mapping[id(value)]()
|
||||
else:
|
||||
# convert the builtin list into a ObservableList
|
||||
# convert the builtin dict into a ObservableDict
|
||||
new_value = _ObservableDict(original_dict=value)
|
||||
self._dict_mapping[id(value)] = new_value
|
||||
|
||||
# Use weakref to allow the GC to collect unused objects
|
||||
self._dict_mapping[id(value)] = weakref.ref(new_value)
|
||||
if isinstance(new_value, ObservableObject):
|
||||
new_value.add_observer(self, str(attr_name_or_key))
|
||||
new_value.add_observer(self, attr_name_or_key)
|
||||
return new_value
|
||||
|
||||
@abstractmethod
|
||||
@@ -137,6 +151,9 @@ class _ObservableList(ObservableObject, list[Any]):
|
||||
for i, item in enumerate(self._original_list):
|
||||
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
||||
|
||||
def __del__(self) -> None:
|
||||
self._list_mapping.pop(id(self._original_list))
|
||||
|
||||
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
|
||||
if hasattr(self, "_observers"):
|
||||
self._remove_observer_if_observable(f"[{key}]")
|
||||
@@ -149,8 +166,7 @@ class _ObservableList(ObservableObject, list[Any]):
|
||||
|
||||
def append(self, __object: Any) -> None:
|
||||
self._notify_change_start("")
|
||||
self._initialise_new_objects(f"[{len(self)}]", __object)
|
||||
super().append(__object)
|
||||
super().append(self._initialise_new_objects(f"[{len(self)}]", __object))
|
||||
self._notify_changed("", self)
|
||||
|
||||
def clear(self) -> None:
|
||||
@@ -224,7 +240,7 @@ class _ObservableList(ObservableObject, list[Any]):
|
||||
return instance_attr_name
|
||||
|
||||
|
||||
class _ObservableDict(dict[str, Any], ObservableObject):
|
||||
class _ObservableDict(ObservableObject, dict[str, Any]):
|
||||
def __init__(
|
||||
self,
|
||||
original_dict: dict[str, Any],
|
||||
@@ -233,24 +249,29 @@ class _ObservableDict(dict[str, Any], ObservableObject):
|
||||
ObservableObject.__init__(self)
|
||||
dict.__init__(self)
|
||||
for key, value in self._original_dict.items():
|
||||
super().__setitem__(key, self._initialise_new_objects(f"['{key}']", value))
|
||||
self.__setitem__(key, self._initialise_new_objects(f'["{key}"]', value))
|
||||
|
||||
def __del__(self) -> None:
|
||||
self._dict_mapping.pop(id(self._original_dict))
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
if not isinstance(key, str):
|
||||
logger.warning("Converting non-string dictionary key %s to string.", key)
|
||||
key = str(key)
|
||||
raise ValueError(
|
||||
f"Invalid key type: {key} ({type(key).__name__}). In pydase services, "
|
||||
"dictionary keys must be strings."
|
||||
)
|
||||
|
||||
if hasattr(self, "_observers"):
|
||||
self._remove_observer_if_observable(f"['{key}']")
|
||||
value = self._initialise_new_objects(key, value)
|
||||
self._notify_change_start(f"['{key}']")
|
||||
self._remove_observer_if_observable(f'["{key}"]')
|
||||
value = self._initialise_new_objects(f'["{key}"]', value)
|
||||
self._notify_change_start(f'["{key}"]')
|
||||
|
||||
super().__setitem__(key, value)
|
||||
|
||||
self._notify_changed(f"['{key}']", value)
|
||||
self._notify_changed(f'["{key}"]', value)
|
||||
|
||||
def _remove_observer_if_observable(self, name: str) -> None:
|
||||
key = name[2:-2]
|
||||
key = str(parse_serialized_key(name))
|
||||
current_value = self.get(key, None)
|
||||
|
||||
if isinstance(current_value, ObservableObject):
|
||||
@@ -262,3 +283,11 @@ class _ObservableDict(dict[str, Any], ObservableObject):
|
||||
if observer_attr_name != "":
|
||||
return f"{observer_attr_name}{instance_attr_name}"
|
||||
return instance_attr_name
|
||||
|
||||
def pop(self, key: str) -> Any: # type: ignore[override]
|
||||
self._remove_observer_if_observable(f'["{key}"]')
|
||||
|
||||
popped_item = super().pop(key)
|
||||
|
||||
self._notify_changed("", self)
|
||||
return popped_item
|
||||
|
||||
@@ -2,19 +2,25 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import Any, Protocol, TypedDict
|
||||
|
||||
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.server.web_server import WebServer
|
||||
|
||||
HANDLED_SIGNALS = (
|
||||
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
|
||||
signal.SIGTERM, # Unix signal 15. Sent by `kill <pid>`.
|
||||
)
|
||||
if sys.platform == "win32": # pragma: py-not-win32
|
||||
HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break.
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -207,8 +213,9 @@ class Server:
|
||||
addin_server.__module__ + "." + addin_server.__class__.__name__
|
||||
)
|
||||
|
||||
future_or_task = self._loop.create_task(addin_server.serve())
|
||||
self.servers[server_name] = future_or_task
|
||||
server_task = self._loop.create_task(addin_server.serve())
|
||||
server_task.add_done_callback(self.handle_server_shutdown)
|
||||
self.servers[server_name] = server_task
|
||||
if self._enable_web:
|
||||
self._web_server = WebServer(
|
||||
data_service_observer=self._observer,
|
||||
@@ -216,8 +223,22 @@ class Server:
|
||||
port=self._web_port,
|
||||
**self._kwargs,
|
||||
)
|
||||
future_or_task = self._loop.create_task(self._web_server.serve())
|
||||
self.servers["web"] = future_or_task
|
||||
server_task = self._loop.create_task(self._web_server.serve())
|
||||
|
||||
server_task.add_done_callback(self.handle_server_shutdown)
|
||||
self.servers["web"] = server_task
|
||||
|
||||
def handle_server_shutdown(self, task: asyncio.Task[Any]) -> None:
|
||||
"""Handle server shutdown. If the service should exit, do nothing. Else, make
|
||||
the service exit."""
|
||||
|
||||
if self.should_exit:
|
||||
return
|
||||
|
||||
try:
|
||||
task.result()
|
||||
except Exception:
|
||||
self.should_exit = True
|
||||
|
||||
async def main_loop(self) -> None:
|
||||
while not self.should_exit:
|
||||
@@ -229,7 +250,9 @@ class Server:
|
||||
logger.info("Saving data to %s.", self._state_manager.filename)
|
||||
self._state_manager.save_state()
|
||||
|
||||
logger.debug("Cancelling servers")
|
||||
await self.__cancel_servers()
|
||||
logger.debug("Cancelling tasks")
|
||||
await self.__cancel_tasks()
|
||||
|
||||
async def __cancel_servers(self) -> None:
|
||||
@@ -240,7 +263,7 @@ class Server:
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Cancelled '%s' server.", server_name)
|
||||
except Exception as e:
|
||||
logger.warning("Unexpected exception: %s", e)
|
||||
logger.error("Unexpected exception: %s", e)
|
||||
|
||||
async def __cancel_tasks(self) -> None:
|
||||
for task in asyncio.all_tasks(self._loop):
|
||||
|
||||
24
src/pydase/server/web_server/api/__init__.py
Normal file
24
src/pydase/server/web_server/api/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
|
||||
import aiohttp.web
|
||||
import aiohttp_middlewares.error
|
||||
|
||||
import pydase.server.web_server.api.v1.application
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_api_application(state_manager: StateManager) -> aiohttp.web.Application:
|
||||
api_application = aiohttp.web.Application(
|
||||
middlewares=(aiohttp_middlewares.error.error_middleware(),)
|
||||
)
|
||||
|
||||
api_application.add_subapp(
|
||||
"/v1/",
|
||||
pydase.server.web_server.api.v1.application.create_api_application(
|
||||
state_manager
|
||||
),
|
||||
)
|
||||
|
||||
return api_application
|
||||
0
src/pydase/server/web_server/api/v1/__init__.py
Normal file
0
src/pydase/server/web_server/api/v1/__init__.py
Normal file
70
src/pydase/server/web_server/api/v1/application.py
Normal file
70
src/pydase/server/web_server/api/v1/application.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp.web
|
||||
import aiohttp_middlewares.error
|
||||
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.server.web_server.api.v1.endpoints import (
|
||||
get_value,
|
||||
trigger_method,
|
||||
update_value,
|
||||
)
|
||||
from pydase.utils.serialization.serializer import dump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydase.server.web_server.sio_setup import TriggerMethodDict, UpdateDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_VERSION = "v1"
|
||||
|
||||
STATUS_OK = 200
|
||||
STATUS_FAILED = 400
|
||||
|
||||
|
||||
def create_api_application(state_manager: StateManager) -> aiohttp.web.Application:
|
||||
api_application = aiohttp.web.Application(
|
||||
middlewares=(aiohttp_middlewares.error.error_middleware(),)
|
||||
)
|
||||
|
||||
async def _get_value(request: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
logger.info("Handle api request: %s", request)
|
||||
|
||||
access_path = request.rel_url.query["access_path"]
|
||||
|
||||
status = STATUS_OK
|
||||
try:
|
||||
result = get_value(state_manager, access_path)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
result = dump(e)
|
||||
status = STATUS_FAILED
|
||||
return aiohttp.web.json_response(result, status=status)
|
||||
|
||||
async def _update_value(request: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
data: UpdateDict = await request.json()
|
||||
|
||||
try:
|
||||
update_value(state_manager, data)
|
||||
|
||||
return aiohttp.web.json_response()
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return aiohttp.web.json_response(dump(e), status=STATUS_FAILED)
|
||||
|
||||
async def _trigger_method(request: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||
data: TriggerMethodDict = await request.json()
|
||||
|
||||
try:
|
||||
return aiohttp.web.json_response(trigger_method(state_manager, data))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return aiohttp.web.json_response(dump(e), status=STATUS_FAILED)
|
||||
|
||||
api_application.router.add_get("/get_value", _get_value)
|
||||
api_application.router.add_put("/update_value", _update_value)
|
||||
api_application.router.add_put("/trigger_method", _trigger_method)
|
||||
|
||||
return api_application
|
||||
38
src/pydase/server/web_server/api/v1/endpoints.py
Normal file
38
src/pydase/server/web_server/api/v1/endpoints.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Any
|
||||
|
||||
import pydase.utils.serialization.deserializer
|
||||
import pydase.utils.serialization.serializer
|
||||
from pydase.data_service.state_manager import StateManager
|
||||
from pydase.server.web_server.sio_setup import TriggerMethodDict, UpdateDict
|
||||
from pydase.utils.helpers import get_object_attr_from_path
|
||||
from pydase.utils.serialization.types import SerializedObject
|
||||
|
||||
loads = pydase.utils.serialization.deserializer.loads
|
||||
Serializer = pydase.utils.serialization.serializer.Serializer
|
||||
|
||||
|
||||
def update_value(state_manager: StateManager, data: UpdateDict) -> None:
|
||||
path = data["access_path"]
|
||||
|
||||
state_manager.set_service_attribute_value_by_path(
|
||||
path=path, serialized_value=data["value"]
|
||||
)
|
||||
|
||||
|
||||
def get_value(state_manager: StateManager, access_path: str) -> SerializedObject:
|
||||
return Serializer.serialize_object(
|
||||
get_object_attr_from_path(state_manager.service, access_path),
|
||||
access_path=access_path,
|
||||
)
|
||||
|
||||
|
||||
def trigger_method(state_manager: StateManager, data: TriggerMethodDict) -> Any:
|
||||
method = get_object_attr_from_path(state_manager.service, data["access_path"])
|
||||
|
||||
serialized_args = data.get("args", None)
|
||||
args = loads(serialized_args) if serialized_args else []
|
||||
|
||||
serialized_kwargs = data.get("kwargs", None)
|
||||
kwargs: dict[str, Any] = loads(serialized_kwargs) if serialized_kwargs else {}
|
||||
|
||||
return Serializer.serialize_object(method(*args, **kwargs))
|
||||
@@ -1,19 +1,29 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, TypedDict
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
from typing_extensions import NotRequired
|
||||
else:
|
||||
from typing import NotRequired
|
||||
|
||||
import click
|
||||
import socketio # type: ignore[import-untyped]
|
||||
|
||||
import pydase.server.web_server.api.v1.endpoints
|
||||
import pydase.utils.serialization.deserializer
|
||||
import pydase.utils.serialization.serializer
|
||||
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
|
||||
from pydase.utils.serialization.serializer import SerializedObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# These functions can be monkey-patched by other libraries at runtime
|
||||
dump = pydase.utils.serialization.serializer.dump
|
||||
|
||||
|
||||
class UpdateDict(TypedDict):
|
||||
"""
|
||||
@@ -34,8 +44,8 @@ class UpdateDict(TypedDict):
|
||||
|
||||
class TriggerMethodDict(TypedDict):
|
||||
access_path: str
|
||||
args: SerializedObject
|
||||
kwargs: SerializedObject
|
||||
args: NotRequired[SerializedObject]
|
||||
kwargs: NotRequired[SerializedObject]
|
||||
|
||||
|
||||
class RunMethodDict(TypedDict):
|
||||
@@ -80,9 +90,9 @@ def setup_sio_server(
|
||||
state_manager = observer.state_manager
|
||||
|
||||
if enable_cors:
|
||||
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
|
||||
sio = socketio.AsyncServer(async_mode="aiohttp", cors_allowed_origins="*")
|
||||
else:
|
||||
sio = socketio.AsyncServer(async_mode="asgi")
|
||||
sio = socketio.AsyncServer(async_mode="aiohttp")
|
||||
|
||||
setup_sio_events(sio, state_manager)
|
||||
setup_logging_handler(sio)
|
||||
@@ -129,25 +139,24 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
|
||||
"Client [%s] requested service serialization",
|
||||
click.style(str(sid), fg="cyan"),
|
||||
)
|
||||
return state_manager.cache
|
||||
return state_manager.cache_manager.cache
|
||||
|
||||
@sio.event
|
||||
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None: # type: ignore
|
||||
path = data["access_path"]
|
||||
|
||||
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None:
|
||||
try:
|
||||
state_manager.set_service_attribute_value_by_path(
|
||||
path=path, serialized_value=data["value"]
|
||||
pydase.server.web_server.api.v1.endpoints.update_value(
|
||||
state_manager=state_manager, data=data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return dump(e)
|
||||
return None
|
||||
|
||||
@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
|
||||
return pydase.server.web_server.api.v1.endpoints.get_value(
|
||||
state_manager=state_manager, access_path=access_path
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
@@ -156,17 +165,14 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
|
||||
@sio.event
|
||||
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
|
||||
try:
|
||||
method = get_object_attr_from_path(
|
||||
state_manager.service, data["access_path"]
|
||||
return pydase.server.web_server.api.v1.endpoints.trigger_method(
|
||||
state_manager=state_manager, data=data
|
||||
)
|
||||
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))
|
||||
logging.getLogger().addHandler(SocketIOHandler(sio))
|
||||
logging.getLogger("pydase").addHandler(SocketIOHandler(sio))
|
||||
|
||||
@@ -4,22 +4,23 @@ 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
|
||||
import aiohttp.web
|
||||
import aiohttp_middlewares.cors
|
||||
|
||||
from pydase.config import ServiceConfig, WebServerConfig
|
||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||
from pydase.server.web_server.api import create_api_application
|
||||
from pydase.server.web_server.sio_setup import (
|
||||
setup_sio_server,
|
||||
)
|
||||
from pydase.utils.helpers import (
|
||||
get_path_from_path_parts,
|
||||
parse_full_access_path,
|
||||
)
|
||||
from pydase.utils.serialization.serializer import generate_serialized_data_paths
|
||||
from pydase.version import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
API_VERSION = "v1"
|
||||
|
||||
|
||||
class WebServer:
|
||||
@@ -87,24 +88,68 @@ class WebServer:
|
||||
|
||||
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)
|
||||
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
|
||||
|
||||
async def index(request: aiohttp.web.Request) -> aiohttp.web.FileResponse:
|
||||
return aiohttp.web.FileResponse(self.frontend_src / "index.html")
|
||||
|
||||
app = aiohttp.web.Application()
|
||||
|
||||
# Add CORS middleware if enabled
|
||||
if self.enable_cors:
|
||||
app.middlewares.append(
|
||||
aiohttp_middlewares.cors.cors_middleware(allow_all=True)
|
||||
)
|
||||
|
||||
# Define routes
|
||||
self._sio.attach(app, socketio_path="/ws/socket.io")
|
||||
app.router.add_static("/assets", self.frontend_src / "assets")
|
||||
app.router.add_get("/service-properties", self._service_properties_route)
|
||||
app.router.add_get("/web-settings", self._web_settings_route)
|
||||
app.router.add_get("/custom.css", self._styles_route)
|
||||
app.add_subapp("/api/", create_api_application(self.state_manager))
|
||||
|
||||
app.router.add_get(r"/", index)
|
||||
app.router.add_get(r"/{tail:.*}", index)
|
||||
|
||||
await aiohttp.web._run_app(
|
||||
app,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
handle_signals=False,
|
||||
print=logger.info,
|
||||
shutdown_timeout=0.1,
|
||||
)
|
||||
# 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()
|
||||
|
||||
async def _service_properties_route(
|
||||
self,
|
||||
request: aiohttp.web.Request,
|
||||
) -> aiohttp.web.Response:
|
||||
return aiohttp.web.json_response(self.state_manager.cache_manager.cache)
|
||||
|
||||
async def _web_settings_route(
|
||||
self,
|
||||
request: aiohttp.web.Request,
|
||||
) -> aiohttp.web.Response:
|
||||
return aiohttp.web.json_response(self.web_settings)
|
||||
|
||||
async def _styles_route(
|
||||
self,
|
||||
request: aiohttp.web.Request,
|
||||
) -> aiohttp.web.FileResponse | aiohttp.web.Response:
|
||||
if self.css is not None:
|
||||
return aiohttp.web.FileResponse(self.css)
|
||||
|
||||
return aiohttp.web.Response(content_type="text/css")
|
||||
|
||||
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 = self._service_config_dir / "web_settings.json"
|
||||
|
||||
# File does not exist, create it with default content
|
||||
file_path.parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
) # Ensure directory exists
|
||||
@@ -131,60 +176,19 @@ class WebServer:
|
||||
if path in current_web_settings:
|
||||
continue
|
||||
|
||||
# Creating the display name by reversely looping through the path parts
|
||||
# until an item does not start with a square bracket, and putting the parts
|
||||
# back together again. This allows for display names like
|
||||
# >>> 'dict_attr["some.dotted.key"]'
|
||||
display_name_parts: list[str] = []
|
||||
for item in parse_full_access_path(path)[::-1]:
|
||||
display_name_parts.insert(0, item)
|
||||
if not item.startswith("["):
|
||||
break
|
||||
|
||||
current_web_settings[path] = {
|
||||
"displayName": path.split(".")[-1],
|
||||
"displayName": get_path_from_path_parts(display_name_parts),
|
||||
"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
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import TypedDict
|
||||
import pint
|
||||
|
||||
units: pint.UnitRegistry = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
|
||||
units.default_format = "~P" # pretty and short format
|
||||
units.formatter.default_format = "~P" # pretty and short format
|
||||
|
||||
Quantity = pint.Quantity
|
||||
Unit = units.Unit
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -25,3 +26,17 @@ def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
# Mark the function for frontend display.
|
||||
func._display_in_frontend = True # type: ignore
|
||||
return func
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
@@ -7,6 +8,96 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_serialized_key(serialized_key: str) -> str | int | float:
|
||||
"""
|
||||
Parse a serialized key and convert it to an appropriate type (int, float, or str).
|
||||
|
||||
Args:
|
||||
serialized_key: str
|
||||
The serialized key, which might be enclosed in brackets and quotes.
|
||||
|
||||
Returns:
|
||||
int | float | str:
|
||||
The processed key as an integer, float, or unquoted string.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
print(parse_serialized_key("attr_name")) # Outputs: attr_name (str)
|
||||
print(parse_serialized_key("[123]")) # Outputs: 123 (int)
|
||||
print(parse_serialized_key("[12.3]")) # Outputs: 12.3 (float)
|
||||
print(parse_serialized_key("['hello']")) # Outputs: hello (str)
|
||||
print(parse_serialized_key('["12.34"]')) # Outputs: 12.34 (str)
|
||||
print(parse_serialized_key('["complex"]')) # Outputs: complex (str)
|
||||
```
|
||||
"""
|
||||
|
||||
# Strip outer brackets if present
|
||||
if serialized_key.startswith("[") and serialized_key.endswith("]"):
|
||||
serialized_key = serialized_key[1:-1]
|
||||
|
||||
# Strip quotes if the resulting string is quoted
|
||||
if serialized_key.startswith(("'", '"')) and serialized_key.endswith(("'", '"')):
|
||||
return serialized_key[1:-1]
|
||||
|
||||
# Try converting to float or int if the string is not quoted
|
||||
try:
|
||||
return float(serialized_key) if "." in serialized_key else int(serialized_key)
|
||||
except ValueError:
|
||||
# Return the original string if it's not a valid number
|
||||
return serialized_key
|
||||
|
||||
|
||||
def parse_full_access_path(path: str) -> list[str]:
|
||||
"""
|
||||
Splits a full access path into its atomic parts, separating attribute names, numeric
|
||||
indices (including floating points), and string keys within indices.
|
||||
|
||||
Args:
|
||||
path: str
|
||||
The full access path string to be split into components.
|
||||
|
||||
Returns:
|
||||
list[str]
|
||||
A list of components that make up the path, including attribute names,
|
||||
numeric indices, and string keys as separate elements.
|
||||
|
||||
Example:
|
||||
>>> parse_full_access_path('dict_attr["some_key"].attr_name["other_key"]')
|
||||
["dict_attr", '["some_key"]', "attr_name", '["other_key"]']
|
||||
"""
|
||||
# Matches:
|
||||
# \w+ - Words
|
||||
# \[\d+\.\d+\] - Floating point numbers inside brackets
|
||||
# \[\d+\] - Integers inside brackets
|
||||
# \["[^"]*"\] - Double-quoted strings inside brackets
|
||||
# \['[^']*'\] - Single-quoted strings inside brackets
|
||||
pattern = r'\w+|\[\d+\.\d+\]|\[\d+\]|\["[^"]*"\]|\[\'[^\']*\']'
|
||||
return re.findall(pattern, path)
|
||||
|
||||
|
||||
def get_path_from_path_parts(path_parts: list[str]) -> str:
|
||||
"""Creates the full access path from its atomic parts.
|
||||
|
||||
The reverse function is given by `parse_full_access_path`.
|
||||
|
||||
Args:
|
||||
path_parts: list[str]
|
||||
A list of components that make up the path, including attribute names,
|
||||
numeric indices and string keys enclosed in square brackets as separate
|
||||
elements.
|
||||
Returns:
|
||||
str
|
||||
The full access path corresponding to the path_parts.
|
||||
"""
|
||||
|
||||
path = ""
|
||||
for path_part in path_parts:
|
||||
if not path_part.startswith("[") and path != "":
|
||||
path += "."
|
||||
path += path_part
|
||||
return path
|
||||
|
||||
|
||||
def get_attribute_doc(attr: Any) -> str | None:
|
||||
"""This function takes an input attribute attr and returns its documentation
|
||||
string if it's different from the documentation of its type, otherwise,
|
||||
@@ -30,6 +121,24 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
|
||||
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
|
||||
|
||||
|
||||
def get_object_by_path_parts(target_obj: Any, path_parts: list[str]) -> Any:
|
||||
"""Gets nested attribute of `target_object` specified by `path_parts`.
|
||||
|
||||
Raises:
|
||||
AttributeError: Attribute does not exist.
|
||||
KeyError: Key in dict does not exist.
|
||||
IndexError: Index out of list range.
|
||||
TypeError: List index in the path is not a valid integer.
|
||||
"""
|
||||
for part in path_parts:
|
||||
if part.startswith("["):
|
||||
deserialized_part = parse_serialized_key(part)
|
||||
target_obj = target_obj[deserialized_part]
|
||||
else:
|
||||
target_obj = getattr(target_obj, part)
|
||||
return target_obj
|
||||
|
||||
|
||||
def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
|
||||
"""
|
||||
Traverse the object tree according to the given path.
|
||||
@@ -44,96 +153,13 @@ def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
|
||||
the path does not exist, the function logs a debug message and returns None.
|
||||
|
||||
Raises:
|
||||
ValueError: If a list index in the path is not a valid integer.
|
||||
AttributeError: Attribute does not exist.
|
||||
KeyError: Key in dict does not exist.
|
||||
IndexError: Index out of list range.
|
||||
TypeError: List index in the path is not a valid integer.
|
||||
"""
|
||||
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)
|
||||
index_str = index_str.replace("]", "")
|
||||
index = int(index_str)
|
||||
target_obj = getattr(target_obj, attr)[index]
|
||||
except ValueError:
|
||||
# No index, so just get the attribute
|
||||
target_obj = getattr(target_obj, part)
|
||||
except AttributeError:
|
||||
# The attribute doesn't exist
|
||||
logger.debug("Attribute % does not exist in the object.", part)
|
||||
return None
|
||||
return target_obj
|
||||
|
||||
|
||||
def update_value_if_changed(
|
||||
target: Any, attr_name_or_index: str | int, new_value: Any
|
||||
) -> None:
|
||||
"""
|
||||
Updates the value of an attribute or a list element on a target object if the new
|
||||
value differs from the current one.
|
||||
|
||||
This function supports updating both attributes of an object and elements of a list.
|
||||
|
||||
- For objects, the function first checks the current value of the attribute. If the
|
||||
current value differs from the new value, the function updates the attribute.
|
||||
|
||||
- For lists, the function checks the current value at the specified index. If the
|
||||
current value differs from the new value, the function updates the list element
|
||||
at the given index.
|
||||
|
||||
Args:
|
||||
target (Any):
|
||||
The target object that has the attribute or the list.
|
||||
attr_name_or_index (str | int):
|
||||
The name of the attribute or the index of the list element.
|
||||
new_value (Any):
|
||||
The new value for the attribute or the list element.
|
||||
"""
|
||||
|
||||
if isinstance(target, list) and isinstance(attr_name_or_index, int):
|
||||
if target[attr_name_or_index] != new_value:
|
||||
target[attr_name_or_index] = new_value
|
||||
elif isinstance(attr_name_or_index, str):
|
||||
# If the type matches and the current value is different from the new value,
|
||||
# update the attribute.
|
||||
if getattr(target, attr_name_or_index) != new_value:
|
||||
setattr(target, attr_name_or_index, new_value)
|
||||
else:
|
||||
logger.error("Incompatible arguments: %s, %s.", target, attr_name_or_index)
|
||||
|
||||
|
||||
def parse_list_attr_and_index(attr_string: str) -> tuple[str, int | None]:
|
||||
"""
|
||||
Parses an attribute string and extracts a potential list attribute name and its
|
||||
index.
|
||||
Logs an error if the index is not a valid digit.
|
||||
|
||||
Args:
|
||||
attr_string (str):
|
||||
The attribute string to parse. Can be a regular attribute name (e.g.,
|
||||
'attr_name') or a list attribute with an index (e.g., 'list_attr[2]').
|
||||
|
||||
Returns:
|
||||
tuple[str, Optional[int]]:
|
||||
A tuple containing the attribute name as a string and the index as an
|
||||
integer if present, otherwise None.
|
||||
|
||||
Examples:
|
||||
>>> parse_attribute_and_index('list_attr[2]')
|
||||
('list_attr', 2)
|
||||
>>> parse_attribute_and_index('attr_name')
|
||||
('attr_name', None)
|
||||
"""
|
||||
|
||||
index = None
|
||||
attr_name = attr_string
|
||||
if "[" in attr_string and attr_string.endswith("]"):
|
||||
attr_name, index_part = attr_string.split("[", 1)
|
||||
index_part = index_part.rstrip("]")
|
||||
if index_part.isdigit():
|
||||
index = int(index_part)
|
||||
else:
|
||||
logger.error("Invalid index format in key: %s", attr_name)
|
||||
return attr_name, index
|
||||
path_parts = parse_full_access_path(path)
|
||||
return get_object_by_path_parts(target_obj, path_parts)
|
||||
|
||||
|
||||
def get_component_classes() -> list[type]:
|
||||
@@ -154,12 +180,12 @@ def get_data_service_class_reference() -> Any:
|
||||
|
||||
|
||||
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)
|
||||
path_parts = parse_full_access_path(access_path)
|
||||
target_obj = get_object_by_path_parts(target_obj, path_parts[:-1])
|
||||
|
||||
# don't have to check if target_obj is dict or list as their content cannot be
|
||||
# properties -> always return False then
|
||||
return isinstance(getattr(type(target_obj), path_parts[-1], None), property)
|
||||
|
||||
|
||||
def function_has_arguments(func: Callable[..., Any]) -> bool:
|
||||
@@ -169,20 +195,4 @@ def function_has_arguments(func: Callable[..., Any]) -> bool:
|
||||
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
|
||||
return len(parameters) > 0
|
||||
|
||||
@@ -1,16 +1,58 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from copy import copy
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import click
|
||||
import socketio # type: ignore[import-untyped]
|
||||
import uvicorn.logging
|
||||
from uvicorn.config import LOGGING_CONFIG
|
||||
|
||||
import pydase.config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
|
||||
if pydase.config.OperationMode().environment == "development":
|
||||
LOG_LEVEL = logging.DEBUG
|
||||
else:
|
||||
LOG_LEVEL = logging.INFO
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"()": "pydase.utils.logging.DefaultFormatter",
|
||||
"fmt": "%(asctime)s.%(msecs)03d | %(levelprefix)s | "
|
||||
"%(name)s:%(funcName)s:%(lineno)d - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"default": {
|
||||
"formatter": "default",
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stderr",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"pydase": {"handlers": ["default"], "level": LOG_LEVEL, "propagate": False},
|
||||
"aiohttp_middlewares": {
|
||||
"handlers": ["default"],
|
||||
"level": logging.WARNING,
|
||||
"propagate": False,
|
||||
},
|
||||
"aiohttp": {
|
||||
"handlers": ["default"],
|
||||
"level": logging.INFO,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class DefaultFormatter(logging.Formatter):
|
||||
"""
|
||||
A custom log formatter class that:
|
||||
|
||||
@@ -19,6 +61,36 @@ class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
|
||||
for formatting the output, instead of the plain text message.
|
||||
"""
|
||||
|
||||
level_name_colors: ClassVar[dict[int, Callable[..., str]]] = {
|
||||
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
|
||||
logging.INFO: lambda level_name: click.style(str(level_name), fg="green"),
|
||||
logging.WARNING: lambda level_name: click.style(str(level_name), fg="yellow"),
|
||||
logging.ERROR: lambda level_name: click.style(str(level_name), fg="red"),
|
||||
logging.CRITICAL: lambda level_name: click.style(
|
||||
str(level_name), fg="bright_red"
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fmt: str | None = None,
|
||||
datefmt: str | None = None,
|
||||
style: Literal["%", "{", "$"] = "%",
|
||||
use_colors: bool | None = None,
|
||||
):
|
||||
if use_colors in (True, False):
|
||||
self.use_colors = use_colors
|
||||
else:
|
||||
self.use_colors = sys.stdout.isatty()
|
||||
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
|
||||
|
||||
def color_level_name(self, level_name: str, level_no: int) -> str:
|
||||
def default(level_name: str) -> str:
|
||||
return str(level_name)
|
||||
|
||||
func = self.level_name_colors.get(level_no, default)
|
||||
return func(level_name)
|
||||
|
||||
def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802
|
||||
recordcopy = copy(record)
|
||||
levelname = recordcopy.levelname
|
||||
@@ -47,7 +119,8 @@ class SocketIOHandler(logging.Handler):
|
||||
self._sio = sio
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return f"{record.name}:{record.funcName}:{record.lineno} - {record.message}"
|
||||
msg = record.getMessage()
|
||||
return f"{record.name}:{record.funcName}:{record.lineno} - {msg}"
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
log_entry = self.format(record)
|
||||
@@ -64,86 +137,16 @@ class SocketIOHandler(logging.Handler):
|
||||
)
|
||||
|
||||
|
||||
def setup_logging(level: str | int | None = None) -> None:
|
||||
def setup_logging() -> None:
|
||||
"""
|
||||
Configures the logging settings for the application.
|
||||
|
||||
This function sets up logging with specific formatting and colorization of log
|
||||
messages. The log level is determined based on the application's operation mode,
|
||||
with an option to override the level. By default, in a development environment, the
|
||||
log level is set to DEBUG, whereas in other environments, it is set to INFO.
|
||||
|
||||
Args:
|
||||
level (Optional[str | int]):
|
||||
A specific log level to set for the application. If None, the log level is
|
||||
determined based on the application's operation mode. Accepts standard log
|
||||
level names ('DEBUG', 'INFO', etc.) and corresponding numerical values.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
>>> import logging
|
||||
>>> setup_logging(logging.DEBUG)
|
||||
>>> setup_logging("INFO")
|
||||
```
|
||||
messages. The log level is determined based on the application's operation mode. By
|
||||
default, in a development environment, the log level is set to DEBUG, whereas in
|
||||
other environments, it is set to INFO.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.debug("Configuring pydase logging.")
|
||||
|
||||
if pydase.config.OperationMode().environment == "development":
|
||||
log_level = logging.DEBUG
|
||||
else:
|
||||
log_level = logging.INFO
|
||||
|
||||
# If a level is specified, check whether it's a string or an integer.
|
||||
if level is not None:
|
||||
if isinstance(level, str):
|
||||
# Convert known log level strings directly to their corresponding logging
|
||||
# module constants.
|
||||
level_name = level.upper() # Ensure level names are uppercase
|
||||
if hasattr(logging, level_name):
|
||||
log_level = getattr(logging, level_name)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid log level: {level}. Must be one of 'DEBUG', 'INFO', "
|
||||
"'WARNING', 'ERROR', etc."
|
||||
)
|
||||
elif isinstance(level, int):
|
||||
log_level = level # Directly use integer levels
|
||||
else:
|
||||
raise ValueError("Log level must be a string or an integer.")
|
||||
|
||||
# Set the logger's level.
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# create console handler and set level to debug
|
||||
ch = logging.StreamHandler()
|
||||
|
||||
# add formatter to ch
|
||||
ch.setFormatter(
|
||||
DefaultFormatter(
|
||||
fmt=(
|
||||
"%(asctime)s.%(msecs)03d | %(levelprefix)s | "
|
||||
"%(name)s:%(funcName)s:%(lineno)d - %(message)s"
|
||||
),
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
|
||||
# add ch to logger
|
||||
logger.addHandler(ch)
|
||||
|
||||
logger.debug("Configuring service logging.")
|
||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
|
||||
# configuring uvicorn logger
|
||||
LOGGING_CONFIG["formatters"]["default"][
|
||||
"fmt"
|
||||
] = "%(asctime)s.%(msecs)03d | %(levelprefix)s %(message)s"
|
||||
LOGGING_CONFIG["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
|
||||
LOGGING_CONFIG["formatters"]["access"]["fmt"] = (
|
||||
"%(asctime)s.%(msecs)03d | %(levelprefix)s %(client_addr)s "
|
||||
'- "%(request_line)s" %(status_code)s'
|
||||
)
|
||||
LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import enum
|
||||
import logging
|
||||
from datetime import datetime
|
||||
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
|
||||
from pydase.utils.serialization.types import (
|
||||
SerializedDatetime,
|
||||
SerializedException,
|
||||
SerializedObject,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -33,6 +39,7 @@ class Deserializer:
|
||||
"dict": cls.deserialize_dict,
|
||||
"method": cls.deserialize_method,
|
||||
"Exception": cls.deserialize_exception,
|
||||
"datetime": cls.deserialize_datetime,
|
||||
}
|
||||
|
||||
# First go through handled types (as ColouredEnum is also within the components)
|
||||
@@ -57,6 +64,10 @@ class Deserializer:
|
||||
def deserialize_quantity(cls, serialized_object: SerializedObject) -> Any:
|
||||
return u.convert_to_quantity(serialized_object["value"]) # type: ignore
|
||||
|
||||
@classmethod
|
||||
def deserialize_datetime(cls, serialized_object: SerializedDatetime) -> datetime:
|
||||
return datetime.fromisoformat(serialized_object["value"])
|
||||
|
||||
@classmethod
|
||||
def deserialize_enum(
|
||||
cls,
|
||||
@@ -88,11 +99,11 @@ class Deserializer:
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def deserialize_exception(cls, serialized_object: SerializedObject) -> NoReturn:
|
||||
def deserialize_exception(cls, serialized_object: SerializedException) -> NoReturn:
|
||||
import builtins
|
||||
|
||||
try:
|
||||
exception = getattr(builtins, serialized_object["name"]) # type: ignore
|
||||
exception = getattr(builtins, serialized_object["name"])
|
||||
except AttributeError:
|
||||
exception = type(serialized_object["name"], (Exception,), {}) # type: ignore
|
||||
raise exception(serialized_object["value"])
|
||||
@@ -108,7 +119,7 @@ class Deserializer:
|
||||
return pydase.DataService
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@classmethod # TODO: this shouldn't be a class method
|
||||
def create_attr_property(cls, serialized_attr: SerializedObject) -> property:
|
||||
attr_name = serialized_attr["full_access_path"].split(".")[-1]
|
||||
|
||||
|
||||
@@ -3,23 +3,26 @@ from __future__ import annotations
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
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.decorators import render_in_frontend
|
||||
from pydase.utils.helpers import (
|
||||
get_attribute_doc,
|
||||
get_component_classes,
|
||||
get_data_service_class_reference,
|
||||
parse_list_attr_and_index,
|
||||
render_in_frontend,
|
||||
parse_full_access_path,
|
||||
parse_serialized_key,
|
||||
)
|
||||
from pydase.utils.serialization.types import (
|
||||
DataServiceTypes,
|
||||
SerializedBool,
|
||||
SerializedDataService,
|
||||
SerializedDatetime,
|
||||
SerializedDict,
|
||||
SerializedEnum,
|
||||
SerializedException,
|
||||
@@ -48,41 +51,40 @@ class SerializationPathError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SerializationValueError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Serializer:
|
||||
@staticmethod
|
||||
def serialize_object(obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
|
||||
@classmethod
|
||||
def serialize_object(cls, obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
|
||||
result: SerializedObject
|
||||
|
||||
if isinstance(obj, Exception):
|
||||
result = Serializer._serialize_exception(obj)
|
||||
result = cls._serialize_exception(obj)
|
||||
|
||||
elif isinstance(obj, datetime):
|
||||
result = cls._serialize_datetime(obj, access_path=access_path)
|
||||
|
||||
elif isinstance(obj, AbstractDataService):
|
||||
result = Serializer._serialize_data_service(obj, access_path=access_path)
|
||||
result = cls._serialize_data_service(obj, access_path=access_path)
|
||||
|
||||
elif isinstance(obj, list):
|
||||
result = Serializer._serialize_list(obj, access_path=access_path)
|
||||
result = cls._serialize_list(obj, access_path=access_path)
|
||||
|
||||
elif isinstance(obj, dict):
|
||||
result = Serializer._serialize_dict(obj, access_path=access_path)
|
||||
result = cls._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)
|
||||
result = cls._serialize_quantity(obj, access_path=access_path)
|
||||
|
||||
# Handling for Enums
|
||||
elif isinstance(obj, Enum):
|
||||
result = Serializer._serialize_enum(obj, access_path=access_path)
|
||||
result = cls._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)
|
||||
result = cls._serialize_method(obj, access_path=access_path)
|
||||
|
||||
elif isinstance(obj, int | float | bool | str | None):
|
||||
result = Serializer._serialize_primitive(obj, access_path=access_path)
|
||||
result = cls._serialize_primitive(obj, access_path=access_path)
|
||||
|
||||
try:
|
||||
return result
|
||||
@@ -91,8 +93,9 @@ class Serializer:
|
||||
f"Could not serialized object of type {type(obj)}."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _serialize_primitive(
|
||||
cls,
|
||||
obj: float | bool | str | None,
|
||||
access_path: str,
|
||||
) -> (
|
||||
@@ -111,8 +114,18 @@ class Serializer:
|
||||
"value": obj,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_exception(obj: Exception) -> SerializedException:
|
||||
@classmethod
|
||||
def _serialize_datetime(cls, obj: datetime, access_path: str) -> SerializedDatetime:
|
||||
return {
|
||||
"type": "datetime",
|
||||
"value": str(obj),
|
||||
"doc": None,
|
||||
"full_access_path": access_path,
|
||||
"readonly": True,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_exception(cls, obj: Exception) -> SerializedException:
|
||||
return {
|
||||
"full_access_path": "",
|
||||
"doc": None,
|
||||
@@ -122,8 +135,8 @@ class Serializer:
|
||||
"name": obj.__class__.__name__,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_enum(obj: Enum, access_path: str = "") -> SerializedEnum:
|
||||
@classmethod
|
||||
def _serialize_enum(cls, obj: Enum, access_path: str = "") -> SerializedEnum:
|
||||
import pydase.components.coloured_enum
|
||||
|
||||
value = obj.name
|
||||
@@ -148,9 +161,9 @@ class Serializer:
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _serialize_quantity(
|
||||
obj: u.Quantity, access_path: str = ""
|
||||
cls, obj: u.Quantity, access_path: str = ""
|
||||
) -> SerializedQuantity:
|
||||
doc = get_attribute_doc(obj)
|
||||
value: u.QuantityDict = {"magnitude": obj.m, "unit": str(obj.u)}
|
||||
@@ -162,14 +175,17 @@ class Serializer:
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_dict(obj: dict[str, Any], access_path: str = "") -> SerializedDict:
|
||||
@classmethod
|
||||
def _serialize_dict(
|
||||
cls, 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()
|
||||
}
|
||||
value = {}
|
||||
for key, val in obj.items():
|
||||
value[key] = cls.serialize_object(
|
||||
val, access_path=f'{access_path}["{key}"]'
|
||||
)
|
||||
return {
|
||||
"full_access_path": access_path,
|
||||
"type": "dict",
|
||||
@@ -178,12 +194,12 @@ class Serializer:
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_list(obj: list[Any], access_path: str = "") -> SerializedList:
|
||||
@classmethod
|
||||
def _serialize_list(cls, 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}]")
|
||||
cls.serialize_object(o, access_path=f"{access_path}[{i}]")
|
||||
for i, o in enumerate(obj)
|
||||
]
|
||||
return {
|
||||
@@ -194,9 +210,9 @@ class Serializer:
|
||||
"doc": doc,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _serialize_method(
|
||||
obj: Callable[..., Any], access_path: str = ""
|
||||
cls, obj: Callable[..., Any], access_path: str = ""
|
||||
) -> SerializedMethod:
|
||||
readonly = True
|
||||
doc = get_attribute_doc(obj)
|
||||
@@ -229,9 +245,9 @@ class Serializer:
|
||||
"frontend_render": frontend_render,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _serialize_data_service(
|
||||
obj: AbstractDataService, access_path: str = ""
|
||||
cls, obj: AbstractDataService, access_path: str = ""
|
||||
) -> SerializedDataService:
|
||||
readonly = False
|
||||
doc = get_attribute_doc(obj)
|
||||
@@ -272,7 +288,7 @@ class Serializer:
|
||||
val = getattr(obj, key)
|
||||
|
||||
path = f"{access_path}.{key}" if access_path else key
|
||||
serialized_object = Serializer.serialize_object(val, access_path=path)
|
||||
serialized_object = cls.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:
|
||||
@@ -301,7 +317,7 @@ def dump(obj: Any) -> SerializedObject:
|
||||
|
||||
|
||||
def set_nested_value_by_path(
|
||||
serialization_dict: dict[str, SerializedObject], path: str, value: Any
|
||||
serialization_dict: dict[Any, SerializedObject], path: str, value: Any
|
||||
) -> None:
|
||||
"""
|
||||
Set a value in a nested dictionary structure, which conforms to the serialization
|
||||
@@ -322,23 +338,24 @@ def set_nested_value_by_path(
|
||||
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
|
||||
path_parts = parse_full_access_path(path)
|
||||
current_dict: dict[Any, SerializedObject] = serialization_dict
|
||||
|
||||
try:
|
||||
for path_part in parent_path_parts:
|
||||
next_level_serialized_object = get_next_level_dict_by_key(
|
||||
for path_part in path_parts[:-1]:
|
||||
next_level_serialized_object = get_container_item_by_key(
|
||||
current_dict, path_part, allow_append=False
|
||||
)
|
||||
current_dict = cast(
|
||||
dict[str, SerializedObject], next_level_serialized_object["value"]
|
||||
dict[Any, SerializedObject],
|
||||
next_level_serialized_object["value"],
|
||||
)
|
||||
|
||||
next_level_serialized_object = get_next_level_dict_by_key(
|
||||
current_dict, attr_name, allow_append=True
|
||||
next_level_serialized_object = get_container_item_by_key(
|
||||
current_dict, path_parts[-1], allow_append=True
|
||||
)
|
||||
except (SerializationPathError, SerializationValueError, KeyError) as e:
|
||||
logger.error(e)
|
||||
except (SerializationPathError, KeyError) as e:
|
||||
logger.error("Error occured trying to change %a: %s", path, e)
|
||||
return
|
||||
|
||||
if next_level_serialized_object["type"] == "method": # state change of task
|
||||
@@ -360,149 +377,182 @@ def set_nested_value_by_path(
|
||||
|
||||
|
||||
def get_nested_dict_by_path(
|
||||
serialization_dict: dict[str, SerializedObject],
|
||||
serialization_dict: dict[Any, SerializedObject],
|
||||
path: str,
|
||||
) -> SerializedObject:
|
||||
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1]
|
||||
current_dict: dict[str, SerializedObject] = serialization_dict
|
||||
path_parts = parse_full_access_path(path)
|
||||
current_dict: dict[Any, SerializedObject] = serialization_dict
|
||||
|
||||
for path_part in parent_path_parts:
|
||||
next_level_serialized_object = get_next_level_dict_by_key(
|
||||
for path_part in path_parts[:-1]:
|
||||
next_level_serialized_object = get_container_item_by_key(
|
||||
current_dict, path_part, allow_append=False
|
||||
)
|
||||
current_dict = cast(
|
||||
dict[str, SerializedObject], next_level_serialized_object["value"]
|
||||
dict[Any, SerializedObject],
|
||||
next_level_serialized_object["value"],
|
||||
)
|
||||
return get_next_level_dict_by_key(current_dict, attr_name, allow_append=False)
|
||||
return get_container_item_by_key(current_dict, path_parts[-1], allow_append=False)
|
||||
|
||||
|
||||
def get_next_level_dict_by_key(
|
||||
serialization_dict: dict[str, SerializedObject],
|
||||
attr_name: str,
|
||||
def create_empty_serialized_object() -> SerializedObject:
|
||||
"""Create a new empty serialized object."""
|
||||
|
||||
return {
|
||||
"full_access_path": "",
|
||||
"value": None,
|
||||
"type": "None",
|
||||
"doc": None,
|
||||
"readonly": False,
|
||||
}
|
||||
|
||||
|
||||
def get_or_create_item_in_container(
|
||||
container: dict[Any, SerializedObject] | list[SerializedObject],
|
||||
key: Any,
|
||||
*,
|
||||
allow_add_key: bool,
|
||||
) -> SerializedObject:
|
||||
"""Ensure the key exists in the dictionary, append if necessary and allowed."""
|
||||
|
||||
try:
|
||||
return container[key]
|
||||
except IndexError:
|
||||
if allow_add_key and key == len(container):
|
||||
cast(list[SerializedObject], container).append(
|
||||
create_empty_serialized_object()
|
||||
)
|
||||
return container[key]
|
||||
raise
|
||||
except KeyError:
|
||||
if allow_add_key:
|
||||
container[key] = create_empty_serialized_object()
|
||||
return container[key]
|
||||
raise
|
||||
|
||||
|
||||
def get_container_item_by_key(
|
||||
container: dict[Any, SerializedObject] | list[SerializedObject],
|
||||
key: str,
|
||||
*,
|
||||
allow_append: bool = False,
|
||||
) -> SerializedObject:
|
||||
"""
|
||||
Retrieve a nested dictionary entry or list item from a data structure serialized
|
||||
with `pydase.utils.serializer.Serializer`.
|
||||
Retrieve an item from a container specified by the passed key. Add an item to the
|
||||
container if allow_append is set to True.
|
||||
|
||||
If specified keys or indexes do not exist, the function can append new elements to
|
||||
dictionaries and to lists if `allow_append` is True and the missing element is
|
||||
exactly the next sequential index (for lists).
|
||||
|
||||
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.
|
||||
container: dict[str, SerializedObject] | list[SerializedObject]
|
||||
The container representing serialized data.
|
||||
key: str
|
||||
The key name representing the attribute in the dictionary, which may include
|
||||
direct keys or indexes (e.g., 'attr_name', '["key"]' or '[0]').
|
||||
allow_append: bool
|
||||
Flag to allow appending a new entry if the specified index is out of range
|
||||
by exactly one position.
|
||||
|
||||
Returns:
|
||||
The dictionary or list item corresponding to the attribute and index.
|
||||
SerializedObject
|
||||
The dictionary or list item corresponding to the specified 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.
|
||||
SerializationPathError:
|
||||
If the path composed of `attr_name` and any specified index is invalid, or
|
||||
leads to an IndexError or KeyError. This error is also raised if an attempt
|
||||
to access a nonexistent key or index occurs without permission to append.
|
||||
"""
|
||||
# Check if the key contains an index part like 'attr_name[<index>]'
|
||||
attr_name, index = parse_list_attr_and_index(attr_name)
|
||||
processed_key = parse_serialized_key(key)
|
||||
|
||||
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 get_or_create_item_in_container(
|
||||
container, processed_key, allow_add_key=allow_append
|
||||
)
|
||||
|
||||
return next_level_serialized_object
|
||||
except IndexError as e:
|
||||
raise SerializationPathError(f"Index '{processed_key}': {e}")
|
||||
except KeyError as e:
|
||||
raise SerializationPathError(f"Key '{processed_key}': {e}")
|
||||
|
||||
|
||||
def generate_serialized_data_paths(
|
||||
data: dict[str, Any], parent_path: str = ""
|
||||
def get_data_paths_from_serialized_object( # noqa: C901
|
||||
serialized_obj: SerializedObject,
|
||||
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.
|
||||
Recursively extracts full access paths from a serialized object.
|
||||
|
||||
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.
|
||||
serialized_obj (SerializedObject):
|
||||
The dictionary representing the serialization of an object. Produced by
|
||||
`pydase.utils.serializer.Serializer`.
|
||||
|
||||
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.
|
||||
list[str]:
|
||||
A list of strings, each representing a full access path in the serialized
|
||||
object.
|
||||
"""
|
||||
|
||||
paths: list[str] = []
|
||||
|
||||
if isinstance(serialized_obj["value"], list):
|
||||
for index, value in enumerate(serialized_obj["value"]):
|
||||
new_path = f"{parent_path}[{index}]"
|
||||
paths.append(new_path)
|
||||
if serialized_dict_is_nested_object(value):
|
||||
paths.extend(get_data_paths_from_serialized_object(value, new_path))
|
||||
|
||||
elif serialized_dict_is_nested_object(serialized_obj):
|
||||
for key, value in cast(
|
||||
dict[str, SerializedObject], serialized_obj["value"]
|
||||
).items():
|
||||
# Serialized dictionaries need to have a different new_path than nested
|
||||
# classes
|
||||
if serialized_obj["type"] == "dict":
|
||||
processed_key = key
|
||||
if isinstance(key, str):
|
||||
processed_key = f'"{key}"'
|
||||
new_path = f"{parent_path}[{processed_key}]"
|
||||
else:
|
||||
new_path = f"{parent_path}.{key}" if parent_path != "" else key
|
||||
|
||||
paths.append(new_path)
|
||||
if serialized_dict_is_nested_object(value):
|
||||
paths.extend(get_data_paths_from_serialized_object(value, new_path))
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def generate_serialized_data_paths(
|
||||
data: dict[str, SerializedObject],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Recursively extracts full access paths from a serialized DataService class instance.
|
||||
|
||||
Args:
|
||||
data (dict[str, SerializedObject]):
|
||||
The value of the "value" key of a serialized DataService class instance.
|
||||
|
||||
Returns:
|
||||
list[str]:
|
||||
A list of strings, each representing a full access path in the serialized
|
||||
object.
|
||||
"""
|
||||
|
||||
paths: list[str] = []
|
||||
|
||||
for key, value in data.items():
|
||||
new_path = f"{parent_path}.{key}" if parent_path else key
|
||||
paths.append(new_path)
|
||||
paths.append(key)
|
||||
|
||||
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))
|
||||
paths.extend(get_data_paths_from_serialized_object(value, key))
|
||||
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)
|
||||
value = serialized_dict["value"]
|
||||
# We are excluding Quantity here as the value corresponding to the "value" key is
|
||||
# a dictionary of the form {"magnitude": ..., "unit": ...}
|
||||
return serialized_dict["type"] != "Quantity" and (isinstance(value, dict | list))
|
||||
|
||||
@@ -45,6 +45,11 @@ class SerializedString(SerializedObjectBase):
|
||||
type: Literal["str"]
|
||||
|
||||
|
||||
class SerializedDatetime(SerializedObjectBase):
|
||||
type: Literal["datetime"]
|
||||
value: str
|
||||
|
||||
|
||||
class SerializedEnum(SerializedObjectBase):
|
||||
name: str
|
||||
value: str
|
||||
@@ -107,6 +112,7 @@ SerializedObject = (
|
||||
| SerializedFloat
|
||||
| SerializedInteger
|
||||
| SerializedString
|
||||
| SerializedDatetime
|
||||
| SerializedList
|
||||
| SerializedDict
|
||||
| SerializedNoneType
|
||||
|
||||
@@ -7,11 +7,13 @@ import pytest
|
||||
from pydase.client.proxy_loader import ProxyAttributeError
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.fixture(scope="module")
|
||||
def pydase_client() -> Generator[pydase.Client, None, Any]:
|
||||
class SubService(pydase.DataService):
|
||||
name = "SubService"
|
||||
|
||||
subservice_instance = SubService()
|
||||
|
||||
class MyService(pydase.DataService):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -19,6 +21,10 @@ def pydase_client() -> Generator[pydase.Client, None, Any]:
|
||||
self._my_property = 12.1
|
||||
self.sub_service = SubService()
|
||||
self.list_attr = [1, 2]
|
||||
self.dict_attr = {
|
||||
"foo": subservice_instance,
|
||||
"dotted.key": subservice_instance,
|
||||
}
|
||||
|
||||
@property
|
||||
def my_property(self) -> float:
|
||||
@@ -39,7 +45,7 @@ def pydase_client() -> Generator[pydase.Client, None, Any]:
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
client = pydase.Client(hostname="localhost", port=9999)
|
||||
client = pydase.Client(url="ws://localhost:9999")
|
||||
|
||||
yield client
|
||||
|
||||
@@ -104,11 +110,27 @@ def test_list(pydase_client: pydase.Client) -> None:
|
||||
assert pydase_client.proxy.list_attr == []
|
||||
|
||||
|
||||
def test_dict(pydase_client: pydase.Client) -> None:
|
||||
pydase_client.proxy.dict_attr["foo"].name = "foo"
|
||||
assert pydase_client.proxy.dict_attr["foo"].name == "foo"
|
||||
assert pydase_client.proxy.dict_attr["dotted.key"].name == "foo"
|
||||
|
||||
# pop will not return anything as the server object was deleted
|
||||
assert pydase_client.proxy.dict_attr.pop("dotted.key") is None
|
||||
|
||||
# pop will remove the dictionary entry on the server
|
||||
assert list(pydase_client.proxy.dict_attr.keys()) == ["foo"]
|
||||
|
||||
pydase_client.proxy.dict_attr["non_existent_key"] = "Hello"
|
||||
assert pydase_client.proxy.dict_attr["non_existent_key"] == "Hello"
|
||||
|
||||
|
||||
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 [
|
||||
"dict_attr",
|
||||
"list_attr",
|
||||
"my_method",
|
||||
"my_property",
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pydase
|
||||
import pydase.components.device_connection
|
||||
import pytest
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio(scope="function")
|
||||
async def test_reconnection(caplog: LogCaptureFixture) -> None:
|
||||
class MyService(pydase.components.device_connection.DeviceConnection):
|
||||
def __init__(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
|
||||
import pydase
|
||||
import pydase.components
|
||||
@@ -7,8 +6,6 @@ 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):
|
||||
|
||||
@@ -8,7 +8,7 @@ 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__)
|
||||
logger = logging.getLogger("pydase")
|
||||
|
||||
|
||||
def test_number_slider(caplog: LogCaptureFixture) -> None:
|
||||
|
||||
11
tests/conftest.py
Normal file
11
tests/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caplog(caplog: pytest.LogCaptureFixture):
|
||||
logger = logging.getLogger("pydase")
|
||||
logger.propagate = True
|
||||
|
||||
yield caplog
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user