85 Commits

Author SHA1 Message Date
Mose Müller
0343abd0b0 Merge pull request #91 from tiqi-group/fix/load_from_file
Fix/load from file
2024-01-09 16:39:59 +01:00
Mose Müller
0c149b85b5 updates version to v0.5.1 2024-01-09 16:39:12 +01:00
Mose Müller
0e331e58ff adds tests for server to check if loading from file is working 2024-01-09 16:36:35 +01:00
Mose Müller
45135927e6 initialises observer before loading state from json file 2024-01-09 16:21:57 +01:00
Mose Müller
d3866010a8 updates version to v0.5.0 2024-01-09 10:01:48 +01:00
Mose Müller
3c0f019af8 Merge pull request #48 from tiqi-group/10-frontend-user-should-be-able-to-add-custom-display-names
Feat: adds web settings file containing display name configuration
2024-01-08 17:17:06 +01:00
Mose Müller
8aa7fd31f8 updates Adding_Components guide 2024-01-08 17:11:55 +01:00
Mose Müller
c9ff3db9e9 Updates Readme 2024-01-08 16:57:46 +01:00
Mose Müller
9e77bae5e7 renaming config option from generate_new_web_settings to generate_web_settings 2024-01-08 16:49:36 +01:00
Mose Müller
6a6d1b27aa updates sio_setup tests (removes mock objects) 2024-01-08 16:35:32 +01:00
Mose Müller
2d3e7d8c1b adds web_server tests 2024-01-08 16:32:36 +01:00
Mose Müller
c7b039beb7 replaces method with read-only property 2024-01-08 16:32:25 +01:00
Mose Müller
62e647c667 generate_new_web_settings will now append to existing config file (not overwrite entries) 2024-01-08 15:45:02 +01:00
Mose Müller
6382be5735 removes index from generated web settings file (move to other PR) 2024-01-08 15:27:46 +01:00
Mose Müller
ea158bf8de adds sio_setup tests 2024-01-08 15:11:03 +01:00
Mose Müller
63ad6d7b93 removes web_settings sio event 2024-01-08 15:10:40 +01:00
Mose Müller
b8e758e479 updates docstring 2024-01-08 15:09:23 +01:00
Mose Müller
a12a708385 udpates Readme 2023-12-21 16:10:44 +01:00
Mose Müller
edb24f5439 Server uses ServiceConfig for web/rpc port default values, configurable through env variables 2023-12-21 15:48:28 +01:00
Mose Müller
2a2b7b800d updates ServiceConfig class 2023-12-21 15:48:28 +01:00
Mose Müller
b6b20c21e4 updates WebServer options to directly default to config class values 2023-12-21 15:25:57 +01:00
Mose Müller
53be794a3c renaming service configuration dir parameter 2023-12-21 13:36:08 +01:00
Mose Müller
a303ba7f0b adds pytest-asyncio to dev dependencies 2023-12-21 13:25:54 +01:00
Mose Müller
2461f85ef0 adds test for starting and stopping tasks 2023-12-21 13:24:54 +01:00
Mose Müller
ca41e12014 updates server to use asyncio.run 2023-12-21 13:13:45 +01:00
Mose Müller
f69723dd58 updates some tests to have a running event loop 2023-12-21 13:11:49 +01:00
Mose Müller
c733026522 fixes task manager loop 2023-12-21 13:11:17 +01:00
Mose Müller
316ce5c7e7 updates type hints 2023-12-21 11:33:00 +01:00
Mose Müller
43c3f746fa npm run build 2023-12-21 11:00:23 +01:00
Mose Müller
fea96c044c removes start_task wrapper 2023-12-21 11:00:19 +01:00
Mose Müller
6543bc6b39 rewrites web server to hot-reload the web settings from the settings file 2023-12-21 10:32:37 +01:00
Mose Müller
ef36c01407 updates serializer and state_manager to deal with serialized methods
I need to get the access path from methods when generating the
web_settings.json file. Thus, methods will not be skipped anymore,
instead, the method checking if the attribute is loadable makes the
distinction.
2023-12-21 10:31:02 +01:00
Mose Müller
9d90fd2b81 displayName of components is now taken from WebSettingsContext 2023-12-21 10:30:21 +01:00
Mose Müller
9fc6d6f910 updates WebSettings.tsx 2023-12-21 10:04:55 +01:00
Mose Müller
805e270107 updates sio_setup to not expect DataService in the parent path 2023-12-21 10:04:55 +01:00
Mose Müller
8e3a1694ce updates frontend components to not have DataService in the fullAccessPath 2023-12-21 10:03:17 +01:00
Mose Müller
32a1d14a40 changes display_name to displayName in web settings 2023-12-21 07:48:03 +01:00
Mose Müller
8940a61d4e adds WebSettings context 2023-12-21 07:48:03 +01:00
Mose Müller
393bde3280 frontend: removes unused stateRef 2023-12-20 16:57:28 +01:00
Mose Müller
eb2da1c5dc adds index to web_settings 2023-12-20 16:52:28 +01:00
Mose Müller
e7b73a99da WebServer uses serializer method now to generate serialized data paths 2023-12-20 10:21:48 +01:00
Mose Müller
392831e0fd uses new serializer method to check if attribute is loadable 2023-12-20 10:16:01 +01:00
Mose Müller
32bda8d910 updates generate_serialized_data_paths method, adds tests 2023-12-20 10:15:25 +01:00
Mose Müller
e106cc4927 adds NumberSlider to state manager tests 2023-12-20 10:14:35 +01:00
Mose Müller
464478cda9 removes helper function to create config folder 2023-12-20 10:14:07 +01:00
Mose Müller
97c026afe0 adds function to initialise web settings (also creating settings if requested), creates web-settings fastapi endpoint 2023-12-19 16:38:46 +01:00
Mose Müller
2f5c415cd5 updates webserver docstring 2023-12-19 16:21:03 +01:00
Mose Müller
728eea09f6 adds configs to WebServer (can also be passed to constructor) 2023-12-19 16:16:13 +01:00
Mose Müller
e3eaf5ffe2 adds ServiceConfig and WebServerConfig 2023-12-19 16:11:32 +01:00
Mose Müller
1dc3b62060 removes usage of rpyc-specific method in WebServer 2023-12-19 14:57:06 +01:00
Mose Müller
8214faf5cb removes ForkingServer rpyc configuration 2023-12-19 13:07:25 +01:00
Mose Müller
232eb53249 renames file 2023-12-19 12:59:18 +01:00
Mose Müller
439f514ea5 fixes WebServer 2023-12-19 12:58:32 +01:00
Mose Müller
c7d63f5139 replaces SioServerWrapper with setup function 2023-12-19 12:58:32 +01:00
Mose Müller
f64b5c35ab renaming sio_server file 2023-12-19 12:56:03 +01:00
Mose Müller
bb4de988e9 updates Server docstring 2023-12-19 11:44:50 +01:00
Mose Müller
36a8e916f6 updates kwargs passed to servers 2023-12-19 11:44:36 +01:00
Mose Müller
1a00f37372 fixes exception emission to web clients 2023-12-19 11:43:29 +01:00
Mose Müller
6630173cec fixes mypy issue 2023-12-19 11:43:29 +01:00
Mose Müller
08a62b2119 updates WebServer docstring 2023-12-19 11:43:29 +01:00
Mose Müller
37ae34ecc0 makes WebServer functions protected 2023-12-19 11:42:39 +01:00
Mose Müller
8b78099178 udpates AdditionalServerProtocol and WebServer
updates WebServer
2023-12-19 11:42:39 +01:00
Mose Müller
3186e04cc1 creates web_server module with WebServer complying with AdditionalServerProtocol 2023-12-19 10:59:24 +01:00
Mose Müller
055acbe591 using get_running_loop instead of soon-deprecated get_event_loop 2023-12-19 10:55:38 +01:00
Mose Müller
0d08c2ce0d removes unnecessary condition check 2023-12-19 10:55:07 +01:00
Mose Müller
68cc5b693e adds socketio event for web_settings 2023-12-18 12:04:33 +01:00
Mose Müller
4fcd5b4d44 adds helper function to create config folder 2023-12-18 12:04:31 +01:00
Mose Müller
9cbc639d0f updates vscode settings 2023-12-18 12:03:20 +01:00
Mose Müller
a48cce32e4 chore: formatting tests 2023-12-18 11:59:20 +01:00
Mose Müller
8c24f5dd67 updates version number 2023-12-13 11:29:28 +01:00
Mose Müller
1c4a878aa8 Merge pull request #86 from tiqi-group/9-add-units-support-for-numberslider
updates Readme explaining how to use units with number sliders
2023-12-13 11:25:58 +01:00
Mose Müller
31967d0d43 updates Readme explaining how to use units with number sliders 2023-12-13 11:23:44 +01:00
Mose Müller
b4edc31030 Merge pull request #84 from tiqi-group/75-numberslider-component-is-not-working
75 numberslider component is not working
2023-12-13 11:12:56 +01:00
Mose Müller
ff7c92547e updates Readme 2023-12-13 11:09:18 +01:00
Mose Müller
fab91f3221 updates number slider test file 2023-12-13 10:39:26 +01:00
Mose Müller
bd77995d96 npm run build 2023-12-13 10:36:00 +01:00
Mose Müller
729f375901 adds support for quantities in slider component (passing object instead of number) 2023-12-13 10:35:28 +01:00
Mose Müller
e643dd6f5c adds number object types to NumberComponent 2023-12-13 10:34:32 +01:00
Mose Müller
53f4cf6690 removes setters for min, max and step_size in NumberSlider, updates docstring 2023-12-13 09:30:21 +01:00
Mose Müller
c0c8591fc4 updates number slider component 2023-12-11 17:46:08 +01:00
Mose Müller
13fba6d3d6 npm run build 2023-12-11 17:30:12 +01:00
Mose Müller
dc4c9ff58f removes unused customEmitUpdate prop from NumberComponent 2023-12-11 17:30:12 +01:00
Mose Müller
83cd07feee updates SliderComponent to emit attribute updates (instead of full state dict) 2023-12-11 17:30:12 +01:00
Mose Müller
09f73a2b1d Merge pull request #83 from tiqi-group/feat/improve_data_service_serialization
fixed serialization of class deriving from class which derives from DataService
2023-12-11 17:28:11 +01:00
Mose Müller
88886e3fd6 fixed serialization of class deriving from class which derives from DataService 2023-12-11 17:25:03 +01:00
51 changed files with 1648 additions and 665 deletions

View File

@@ -2,6 +2,7 @@
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance"
"ms-python.vscode-pylance",
"ms-python.mypy-type-checker"
]
}

2
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"type": "python",
"request": "launch",
"module": "foo",
"justMyCode": true,
"justMyCode": false,
"env": {
"ENVIRONMENT": "development"
}

View File

@@ -8,8 +8,8 @@
"editor.tabSize": 4,
"editor.detectIndentation": false,
"editor.codeActionsOnSave": {
"source.organizeImports": true,
"source.fixAll": true
"source.organizeImports": "explicit",
"source.fixAll": "explicit"
}
},
"[yaml]": {
@@ -23,7 +23,7 @@
"editor.formatOnType": false,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
}
}
}

282
README.md
View File

@@ -21,12 +21,16 @@
- [`NumberSlider`](#numberslider)
- [`ColouredEnum`](#colouredenum)
- [Extending with New Components](#extending-with-new-components)
- [Customizing Web Interface Style](#customizing-web-interface-style)
- [Understanding Service Persistence](#understanding-service-persistence)
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
- [Understanding Units in pydase](#understanding-units-in-pydase)
- [Changing the Log Level](#changing-the-log-level)
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
- [Customizing the Web Interface](#customizing-the-web-interface)
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
- [Tailoring Frontend Component Layout](#tailoring-frontend-component-layout)
- [Logging in pydase](#logging-in-pydase)
- [Changing the Log Level](#changing-the-log-level)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
@@ -42,8 +46,7 @@
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
- [Support for units](#understanding-units-in-pydase)
<!-- * Event-based callback functionality for real-time updates
- Support for additional servers for specific use-cases -->
<!-- Support for additional servers for specific use-cases -->
## Installation
@@ -286,26 +289,172 @@ if __name__ == "__main__":
#### `NumberSlider`
This component provides an interactive slider interface for adjusting numerical values on the frontend. It supports both floats and integers. The values adjusted on the frontend are synchronized with the backend in real-time, ensuring consistent data representation.
The `NumberSlider` component in the `pydase` package provides an interactive slider interface for adjusting numerical values on the frontend. It is designed to support both numbers and quantities and ensures that values adjusted on the frontend are synchronized with the backend.
The slider can be customized with initial values, minimum and maximum limits, and step sizes to fit various use cases.
To utilize the `NumberSlider`, users should implement a class that derives from `NumberSlider`. This class can then define the initial values, minimum and maximum limits, step sizes, and additional logic as needed.
Here's an example of how to implement and use a custom slider:
```python
import pydase
from pydase.components import NumberSlider
import pydase.components
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: float = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
) -> None:
super().__init__(value, min_, max_, step_size)
@property
def min(self) -> float:
return self._min
@min.setter
def min(self, value: float) -> None:
self._min = value
@property
def max(self) -> float:
return self._max
@max.setter
def max(self, value: float) -> None:
self._max = value
@property
def step_size(self) -> float:
return self._step_size
@step_size.setter
def step_size(self, value: float) -> None:
self._step_size = value
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, value: float) -> None:
if value < self._min or value > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = value
class MyService(pydase.DataService):
slider = NumberSlider(value=3.5, min=0, max=10, step_size=0.1, type="float")
def __init__(self) -> None:
super().__init__()
self.voltage = MySlider()
if __name__ == "__main__":
service = MyService()
pydase.Server(service).run()
service_instance = MyService()
service_instance.voltage.value = 5
print(service_instance.voltage.value) # Output: 5
pydase.Server(service_instance).run()
```
In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value` properties. Users can make any of these properties read-only by omitting the corresponding setter method.
![Slider Component](docs/images/Slider_component.png)
- Accessing parent class resources in `NumberSlider`
In scenarios where you need the slider component to interact with or access resources from its parent class, you can achieve this by passing a callback function to it. This method avoids directly passing the entire parent class instance (`self`) and offers a more encapsulated approach. The callback function can be designed to utilize specific attributes or methods of the parent class, allowing the slider to perform actions or retrieve data in response to slider events.
Here's an illustrative example:
```python
from collections.abc import Callable
import pydase
import pydase.components
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: float,
on_change: Callable[[float], None],
) -> None:
super().__init__(value=value)
self._on_change = on_change
# ... other properties ...
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, new_value: float) -> None:
if new_value < self._min or new_value > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = new_value
self._on_change(new_value)
class MyService(pydase.DataService):
def __init__(self) -> None:
self.voltage = MySlider(
5,
on_change=self.handle_voltage_change,
)
def handle_voltage_change(self, new_voltage: float) -> None:
print(f"Voltage changed to: {new_voltage}")
# Additional logic here
if __name__ == "__main__":
service_instance = MyService()
my_service.voltage.value = 7 # Output: "Voltage changed to: 7"
pydase.Server(service_instance).run()
```
- Incorporating units in `NumberSlider`
The `NumberSlider` is capable of displaying units alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.
Here's how to implement a `NumberSlider` with unit display:
```python
import pydase
import pydase.components
import pydase.units as u
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: u.Quantity = 0.0 * u.units.V,
) -> None:
super().__init__(value)
@property
def value(self) -> u.Quantity:
return self._value
@value.setter
def value(self, value: u.Quantity) -> None:
if value.m < self._min or value.m > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = value
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.voltage = MySlider()
if __name__ == "__main__":
service_instance = MyService()
service_instance.voltage.value = 5 * u.units.V
print(service_instance.voltage.value) # Output: 5 V
pydase.Server(service_instance).run()
```
#### `ColouredEnum`
This component provides a way to visually represent different states or categories in a data service using colour-coded options. It behaves similarly to a standard `Enum`, but the values encode colours in a format understood by CSS. The colours can be defined using various methods like Hexadecimal, RGB, HSL, and more.
@@ -352,31 +501,6 @@ Users can also extend the library by creating custom components. This involves d
<!-- Component User Guide End -->
## Customizing Web Interface Style
`pydase` allows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
Here's how you can use this feature:
1. Prepare your custom CSS file with the desired styles.
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
```python
from pydase import Server, DataService
class Device(DataService):
# ... your service definition ...
if __name__ == "__main__":
service = MyService()
server = Server(service, css="path/to/your/custom.css").run()
```
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
## Understanding Service Persistence
`pydase` allows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.
@@ -531,6 +655,78 @@ if __name__ == "__main__":
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
## Configuring pydase via Environment Variables
Configuring `pydase` through environment variables enhances flexibility, security, and reusability. This approach allows for easy adaptation of services across different environments without code changes, promoting scalability and maintainability. With that, it simplifies deployment processes and facilitates centralized configuration management. Moreover, environment variables enable separation of configuration from code, aiding in secure and collaborative development.
`pydase` offers various configurable options:
- **`ENVIRONMENT`**: Sets the operation mode to either "development" or "production". Affects logging behaviour (see [logging section](#logging-in-pydase)).
- **`SERVICE_CONFIG_DIR`**: Specifies the directory for service configuration files, like `web_settings.json`. This directory can also be used to hold user-defined configuration files. Default is the `config` folder in the service root folder. The variable can be accessed through:
```python
import pydase.config
pydase.config.ServiceConfig().config_dir
```
- **`SERVICE_WEB_PORT`**: Defines the port number for the web server. This has to be different for each services running on the same host. Default is 8001.
- **`SERVICE_RPC_PORT`**: Defines the port number for the rpc server. This has to be different for each services running on the same host. Default is 18871.
- **`GENERATE_WEB_SETTINGS`**: When set to true, generates / updates the `web_settings.json` file. If the file already exists, only new entries are appended.
Some of those settings can also be altered directly in code when initializing the server:
```python
import pathlib
from pydase import Server
from your_service_module import YourService
server = Server(
YourService(),
web_port=8080,
rpc_port=18880,
config_dir=pathlib.Path("other_config_dir"), # note that you need to provide an argument of type pathlib.Path
generate_web_settings=True
).run()
```
## Customizing the Web Interface
### Enhancing the Web Interface Style with Custom CSS
`pydase` allows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
Here's how you can use this feature:
1. Prepare your custom CSS file with the desired styles.
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
```python
from pydase import Server, DataService
class MyService(DataService):
# ... your service definition ...
if __name__ == "__main__":
service = MyService()
server = Server(service, css="path/to/your/custom.css").run()
```
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
### Tailoring Frontend Component Layout
`pydase` enables users to customize the frontend layout via the `web_settings.json` file. Each key in the file corresponds to the full access path of public attributes, properties, and methods of the exposed service, using dot-notation.
- **Custom Display Names**: Modify the `"displayName"` value in the file to change how each component appears in the frontend.
<!-- - **Adjustable Component Order**: The `"index"` 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).
## Logging in pydase
The `pydase` library organizes its loggers on a per-module basis, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library.
@@ -542,34 +738,34 @@ You have two primary ways to adjust the log levels in `pydase`:
1. directly targeting `pydase` loggers
You can set the log level for any `pydase` logger directly in your code. This method is useful for fine-tuning logging levels for specific modules within `pydase`. For instance, if you want to change the log level of the main `pydase` logger or target a submodule like `pydase.data_service`, you can do so as follows:
```python
# <your_script.py>
import logging
# Set the log level for the main pydase logger
logging.getLogger("pydase").setLevel(logging.INFO)
# Optionally, target a specific submodule logger
# logging.getLogger("pydase.data_service").setLevel(logging.DEBUG)
# Your logger for the current script
logger = logging.getLogger(__name__)
logger.info("My info message.")
```
This approach allows for specific control over different parts of the `pydase` library, depending on your logging needs.
2. using the `ENVIRONMENT` environment variable
For a more global setting that affects the entire `pydase` library, you can utilize the `ENVIRONMENT` environment variable. Setting this variable to "production" will configure all `pydase` loggers to only log messages of level "INFO" and above, filtering out more verbose logging. This is particularly useful for production environments where excessive logging can be overwhelming or unnecessary.
```bash
ENVIRONMENT="production" python -m <module_using_pydase>
```
In the absence of this setting, the default behavior is to log everything of level "DEBUG" and above, suitable for development environments where more detailed logs are beneficial.
**Note**: It is recommended to avoid calling the `pydase.utils.logging.setup_logging` function directly, as this may result in duplicated logging messages.
## Documentation

View File

@@ -111,6 +111,7 @@ import { setAttribute, runMethod } from '../socket'; // use this when your comp
// or runs a method, respectively
import { DocStringComponent } from './DocStringComponent';
import React, { useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
@@ -119,7 +120,7 @@ import { LevelName } from './NotificationsComponent';
interface ImageComponentProps {
name: string;
parentPath: string;
parentPath?: string;
readOnly: boolean;
docString: string;
addNotification: (message: string, levelname?: LevelName) => void;
@@ -133,9 +134,17 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
const renderCount = useRef(0);
const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
const fullAccessPath = parentPath.concat('.' + name);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
// Web settings contain the user-defined display name of the components (and possibly more later)
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
});
@@ -156,7 +165,7 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{name} {open ? <ChevronDown /> : <ChevronRight />}
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
@@ -206,6 +215,7 @@ React components in the frontend often need to send updates to the backend, part
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
// ...
const { name, parentPath, value } = props;
let displayName = ... // to access the user-defined display name
const setChecked = (checked: boolean) => {
setAttribute(name, parentPath, checked);
@@ -217,7 +227,7 @@ React components in the frontend often need to send updates to the backend, part
value={parentPath}
// ... other props
onChange={(e) => setChecked(e.currentTarget.checked)}>
<p>{name}</p>
{displayName}
</ToggleButton>
);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { useCallback, useEffect, useReducer, useState } from 'react';
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
import { hostname, port, socket } from './socket';
import {
@@ -13,6 +13,7 @@ import {
} from './components/NotificationsComponent';
import { ConnectionToast } from './components/ConnectionToast';
import { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
import { WebSettingsContext, WebSetting } from './WebSettings';
type Action =
| { type: 'SET_DATA'; data: State }
@@ -42,18 +43,13 @@ const reducer = (state: State, action: Action): State => {
};
const App = () => {
const [state, dispatch] = useReducer(reducer, null);
const stateRef = useRef(state); // Declare a reference to hold the current state
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [connectionStatus, setConnectionStatus] = useState('connecting');
// Keep the state reference up to date
useEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(() => {
// Allow the user to add a custom css file
fetch(`http://${hostname}:${port}/custom.css`)
@@ -74,6 +70,9 @@ const App = () => {
fetch(`http://${hostname}:${port}/service-properties`)
.then((response) => response.json())
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
fetch(`http://${hostname}:${port}/web-settings`)
.then((response) => response.json())
.then((data: Record<string, WebSetting>) => setWebSettings(data));
setConnectionStatus('connected');
});
socket.on('disconnect', () => {
@@ -184,12 +183,14 @@ const App = () => {
</Offcanvas>
<div className="App navbarOffset">
<DataServiceComponent
name={''}
props={state as DataServiceJSON}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
<WebSettingsContext.Provider value={webSettings}>
<DataServiceComponent
name={''}
props={state as DataServiceJSON}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
</WebSettingsContext.Provider>
</div>
<ConnectionToast connectionStatus={connectionStatus} />
</>

View File

@@ -0,0 +1,8 @@
import { createContext } from 'react';
export const WebSettingsContext = createContext<Record<string, WebSetting>>({});
export type WebSetting = {
displayName: string;
index: number;
};

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useRef } from 'react';
import React, { useContext, useEffect, useRef } from 'react';
import { runMethod } from '../socket';
import { InputGroup, Form, Button } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
import { WebSettingsContext } from '../WebSettings';
interface AsyncMethodProps {
name: string;
@@ -19,7 +20,14 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const { name, parentPath, docString, value: runningTask, addNotification } = props;
const renderCount = useRef(0);
const formRef = useRef(null);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -95,7 +103,7 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
<div>Render count: {renderCount.current}</div>
)}
<h5>
Function: {name}
Function: {displayName}
<DocStringComponent docString={docString} />
</h5>
<Form onSubmit={execute} ref={formRef}>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import React, { useContext, useEffect, useRef } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
@@ -16,10 +17,16 @@ interface ButtonComponentProps {
}
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const { name, parentPath, value, readOnly, docString, mapping, addNotification } =
props;
const buttonName = mapping ? (value ? mapping[0] : mapping[1]) : name;
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const { name, parentPath, value, readOnly, docString, addNotification } = props;
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
const renderCount = useRef(0);
@@ -50,7 +57,7 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
value={parentPath}
disabled={readOnly}
onChange={(e) => setChecked(e.currentTarget.checked)}>
{buttonName}
{displayName}
</ToggleButton>
</div>
);

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import React, { useContext, useEffect, useRef } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
@@ -26,7 +27,14 @@ export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentPro
addNotification
} = props;
const renderCount = useRef(0);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -48,7 +56,7 @@ export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentPro
<DocStringComponent docString={docString} />
<Row>
<Col className="d-flex align-items-center">
<InputGroup.Text>{name}</InputGroup.Text>
<InputGroup.Text>{displayName}</InputGroup.Text>
{readOnly ? (
// Display the Form.Control when readOnly is true
<Form.Control

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useContext, useState } from 'react';
import React from 'react';
import { Card, Collapse } from 'react-bootstrap';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { Attribute, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
import { WebSettingsContext } from '../WebSettings';
type DataServiceProps = {
name: string;
@@ -20,17 +21,24 @@ export const DataServiceComponent = React.memo(
({
name,
props,
parentPath = 'DataService',
parentPath = '',
isInstantUpdate,
addNotification
}: DataServiceProps) => {
const [open, setOpen] = useState(true);
let fullAccessPath = parentPath;
if (name) {
fullAccessPath = parentPath.concat('.' + name);
fullAccessPath = [parentPath, name].filter((element) => element).join('.');
}
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = fullAccessPath;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
return (
<div className="dataServiceComponent" id={id}>
<Card className="mb-3">
@@ -38,7 +46,7 @@ export const DataServiceComponent = React.memo(
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{fullAccessPath} {open ? <ChevronDown /> : <ChevronRight />}
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useRef } from 'react';
import React, { useContext, useEffect, useRef } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
@@ -24,6 +26,14 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
} = props;
const renderCount = useRef(0);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -38,14 +48,14 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
};
return (
<div className={'enumComponent'} id={parentPath.concat('.' + name)}>
<div className={'enumComponent'} id={id}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} />
<Row>
<Col className="d-flex align-items-center">
<InputGroup.Text>{name}</InputGroup.Text>
<InputGroup.Text>{displayName}</InputGroup.Text>
<Form.Select
aria-label="Default select example"
value={value}

View File

@@ -97,10 +97,10 @@ export const GenericComponent = React.memo(
parentPath={parentPath}
docString={attribute.doc}
readOnly={attribute.readonly}
value={attribute.value['value']['value']}
min={attribute.value['min']['value']}
max={attribute.value['max']['value']}
stepSize={attribute.value['step_size']['value']}
value={attribute.value['value']}
min={attribute.value['min']}
max={attribute.value['max']}
stepSize={attribute.value['step_size']}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
@@ -186,7 +186,6 @@ export const GenericComponent = React.memo(
/>
);
} else if (attribute.type === 'ColouredEnum') {
console.log(attribute);
return (
<ColouredEnumComponent
name={name}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
@@ -20,7 +21,14 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
const renderCount = useRef(0);
const [open, setOpen] = useState(true);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -40,7 +48,7 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{name} {open ? <ChevronDown /> : <ChevronRight />}
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>

View File

@@ -18,7 +18,8 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
props;
const renderCount = useRef(0);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
useEffect(() => {
renderCount.current++;

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { runMethod } from '../socket';
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
@@ -21,7 +22,14 @@ export const MethodComponent = React.memo((props: MethodProps) => {
const [hideOutput, setHideOutput] = useState(false);
// Add a new state variable to hold the list of function calls
const [functionCalls, setFunctionCalls] = useState([]);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -80,7 +88,7 @@ export const MethodComponent = React.memo((props: MethodProps) => {
<div>Render count: {renderCount.current}</div>
)}
<h5 onClick={() => setHideOutput(!hideOutput)} style={{ cursor: 'pointer' }}>
Function: {name}
Function: {displayName}
<DocStringComponent docString={docString} />
</h5>
<Form onSubmit={execute}>

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { Form, InputGroup } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
@@ -8,6 +9,29 @@ import { LevelName } from './NotificationsComponent';
// TODO: add button functionality
export type QuantityObject = {
type: 'Quantity';
readonly: boolean;
value: {
magnitude: number;
unit: string;
};
doc?: string;
};
export type IntObject = {
type: 'int';
readonly: boolean;
value: number;
doc?: string;
};
export type FloatObject = {
type: 'float';
readonly: boolean;
value: number;
doc?: string;
};
export type NumberObject = IntObject | FloatObject | QuantityObject;
interface NumberComponentProps {
name: string;
type: 'float' | 'int';
@@ -18,12 +42,6 @@ interface NumberComponentProps {
isInstantUpdate: boolean;
unit?: string;
showName?: boolean;
customEmitUpdate?: (
name: string,
parent_path: string,
value: number,
callback?: (ack: unknown) => void
) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
@@ -123,18 +141,20 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Whether to show the name infront of the component (false if used with a slider)
const showName = props.showName !== undefined ? props.showName : true;
// If emitUpdate is passed, use this instead of the setAttribute from the socket
// Also used when used with a slider
const emitUpdate =
props.customEmitUpdate !== undefined ? props.customEmitUpdate : setAttribute;
const renderCount = useRef(0);
// Create a state for the cursor position
const [cursorPosition, setCursorPosition] = useState(null);
// Create a state for the input string
const [inputString, setInputString] = useState(props.value.toString());
const fullAccessPath = parentPath.concat('.' + name);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -263,7 +283,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
selectionEnd
));
} else if (key === 'Enter' && !isInstantUpdate) {
emitUpdate(name, parentPath, Number(newValue));
setAttribute(name, parentPath, Number(newValue));
return;
} else {
console.debug(key);
@@ -272,7 +292,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Update the input value and maintain the cursor position
if (isInstantUpdate) {
emitUpdate(name, parentPath, Number(newValue));
setAttribute(name, parentPath, Number(newValue));
}
setInputString(newValue);
@@ -284,7 +304,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
const handleBlur = () => {
if (!isInstantUpdate) {
// If not in "instant update" mode, emit an update when the input field loses focus
emitUpdate(name, parentPath, Number(inputString));
setAttribute(name, parentPath, Number(inputString));
}
};
@@ -296,7 +316,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
<DocStringComponent docString={docString} />
<div className="d-flex">
<InputGroup>
{showName && <InputGroup.Text>{name}</InputGroup.Text>}
{showName && <InputGroup.Text>{displayName}</InputGroup.Text>}
<Form.Control
type="text"
value={inputString}

View File

@@ -1,21 +1,22 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { WebSettingsContext } from '../WebSettings';
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { Slider } from '@mui/material';
import { NumberComponent } from './NumberComponent';
import { NumberComponent, NumberObject } from './NumberComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface SliderComponentProps {
name: string;
min: number;
max: number;
min: NumberObject;
max: NumberObject;
parentPath?: string;
value: number;
value: NumberObject;
readOnly: boolean;
docString: string;
stepSize: number;
stepSize: NumberObject;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
}
@@ -30,13 +31,18 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
min,
max,
stepSize,
readOnly,
docString,
isInstantUpdate,
addNotification
} = props;
const fullAccessPath = parentPath.concat('.' + name);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -58,52 +64,41 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
addNotification(`${parentPath}.${name}.stepSize changed to ${stepSize}.`);
}, [props.stepSize]);
const emitSliderUpdate = (
name: string,
parentPath: string,
value: number,
callback?: (ack: unknown) => void,
min: number = props.min,
max: number = props.max,
stepSize: number = props.stepSize
) => {
setAttribute(
name,
parentPath,
{
value: value,
min: min,
max: max,
step_size: stepSize
},
callback
);
};
const handleOnChange = (event, newNumber: number | number[]) => {
// This will never be the case as we do not have a range slider. However, we should
// make sure this is properly handled.
if (Array.isArray(newNumber)) {
newNumber = newNumber[0];
}
emitSliderUpdate(name, parentPath, newNumber);
setAttribute(`${name}.value`, parentPath, newNumber);
};
const handleValueChange = (newValue: number, valueType: string) => {
switch (valueType) {
case 'min':
emitSliderUpdate(name, parentPath, value, undefined, newValue);
break;
case 'max':
emitSliderUpdate(name, parentPath, value, undefined, min, newValue);
break;
case 'stepSize':
emitSliderUpdate(name, parentPath, value, undefined, min, max, newValue);
break;
default:
break;
}
setAttribute(`${name}.${valueType}`, parentPath, newValue);
};
const deconstructNumberDict = (
numberDict: NumberObject
): [number, boolean, string | null] => {
let numberMagnitude: number;
let numberUnit: string | null = null;
const numberReadOnly = numberDict.readonly;
if (numberDict.type === 'int' || numberDict.type === 'float') {
numberMagnitude = numberDict.value;
} else if (numberDict.type === 'Quantity') {
numberMagnitude = numberDict.value.magnitude;
numberUnit = numberDict.value.unit;
}
return [numberMagnitude, numberReadOnly, numberUnit];
};
const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value);
const [minMagnitude, minReadOnly] = deconstructNumberDict(min);
const [maxMagnitude, maxReadOnly] = deconstructNumberDict(max);
const [stepSizeMagnitude, stepSizeReadOnly] = deconstructNumberDict(stepSize);
return (
<div className="sliderComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
@@ -113,22 +108,22 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<DocStringComponent docString={docString} />
<Row>
<Col xs="auto" xl="auto">
<InputGroup.Text>{name}</InputGroup.Text>
<InputGroup.Text>{displayName}</InputGroup.Text>
</Col>
<Col xs="5" xl>
<Slider
style={{ margin: '0px 0px 10px 0px' }}
aria-label="Always visible"
// valueLabelDisplay="on"
disabled={readOnly}
value={value}
disabled={valueReadOnly}
value={valueMagnitude}
onChange={(event, newNumber) => handleOnChange(event, newNumber)}
min={min}
max={max}
step={stepSize}
min={minMagnitude}
max={maxMagnitude}
step={stepSizeMagnitude}
marks={[
{ value: min, label: `${min}` },
{ value: max, label: `${max}` }
{ value: minMagnitude, label: `${minMagnitude}` },
{ value: maxMagnitude, label: `${maxMagnitude}` }
]}
/>
</Col>
@@ -136,13 +131,13 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<NumberComponent
isInstantUpdate={isInstantUpdate}
parentPath={parentPath}
name={name}
name={`${name}.value`}
docString=""
readOnly={readOnly}
readOnly={valueReadOnly}
type="float"
value={value}
value={valueMagnitude}
unit={valueUnit}
showName={false}
customEmitUpdate={emitSliderUpdate}
addNotification={() => null}
/>
</Col>
@@ -178,7 +173,8 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Min Value</Form.Label>
<Form.Control
type="number"
value={min}
value={minMagnitude}
disabled={minReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), 'min')}
/>
</Col>
@@ -187,7 +183,8 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Max Value</Form.Label>
<Form.Control
type="number"
value={max}
value={maxMagnitude}
disabled={maxReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), 'max')}
/>
</Col>
@@ -196,8 +193,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Step Size</Form.Label>
<Form.Control
type="number"
value={stepSize}
onChange={(e) => handleValueChange(Number(e.target.value), 'stepSize')}
value={stepSizeMagnitude}
disabled={stepSizeReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), 'step_size')}
/>
</Col>
</Row>

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
import { WebSettingsContext } from '../WebSettings';
// TODO: add button functionality
@@ -24,8 +25,14 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
const renderCount = useRef(0);
const [inputString, setInputString] = useState(props.value);
const fullAccessPath = parentPath.concat('.' + name);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = name;
if (webSettings[fullAccessPath] && webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
useEffect(() => {
renderCount.current++;
@@ -65,7 +72,7 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
)}
<DocStringComponent docString={docString} />
<InputGroup>
<InputGroup.Text>{name}</InputGroup.Text>
<InputGroup.Text>{displayName}</InputGroup.Text>
<Form.Control
type="text"
value={inputString}

View File

@@ -1,12 +1,16 @@
export function getIdFromFullAccessPath(fullAccessPath: string) {
// Replace '].' with a single dash
let id = fullAccessPath.replace(/\]\./g, '-');
if (fullAccessPath) {
// Replace '].' with a single dash
let id = fullAccessPath.replace(/\]\./g, '-');
// Replace any character that is not a word character or underscore with a dash
id = id.replace(/[^\w_]+/g, '-');
// Replace any character that is not a word character or underscore with a dash
id = id.replace(/[^\w_]+/g, '-');
// Remove any trailing dashes
id = id.replace(/-+$/, '');
// Remove any trailing dashes
id = id.replace(/-+$/, '');
return id;
return id;
} else {
return 'main';
}
}

20
poetry.lock generated
View File

@@ -1217,6 +1217,24 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "0.23.2"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-asyncio-0.23.2.tar.gz", hash = "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc"},
{file = "pytest_asyncio-0.23.2-py3-none-any.whl", hash = "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f"},
]
[package.dependencies]
pytest = ">=7.0.0"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "4.1.0"
@@ -1714,4 +1732,4 @@ h11 = ">=0.9.0,<1"
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "817578b5a679df165d8a01cc67aab7aecf1bd90bdb2181b946cdd4f123328bdc"
content-hash = "5517c75ad8c7968145c6883d7a0035e5b3b6c839fc8ac69e7082a96c9d646b67"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydase"
version = "0.4.0"
version = "0.5.1"
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"
@@ -31,6 +31,7 @@ matplotlib = "^3.7.2"
pyright = "^1.1.323"
pytest-mock = "^3.11.1"
ruff = "^0.1.5"
pytest-asyncio = "^0.23.2"
[tool.poetry.group.docs]
optional = true

View File

@@ -1,5 +1,5 @@
import logging
from typing import Any, Literal
from typing import Any
from pydase.data_service.data_service import DataService
@@ -21,15 +21,60 @@ class NumberSlider(DataService):
The maximum value of the slider. Defaults to 100.
step_size (float, optional):
The increment/decrement step size of the slider. Defaults to 1.0.
type (Literal["int", "float"], optional):
The type of the slider value. Determines if the value is an integer or float.
Defaults to "float".
Example:
--------
```python
class MyService(DataService):
voltage = NumberSlider(1, 0, 10, 0.1, "int")
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: float = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
) -> None:
super().__init__(value, min_, max_, step_size)
@property
def min(self) -> float:
return self._min
@min.setter
def min(self, value: float) -> None:
self._min = value
@property
def max(self) -> float:
return self._max
@max.setter
def max(self, value: float) -> None:
self._max = value
@property
def step_size(self) -> float:
return self._step_size
@step_size.setter
def step_size(self, value: float) -> None:
self._step_size = value
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, value: float) -> None:
if value < self._min or value > self._max:
raise ValueError(
"Value is either below allowed min or above max value."
)
self._value = value
class MyService(pydase.DataService):
def __init__(self) -> None:
self.voltage = MyService()
# Modifying or accessing the voltage value:
my_service = MyService()
@@ -38,29 +83,39 @@ class NumberSlider(DataService):
```
"""
def __init__( # noqa: PLR0913
def __init__(
self,
value: float = 0,
value: Any = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
type_: Literal["int", "float"] = "float",
) -> None:
super().__init__()
if type_ not in {"float", "int"}:
logger.error("Unknown type '%s'. Using 'float'.", type_)
type_ = "float"
self._step_size = step_size
self._value = value
self._min = min_
self._max = max_
self._type = type_
self.step_size = step_size
self.value = value
self.min = min_
self.max = max_
@property
def min(self) -> float:
"""The min property."""
return self._min
def __setattr__(self, name: str, value: Any) -> None:
if name in ["value", "step_size"]:
value = int(value) if self._type == "int" else float(value)
elif not name.startswith("_"):
value = float(value)
@property
def max(self) -> float:
"""The min property."""
return self._max
return super().__setattr__(name, value)
@property
def step_size(self) -> float:
"""The min property."""
return self._step_size
@property
def value(self) -> Any:
"""The value property."""
return self._value
@value.setter
def value(self, value: Any) -> None:
self._value = value

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from typing import Literal
from confz import BaseConfig, EnvSource
@@ -7,3 +8,17 @@ class OperationMode(BaseConfig): # type: ignore[misc]
environment: Literal["development", "production"] = "development"
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
class ServiceConfig(BaseConfig): # type: ignore[misc]
config_dir: Path = Path("config")
web_port: int = 8001
rpc_port: int = 18871
CONFIG_SOURCES = EnvSource(prefix="SERVICE_")
class WebServerConfig(BaseConfig): # type: ignore[misc]
generate_web_settings: bool = False
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])

View File

@@ -9,12 +9,14 @@ import pydase.units as u
from pydase.data_service.data_service_cache import DataServiceCache
from pydase.utils.helpers import (
get_object_attr_from_path_list,
is_property_attribute,
parse_list_attr_and_index,
)
from pydase.utils.serializer import (
dump,
generate_serialized_data_paths,
get_nested_dict_by_path,
serialized_dict_is_nested_object,
)
if TYPE_CHECKING:
@@ -246,7 +248,7 @@ class StateManager:
else:
setattr(target_obj, attr_name, value)
def __is_loadable_state_attribute(self, property_path: str) -> bool:
def __is_loadable_state_attribute(self, full_access_path: str) -> bool:
"""Checks if an attribute defined by a dot-separated path should be loaded from
storage.
@@ -255,13 +257,12 @@ class StateManager:
"""
parent_object = get_object_attr_from_path_list(
self.service, property_path.split(".")[:-1]
self.service, full_access_path.split(".")[:-1]
)
attr_name = property_path.split(".")[-1]
attr_name = full_access_path.split(".")[-1]
prop = getattr(type(parent_object), attr_name, None)
if isinstance(prop, property):
if is_property_attribute(parent_object, attr_name):
prop = getattr(type(parent_object), attr_name)
has_decorator = has_load_state_decorator(prop)
if not has_decorator:
logger.debug(
@@ -270,4 +271,13 @@ class StateManager:
attr_name,
)
return has_decorator
return True
cached_serialization_dict = get_nested_dict_by_path(
self.cache, full_access_path
)
if cached_serialization_dict["value"] == "method":
return False
# nested objects cannot be loaded
return not serialized_dict_is_nested_object(cached_serialization_dict)

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
import inspect
import logging
from functools import wraps
from typing import TYPE_CHECKING, Any, TypedDict
from pydase.data_service.abstract_data_service import AbstractDataService
@@ -78,7 +77,6 @@ class TaskManager:
def __init__(self, service: DataService) -> None:
self.service = service
self._loop = asyncio.get_event_loop()
self.tasks: dict[str, TaskDict] = {}
"""A dictionary to keep track of running tasks. The keys are the names of the
@@ -88,6 +86,10 @@ class TaskManager:
self._set_start_and_stop_for_async_methods()
@property
def _loop(self) -> asyncio.AbstractEventLoop:
return asyncio.get_running_loop()
def _set_start_and_stop_for_async_methods(self) -> None:
# inspect the methods of the class
for name, method in inspect.getmembers(
@@ -154,7 +156,6 @@ class TaskManager:
method (callable): The coroutine to be turned into an asyncio task.
"""
@wraps(method)
def start_task(*args: Any, **kwargs: Any) -> None:
def task_done_callback(task: asyncio.Task[None], name: str) -> None:
"""Handles tasks that have finished.

View File

@@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.2d8458eb.css",
"main.js": "/static/js/main.7f907b0f.js",
"main.js": "/static/js/main.ea55bba6.js",
"index.html": "/index.html",
"main.2d8458eb.css.map": "/static/css/main.2d8458eb.css.map",
"main.7f907b0f.js.map": "/static/js/main.7f907b0f.js.map"
"main.ea55bba6.js.map": "/static/js/main.ea55bba6.js.map"
},
"entrypoints": [
"static/css/main.2d8458eb.css",
"static/js/main.7f907b0f.js"
"static/js/main.ea55bba6.js"
]
}

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,7 @@
from pydase.server.server import Server
from pydase.server.web_server.web_server import WebServer
__all__ = ["Server"]
__all__ = [
"Server",
"WebServer",
]

View File

@@ -8,16 +8,14 @@ from pathlib import Path
from types import FrameType
from typing import Any, Protocol, TypedDict
import uvicorn
from rpyc import ForkingServer, ThreadedServer # type: ignore[import-untyped]
from rpyc import ThreadedServer # type: ignore[import-untyped]
from uvicorn.server import HANDLED_SIGNALS
from pydase import DataService
from pydase.config import ServiceConfig
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.utils.serializer import dump
from .web_server import WebAPI
from pydase.server.web_server import WebServer
logger = logging.getLogger(__name__)
@@ -31,35 +29,27 @@ class AdditionalServerProtocol(Protocol):
any server implementing it should have an __init__ method for initialization and a
serve method for starting the server.
Parameters:
-----------
service: DataService
The instance of DataService that the server will use. This could be the main
application or a specific service that the server will provide.
port: int
The port number at which the server will be accessible. This should be a valid
port number, typically in the range 1024-65535.
host: str
The hostname or IP address at which the server will be hosted. This could be a
local address (like '127.0.0.1' for localhost) or a public IP address.
state_manager: StateManager
The state manager managing the state cache and persistence of the exposed
service.
**kwargs: Any
Any additional parameters required for initializing the server. These parameters
are specific to the server's implementation.
Args:
data_service_observer:
Observer for the DataService, handling state updates and communication to
connected clients through injected callbacks. Can be utilized to access the
service and state manager, and to add custom state-update callbacks.
host:
Hostname or IP address where the server is accessible. Commonly '0.0.0.0' to
bind to all network interfaces.
port:
Port number on which the server listens. Typically in the range 1024-65535
(non-standard ports).
**kwargs:
Any additional parameters required for initializing the server. These
parameters are specific to the server's implementation.
"""
def __init__(
self,
service: DataService,
port: int,
data_service_observer: DataServiceObserver,
host: str,
state_manager: StateManager,
port: int,
**kwargs: Any,
) -> None:
...
@@ -89,87 +79,86 @@ class Server:
"""
The `Server` class provides a flexible server implementation for the `DataService`.
Parameters:
-----------
service: DataService
The DataService instance that this server will manage.
host: str
The host address for the server. Default is '0.0.0.0', which means all available
network interfaces.
rpc_port: int
The port number for the RPC server. Default is 18871.
web_port: int
The port number for the web server. Default is 8001.
enable_rpc: bool
Whether to enable the RPC server. Default is True.
enable_web: bool
Whether to enable the web server. Default is True.
filename: str | Path | None
Filename of the file managing the service state persistence. Defaults to None.
use_forking_server: bool
Whether to use ForkingServer for multiprocessing. Default is False.
additional_servers : list[AdditionalServer]
A list of additional servers to run alongside the main server. Each entry in the
list should be a dictionary with the following structure:
Args:
service: DataService
The DataService instance that this server will manage.
host: str
The host address for the server. Default is '0.0.0.0', which means all
available network interfaces.
rpc_port: int
The port number for the RPC server. Default is
`pydase.config.ServiceConfig().rpc_port`.
web_port: int
The port number for the web server. Default is
`pydase.config.ServiceConfig().web_port`.
enable_rpc: bool
Whether to enable the RPC server. Default is True.
enable_web: bool
Whether to enable the web server. Default is True.
filename: str | Path | None
Filename of the file managing the service state persistence. Defaults to None.
use_forking_server: bool
Whether to use ForkingServer for multiprocessing. Default is False.
additional_servers : list[AdditionalServer]
A list of additional servers to run alongside the main server. Each entry in
the list should be a dictionary with the following structure:
- server: A class that adheres to the AdditionalServerProtocol. This class
should have an `__init__` method that accepts the DataService instance,
port, host, and optional keyword arguments, and a `serve` method that is
a coroutine responsible for starting the server.
- port: The port on which the additional server will be running.
- kwargs: A dictionary containing additional keyword arguments that will be
passed to the server's `__init__` method.
- server: A class that adheres to the AdditionalServerProtocol. This class
should have an `__init__` method that accepts the DataService instance,
port, host, and optional keyword arguments, and a `serve` method that is a
coroutine responsible for starting the server.
- port: The port on which the additional server will be running.
- kwargs: A dictionary containing additional keyword arguments that will be
passed to the server's `__init__` method.
Here's an example of how you might define an additional server:
Here's an example of how you might define an additional server:
>>> class MyCustomServer:
... def __init__(
... self,
... service: DataService,
... port: int,
... host: str,
... state_manager: StateManager,
... **kwargs: Any
... ):
... self.service = service
... self.state_manager = state_manager
... self.port = port
... self.host = host
... # handle any additional arguments...
...
... async def serve(self):
... # code to start the server...
>>> class MyCustomServer:
... def __init__(
... self,
... data_service_observer: DataServiceObserver,
... host: str,
... port: int,
... **kwargs: Any,
... ) -> None:
... self.observer = data_service_observer
... self.state_manager = self.observer.state_manager
... self.service = self.state_manager.service
... self.port = port
... self.host = host
... # handle any additional arguments...
...
... async def serve(self):
... # code to start the server...
And here's how you might add it to the `additional_servers` list when creating a
`Server` instance:
And here's how you might add it to the `additional_servers` list when creating
a `Server` instance:
>>> server = Server(
... service=my_data_service,
... additional_servers=[
... {
... "server": MyCustomServer,
... "port": 12345,
... "kwargs": {"some_arg": "some_value"}
... }
... ],
... )
... server.run()
>>> server = Server(
... service=my_data_service,
... additional_servers=[
... {
... "server": MyCustomServer,
... "port": 12345,
... "kwargs": {"some_arg": "some_value"}
... }
... ],
... )
... server.run()
**kwargs: Any
Additional keyword arguments.
**kwargs: Any
Additional keyword arguments.
"""
def __init__( # noqa: PLR0913
self,
service: DataService,
host: str = "0.0.0.0",
rpc_port: int = 18871,
web_port: int = 8001,
rpc_port: int = ServiceConfig().rpc_port,
web_port: int = ServiceConfig().web_port,
enable_rpc: bool = True,
enable_web: bool = True,
filename: str | Path | None = None,
use_forking_server: bool = False,
additional_servers: list[AdditionalServer] | None = None,
**kwargs: Any,
) -> None:
@@ -183,7 +172,6 @@ class Server:
self._enable_web = enable_web
self._kwargs = kwargs
self._loop: asyncio.AbstractEventLoop
self._rpc_server_type = ForkingServer if use_forking_server else ThreadedServer
self._additional_servers = additional_servers
self.should_exit = False
self.servers: dict[str, asyncio.Future[Any]] = {}
@@ -191,31 +179,16 @@ class Server:
self._state_manager = StateManager(self._service, filename)
if getattr(self._service, "_filename", None) is not None:
self._service._state_manager = self._state_manager
self._state_manager.load_state()
self._observer = DataServiceObserver(self._state_manager)
self._state_manager.load_state()
def run(self) -> None:
"""
Initializes the asyncio event loop and starts the server.
This method should be called to start the server after it's been instantiated.
Raises
------
Exception
If there's an error while running the server, the error will be propagated
after the server is shut down.
"""
try:
self._loop = asyncio.get_event_loop()
except RuntimeError:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self.serve())
except Exception:
self._loop.run_until_complete(self.shutdown())
raise
asyncio.run(self.serve())
async def serve(self) -> None:
process_id = os.getpid()
@@ -230,7 +203,7 @@ class Server:
logger.info("Finished server process [%s]", process_id)
async def startup(self) -> None: # noqa: C901
async def startup(self) -> None:
self._loop = asyncio.get_running_loop()
self._loop.set_exception_handler(self.custom_exception_handler)
self.install_signal_handlers()
@@ -238,7 +211,7 @@ class Server:
if self._enable_rpc:
self.executor = ThreadPoolExecutor()
self._rpc_server = self._rpc_server_type(
self._rpc_server = ThreadedServer(
self._service,
port=self._rpc_port,
protocol_config={
@@ -252,10 +225,9 @@ class Server:
self.servers["rpyc"] = future_or_task
for server in self._additional_servers:
addin_server = server["server"](
self._service,
port=server["port"],
data_service_observer=self._observer,
host=self._host,
state_manager=self._state_manager,
port=server["port"],
**server["kwargs"],
)
@@ -266,49 +238,13 @@ class Server:
future_or_task = self._loop.create_task(addin_server.serve())
self.servers[server_name] = future_or_task
if self._enable_web:
self._wapi = WebAPI(
service=self._service,
state_manager=self._state_manager,
self._web_server = WebServer(
data_service_observer=self._observer,
host=self._host,
port=self._web_port,
**self._kwargs,
)
web_server = uvicorn.Server(
uvicorn.Config(
self._wapi.fastapi_app, host=self._host, port=self._web_port
)
)
def sio_callback(
full_access_path: str, value: Any, cached_value_dict: dict[str, Any]
) -> None:
if cached_value_dict != {}:
serialized_value = dump(value)
if cached_value_dict["type"] != "method":
cached_value_dict["type"] = serialized_value["type"]
cached_value_dict["value"] = serialized_value["value"]
async def notify() -> None:
try:
await self._wapi.sio.emit(
"notify",
{
"data": {
"full_access_path": full_access_path,
"value": cached_value_dict,
}
},
)
except Exception as e:
logger.warning("Failed to send notification: %s", e)
self._loop.create_task(notify())
self._observer.add_notification_callback(sio_callback)
# overwrite uvicorn's signal handlers, otherwise it will bogart SIGINT and
# SIGTERM, which makes it impossible to escape out of
web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
future_or_task = self._loop.create_task(web_server.serve())
future_or_task = self._loop.create_task(self._web_server.serve())
self.servers["web"] = future_or_task
async def main_loop(self) -> None:
@@ -319,8 +255,7 @@ class Server:
logger.info("Shutting down")
logger.info("Saving data to %s.", self._state_manager.filename)
if self._state_manager is not None:
self._state_manager.save_state()
self._state_manager.save_state()
await self.__cancel_servers()
await self.__cancel_tasks()
@@ -382,7 +317,7 @@ class Server:
async def emit_exception() -> None:
try:
await self._wapi.sio.emit(
await self._web_server._sio.emit(
"exception",
{
"data": {

View File

@@ -1,175 +0,0 @@
import logging
from pathlib import Path
from typing import Any, TypedDict
import socketio # type: ignore[import-untyped]
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydase import DataService
from pydase.data_service.data_service import process_callable_attribute
from pydase.data_service.state_manager import StateManager
from pydase.utils.helpers import get_object_attr_from_path_list
from pydase.utils.logging import SocketIOHandler
from pydase.version import __version__
logger = logging.getLogger(__name__)
class UpdateDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for updating attributes in a
DataService.
Attributes:
----------
name : str
The name of the attribute to be updated in the DataService instance.
If the attribute is part of a nested structure, this would be the name of the
attribute in the last nested object. For example, for an attribute access path
'attr1.list_attr[0].attr2', 'attr2' would be the name.
parent_path : str
The access path for the parent object of the attribute to be updated. This is
used to construct the full access path for the attribute. For example, for an
attribute access path 'attr1.list_attr[0].attr2', 'attr1.list_attr[0]' would be
the parent_path.
value : Any
The new value to be assigned to the attribute. The type of this value should
match the type of the attribute to be updated.
"""
name: str
parent_path: str
value: Any
class RunMethodDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for running methods from the
exposed DataService.
Attributes:
name (str): The name of the method to be run.
parent_path (str): The access path for the parent object of the method to be
run. This is used to construct the full access path for the method. For
example, for an method with access path 'attr1.list_attr[0].method_name',
'attr1.list_attr[0]' would be the parent_path.
kwargs (dict[str, Any]): The arguments passed to the method.
"""
name: str
parent_path: str
kwargs: dict[str, Any]
class WebAPI:
__sio_app: socketio.ASGIApp
__fastapi_app: FastAPI
def __init__( # noqa: PLR0913
self,
service: DataService,
state_manager: StateManager,
frontend: str | Path | None = None,
css: str | Path | None = None,
enable_cors: bool = True,
*args: Any,
**kwargs: Any,
) -> None:
self.service = service
self.state_manager = state_manager
self.frontend = frontend
self.css = css
self.enable_cors = enable_cors
self.args = args
self.kwargs = kwargs
self.setup_socketio()
self.setup_fastapi_app()
self.setup_logging_handler()
def setup_logging_handler(self) -> None:
logger = logging.getLogger()
logger.addHandler(SocketIOHandler(self.__sio))
def setup_socketio(self) -> None:
# the socketio ASGI app, to notify clients when params update
if self.enable_cors:
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
else:
sio = socketio.AsyncServer(async_mode="asgi")
@sio.event
def set_attribute(sid: str, data: UpdateDict) -> Any:
logger.debug("Received frontend update: %s", data)
path_list = [*data["parent_path"].split("."), data["name"]]
path_list.remove("DataService") # always at the start, does not do anything
path = ".".join(path_list)
return self.state_manager.set_service_attribute_value_by_path(
path=path, value=data["value"]
)
@sio.event
def run_method(sid: str, data: RunMethodDict) -> Any:
logger.debug("Running method: %s", data)
path_list = [*data["parent_path"].split("."), data["name"]]
path_list.remove("DataService") # always at the start, does not do anything
method = get_object_attr_from_path_list(self.service, path_list)
return process_callable_attribute(method, data["kwargs"])
self.__sio = sio
self.__sio_app = socketio.ASGIApp(self.__sio)
def setup_fastapi_app(self) -> None:
app = FastAPI()
if self.enable_cors:
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/ws", self.__sio_app)
@app.get("/version")
def version() -> str:
return __version__
@app.get("/name")
def name() -> str:
return self.service.get_service_name()
@app.get("/service-properties")
def service_properties() -> dict[str, Any]:
return self.state_manager.cache
# exposing custom.css file provided by user
if self.css is not None:
@app.get("/custom.css")
async def styles() -> FileResponse:
return FileResponse(str(self.css))
app.mount(
"/",
StaticFiles(
directory=Path(__file__).parent.parent / "frontend",
html=True,
),
)
self.__fastapi_app = app
@property
def sio(self) -> socketio.AsyncServer:
return self.__sio
@property
def fastapi_app(self) -> FastAPI:
return self.__fastapi_app

View File

@@ -0,0 +1,3 @@
from pydase.server.web_server.web_server import WebServer
__all__ = ["WebServer"]

View File

@@ -0,0 +1,149 @@
import asyncio
import logging
from typing import Any, TypedDict
import socketio # type: ignore[import-untyped]
from pydase.data_service.data_service import process_callable_attribute
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_list
from pydase.utils.logging import SocketIOHandler
from pydase.utils.serializer import dump
logger = logging.getLogger(__name__)
class UpdateDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for updating attributes in a
DataService.
Attributes:
----------
name : str
The name of the attribute to be updated in the DataService instance.
If the attribute is part of a nested structure, this would be the name of the
attribute in the last nested object. For example, for an attribute access path
'attr1.list_attr[0].attr2', 'attr2' would be the name.
parent_path : str
The access path for the parent object of the attribute to be updated. This is
used to construct the full access path for the attribute. For example, for an
attribute access path 'attr1.list_attr[0].attr2', 'attr1.list_attr[0]' would be
the parent_path.
value : Any
The new value to be assigned to the attribute. The type of this value should
match the type of the attribute to be updated.
"""
name: str
parent_path: str
value: Any
class RunMethodDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for running methods from the
exposed DataService.
Attributes:
name (str): The name of the method to be run.
parent_path (str): The access path for the parent object of the method to be
run. This is used to construct the full access path for the method. For
example, for an method with access path 'attr1.list_attr[0].method_name',
'attr1.list_attr[0]' would be the parent_path.
kwargs (dict[str, Any]): The arguments passed to the method.
"""
name: str
parent_path: str
kwargs: dict[str, Any]
def setup_sio_server(
observer: DataServiceObserver,
enable_cors: bool,
loop: asyncio.AbstractEventLoop,
) -> socketio.AsyncServer:
"""
Sets up and configures a Socket.IO asynchronous server.
Args:
observer (DataServiceObserver):
The observer managing state updates and communication.
enable_cors (bool):
Flag indicating whether CORS should be enabled for the server.
loop (asyncio.AbstractEventLoop):
The event loop in which the server will run.
Returns:
socketio.AsyncServer: The configured Socket.IO asynchronous server.
"""
state_manager = observer.state_manager
if enable_cors:
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
else:
sio = socketio.AsyncServer(async_mode="asgi")
setup_sio_events(sio, state_manager)
setup_logging_handler(sio)
# Add notification callback to observer
def sio_callback(
full_access_path: str, value: Any, cached_value_dict: dict[str, Any]
) -> None:
if cached_value_dict != {}:
serialized_value = dump(value)
if cached_value_dict["type"] != "method":
cached_value_dict["type"] = serialized_value["type"]
cached_value_dict["value"] = serialized_value["value"]
async def notify() -> None:
try:
await sio.emit(
"notify",
{
"data": {
"full_access_path": full_access_path,
"value": cached_value_dict,
}
},
)
except Exception as e:
logger.warning("Failed to send notification: %s", e)
loop.create_task(notify())
observer.add_notification_callback(sio_callback)
return sio
def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) -> None:
@sio.event
def set_attribute(sid: str, data: UpdateDict) -> Any:
logger.debug("Received frontend update: %s", data)
parent_path = data["parent_path"].split(".")
path_list = [element for element in parent_path if element] + [data["name"]]
path = ".".join(path_list)
return state_manager.set_service_attribute_value_by_path(
path=path, value=data["value"]
)
@sio.event
def run_method(sid: str, data: RunMethodDict) -> Any:
logger.debug("Running method: %s", data)
parent_path = data["parent_path"].split(".")
path_list = [element for element in parent_path if element] + [data["name"]]
method = get_object_attr_from_path_list(state_manager.service, path_list)
return process_callable_attribute(method, data["kwargs"])
def setup_logging_handler(sio: socketio.AsyncServer) -> None:
logger = logging.getLogger()
logger.addHandler(SocketIOHandler(sio))

View File

@@ -0,0 +1,185 @@
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
import socketio # type: ignore[import-untyped]
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydase.config import ServiceConfig, WebServerConfig
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.server.web_server.sio_setup import (
setup_sio_server,
)
from pydase.utils.serializer import generate_serialized_data_paths
from pydase.version import __version__
logger = logging.getLogger(__name__)
class WebServer:
"""
Represents a web server that adheres to the AdditionalServerProtocol, designed to
work with a DataService instance. This server facilitates client-server
communication and state management through web protocols and socket connections.
The WebServer class initializes and manages a web server environment using FastAPI
and Socket.IO, allowing for HTTP and WebSocket communications. It incorporates CORS
(Cross-Origin Resource Sharing) support, custom CSS, and serves a frontend static
files directory. It also initializes web server settings based on configuration
files or generates default settings if necessary.
Configuration for the web server (like service configuration directory and whether
to generate new web settings) is determined in the following order of precedence:
1. Values provided directly to the constructor.
2. Environment variable settings (via configuration classes like
`pydase.config.ServiceConfig` and `pydase.config.WebServerConfig`).
3. Default values defined in the configuration classes.
Args:
data_service_observer (DataServiceObserver): Observer for the DataService,
handling state updates and communication to connected clients.
host (str): Hostname or IP address where the server is accessible. Commonly
'0.0.0.0' to bind to all network interfaces.
port (int): Port number on which the server listens. Typically in the range
1024-65535 (non-standard ports).
css (str | Path | None, optional): Path to a custom CSS file for styling the
frontend. If None, no custom styles are applied. Defaults to None.
enable_cors (bool, optional): Flag to enable or disable CORS policy. When True,
CORS is enabled, allowing cross-origin requests. Defaults to True.
config_dir (Path | None, optional): Path to the configuration
directory where the web settings will be stored. Defaults to
`pydase.config.ServiceConfig().config_dir`.
generate_new_web_settings (bool | None, optional): Flag to enable or disable
generation of new web settings if the configuration file is missing. Defaults
to `pydase.config.WebServerConfig().generate_new_web_settings`.
**kwargs (Any): Additional unused keyword arguments.
"""
def __init__( # noqa: PLR0913
self,
data_service_observer: DataServiceObserver,
host: str,
port: int,
css: str | Path | None = None,
enable_cors: bool = True,
config_dir: Path = ServiceConfig().config_dir,
generate_web_settings: bool = WebServerConfig().generate_web_settings,
**kwargs: Any,
) -> None:
self.observer = data_service_observer
self.state_manager = self.observer.state_manager
self.service = self.state_manager.service
self.port = port
self.host = host
self.css = css
self.enable_cors = enable_cors
self._service_config_dir = config_dir
self._generate_web_settings = generate_web_settings
self._loop: asyncio.AbstractEventLoop
self._initialise_configuration()
async def serve(self) -> None:
self._loop = asyncio.get_running_loop()
self._setup_socketio()
self._setup_fastapi_app()
self.web_server = uvicorn.Server(
uvicorn.Config(self.__fastapi_app, host=self.host, port=self.port)
)
# overwrite uvicorn's signal handlers, otherwise it will bogart SIGINT and
# SIGTERM, which makes it impossible to escape out of
self.web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
await self.web_server.serve()
def _initialise_configuration(self) -> None:
logger.debug("Initialising web server configuration...")
file_path = self._service_config_dir / "web_settings.json"
if self._generate_web_settings:
# File does not exist, create it with default content
logger.debug("Generating web settings file...")
file_path.parent.mkdir(
parents=True, exist_ok=True
) # Ensure directory exists
file_path.write_text(json.dumps(self.web_settings, indent=4))
def _get_web_settings_from_file(self) -> dict[str, dict[str, Any]]:
file_path = self._service_config_dir / "web_settings.json"
web_settings = {}
# File exists, read its content
if file_path.exists():
logger.debug(
"Reading configuration from file '%s' ...", file_path.absolute()
)
web_settings = json.loads(file_path.read_text())
return web_settings
@property
def web_settings(self) -> dict[str, dict[str, Any]]:
current_web_settings = self._get_web_settings_from_file()
for path in generate_serialized_data_paths(self.state_manager.cache):
if path in current_web_settings:
continue
current_web_settings[path] = {"displayName": path.split(".")[-1]}
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
@app.get("/web-settings")
def web_settings() -> dict[str, Any]:
return self.web_settings
# exposing custom.css file provided by user
if self.css is not None:
@app.get("/custom.css")
async def styles() -> FileResponse:
return FileResponse(str(self.css))
app.mount(
"/",
StaticFiles(
directory=Path(__file__).parent.parent.parent / "frontend",
html=True,
),
)
self.__fastapi_app = app

View File

@@ -177,19 +177,21 @@ def parse_list_attr_and_index(attr_string: str) -> tuple[str, int | None]:
return attr_name, index
def get_component_class_names() -> list[str]:
def get_component_classes() -> list[type]:
"""
Returns the names of the component classes in a list.
It takes the names from the pydase/components/__init__.py file, so this file should
always be up-to-date with the currently available components.
Returns:
list[str]: List of component class names
Returns references to the component classes in a list.
"""
import pydase.components
return pydase.components.__all__
return [
getattr(pydase.components, cls_name) for cls_name in pydase.components.__all__
]
def get_data_service_class_reference() -> Any:
import pydase.data_service.data_service
return getattr(pydase.data_service.data_service, "DataService")
def is_property_attribute(target_obj: Any, attr_name: str) -> bool:

View File

@@ -8,7 +8,8 @@ import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.utils.helpers import (
get_attribute_doc,
get_component_class_names,
get_component_classes,
get_data_service_class_reference,
parse_list_attr_and_index,
)
@@ -157,24 +158,26 @@ class Serializer:
def _serialize_data_service(obj: AbstractDataService) -> dict[str, Any]:
readonly = False
doc = get_attribute_doc(obj)
obj_type = type(obj).__name__
if type(obj).__name__ not in get_component_class_names():
obj_type = "DataService"
obj_type = "DataService"
# Get the dictionary of the base class
base_set = set(type(obj).__base__.__dict__)
# Get the dictionary of the derived class
derived_set = set(type(obj).__dict__)
# Get the difference between the two dictionaries
derived_only_set = derived_set - base_set
# Get component base class if any
component_base_cls = next(
(cls for cls in get_component_classes() if isinstance(obj, cls)), None
)
if component_base_cls:
obj_type = component_base_cls.__name__
# Get the set of DataService class attributes
data_service_attr_set = set(dir(get_data_service_class_reference()))
# Get the set of the object attributes
obj_attr_set = set(dir(obj))
# Get the difference between the two sets
derived_only_attr_set = obj_attr_set - data_service_attr_set
instance_dict = set(obj.__dict__)
# Merge the class and instance dictionaries
merged_set = derived_only_set | instance_dict
value = {}
# Iterate over attributes, properties, class attributes, and methods
for key in sorted(merged_set):
for key in sorted(derived_only_attr_set):
if key.startswith("_"):
continue # Skip attributes that start with underscore
@@ -338,41 +341,48 @@ def get_next_level_dict_by_key(
def generate_serialized_data_paths(
data: dict[str, Any], parent_path: str = ""
data: dict[str, dict[str, Any]], parent_path: str = ""
) -> list[str]:
"""
Generate a list of access paths for all attributes in a dictionary representing
data serialized with `pydase.utils.serializer.Serializer`, excluding those that are
methods.
methods. This function handles nested structures, including lists, by generating
paths for each element in the nested lists.
Args:
data: The dictionary representing serialized data, typically produced by
`pydase.utils.serializer.Serializer`.
parent_path: The base path to prepend to the keys in the `data` dictionary to
form the access paths. Defaults to an empty string.
data (dict[str, Any]): The dictionary representing serialized data, typically
produced by `pydase.utils.serializer.Serializer`.
parent_path (str, optional): The base path to prepend to the keys in the `data`
dictionary to form the access paths. Defaults to an empty string.
Returns:
A list of strings where each string is a dot-notation access path to an
attribute in the serialized data.
list[str]: A list of strings where each string is a dot-notation access path
to an attribute in the serialized data. For list elements, the path includes
the index in square brackets.
"""
paths: list[str] = []
for key, value in data.items():
if value["type"] == "method":
# ignoring methods
continue
new_path = f"{parent_path}.{key}" if parent_path else key
if isinstance(value["value"], dict) and value["type"] != "Quantity":
paths.extend(generate_serialized_data_paths(value["value"], new_path))
elif isinstance(value["value"], list):
for index, item in enumerate(value["value"]):
indexed_key_path = f"{new_path}[{index}]"
if isinstance(item["value"], dict):
paths.extend(
generate_serialized_data_paths(item["value"], indexed_key_path)
)
else:
paths.append(new_path)
if serialized_dict_is_nested_object(value):
if isinstance(value["value"], list):
for index, item in enumerate(value["value"]):
indexed_key_path = f"{new_path}[{index}]"
paths.append(indexed_key_path)
else:
paths.append(new_path)
if serialized_dict_is_nested_object(item):
paths.extend(
generate_serialized_data_paths(
item["value"], indexed_key_path
)
)
continue
paths.extend(generate_serialized_data_paths(value["value"], new_path))
return paths
def serialized_dict_is_nested_object(serialized_dict: dict[str, Any]) -> bool:
return (
serialized_dict["type"] != "Quantity"
and isinstance(serialized_dict["value"], dict)
) or isinstance(serialized_dict["value"], list)

View File

@@ -1,46 +1,83 @@
import logging
from collections.abc import Callable
from pydase.components.number_slider import NumberSlider
from pydase.data_service.data_service import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
from tests.utils.test_serializer import pytest
def test_NumberSlider(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
number_slider = NumberSlider(1, 0, 10, 1)
int_number_slider = NumberSlider(1, 0, 10, 1, "int")
logger = logging.getLogger(__name__)
service_instance = ServiceClass()
def test_number_slider(caplog: LogCaptureFixture) -> None:
class MySlider(NumberSlider):
def __init__(
self,
value: float = 0,
min_: float = 0,
max_: float = 100,
step_size: float = 1,
callback: Callable[..., None] = lambda: None,
) -> None:
super().__init__(value, min_, max_, step_size)
self._callback = callback
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, value: float) -> None:
self._callback(value)
self._value = value
@property
def max(self) -> float:
return self._max
@max.setter
def max(self, value: float) -> None:
self._max = value
@property
def step_size(self) -> float:
return self._step_size
@step_size.setter
def step_size(self, value: float) -> None:
self._step_size = value
class MyService(DataService):
def __init__(self) -> None:
super().__init__()
self.my_slider = MySlider(callback=self.some_method)
def some_method(self, slider_value: float) -> None:
logger.info("Slider changed to '%s'", slider_value)
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert service_instance.number_slider.value == 1
assert isinstance(service_instance.number_slider.value, float)
assert service_instance.number_slider.min == 0
assert isinstance(service_instance.number_slider.min, float)
assert service_instance.number_slider.max == 10
assert isinstance(service_instance.number_slider.max, float)
assert service_instance.number_slider.step_size == 1
assert isinstance(service_instance.number_slider.step_size, float)
service_instance.my_slider.value = 10.0
assert service_instance.int_number_slider.value == 1
assert isinstance(service_instance.int_number_slider.value, int)
assert service_instance.int_number_slider.step_size == 1
assert isinstance(service_instance.int_number_slider.step_size, int)
service_instance.number_slider.value = 10.0
service_instance.int_number_slider.value = 10.1
assert "'number_slider.value' changed to '10.0'" in caplog.text
assert "'int_number_slider.value' changed to '10'" in caplog.text
assert "'my_slider.value' changed to '10.0'" in caplog.text
assert "Slider changed to '10.0'" in caplog.text
caplog.clear()
service_instance.number_slider.min = 1.1
service_instance.my_slider.max = 12.0
assert "'number_slider.min' changed to '1.1'" in caplog.text
assert "'my_slider.max' changed to '12.0'" in caplog.text
caplog.clear()
service_instance.my_slider.step_size = 0.1
def test_init_error(caplog: LogCaptureFixture) -> None:
number_slider = NumberSlider(type_="str") # type: ignore # noqa
assert "'my_slider.step_size' changed to '0.1'" in caplog.text
caplog.clear()
assert "Unknown type 'str'. Using 'float'" in caplog.text
# by overriding the getter only you can make the property read-only
with pytest.raises(AttributeError):
service_instance.my_slider.min = 1.1 # type: ignore[reportGeneralTypeIssues, misc]

View File

@@ -1,6 +1,7 @@
import logging
import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
@@ -34,7 +35,8 @@ def test_nested_attributes_cache_callback() -> None:
)
def test_task_status_update() -> None:
@pytest.mark.asyncio
async def test_task_status_update() -> None:
class ServiceClass(pydase.DataService):
name = "World"

View File

@@ -3,9 +3,9 @@ from pathlib import Path
from typing import Any
import pydase
import pydase.components
import pydase.units as u
import pytest
from pydase.components.coloured_enum import ColouredEnum
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import (
StateManager,
@@ -19,12 +19,53 @@ class SubService(pydase.DataService):
name = "SubService"
class State(ColouredEnum):
class State(pydase.components.ColouredEnum):
RUNNING = "#0000FF80"
COMPLETED = "hsl(120, 100%, 50%)"
FAILED = "hsla(0, 100%, 50%, 0.7)"
class MySlider(pydase.components.NumberSlider):
@property
def min(self) -> float:
return self._min
@min.setter
@load_state
def min(self, value: float) -> None:
self._min = value
@property
def max(self) -> float:
return self._max
@max.setter
@load_state
def max(self, value: float) -> None:
self._max = value
@property
def step_size(self) -> float:
return self._step_size
@step_size.setter
@load_state
def step_size(self, value: float) -> None:
self._step_size = value
@property
def value(self) -> float:
return self._value
@value.setter
@load_state
def value(self, value: float) -> None:
if value < self._min or value > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = value
class Service(pydase.DataService):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
@@ -35,6 +76,7 @@ class Service(pydase.DataService):
self._property_attr = 1337.0
self._name = "Service"
self.state = State.RUNNING
self.my_slider = MySlider()
@property
def name(self) -> str:
@@ -61,6 +103,37 @@ LOAD_STATE = {
"readonly": False,
"doc": None,
},
"my_slider": {
"type": "NumberSlider",
"value": {
"max": {
"type": "float",
"value": 101.0,
"readonly": False,
"doc": "The min property.",
},
"min": {
"type": "float",
"value": 1.0,
"readonly": False,
"doc": "The min property.",
},
"step_size": {
"type": "float",
"value": 2.0,
"readonly": False,
"doc": "The min property.",
},
"value": {
"type": "float",
"value": 1.0,
"readonly": False,
"doc": "The value property.",
},
},
"readonly": False,
"doc": None,
},
"name": {
"type": "str",
"value": "Another name",
@@ -153,6 +226,10 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
assert service.name == "Service" # has not changed as readonly
assert service.some_float == 1.0 # has not changed due to different type
assert service.subservice.name == "SubService" # didn't change
assert service.my_slider.value == 1.0 # changed
assert service.my_slider.min == 1.0 # changed
assert service.my_slider.max == 101.0 # changed
assert service.my_slider.step_size == 2.0 # changed
assert "'some_unit' changed to '12.0 A'" in caplog.text
assert (
@@ -168,6 +245,10 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
"Ignoring value from JSON file..." in caplog.text
)
assert "Value of attribute 'subservice.name' has not changed..." in caplog.text
assert "'my_slider.value' changed to '1.0'" in caplog.text
assert "'my_slider.min' changed to '1.0'" in caplog.text
assert "'my_slider.max' changed to '101.0'" in caplog.text
assert "'my_slider.step_size' changed to '2.0'" in caplog.text
def test_filename_warning(tmp_path: Path, caplog: LogCaptureFixture) -> None:

View File

@@ -1,6 +1,8 @@
import asyncio
import logging
import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
@@ -8,13 +10,14 @@ from pytest import LogCaptureFixture
logger = logging.getLogger()
def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
@pytest.mark.asyncio
async def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._autostart_tasks = { # type: ignore
"my_task": (),
"my_other_task": (),
"my_task": (), # type: ignore
"my_other_task": (), # type: ignore
}
async def my_task(self) -> None:
@@ -23,6 +26,7 @@ def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
async def my_other_task(self) -> None:
logger.info("Triggered other task.")
# Your test code here
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
@@ -32,7 +36,8 @@ def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
assert "'my_other_task' changed to '{}'" in caplog.text
def test_DataService_subclass_autostart_task_callback(
@pytest.mark.asyncio
async def test_DataService_subclass_autostart_task_callback(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
@@ -61,7 +66,8 @@ def test_DataService_subclass_autostart_task_callback(
assert "'sub_service.my_other_task' changed to '{}'" in caplog.text
def test_DataService_subclass_list_autostart_task_callback(
@pytest.mark.asyncio
async def test_DataService_subclass_list_autostart_task_callback(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
@@ -90,3 +96,30 @@ def test_DataService_subclass_list_autostart_task_callback(
assert "'sub_services_list[0].my_other_task' changed to '{}'" in caplog.text
assert "'sub_services_list[1].my_task' changed to '{}'" in caplog.text
assert "'sub_services_list[1].my_other_task' changed to '{}'" in caplog.text
@pytest.mark.asyncio
async def test_start_and_stop_task_methods(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
async def my_task(self, param: str) -> None:
while True:
logger.debug("Logging param: %s", param)
await asyncio.sleep(0.1)
# Your test code here
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.start_my_task("Hello")
await asyncio.sleep(0.01)
assert "'my_task' changed to '{'param': 'Hello'}'" in caplog.text
assert "Logging param: Hello" in caplog.text
caplog.clear()
service_instance.stop_my_task()
await asyncio.sleep(0.01)
assert "Task 'my_task' was cancelled" in caplog.text

View File

@@ -1,8 +1,15 @@
import json
import signal
from pytest_mock import MockerFixture
from pathlib import Path
from typing import Any
import pydase
import pydase.components
import pydase.units as u
from pydase.data_service.state_manager import load_state
from pydase.server.server import Server
from pytest import LogCaptureFixture
from pytest_mock import MockerFixture
def test_signal_handling(mocker: MockerFixture):
@@ -33,3 +40,64 @@ def test_signal_handling(mocker: MockerFixture):
# Simulate receiving a SIGINT signal for the second time
server.handle_exit(signal.SIGINT, None)
mock_exit.assert_called_once_with(1)
class Service(pydase.DataService):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.some_unit: u.Quantity = 1.2 * u.units.A
self.some_float = 1.0
self._property_attr = 1337.0
@property
def property_attr(self) -> float:
return self._property_attr
@property_attr.setter
@load_state
def property_attr(self, value: float) -> None:
self._property_attr = value
CURRENT_STATE = Service().serialize()
LOAD_STATE = {
"some_float": {
"type": "float",
"value": 10.0,
"readonly": False,
"doc": None,
},
"property_attr": {
"type": "float",
"value": 1337.1,
"readonly": False,
"doc": None,
},
"some_unit": {
"type": "Quantity",
"value": {"magnitude": 12.0, "unit": "A"},
"readonly": False,
"doc": None,
},
}
def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"
# Write a temporary JSON file to read back
with open(file, "w") as f:
json.dump(LOAD_STATE, f, indent=4)
service = Service()
Server(service, filename=str(file))
assert service.some_unit == u.Quantity(12, "A")
assert service.property_attr == 1337.1
assert service.some_float == 10.0
assert "'some_unit' changed to '12.0 A'" in caplog.text
assert "'some_float' changed to '10.0'" in caplog.text
assert "'property_attr' changed to '1337.1'" in caplog.text

View File

@@ -0,0 +1,68 @@
import asyncio
import logging
import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.server.web_server.sio_setup import (
RunMethodDict,
UpdateDict,
setup_sio_server,
)
logger = logging.getLogger(__name__)
@pytest.mark.asyncio
async def test_set_attribute_event() -> None:
class SubClass(pydase.DataService):
name = "SubClass"
class ServiceClass(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.sub_class = SubClass()
def some_method(self) -> None:
logger.info("Triggered 'test_method'.")
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
observer = DataServiceObserver(state_manager)
server = setup_sio_server(observer, False, asyncio.get_running_loop())
test_sid = 1234
test_data: UpdateDict = {
"parent_path": "sub_class",
"name": "name",
"value": "new name",
}
server.handlers["/"]["set_attribute"](test_sid, test_data)
assert service_instance.sub_class.name == "new name"
@pytest.mark.asyncio
async def test_run_method_event(caplog: pytest.LogCaptureFixture):
class ServiceClass(pydase.DataService):
def test_method(self) -> None:
logger.info("Triggered 'test_method'.")
state_manager = StateManager(ServiceClass())
observer = DataServiceObserver(state_manager)
server = setup_sio_server(observer, False, asyncio.get_running_loop())
test_sid = 1234
test_data: RunMethodDict = {
"parent_path": "",
"name": "test_method",
"kwargs": {},
}
server.handlers["/"]["run_method"](test_sid, test_data)
assert "Triggered 'test_method'." in caplog.text

View File

@@ -0,0 +1,51 @@
import json
import logging
import tempfile
from pathlib import Path
import pydase
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.server.web_server.web_server import WebServer
logger = logging.getLogger(__name__)
def test_web_settings() -> None:
class SubClass(pydase.DataService):
name = "Hello"
class ServiceClass(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.attr_1 = SubClass()
self.added = "added"
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
observer = DataServiceObserver(state_manager)
with tempfile.TemporaryDirectory() as tmp:
web_settings = {
"attr_1": {"displayName": "Attribute"},
"attr_1.name": {"displayName": "Attribute name"},
}
web_settings_file = Path(tmp) / "web_settings.json"
with web_settings_file.open("w") as file:
file.write(json.dumps(web_settings))
server = WebServer(
observer,
host="0.0.0.0",
port=8001,
generate_web_settings=True,
config_dir=Path(tmp),
)
new_web_settings = server.web_settings
# existing entries are not overwritten, new entries are appended
assert new_web_settings == {**web_settings, "added": {"displayName": "added"}}
assert json.loads(web_settings_file.read_text()) == {
**web_settings,
"added": {"displayName": "added"},
}

View File

@@ -1,6 +1,8 @@
import pytest
from pydase.utils.helpers import is_property_attribute
import pytest
from pydase.utils.helpers import (
is_property_attribute,
)
@pytest.mark.parametrize(

View File

@@ -1,8 +1,7 @@
import logging
from pytest import LogCaptureFixture
from pydase.utils.logging import setup_logging
from pytest import LogCaptureFixture
def test_log_error(caplog: LogCaptureFixture):

View File

@@ -11,6 +11,7 @@ from pydase.utils.serializer import (
dump,
get_nested_dict_by_path,
get_next_level_dict_by_key,
serialized_dict_is_nested_object,
set_nested_value_by_path,
)
@@ -124,7 +125,8 @@ def test_ColouredEnum_serialize() -> None:
}
def test_method_serialization() -> None:
@pytest.mark.asyncio
async def test_method_serialization() -> None:
class ClassWithMethod(pydase.DataService):
def some_method(self) -> str:
return "some method"
@@ -294,6 +296,30 @@ def test_dict_serialization() -> None:
}
def test_derived_data_service_serialization() -> None:
class BaseService(pydase.DataService):
class_attr = 1337
def __init__(self) -> None:
super().__init__()
self._name = "Service"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
class DerivedService(BaseService):
...
base_instance = BaseService()
service_instance = DerivedService()
assert service_instance.serialize() == base_instance.serialize()
@pytest.fixture
def setup_dict():
class MySubclass(pydase.DataService):
@@ -336,7 +362,9 @@ def test_update_invalid_list_index(setup_dict, caplog: pytest.LogCaptureFixture)
)
def test_update_invalid_path(setup_dict, caplog: pytest.LogCaptureFixture):
def test_update_invalid_path(
setup_dict: dict[str, Any], caplog: pytest.LogCaptureFixture
) -> None:
set_nested_value_by_path(setup_dict, "invalid_path", 30)
assert (
"Error occured trying to access the key 'invalid_path': it is either "
@@ -345,66 +373,165 @@ def test_update_invalid_path(setup_dict, caplog: pytest.LogCaptureFixture):
)
def test_update_list_inside_class(setup_dict):
def test_update_list_inside_class(setup_dict: dict[str, Any]) -> None:
set_nested_value_by_path(setup_dict, "attr2.list_attr[1]", 40)
assert setup_dict["attr2"]["value"]["list_attr"]["value"][1]["value"] == 40
def test_update_class_attribute_inside_list(setup_dict):
def test_update_class_attribute_inside_list(setup_dict: dict[str, Any]) -> None:
set_nested_value_by_path(setup_dict, "attr_list[2].attr3", 50)
assert setup_dict["attr_list"]["value"][2]["value"]["attr3"]["value"] == 50
def test_get_next_level_attribute_nested_dict(setup_dict):
def test_get_next_level_attribute_nested_dict(setup_dict: dict[str, Any]) -> None:
nested_dict = get_next_level_dict_by_key(setup_dict, "attr1")
assert nested_dict == setup_dict["attr1"]
def test_get_next_level_list_entry_nested_dict(setup_dict):
def test_get_next_level_list_entry_nested_dict(setup_dict: dict[str, Any]) -> None:
nested_dict = get_next_level_dict_by_key(setup_dict, "attr_list[0]")
assert nested_dict == setup_dict["attr_list"]["value"][0]
def test_get_next_level_invalid_path_nested_dict(setup_dict):
def test_get_next_level_invalid_path_nested_dict(setup_dict: dict[str, Any]) -> None:
with pytest.raises(SerializationPathError):
get_next_level_dict_by_key(setup_dict, "invalid_path")
def test_get_next_level_invalid_list_index(setup_dict):
def test_get_next_level_invalid_list_index(setup_dict: dict[str, Any]) -> None:
with pytest.raises(SerializationPathError):
get_next_level_dict_by_key(setup_dict, "attr_list[10]")
def test_get_attribute(setup_dict):
def test_get_attribute(setup_dict: dict[str, Any]) -> None:
nested_dict = get_nested_dict_by_path(setup_dict, "attr1")
assert nested_dict["value"] == 1.0
def test_get_nested_attribute(setup_dict):
def test_get_nested_attribute(setup_dict: dict[str, Any]) -> None:
nested_dict = get_nested_dict_by_path(setup_dict, "attr2.attr3")
assert nested_dict["value"] == 1.0
def test_get_list_entry(setup_dict):
def test_get_list_entry(setup_dict: dict[str, Any]) -> None:
nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[1]")
assert nested_dict["value"] == 1
def test_get_list_inside_class(setup_dict):
def test_get_list_inside_class(setup_dict: dict[str, Any]) -> None:
nested_dict = get_nested_dict_by_path(setup_dict, "attr2.list_attr[1]")
assert nested_dict["value"] == 1.0
def test_get_class_attribute_inside_list(setup_dict):
def test_get_class_attribute_inside_list(setup_dict: dict[str, Any]) -> None:
nested_dict = get_nested_dict_by_path(setup_dict, "attr_list[2].attr3")
assert nested_dict["value"] == 1.0
def test_get_invalid_list_index(setup_dict, caplog: pytest.LogCaptureFixture):
def test_get_invalid_list_index(setup_dict: dict[str, Any]) -> None:
with pytest.raises(SerializationPathError):
get_nested_dict_by_path(setup_dict, "attr_list[10]")
def test_get_invalid_path(setup_dict, caplog: pytest.LogCaptureFixture):
def test_get_invalid_path(setup_dict: dict[str, Any]) -> None:
with pytest.raises(SerializationPathError):
get_nested_dict_by_path(setup_dict, "invalid_path")
def test_serialized_dict_is_nested_object() -> None:
serialized_dict = {
"list_attr": {
"type": "list",
"value": [
{"type": "float", "value": 1.4, "readonly": False, "doc": None},
{"type": "float", "value": 2.0, "readonly": False, "doc": None},
],
"readonly": False,
"doc": None,
},
"my_slider": {
"type": "NumberSlider",
"value": {
"max": {
"type": "float",
"value": 101.0,
"readonly": False,
"doc": "The min property.",
},
"min": {
"type": "float",
"value": 1.0,
"readonly": False,
"doc": "The min property.",
},
"step_size": {
"type": "float",
"value": 2.0,
"readonly": False,
"doc": "The min property.",
},
"value": {
"type": "float",
"value": 1.0,
"readonly": False,
"doc": "The value property.",
},
},
"readonly": False,
"doc": None,
},
"string": {
"type": "str",
"value": "Another name",
"readonly": True,
"doc": None,
},
"float": {
"type": "int",
"value": 10,
"readonly": False,
"doc": None,
},
"unit": {
"type": "Quantity",
"value": {"magnitude": 12.0, "unit": "A"},
"readonly": False,
"doc": None,
},
"state": {
"type": "ColouredEnum",
"value": "FAILED",
"readonly": True,
"doc": None,
"enum": {
"RUNNING": "#0000FF80",
"COMPLETED": "hsl(120, 100%, 50%)",
"FAILED": "hsla(0, 100%, 50%, 0.7)",
},
},
"subservice": {
"type": "DataService",
"value": {
"name": {
"type": "str",
"value": "SubService",
"readonly": False,
"doc": None,
}
},
"readonly": False,
"doc": None,
},
}
assert serialized_dict_is_nested_object(serialized_dict["list_attr"])
assert serialized_dict_is_nested_object(serialized_dict["my_slider"])
assert serialized_dict_is_nested_object(serialized_dict["subservice"])
assert not serialized_dict_is_nested_object(
serialized_dict["list_attr"]["value"][0] # type: ignore[index]
)
assert not serialized_dict_is_nested_object(serialized_dict["string"])
assert not serialized_dict_is_nested_object(serialized_dict["unit"])
assert not serialized_dict_is_nested_object(serialized_dict["float"])
assert not serialized_dict_is_nested_object(serialized_dict["state"])