6 Commits

Author SHA1 Message Date
Mose Müller
a158308686 removing python 3.8 from workflows 2023-09-19 18:16:14 +02:00
Mose Müller
7a55903b01 removing legacy type hints (<v3.9) 2023-09-19 18:16:14 +02:00
Mose Müller
dc432a1238 removing python 3.8 support 2023-09-19 18:16:14 +02:00
Mose Müller
c182e11527 updating pydase.units 2023-09-19 18:16:14 +02:00
Mose Müller
fe5b6c591d fix: flake8 errors 2023-09-19 18:16:14 +02:00
Mose Müller
f948605b58 feat: adding support for python 3.8, 3.9 2023-09-19 18:16:14 +02:00
130 changed files with 8873 additions and 11681 deletions

View File

@@ -2,3 +2,5 @@
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
omit =
src/pydase/utils/logging.py

8
.flake8 Normal file
View File

@@ -0,0 +1,8 @@
[flake8]
ignore = E501,W503,FS003,F403,F405,E203,UNT001
include = src
max-line-length = 88
max-doc-length = 88
max-complexity = 7
max-expression-complexity = 5.5
use_class_attributes_order_strict_mode=True

View File

@@ -1,25 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'bug'
assignees: ''
---
## Describe the bug
A clear and concise description of what the bug is.
## To Reproduce
Provide steps to reproduce the behaviour, including a minimal code snippet (if applicable):
```python
# Minimal code snippet that reproduces the error
```
## Expected behaviour
A clear and concise description of what you expected to happen.
## Screenshot/Video
If applicable, add visual content that helps explain your problem.
## Additional context
Add any other context about the problem here.

View File

@@ -86,26 +86,26 @@ jobs:
'${{ github.ref_name }}' dist/**
--repo '${{ github.repository }}'
# publish-to-testpypi:
# name: Publish Python 🐍 distribution 📦 to TestPyPI
# needs:
# - build
# runs-on: ubuntu-latest
#
# environment:
# name: testpypi
# url: https://test.pypi.org/p/pydase
#
# permissions:
# id-token: write # IMPORTANT: mandatory for trusted publishing
#
# steps:
# - name: Download all the dists
# uses: actions/download-artifact@v3
# with:
# name: python-package-distributions
# path: dist/
# - name: Publish distribution 📦 to TestPyPI
# uses: pypa/gh-action-pypi-publish@release/v1
# with:
# repository-url: https://test.pypi.org/legacy/
publish-to-testpypi:
name: Publish Python 🐍 distribution 📦 to TestPyPI
needs:
- build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/pydase
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing
steps:
- name: Download all the dists
uses: actions/download-artifact@v3
with:
name: python-package-distributions
path: dist/
- name: Publish distribution 📦 to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/

View File

@@ -5,39 +5,36 @@ name: Python package
on:
push:
branches: [ "main" ]
branches: ['main']
pull_request:
branches: [ "main" ]
branches: ['main']
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11"]
python-version: ['3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v3
- uses: chartboost/ruff-action@v1
with:
src: "./src"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install poetry
poetry install --with dev
- name: Test with pytest
run: |
poetry run pytest
- name: Test with pyright
run: |
poetry run pyright
- name: Test with mypy
run: |
poetry run mypy src
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install poetry
poetry install
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
poetry run flake8 src/pydase --count --show-source --statistics
- name: Test with pytest
run: |
poetry run pytest
- name: Test with pyright
run: |
poetry run pyright src/pydase

3
.gitignore vendored
View File

@@ -128,9 +128,6 @@ venv.bak/
.dmypy.json
dmypy.json
# ruff
.ruff_cache/
# Pyre type checker
.pyre/

View File

@@ -1,21 +0,0 @@
# Read the Docs configuration file for MkDocs projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
mkdocs:
configuration: mkdocs.yml
# Optionally declare the Python requirements required to build your docs
python:
install:
- method: pip
path: .
- requirements: docs/requirements.txt

View File

@@ -1,8 +0,0 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.mypy-type-checker"
]
}

11
.vscode/launch.json vendored
View File

@@ -1,4 +1,7 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
@@ -6,7 +9,7 @@
"type": "python",
"request": "launch",
"module": "foo",
"justMyCode": false,
"justMyCode": true,
"env": {
"ENVIRONMENT": "development"
}
@@ -16,7 +19,7 @@
"type": "python",
"request": "launch",
"module": "bar",
"justMyCode": false,
"justMyCode": true,
"env": {
"ENVIRONMENT": "development"
}
@@ -26,7 +29,7 @@
"request": "launch",
"name": "react: firefox",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/frontend"
"webRoot": "${workspaceFolder}/frontend",
}
]
}
}

31
.vscode/settings.json vendored
View File

@@ -1,15 +1,25 @@
{
"autoDocstring.docstringFormat": "google",
"autoDocstring.startOnNewLine": true,
"autoDocstring.generateDocstringOnEnter": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.rulers": [
88
],
"python.defaultInterpreterPath": ".venv/bin/python",
"python.formatting.provider": "black",
"python.linting.lintOnSave": true,
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.rulers": [
88
],
"editor.tabSize": 4,
"editor.detectIndentation": false,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll": "explicit"
"source.organizeImports": true
}
},
"[yaml]": {
@@ -19,11 +29,12 @@
"[typescript][javascript][vue][typescriptreact]": {
"editor.tabSize": 2,
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.formatOnPaste": false,
"editor.formatOnType": false,
"editor.formatOnSaveMode": "file",
"editor.formatOnPaste": false, // required
"editor.formatOnType": false, // required
"editor.formatOnSave": true, // optional
"editor.formatOnSaveMode": "file", // required to format on save
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
"source.fixAll.eslint": true
}
}
}

677
README.md
View File

@@ -1,8 +1,5 @@
# pydase (Python Data Service) <!-- omit from toc -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Documentation Status](https://readthedocs.org/projects/pydase/badge/?version=latest)](https://pydase.readthedocs.io/en/latest/?badge=latest)
`pydase` is a Python library for creating data service servers with integrated web and RPC servers. It's designed to handle the management of data structures, automated tasks, and callbacks, and provides built-in functionality for serving data over different protocols.
- [Features](#features)
@@ -12,26 +9,9 @@
- [Running the Server](#running-the-server)
- [Accessing the Web Interface](#accessing-the-web-interface)
- [Connecting to the Service using rpyc](#connecting-to-the-service-using-rpyc)
- [Understanding the Component System](#understanding-the-component-system)
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
- [Method Components](#method-components)
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
- [`DeviceConnection`](#deviceconnection)
- [`Image`](#image)
- [`NumberSlider`](#numberslider)
- [`ColouredEnum`](#colouredenum)
- [Extending with New Components](#extending-with-new-components)
- [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)
- [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)
@@ -39,38 +19,30 @@
## Features
<!-- no toc -->
- [Simple data service definition through class-based interface](#defining-a-dataService)
- [Integrated web interface for interactive access and control of your data service](#accessing-the-web-interface)
- [Support for `rpyc` connections, allowing for programmatic control and interaction with your service](#connecting-to-the-service-using-rpyc)
- [Component system bridging Python backend with frontend visual representation](#understanding-the-component-system)
- [Customizable styling for the web interface through user-defined CSS](#customizing-web-interface-style)
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
- [Support for units](#understanding-units-in-pydase)
<!-- Support for additional servers for specific use-cases -->
* [Integrated web interface for interactive access and control of your data service](#accessing-the-web-interface)
* [Support for `rpyc` connections, allowing for programmatic control and interaction with your service](#connecting-to-the-service-using-rpyc)
* [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
## Installation
<!--installation-start-->
Install `pydase` using [`poetry`](https://python-poetry.org/):
Install pydase using [`poetry`](https://python-poetry.org/):
```bash
poetry add pydase
poetry add git+https://github.com/tiqi-group/pydase.git
```
or `pip`:
```bash
pip install pydase
pip install git+https://github.com/tiqi-group/pydase.git
```
<!--installation-end-->
## Usage
<!--usage-start-->
Using `pydase` involves three main steps: defining a `DataService` subclass, running the server, and then connecting to the service either programmatically using `rpyc` or through the web interface.
### Defining a DataService
@@ -81,7 +53,6 @@ Here's an example:
```python
from pydase import DataService, Server
from pydase.utils.decorators import frontend
class Device(DataService):
@@ -119,7 +90,6 @@ class Device(DataService):
# run code to set power state
self._power = value
@frontend
def reset(self) -> None:
self.current = 0.0
self.voltage = 0.0
@@ -146,7 +116,7 @@ if __name__ == "__main__":
Server(service).run()
```
This will start the server, making your Device service accessible via RPC and a web server at [http://localhost:8001](http://localhost:8001).
This will start the server, making your Device service accessible via RPC and a web server at http://localhost:8001.
### Accessing the Web Interface
@@ -174,448 +144,11 @@ print(client.voltage) # prints 5.0
In this example, replace `<ip_addr>` with the IP address of the machine where the service is running. After establishing a connection, you can interact with the service attributes as if they were local attributes.
<!--usage-end-->
## Understanding the Component System
<!-- Component User Guide Start -->
In `pydase`, components are fundamental building blocks that bridge the Python backend logic with frontend visual representation and interactions. This system can be understood based on the following categories:
### Built-in Type and Enum Components
`pydase` automatically maps standard Python data types to their corresponding frontend components:
- `str`: Translated into a `StringComponent` on the frontend.
- `int` and `float`: Manifested as the `NumberComponent`.
- `bool`: Rendered as a `ButtonComponent`.
- `list`: Each item displayed individually, named after the list attribute and its index.
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
### Method Components
Within the `DataService` class of `pydase`, only methods devoid of arguments can be represented in the frontend, classified into two distinct categories
1. [**Tasks**](#understanding-tasks-in-pydase): Argument-free asynchronous functions, identified within `pydase` as tasks, are inherently designed for frontend interaction. These tasks are automatically rendered with a start/stop button, allowing users to initiate or halt the task execution directly from the web interface.
2. **Synchronous Methods with `@frontend` Decorator**: Synchronous methods without arguments can also be presented in the frontend. For this, they have to be decorated with the `@frontend` decorator.
```python
import pydase
import pydase.components
import pydase.units as u
from pydase.utils.decorators import frontend
class MyService(pydase.DataService):
@frontend
def exposed_method(self) -> None:
...
async def my_task(self) -> None:
while True:
# ...
```
![Method Components](docs/images/method_components.png)
You can still define synchronous tasks with arguments and call them using a python client. However, decorating them with the `@frontend` decorator will raise a `FunctionDefinitionError`. Defining a task with arguments will raise a `TaskDefinitionError`.
I decided against supporting function arguments for functions rendered in the frontend due to the following reasons:
1. Feature Request Pitfall: supporting function arguments create a bottomless pit of feature requests. As users encounter the limitations of supported types, demands for extending support to more complex types would grow.
2. Complexity in Supported Argument Types: while simple types like `int`, `float`, `bool` and `str` could be easily supported, more complicated types are not (representation, (de-)serialization).
### DataService Instances (Nested Classes)
Nested `DataService` instances offer an organized hierarchy for components, enabling richer applications. Each nested class might have its own attributes and methods, each mapped to a frontend component.
Here is an example:
```python
from pydase import DataService, Server
class Channel(DataService):
def __init__(self, channel_id: int) -> None:
super().__init__()
self._channel_id = channel_id
self._current = 0.0
@property
def current(self) -> float:
# run code to get current
result = self._current
return result
@current.setter
def current(self, value: float) -> None:
# run code to set current
self._current = value
class Device(DataService):
def __init__(self) -> None:
super().__init__()
self.channels = [Channel(i) for i in range(2)]
if __name__ == "__main__":
service = Device()
Server(service).run()
```
![Nested Classes App](docs/images/Nested_Class_App.png)
**Note** that defining classes within `DataService` classes is not supported (see [this issue](https://github.com/tiqi-group/pydase/issues/16)).
### Custom Components (`pydase.components`)
The custom components in `pydase` have two main parts:
- A **Python Component Class** in the backend, implementing the logic needed to set, update, and manage the component's state and data.
- A **Frontend React Component** that renders and manages user interaction in the browser.
Below are the components available in the `pydase.components` module, accompanied by their Python usage:
#### `DeviceConnection`
The `DeviceConnection` component acts as a base class within the `pydase` framework for managing device connections. It provides a structured approach to handle connections by offering a customizable `connect` method and a `connected` property. This setup facilitates the implementation of automatic reconnection logic, which periodically attempts reconnection whenever the connection is lost.
In the frontend, this class abstracts away the direct interaction with the `connect` method and the `connected` property. Instead, it showcases user-defined attributes, methods, and properties. When the `connected` status is `False`, the frontend displays an overlay that prompts manual reconnection through the `connect()` method. Successful reconnection removes the overlay.
```python
import pydase.components
import pydase.units as u
class Device(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
self._voltage = 10 * u.units.V
def connect(self) -> None:
if not self._connected:
self._connected = True
@property
def voltage(self) -> float:
return self._voltage
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.device = Device()
if __name__ == "__main__":
service_instance = MyService()
pydase.Server(service_instance).run()
```
![DeviceConnection Component](docs/images/DeviceConnection_component.png)
##### Customizing Connection Logic
Users are encouraged to primarily override the `connect` method to tailor the connection process to their specific device. This method should adjust the `self._connected` attribute based on the outcome of the connection attempt:
```python
import pydase.components
class MyDeviceConnection(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
# Add any necessary initialization code here
def connect(self) -> None:
# Implement device-specific connection logic here
# Update self._connected to `True` if the connection is successful,
# or `False` if unsuccessful
...
```
Moreover, if the connection status requires additional logic, users can override the `connected` property:
```python
import pydase.components
class MyDeviceConnection(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
# Add any necessary initialization code here
def connect(self) -> None:
# Implement device-specific connection logic here
# Ensure self._connected reflects the connection status accurately
...
@property
def connected(self) -> bool:
# Implement custom logic to accurately report connection status
return self._connected
```
##### Reconnection Interval
The `DeviceConnection` component automatically executes a task that checks for device availability at a default interval of 10 seconds. This interval is adjustable by modifying the `_reconnection_wait_time` attribute on the class instance.
#### `Image`
This component provides a versatile interface for displaying images within the application. Users can update and manage images from various sources, including local paths, URLs, and even matplotlib figures.
The component offers methods to load images seamlessly, ensuring that visual content is easily integrated and displayed within the data service.
```python
import matplotlib.pyplot as plt
import numpy as np
import pydase
from pydase.components.image import Image
class MyDataService(pydase.DataService):
my_image = Image()
if __name__ == "__main__":
service = MyDataService()
# loading from local path
service.my_image.load_from_path("/your/image/path/")
# loading from a URL
service.my_image.load_from_url("https://cataas.com/cat")
# loading a matplotlib figure
fig = plt.figure()
x = np.linspace(0, 2 * np.pi)
plt.plot(x, np.sin(x))
plt.grid()
service.my_image.load_from_matplotlib_figure(fig)
pydase.Server(service).run()
```
![Image Component](docs/images/Image_component.png)
#### `NumberSlider`
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.
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
import pydase.components
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: float = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
) -> None:
super().__init__(value, min_, max_, step_size)
@property
def min(self) -> float:
return self._min
@min.setter
def min(self, value: float) -> None:
self._min = value
@property
def max(self) -> float:
return self._max
@max.setter
def max(self, value: float) -> None:
self._max = value
@property
def step_size(self) -> float:
return self._step_size
@step_size.setter
def step_size(self, value: float) -> None:
self._step_size = value
@property
def value(self) -> float:
"""Slider value."""
return self._value
@value.setter
def value(self, value: float) -> None:
if value < self._min or value > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = value
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.voltage = MySlider()
if __name__ == "__main__":
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](#understanding-units-in-pydase) alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.
Here's how to implement a `NumberSlider` with unit display:
```python
import pydase
import pydase.components
import pydase.units as u
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: u.Quantity = 0.0 * u.units.V,
) -> None:
super().__init__(value)
@property
def value(self) -> u.Quantity:
return self._value
@value.setter
def value(self, value: u.Quantity) -> None:
if value.m < self._min or value.m > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = value
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.voltage = MySlider()
if __name__ == "__main__":
service_instance = MyService()
service_instance.voltage.value = 5 * u.units.V
print(service_instance.voltage.value) # Output: 5 V
pydase.Server(service_instance).run()
```
#### `ColouredEnum`
This component provides a way to visually represent different states or categories in a data service using colour-coded options. It behaves similarly to a standard `Enum`, but the values encode colours in a format understood by CSS. The colours can be defined using various methods like Hexadecimal, RGB, HSL, and more.
If the property associated with the `ColouredEnum` has a setter function, the keys of the enum will be rendered as a dropdown menu, allowing users to interact and select different options. Without a setter function, the selected key will simply be displayed as a coloured box with text inside, serving as a visual indicator.
```python
import pydase
import pydase.components as pyc
class MyStatus(pyc.ColouredEnum):
PENDING = "#FFA500" # Hexadecimal colour (Orange)
RUNNING = "#0000FF80" # Hexadecimal colour with transparency (Blue)
PAUSED = "rgb(169, 169, 169)" # RGB colour (Dark Gray)
RETRYING = "rgba(255, 255, 0, 0.3)" # RGB colour with transparency (Yellow)
COMPLETED = "hsl(120, 100%, 50%)" # HSL colour (Green)
FAILED = "hsla(0, 100%, 50%, 0.7)" # HSL colour with transparency (Red)
CANCELLED = "SlateGray" # Cross-browser colour name (Slate Gray)
class StatusTest(pydase.DataService):
_status = MyStatus.RUNNING
@property
def status(self) -> MyStatus:
return self._status
@status.setter
def status(self, value: MyStatus) -> None:
# do something ...
self._status = value
# Modifying or accessing the status value:
my_service = StatusExample()
my_service.status = MyStatus.FAILED
```
![ColouredEnum Component](docs/images/ColouredEnum_component.png)
#### Extending with New Components
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
<!-- Component User Guide End -->
## 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.
`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.
To save the state of your service, pass a `filename` keyword argument to the constructor of the `pydase.Server` class. If the file specified by `filename` does not exist, the state manager will create this file and store its state in it when the service is shut down. If the file already exists, the state manager will load the state from this file, setting the values of its attributes to the values stored in the file.
To save the state of your service, pass a `filename` keyword argument to the `__init__` method of the `DataService` base class. If the file specified by `filename` does not exist, the service will create this file and store its state in it when the service is shut down. If the file already exists, the service will load the state from this file, setting the values of its attributes to the values stored in the file.
Here's an example:
@@ -623,48 +156,29 @@ Here's an example:
from pydase import DataService, Server
class Device(DataService):
def __init__(self, filename: str) -> None:
# ... your init code ...
# Pass the filename argument to the parent class
super().__init__(filename=filename)
# ... defining the Device class ...
if __name__ == "__main__":
service = Device()
Server(service, filename="device_state.json").run()
service = Device("device_state.json")
Server(service).run()
```
In this example, the state of the `Device` service will be saved to `device_state.json` when the service is shut down. If `device_state.json` exists when the server is started, the state manager will restore the state of the service from this file.
In this example, the state of the `Device` service will be saved to `device_state.json` when the service is shut down. If `device_state.json` exists when the service is started, the service will restore its state from this file.
### Controlling Property State Loading with `@load_state`
By default, the state manager only restores values for public attributes of your service. If you have properties that you want to control the loading for, you can use the `@load_state` decorator on your property setters. This indicates to the state manager that the value of the property should be loaded from the state file.
Here is how you can apply the `@load_state` decorator:
```python
from pydase import DataService
from pydase.data_service.state_manager import load_state
class Device(DataService):
_name = "Default Device Name"
@property
def name(self) -> str:
return self._name
@name.setter
@load_state
def name(self, value: str) -> None:
self._name = value
```
With the `@load_state` decorator applied to the `name` property setter, the state manager will load and apply the `name` property's value from the file storing the state upon server startup, assuming it exists.
Note: If the service class structure has changed since the last time its state was saved, only the attributes and properties decorated with `@load_state` that have remained the same will be restored from the settings file.
Note: If the service class structure has changed since the last time its state was saved, only the attributes that have remained the same will be restored from the settings file.
## Understanding Tasks in pydase
In `pydase`, a task is defined as an asynchronous function without arguments contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions.
In `pydase`, a task is defined as an asynchronous function contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions.
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. One core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and python clients, giving you flexible and powerful control over your service's operation.
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. The core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and an `rpyc` client, giving you flexible and powerful control over your service's operation.
Another powerful feature of `pydase` is its ability to automatically start tasks upon initialization of the service. By specifying the tasks and their arguments in the `_autostart_tasks` dictionary in your service class's `__init__` method, `pydase` will automatically start these tasks when the server is started. Here's an example:
@@ -673,9 +187,9 @@ from pydase import DataService, Server
class SensorService(DataService):
def __init__(self):
super().__init__()
self.readout_frequency = 1.0
self._autostart_tasks["read_sensor_data"] = ()
self._autostart_tasks = {"read_sensor_data": ()} # args passed to the function go there
super().__init__()
def _process_data(self, data: ...) -> None:
...
@@ -695,22 +209,22 @@ if __name__ == "__main__":
Server(service).run()
```
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. By adding it to the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed.
As with all tasks, `pydase` will generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task. The readout frequency can be updated using the `readout_frequency` attribute.
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. The readout frequency can be updated using the `readout_frequency` attribute.
By listing it in the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed.
As with all tasks, `pydase` will also generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task.
## Understanding Units in pydase
`pydase` integrates with the [`pint`](https://pint.readthedocs.io/en/stable/) package to allow you to work with physical quantities within your service. This enables you to define attributes with units, making your service more expressive and ensuring consistency in the handling of physical quantities.
You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality.
You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality. These quantities can be set and accessed like regular attributes, and `pydase` will automatically handle the conversion between floats and quantities with units.
Here's an example:
```python
from typing import Any
import pydase.units as u
from pydase import DataService, Server
import pydase.units as u
class ServiceClass(DataService):
@@ -722,15 +236,17 @@ class ServiceClass(DataService):
return self._current
@current.setter
def current(self, value: u.Quantity) -> None:
def current(self, value: Any) -> None:
self._current = value
if __name__ == "__main__":
service = ServiceClass()
service.voltage = 10.0 * u.units.V
service.current = 1.5 * u.units.mA
# You can just set floats to the Quantity objects. The DataService __setattr__ will
# automatically convert this
service.voltage = 10.0
service.current = 1.5
Server(service).run()
```
@@ -763,127 +279,14 @@ 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.
### Changing the Log Level
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
The full documentation provides more detailed information about `pydase`, including advanced usage examples, API references, and tips for troubleshooting common issues. See the [full documentation](https://pydase.readthedocs.io/en/latest/) for more information.
The full documentation provides more detailed information about `pydase`, including advanced usage examples, API references, and tips for troubleshooting common issues. See the [full documentation](URL_TO_YOUR_DOCUMENTATION) for more information.
## Contributing
We welcome contributions! Please see [contributing.md](https://pydase.readthedocs.io/en/latest/about/contributing/) for details on how to contribute.
We welcome contributions! Please see [CONTRIBUTING.md](URL_TO_YOUR_CONTRIBUTING_GUIDELINES) for details on how to contribute.
## License
`pydase` is licensed under the [MIT License](https://github.com/tiqi-group/pydase/blob/main/LICENSE).
`pydase` is licensed under the [MIT License](./LICENSE).

View File

@@ -1,10 +0,0 @@
License
=======
`pydase` is released under the *MIT license*:
```{.license}
{%
include "../../LICENSE"
comments=false
%}
```

View File

@@ -1,3 +0,0 @@
.language-license{
background-color: #eeffcc !important;
}

View File

@@ -1,366 +0,0 @@
# Adding Components to `pydase`
This guide provides a step-by-step process for adding new components to the `pydase` package. Components in `pydase` consist of both backend (Python) and frontend (React) parts. They work together to create interactive and dynamic data services.
## Overview
A component in `pydase` is a unique combination of a backend class (e.g., `Image`) and its corresponding frontend React component. The backend class stores the attributes needed for the component, and possibly methods for setting those in the backend, while the frontend part is responsible for rendering and interacting with the component.
## Adding a Backend Component to `pydase`
Backend components belong in the `src/pydase/components` directory.
### Step 1: Create a New Python File in the Components Directory
Navigate to the `src/pydase/components` directory and create a new Python file for your component. The name of the file should be descriptive of the component's functionality.
For example, for a `Image` component, create a file named `image.py`.
### Step 2: Define the Backend Class
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain (public) methods which you can provide for the user to interact with the component from the backend (or python clients).
For the `Image` component, the class may look like this:
```python
# file: pydase/components/image.py
from pydase.data_service.data_service import DataService
class Image(DataService):
def __init__(
self,
) -> None:
super().__init__()
self._value: str = ""
self._format: str = ""
@property
def value(self) -> str:
return self._value
@property
def format(self) -> str:
return self._format
def load_from_path(self, path: Path | str) -> None:
# changing self._value and self._format
...
```
So, calling `load_from_path` will push the updated value and format to the browsers clients connected to the service.
### Step 3: Register the Backend Class
The component should be added to the `__init__.py` file to ensure `pydase` handles them properly:
```python
# file: pydase/components/__init__.py
from pydase.components.image import Image
from pydase.components.number_slider import NumberSlider
__all__ = [
"NumberSlider",
"Image", # add the new components here
]
```
### Step 4: Implement Necessary Methods (Optional)
If your component requires specific logic or methods, implement them within the class. Document any public methods or attributes to ensure that other developers understand their purpose and usage.
### Step 5: Write Tests for the Component (Recommended)
Consider writing unit tests for the component to verify its behavior. Place the tests in the appropriate directory within the `tests` folder.
For example, a test for the `Image` component could look like this:
```python
from pytest import CaptureFixture
from pydase.components.image import Image
from pydase.data_service.data_service import DataService
def test_Image(capsys: CaptureFixture) -> None:
class ServiceClass(DataService):
image = Image()
service_instance = ServiceClass()
service_instance.image.load_from_path("<path/to/image>.png")
assert service_instance.image.format == "PNG"
```
## Adding a Frontend Component to `pydase`
Frontend components in `pydase` live in the `frontend/src/components/` directory. Follow these steps to create and add a new frontend component:
### Step 1: Create a New React Component File in the Components Directory
Navigate to the `frontend/src/components/` directory and create a new React component file for your component. The name of the file should be descriptive of the component's functionality and reflect the naming conventions used in your project.
For example, for an `Image` component, create a file named `ImageComponent.tsx`.
### Step 2: Write the React Component Code
Write the React component code, following the structure and patterns used in existing components. Make sure to import necessary libraries and dependencies.
For example, for the `Image` component, a template could look like this:
```tsx
import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { LevelName } from './NotificationsComponent';
type ImageComponentProps = {
name: string; // needed to create the fullAccessPath
parentPath: string; // needed to create the fullAccessPath
readOnly: boolean; // component changable through frontend?
docString: string; // contains docstring of your component
displayName: string; // name defined in the web_settings.json
id: string; // unique identifier - created from fullAccessPath
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: ( // function used to communicate changes to the backend
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
// component-specific properties
value: string;
format: string;
};
export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { value, docString, format, addNotification, displayName, id } = props;
const renderCount = useRef(0);
const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
// Your component logic here
useEffect(() => {
renderCount.current++;
});
// This will trigger a notification if notifications are enabled.
useEffect(() => {
addNotification(`${fullAccessPath} changed.`);
}, [props.value]);
return (
<div className="component imageComponent" id={id}>
{/* Add the Card and Collapse components here if you want to be able to expand and
collapse your component. */}
<Card>
<Card.Header
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{displayName}
<DocStringComponent docString={docString} />
{open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
)}
{/* Your component TSX here */}
</Card.Body>
</Collapse>
</Card>
</div>
);
});
```
### Step 3: Emitting Updates to the Backend
React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for communicating these changes.<br>
There are two different events a component might want to trigger: updating an attribute or triggering a method. Below is a guide on how to emit these events from your frontend component:
1. **Updating Attributes**
Updating the value of an attribute or property in the backend is a very common requirement. However, we want to define components in a reusable way, i.e. they can be linked to the backend but also be used without emitting change events.<br>
This is why we pass a `changeCallback` function as a prop to the component which it can use to communicate changes. If no function is passed, the component can be used in forms, for example.
The `changeCallback` function takes the following arguments:
- `value`: the new value for the attribute, which must match the backend attribute type.
- `attributeName`: the name of the attribute within the `DataService` instance to update. Defaults to the `name` prop of the component.
- `prefix`: the access path for the parent object of the attribute to be updated. Defaults to the `parentPath` prop of the component.
- `callback`: the function that will be called when the server sends an acknowledgement. Defaults to `undefined`
For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
// file: frontend/src/components/ButtonComponent.tsx
// ... (import statements)
type ButtonComponentProps = {
// ...
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
};
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const {
// ...
changeCallback = () => {},
} = props;
const setChecked = (checked: boolean) => {
changeCallback(checked);
};
return (
<ToggleButton
// ... other props
onChange={(e) => setChecked(e.currentTarget.checked)}>
{/* component TSX */}
</ToggleButton>
);
});
```
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `changeCallback`.
2. **Triggering Methods**
To trigger method through your component, you can either use the `MethodComponent` (which will render a button in the frontend), or use the low-level `runMethod` function. Its parameters are slightly different to the `changeCallback` function:
- `name`: the name of the method to be executed in the backend.
- `parentPath`: the access path to the object containing the method.
- `kwargs`: a dictionary of keyword arguments that the method requires.
To see how to use the `MethodComponent` in your component, have a look at the `DeviceConnection.tsx` file. Here is an example that demonstrates the usage of the `runMethod` function (also, have a look at the `MethodComponent.tsx` file):
```tsx
import { runMethod } from '../socket';
// ... (other imports)
type ComponentProps = {
name: string;
parentPath: string;
// ...
};
export const Component = React.memo((props: ComponentProps) => {
const {
name,
parentPath,
// ...
} = props;
// ...
const someFunction = () => {
// ...
runMethod(name, parentPath, {});
};
return (
{/* component TSX */}
);
});
```
### Step 4: Add the New Component to the GenericComponent
The `GenericComponent` is responsible for rendering different types of components based on the attribute type. You can add the new `ImageComponent` to the `GenericComponent` by following these sub-steps:
#### 1. Import the New Component
At the beginning of the `GenericComponent` file, import the newly created `ImageComponent`:
```tsx
// file: frontend/src/components/GenericComponent.tsx
import { ImageComponent } from './ImageComponent';
```
#### 2. Update the AttributeType
Update the `AttributeType` type definition to include the new type for the `ImageComponent`.
For example, if the new attribute type is `'Image'` (which should correspond to the name of the backend component class), you can add it to the union:
```tsx
type AttributeType =
| 'str'
| 'bool'
| 'float'
| 'int'
| 'Quantity'
| 'list'
| 'method'
| 'DataService'
| 'Enum'
| 'NumberSlider'
| 'Image'; // Add the name of the backend component class here
```
#### 3. Add a Conditional Branch for the New Component
Inside the `GenericComponent` function, add a new conditional branch to render the `ImageComponent` when the attribute type is `'Image'`:
```tsx
} else if (attribute.type === 'Image') {
return (
<ImageComponent
name={name}
parentPath={parentPath}
docString={attribute.value['value'].doc}
displayName={displayName}
id={id}
addNotification={addNotification}
changeCallback={changeCallback}
// Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string}
/>
);
} else if (...) {
// other code
```
Make sure to update the props passed to the `ImageComponent` based on its specific requirements.
### Step 5: Adding Custom Notification Message (Optional)
In some cases, you may want to provide a custom notification message to the user when an attribute of a specific type is updated. This can be useful for enhancing user experience and providing contextual information about the changes.
For example, updating an `Image` component corresponds to setting a very long string. We don't want to display the whole string in the notification but just notify the user that the image was updated (and maybe also the format).
To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this:
```tsx
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => {
addNotification(`${fullAccessPath} changed.`);
}, [props.value]);
```
However, you might want to use the `addNotification` at different places. For an example, see the `MethodComponent`.
**Note**: you can specify the notification level by passing a string of type `LevelName` (one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'). The default value is 'DEBUG'.
### Step 6: Write Tests for the Component (TODO)
Test the frontend component to ensure that it renders correctly and interacts seamlessly
with the backend. Consider writing unit tests using a testing library like Jest or React
Testing Library, and manually test the component in the browser.

View File

@@ -1,27 +0,0 @@
# Observer Pattern Implementation in Pydase
## Overview
The Observer Pattern is a fundamental design pattern in the `pydase` package, serving as the central communication mechanism for state updates to clients connected to a service.
## How it Works
### The Observable Class
The `Observable` class is at the core of the pattern. It maintains a list of observers and is responsible for notifying them about state changes. It does so by overriding the following methods:
- `__setattr__`: This function emits a notification before and after a new value is set. These two notifications are important to track which attributes are being set to avoid endless recursion (e.g. when accessing a property within another property). Moreover, when setting an attribute to another observable, the former class will add itself as an observer to the latter class, ensuring that nested classes are properly observed.
- `__getattribute__`: This function notifies the observers when a property getter is called, allowing for monitoring state changes in remote devices, as opposed to local instance attributes.
### Custom Collection Classes
To handle collections (like lists and dictionaries), the `Observable` class converts them into custom collection classes `_ObservableList` and `_ObservableDict` that notify observers of any changes in their state. For this, they have to override the methods changing the state, e.g., `__setitem__` or `append` for lists.
### The Observer Class
The `Observer` is the final element in the chain of observers. The notifications of attribute changes it receives include the full access path (in dot-notation) and the new value. It implements logic to handle state changes, like caching, error logging for type changes, etc. This can be extended by custom notification callbacks (implemented using `add_notification_callback` in `DataServiceObserver`). This enables the user to perform specific actions in response to changes. In `pydase`, the web server adds an additional notification callback that emits the websocket events (`sio_callback`).
Furthermore, the `DataServiceObserver` implements logic to reload the values of properties when an attribute change occurs that a property depends on.
- **Dynamic Inspection**: The observer dynamically inspects the observable object (recursively) to create a mapping of properties and their dependencies. This mapping is constructed based on the class or instance attributes used within the source code of the property getters.
- **Dependency Management**: When a change in an attribute occurs, `DataServiceObserver` updates any properties that depend on this attribute. This ensures that the overall state remains consistent and up-to-date, especially in complex scenarios where properties depend on other instance attribute or properties.

View File

@@ -1,8 +0,0 @@
# Developer Guide
Extending `pydase`.
---
- [API Reference](api.md)
- [Adding Components](Adding_Components.md)

View File

View File

@@ -1,14 +0,0 @@
# Getting Started
## Installation
{%
include-markdown "../README.md"
start="<!--installation-start-->"
end="<!--installation-end-->"
%}
## Usage
{%
include-markdown "../README.md"
start="<!--usage-start-->"
end="<!--usage-end-->"
%}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1 +0,0 @@
{% include-markdown "../README.md" %}

View File

@@ -1,20 +0,0 @@
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
mergedeep==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-autorefs==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-include-markdown-plugin==3.9.1 ; python_version >= "3.10" and python_version < "4.0"
mkdocs==1.5.3 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings==0.22.0 ; python_version >= "3.10" and python_version < "4.0"
packaging==23.1 ; python_version >= "3.10" and python_version < "4.0"
pathspec==0.11.2 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "4.0"
pymdown-extensions==10.3 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
pyyaml-env-tag==0.1 ; python_version >= "3.10" and python_version < "4.0"
pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
watchdog==3.0.0 ; python_version >= "3.10" and python_version < "4.0"

View File

@@ -1,6 +0,0 @@
# Components Guide
{%
include-markdown "../../README.md"
start="<!-- Component User Guide Start -->"
end="<!-- Component User Guide End -->"
%}

View File

@@ -7,10 +7,12 @@
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"prettier/prettier": "error"
"no-console": 1, // Means warning
"prettier/prettier": 2 // Means error }
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fsouza/prettierd": "^0.25.1",
"@mui/material": "^5.14.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
@@ -47,12 +46,9 @@
"@types/node": "^20.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.9.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.0.3",
"typescript": "^4.9.0"
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"prettier": "^3.0.0",
"@babel/plugin-proposal-private-property-in-object": "7.21.11"
}
}

View File

@@ -1,52 +1,25 @@
body {
min-width: 576px;
max-width: 2000px;
max-width: 1200px;
}
input.instantUpdate {
background-color: rgba(255, 0, 0, 0.1);
}
.numberComponentButton {
padding: 0.15em 6px !important;
font-size: 0.70rem !important;
}
.navbarOffset {
padding-top: 60px !important;
right: 20;
}
.toastContainer {
position: fixed !important;
padding: 5px;
}
.debugToast,
.infoToast {
/* .toastContainer {
position: fixed;
} */
.notificationToast {
background-color: rgba(114, 214, 253, 0.5) !important;
}
.warningToast {
background-color: rgba(255, 181, 44, 0.603) !important;
}
.errorToast,
.criticalToast {
.exceptionToast {
background-color: rgba(216, 41, 18, 0.678) !important;
}
.component {
position: relative;
float: left !important;
padding: 5px !important;
z-index: 1;
}
.dataServiceComponent {
width: 100%;
}
.deviceConnectionComponent {
position: relative;
float: left !important;
width: 100%;
z-index: 1;
}
.overlayContent {
position: absolute;
inset: 5px; /* (see https://developer.mozilla.org/en-US/docs/Web/CSS/inset) */
background: rgba(155, 155, 155, 0.75);
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column; /* Stack children vertically */
border-radius: var(--bs-border-radius);
border: var(--bs-border-width) solid var(--bs-border-color-translucent)
}
}

View File

@@ -1,146 +1,204 @@
import { useCallback, useEffect, useReducer, useState } from 'react';
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
import { hostname, port, socket } from './socket';
import './App.css';
import {
Notifications,
Notification,
LevelName
} from './components/NotificationsComponent';
import { ConnectionToast } from './components/ConnectionToast';
import { setNestedValueByPath, State } from './utils/stateUtils';
import { WebSettingsContext, WebSetting } from './WebSettings';
import { SerializedValue, GenericComponent } from './components/GenericComponent';
DataServiceComponent,
DataServiceJSON
} from './components/DataServiceComponent';
import './App.css';
import { Notifications } from './components/NotificationsComponent';
type ValueType = boolean | string | number | object;
type State = DataServiceJSON | null;
type Action =
| { type: 'SET_DATA'; data: State }
| {
type: 'UPDATE_ATTRIBUTE';
fullAccessPath: string;
newValue: SerializedValue;
};
| { type: 'SET_DATA'; data: DataServiceJSON }
| { type: 'UPDATE_ATTRIBUTE'; parentPath: string; name: string; value: ValueType };
type UpdateMessage = {
data: { full_access_path: string; value: SerializedValue };
data: { parent_path: string; name: string; value: object };
};
type LogMessage = {
levelname: LevelName;
message: string;
type ExceptionMessage = {
data: { exception: string; type: string };
};
/**
* A function to update a specific property in a deeply nested object.
* The property to be updated is specified by a path array.
*
* Each path element can be a regular object key or an array index of the
* form "attribute[index]", where "attribute" is the key of the array in
* the object and "index" is the index of the element in the array.
*
* For array indices, the element at the specified index in the array is
* updated.
*
* If the property to be updated is an object or an array, it is updated
* recursively.
*
* @param {Array<string>} path - An array where each element is a key in the object,
* forming a path to the property to be updated.
* @param {object} obj - The object to be updated.
* @param {object} value - The new value for the property specified by the path.
* @return {object} - A new object with the specified property updated.
*/
function updateNestedObject(path: Array<string>, obj: object, value: ValueType) {
// Base case: If the path is empty, return the new value.
// This means we've reached the nested property to be updated.
if (path.length === 0) {
return value;
}
// Recursive case: If the path is not empty, split it into the first key and the rest
// of the path.
const [first, ...rest] = path;
// Check if 'first' is an array index.
const indexMatch = first.match(/^(\w+)\[(\d+)\]$/);
// If 'first' is an array index of the form "attribute[index]", then update the
// element at the specified index in the array. Otherwise, update the property
// specified by 'first' in the object.
if (indexMatch) {
const attribute = indexMatch[1];
const index = parseInt(indexMatch[2]);
if (Array.isArray(obj[attribute]?.value)) {
return {
...obj,
[attribute]: {
...obj[attribute],
value: obj[attribute].value.map((item, i) =>
i === index
? {
...item,
value: updateNestedObject(rest, item.value || {}, value)
}
: item
)
}
};
} else {
throw new Error(
`Expected ${attribute}.value to be an array, but received ${typeof obj[
attribute
]?.value}`
);
}
} else {
return {
...obj,
[first]: {
...obj[first],
value: updateNestedObject(rest, obj[first]?.value || {}, value)
}
};
}
}
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'SET_DATA':
return action.data;
case 'UPDATE_ATTRIBUTE': {
return {
...state,
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
};
const path = action.parentPath.split('.').slice(1).concat(action.name);
return updateNestedObject(path, state, action.value);
}
default:
throw new Error();
}
};
const App = () => {
const [state, dispatch] = useReducer(reducer, null);
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
const stateRef = useRef(state); // Declare a reference to hold the current state
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');
const [showNotification, setShowNotification] = useState(true);
const [notifications, setNotifications] = useState([]);
const [exceptions, setExceptions] = useState([]);
// 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`)
.then((response) => {
if (response.ok) {
// If the file exists, create a link element for the custom CSS
const link = document.createElement('link');
link.href = `http://${hostname}:${port}/custom.css`;
link.type = 'text/css';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
})
.catch(console.error); // Handle the error appropriately
socket.on('connect', () => {
// Fetch data from the API when the client connects
fetch(`http://${hostname}:${port}/service-properties`)
.then((response) => response.json())
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
fetch(`http://${hostname}:${port}/web-settings`)
.then((response) => response.json())
.then((data: Record<string, WebSetting>) => setWebSettings(data));
setConnectionStatus('connected');
});
socket.on('disconnect', () => {
setConnectionStatus('disconnected');
setTimeout(() => {
// Only set "reconnecting" is the state is still "disconnected"
// E.g. when the client has already reconnected
setConnectionStatus((currentState) =>
currentState === 'disconnected' ? 'reconnecting' : currentState
);
}, 2000);
});
// Fetch data from the API when the component mounts
fetch(`http://${hostname}:${port}/service-properties`)
.then((response) => response.json())
.then((data: DataServiceJSON) => dispatch({ type: 'SET_DATA', data }));
socket.on('notify', onNotify);
socket.on('log', onLogMessage);
socket.on('exception', onException);
return () => {
socket.off('notify', onNotify);
socket.off('log', onLogMessage);
socket.off('exception', onException);
};
}, []);
// Adding useCallback to prevent notify to change causing a re-render of all
// components
const addNotification = useCallback(
(message: string, levelname: LevelName = 'DEBUG') => {
// Getting the current time in the required format
const timeStamp = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
const addNotification = useCallback((text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ levelname, id, message, timeStamp },
...prevNotifications
]);
},
[]
);
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
}, []);
const notifyException = (text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setExceptions((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
};
const removeNotificationById = (id: number) => {
setNotifications((prevNotifications) =>
prevNotifications.filter((n) => n.id !== id)
);
};
const removeExceptionById = (id: number) => {
setExceptions((prevNotifications) => prevNotifications.filter((n) => n.id !== id));
};
const handleCloseSettings = () => setShowSettings(false);
const handleShowSettings = () => setShowSettings(true);
function onNotify(value: UpdateMessage) {
// Extracting data from the notification
const { full_access_path: fullAccessPath, value: newValue } = value.data;
const { parent_path: parentPath, name, value: newValue } = value.data;
// Dispatching the update to the reducer
dispatch({
type: 'UPDATE_ATTRIBUTE',
fullAccessPath,
newValue
parentPath,
name,
value: newValue
});
}
function onLogMessage(value: LogMessage) {
addNotification(value.message, value.levelname);
function onException(value: ExceptionMessage) {
const newException = `${value.data.type}: ${value.data.exception}.`;
notifyException(newException);
}
// While the data is loading
if (!state) {
return <ConnectionToast connectionStatus={connectionStatus} />;
return <p>Loading...</p>;
}
return (
<>
@@ -154,7 +212,9 @@ const App = () => {
<Notifications
showNotification={showNotification}
notifications={notifications}
exceptions={exceptions}
removeNotificationById={removeNotificationById}
removeExceptionById={removeExceptionById}
/>
<Offcanvas
@@ -183,17 +243,12 @@ const App = () => {
</Offcanvas>
<div className="App navbarOffset">
<WebSettingsContext.Provider value={webSettings}>
<GenericComponent
name=""
parentPath=""
attribute={state as SerializedValue}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
</WebSettingsContext.Provider>
<DataServiceComponent
props={state as DataServiceJSON}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
</div>
<ConnectionToast connectionStatus={connectionStatus} />
</>
);
};

View File

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

View File

@@ -1,49 +1,52 @@
import React, { useEffect, useRef } from 'react';
import { runMethod } from '../socket';
import { Form, Button, InputGroup } from 'react-bootstrap';
import { emit_update } from '../socket';
import { InputGroup, Form, Button } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
type AsyncMethodProps = {
interface AsyncMethodProps {
name: string;
parentPath: string;
value: 'RUNNING' | null;
parameters: Record<string, string>;
value: Record<string, string>;
docString?: string;
hideOutput?: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
render: boolean;
};
addNotification: (string) => void;
}
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const {
name,
parentPath,
docString,
value: runningTask,
addNotification,
displayName,
id
} = props;
// Conditional rendering based on the 'render' prop.
if (!props.render) {
return null;
}
const { name, parentPath, docString, value: runningTask, addNotification } = props;
const renderCount = useRef(0);
const formRef = useRef(null);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => {
renderCount.current++;
// updates the value of each form control that has a matching name in the
// runningTask object
if (runningTask) {
const formElement = formRef.current;
if (formElement) {
Object.entries(runningTask).forEach(([name, value]) => {
const inputElement = formElement.elements.namedItem(name);
if (inputElement) {
inputElement.value = value;
}
});
}
}
}, [runningTask]);
useEffect(() => {
let message: string;
if (runningTask === null) {
message = `${fullAccessPath} task was stopped.`;
message = `${parentPath}.${name} task was stopped.`;
} else {
message = `${fullAccessPath} was started.`;
const runningTaskEntries = Object.entries(runningTask)
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
message = `${parentPath}.${name} was started with parameters { ${runningTaskEntries} }.`;
}
addNotification(message);
}, [props.value]);
@@ -51,31 +54,58 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const execute = async (event: React.FormEvent) => {
event.preventDefault();
let method_name: string;
const args = {};
if (runningTask !== undefined && runningTask !== null) {
method_name = `stop_${name}`;
} else {
Object.keys(props.parameters).forEach(
(name) => (args[name] = event.target[name].value)
);
method_name = `start_${name}`;
}
runMethod(method_name, parentPath, {});
emit_update(method_name, parentPath, { args: args });
};
const args = Object.entries(props.parameters).map(([name, type], index) => {
const form_name = `${name} (${type})`;
const value = runningTask && runningTask[name];
const isRunning = value !== undefined && value !== null;
return (
<InputGroup key={index}>
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
<Form.Control
type="text"
name={name}
defaultValue={isRunning ? value : ''}
disabled={isRunning}
/>
</InputGroup>
);
});
return (
<div className="component asyncMethodComponent" id={id}>
<div
className="align-items-center asyncMethodComponent"
id={parentPath.concat('.' + name)}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<p>Render count: {renderCount.current}</p>
)}
<h5>
Function: {name}
<DocStringComponent docString={docString} />
</h5>
<Form onSubmit={execute} ref={formRef}>
<InputGroup>
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
<Button id={`button-${id}`} type="submit">
{runningTask === 'RUNNING' ? 'Stop ' : 'Start '}
</Button>
</InputGroup>
{args}
<Button
id={`button-${parentPath}.${name}`}
name={name}
value={parentPath}
type="submit">
{runningTask ? 'Stop' : 'Start'}
</Button>
</Form>
</div>
);

View File

@@ -1,40 +1,22 @@
import React, { useEffect, useRef } from 'react';
import { ToggleButton } from 'react-bootstrap';
import { emit_update } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
type ButtonComponentProps = {
interface ButtonComponentProps {
name: string;
parentPath?: string;
value: boolean;
readOnly: boolean;
docString: string;
mapping?: [string, string]; // Enforce a tuple of two strings
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
addNotification: (string) => void;
}
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const {
value,
readOnly,
docString,
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
const { name, parentPath, value, readOnly, docString, mapping, addNotification } =
props;
const buttonName = mapping ? (value ? mapping[0] : mapping[1]) : name;
const renderCount = useRef(0);
@@ -43,29 +25,29 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
});
useEffect(() => {
addNotification(`${fullAccessPath} changed to ${value}.`);
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
const setChecked = (checked: boolean) => {
changeCallback(checked);
emit_update(name, parentPath, checked);
};
return (
<div className={'component buttonComponent'} id={id}>
<div className={'buttonComponent'} id={parentPath.concat('.' + name)}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
<ToggleButton
id={`toggle-check-${id}`}
id={`toggle-check-${parentPath}.${name}`}
type="checkbox"
variant={value ? 'success' : 'secondary'}
checked={value}
value={displayName}
value={parentPath}
disabled={readOnly}
onChange={(e) => setChecked(e.currentTarget.checked)}>
{displayName}
<DocStringComponent docString={docString} />
<p>{buttonName}</p>
</ToggleButton>
</div>
);

View File

@@ -1,100 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
type ColouredEnumComponentProps = {
name: string;
parentPath: string;
value: string;
docString?: string;
readOnly: boolean;
enumDict: Record<string, string>;
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => {
const {
name,
value,
docString,
enumDict,
readOnly,
addNotification,
displayName,
id
} = props;
let { changeCallback } = props;
if (changeCallback === undefined) {
changeCallback = (value: string) => {
setEnumValue(() => {
return value;
});
};
}
const renderCount = useRef(0);
const [enumValue, setEnumValue] = useState(value);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
setEnumValue(() => {
return props.value;
});
addNotification(`${fullAccessPath} changed to ${value}.`);
}, [props.value]);
return (
<div className={'component enumComponent'} id={id}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<Row>
<Col className="d-flex align-items-center">
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
{readOnly ? (
// Display the Form.Control when readOnly is true
<Form.Control
value={enumValue}
name={name}
disabled={true}
style={{ backgroundColor: enumDict[enumValue] }}
/>
) : (
// Display the Form.Select when readOnly is false
<Form.Select
aria-label="coloured-enum-select"
value={enumValue}
name={name}
style={{ backgroundColor: enumDict[enumValue] }}
onChange={(event) => changeCallback(event.target.value)}>
{Object.entries(enumDict).map(([key]) => (
<option key={key} value={key}>
{key}
</option>
))}
</Form.Select>
)}
</Col>
</Row>
</div>
);
});

View File

@@ -1,86 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Toast, Button, ToastContainer } from 'react-bootstrap';
type ConnectionToastProps = {
connectionStatus: string;
};
/**
* ConnectionToast Component
*
* Displays a toast notification that reflects the current connection status.
*
* Props:
* - connectionStatus (string): The current status of the connection which can be
* 'connecting', 'connected', 'disconnected', or 'reconnecting'. The component uses this
* status to determine the message, background color (`bg`), and auto-hide delay of the toast.
*
* The toast is designed to automatically appear based on changes to the `connectionStatus` prop
* and provides a close button to manually dismiss the toast. It uses `react-bootstrap`'s Toast
* component to show the connection status in a stylized format, and Bootstrap's utility classes
* for alignment and spacing.
*/
export const ConnectionToast = React.memo(
({ connectionStatus }: ConnectionToastProps) => {
const [show, setShow] = useState(true);
useEffect(() => {
setShow(true);
}, [connectionStatus]);
const handleClose = () => setShow(false);
const getToastContent = (): {
message: string;
bg: string; // bootstrap uses `bg` prop for background color
delay: number | undefined;
} => {
switch (connectionStatus) {
case 'connecting':
return {
message: 'Connecting...',
bg: 'info',
delay: undefined
};
case 'connected':
return { message: 'Connected', bg: 'success', delay: 1000 };
case 'disconnected':
return {
message: 'Disconnected',
bg: 'danger',
delay: undefined
};
case 'reconnecting':
return {
message: 'Reconnecting...',
bg: 'info',
delay: undefined
};
default:
return {
message: '',
bg: 'info',
delay: undefined
};
}
};
const { message, bg, delay } = getToastContent();
return (
<ToastContainer position="bottom-center" className="toastContainer">
<Toast
show={show}
onClose={handleClose}
delay={delay}
autohide={delay !== undefined}
bg={bg}>
<Toast.Body className="d-flex justify-content-between">
{message}
<Button variant="close" size="sm" onClick={handleClose} />
</Toast.Body>
</Toast>
</ToastContainer>
);
}
);

View File

@@ -2,73 +2,53 @@ import { useState } from 'react';
import React from 'react';
import { Card, Collapse } from 'react-bootstrap';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { SerializedValue, GenericComponent } from './GenericComponent';
import { LevelName } from './NotificationsComponent';
import { Attribute, GenericComponent } from './GenericComponent';
type DataServiceProps = {
name: string;
props: DataServiceJSON;
parentPath?: string;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
addNotification: (string) => void;
};
export type DataServiceJSON = Record<string, SerializedValue>;
export type DataServiceJSON = Record<string, Attribute>;
export const DataServiceComponent = React.memo(
({
name,
props,
parentPath = undefined,
parentPath = 'DataService',
isInstantUpdate,
addNotification,
displayName,
id
addNotification
}: DataServiceProps) => {
const [open, setOpen] = useState(true);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
if (displayName !== '') {
return (
<div className="component dataServiceComponent" id={id}>
<Card>
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{Object.entries(props).map(([key, value]) => (
return (
<div className="dataServiceComponent">
<Card className="mb-3">
<Card.Header
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{parentPath} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{Object.entries(props).map(([key, value]) => {
return (
<GenericComponent
key={key}
attribute={value}
name={key}
parentPath={fullAccessPath}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
))}
</Card.Body>
</Collapse>
</Card>
</div>
);
} else {
return (
<div className="component dataServiceComponent" id={id}>
{Object.entries(props).map(([key, value]) => (
<GenericComponent
key={key}
attribute={value}
name={key}
parentPath={fullAccessPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
))}
</div>
);
}
);
})}
</Card.Body>
</Collapse>
</Card>
</div>
);
}
);

View File

@@ -1,61 +0,0 @@
import React from 'react';
import { LevelName } from './NotificationsComponent';
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { MethodComponent } from './MethodComponent';
type DeviceConnectionProps = {
name: string;
props: DataServiceJSON;
parentPath: string;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
};
export const DeviceConnectionComponent = React.memo(
({
name,
props,
parentPath,
isInstantUpdate,
addNotification,
displayName,
id
}: DeviceConnectionProps) => {
const { connected, connect, ...updatedProps } = props;
const connectedVal = connected.value;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
return (
<div className="deviceConnectionComponent" id={id}>
{!connectedVal && (
<div className="overlayContent">
<div>
{displayName != '' ? displayName : 'Device'} is currently not available!
</div>
<MethodComponent
name="connect"
parentPath={fullAccessPath}
docString={connect.doc}
addNotification={addNotification}
displayName={'reconnect'}
id={id + '-connect'}
render={true}
/>
</div>
)}
<DataServiceComponent
name={name}
props={updatedProps}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
displayName={displayName}
id={id}
/>
</div>
);
}
);

View File

@@ -1,9 +1,9 @@
import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
import React from 'react';
type DocStringProps = {
interface DocStringProps {
docString?: string;
};
}
export const DocStringComponent = React.memo((props: DocStringProps) => {
const { docString } = props;

View File

@@ -1,90 +1,60 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { emit_update } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
type EnumComponentProps = {
interface EnumComponentProps {
name: string;
parentPath: string;
value: string;
docString?: string;
readOnly: boolean;
enumDict: Record<string, string>;
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
addNotification: (string) => void;
}
export const EnumComponent = React.memo((props: EnumComponentProps) => {
const {
name,
parentPath: parentPath,
value,
docString,
enumDict,
addNotification,
displayName,
id,
readOnly
addNotification
} = props;
let { changeCallback } = props;
if (changeCallback === undefined) {
changeCallback = (value: string) => {
setEnumValue(() => {
return value;
});
};
}
const renderCount = useRef(0);
const [enumValue, setEnumValue] = useState(value);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
addNotification(`${fullAccessPath} changed to ${value}.`);
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
const handleValueChange = (newValue: string) => {
emit_update(name, parentPath, newValue);
};
return (
<div className={'component enumComponent'} id={id}>
<div className={'enumComponent'} id={parentPath.concat('.' + name)}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
<Row>
<Col className="d-flex align-items-center">
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
{readOnly ? (
// Display the Form.Control when readOnly is true
<Form.Control value={enumValue} name={name} disabled={true} />
) : (
// Display the Form.Select when readOnly is false
<Form.Select
aria-label="example-select"
value={enumValue}
name={name}
onChange={(event) => changeCallback(event.target.value)}>
{Object.entries(enumDict).map(([key, val]) => (
<option key={key} value={key}>
{key} - {val}
</option>
))}
</Form.Select>
)}
<InputGroup.Text>{name}</InputGroup.Text>
<Form.Select
aria-label="Default select example"
value={value}
onChange={(event) => handleValueChange(event.target.value)}>
{Object.entries(enumDict).map(([key, val]) => (
<option key={key} value={key}>
{key} - {val}
</option>
))}
</Form.Select>
</Col>
</Row>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React from 'react';
import { ButtonComponent } from './ButtonComponent';
import { NumberComponent } from './NumberComponent';
import { SliderComponent } from './SliderComponent';
@@ -8,13 +8,7 @@ import { AsyncMethodComponent } from './AsyncMethodComponent';
import { StringComponent } from './StringComponent';
import { ListComponent } from './ListComponent';
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { DeviceConnectionComponent } from './DeviceConnection';
import { ImageComponent } from './ImageComponent';
import { ColouredEnumComponent } from './ColouredEnumComponent';
import { LevelName } from './NotificationsComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { WebSettingsContext } from '../WebSettings';
import { setAttribute } from '../socket';
type AttributeType =
| 'str'
@@ -25,28 +19,26 @@ type AttributeType =
| 'list'
| 'method'
| 'DataService'
| 'DeviceConnection'
| 'Enum'
| 'NumberSlider'
| 'Image'
| 'ColouredEnum';
| 'Image';
type ValueType = boolean | string | number | Record<string, unknown>;
export type SerializedValue = {
type ValueType = boolean | string | number | object;
export interface Attribute {
type: AttributeType;
value?: ValueType | ValueType[];
readonly: boolean;
doc?: string | null;
parameters?: Record<string, string>;
async?: boolean;
frontend_render?: boolean;
enum?: Record<string, string>;
};
}
type GenericComponentProps = {
attribute: SerializedValue;
attribute: Attribute;
name: string;
parentPath: string;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
addNotification: (string) => void;
};
export const GenericComponent = React.memo(
@@ -57,24 +49,6 @@ export const GenericComponent = React.memo(
isInstantUpdate,
addNotification
}: GenericComponentProps) => {
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;
}
function changeCallback(
value: unknown,
attributeName: string = name,
prefix: string = parentPath,
callback: (ack: unknown) => void = undefined
) {
setAttribute(attributeName, prefix, value, callback);
}
if (attribute.type === 'bool') {
return (
<ButtonComponent
@@ -84,9 +58,6 @@ export const GenericComponent = React.memo(
readOnly={attribute.readonly}
value={Boolean(attribute.value)}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'float' || attribute.type === 'int') {
@@ -100,9 +71,6 @@ export const GenericComponent = React.memo(
value={Number(attribute.value)}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'Quantity') {
@@ -117,9 +85,6 @@ export const GenericComponent = React.memo(
unit={attribute.value['unit']}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'NumberSlider') {
@@ -127,17 +92,14 @@ export const GenericComponent = React.memo(
<SliderComponent
name={name}
parentPath={parentPath}
docString={attribute.value['value'].doc}
docString={attribute.doc}
readOnly={attribute.readonly}
value={attribute.value['value']}
min={attribute.value['min']}
max={attribute.value['max']}
stepSize={attribute.value['step_size']}
value={attribute.value['value']['value']}
min={attribute.value['min']['value']}
max={attribute.value['max']['value']}
stepSize={attribute.value['step_size']['value']}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'Enum') {
@@ -147,12 +109,8 @@ export const GenericComponent = React.memo(
parentPath={parentPath}
docString={attribute.doc}
value={String(attribute.value)}
readOnly={attribute.readonly}
enumDict={attribute.enum}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'method') {
@@ -162,10 +120,8 @@ export const GenericComponent = React.memo(
name={name}
parentPath={parentPath}
docString={attribute.doc}
parameters={attribute.parameters}
addNotification={addNotification}
displayName={displayName}
id={id}
render={attribute.frontend_render}
/>
);
} else {
@@ -174,11 +130,9 @@ export const GenericComponent = React.memo(
name={name}
parentPath={parentPath}
docString={attribute.doc}
parameters={attribute.parameters}
value={attribute.value as Record<string, string>}
addNotification={addNotification}
displayName={displayName}
id={id}
render={attribute.frontend_render}
/>
);
}
@@ -192,45 +146,26 @@ export const GenericComponent = React.memo(
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'DataService') {
return (
<DataServiceComponent
name={name}
props={attribute.value as DataServiceJSON}
parentPath={parentPath}
parentPath={parentPath.concat('.', name)}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'DeviceConnection') {
return (
<DeviceConnectionComponent
name={name}
props={attribute.value as DataServiceJSON}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'list') {
return (
<ListComponent
name={name}
value={attribute.value as SerializedValue[]}
value={attribute.value as Attribute[]}
docString={attribute.doc}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
id={id}
/>
);
} else if (attribute.type === 'Image') {
@@ -238,28 +173,12 @@ export const GenericComponent = React.memo(
<ImageComponent
name={name}
parentPath={parentPath}
docString={attribute.value['value'].doc}
displayName={displayName}
id={id}
addNotification={addNotification}
// Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string}
/>
);
} else if (attribute.type === 'ColouredEnum') {
return (
<ColouredEnumComponent
name={name}
parentPath={parentPath}
docString={attribute.doc}
value={String(attribute.value)}
readOnly={attribute.readonly}
enumDict={attribute.enum}
docString={attribute.doc}
// Add any other specific props for the ImageComponent here
format={attribute.value['format']['value'] as string}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else {

View File

@@ -2,52 +2,47 @@ import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { LevelName } from './NotificationsComponent';
type ImageComponentProps = {
interface ImageComponentProps {
name: string;
parentPath: string;
value: string;
readOnly: boolean;
docString: string;
format: string;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
};
addNotification: (string) => void;
}
export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { value, docString, format, addNotification, displayName, id } = props;
const { name, parentPath, value, docString, format, addNotification } = props;
const renderCount = useRef(0);
const [open, setOpen] = useState(true);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
addNotification(`${fullAccessPath} changed.`);
addNotification(`${parentPath}.${name} changed.`);
}, [props.value]);
return (
<div className="component imageComponent" id={id}>
<div className={'imageComponent'} id={parentPath.concat('.' + name)}>
<Card>
<Card.Header
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{displayName}
<DocStringComponent docString={docString} />
{open ? <ChevronDown /> : <ChevronRight />}
{name} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
{/* Your component JSX here */}
{format === '' && value === '' ? (
<p>No image set in the backend.</p>
) : (

View File

@@ -1,20 +1,18 @@
import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from './DocStringComponent';
import { SerializedValue, GenericComponent } from './GenericComponent';
import { LevelName } from './NotificationsComponent';
import { Attribute, GenericComponent } from './GenericComponent';
type ListComponentProps = {
interface ListComponentProps {
name: string;
parentPath?: string;
value: SerializedValue[];
value: Attribute[];
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
id: string;
};
addNotification: (string) => void;
}
export const ListComponent = React.memo((props: ListComponentProps) => {
const { name, parentPath, value, docString, isInstantUpdate, addNotification, id } =
const { name, parentPath, value, docString, isInstantUpdate, addNotification } =
props;
const renderCount = useRef(0);
@@ -24,9 +22,9 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
}, [props]);
return (
<div className={'listComponent'} id={id}>
<div className={'listComponent'} id={parentPath.concat(name)}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
{value.map((item, index) => {

View File

@@ -1,59 +1,108 @@
import React, { useEffect, useRef } from 'react';
import { runMethod } from '../socket';
import { Button, Form } from 'react-bootstrap';
import React, { useState, useEffect, useRef } from 'react';
import { emit_update } from '../socket';
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
type MethodProps = {
interface MethodProps {
name: string;
parentPath: string;
parameters: Record<string, string>;
docString?: string;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
render: boolean;
};
hideOutput?: boolean;
addNotification: (string) => void;
}
export const MethodComponent = React.memo((props: MethodProps) => {
const { name, parentPath, docString, addNotification, displayName, id } = props;
// Conditional rendering based on the 'render' prop.
if (!props.render) {
return null;
}
const { name, parentPath, docString, addNotification } = props;
const renderCount = useRef(0);
const formRef = useRef(null);
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
const [hideOutput, setHideOutput] = useState(false);
// Add a new state variable to hold the list of function calls
const [functionCalls, setFunctionCalls] = useState([]);
const triggerNotification = () => {
const message = `Method ${fullAccessPath} was triggered.`;
useEffect(() => {
renderCount.current++;
if (props.hideOutput !== undefined) {
setHideOutput(props.hideOutput);
}
});
const triggerNotification = (args: Record<string, string>) => {
const argsString = Object.entries(args)
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
let message = `Method ${parentPath}.${name} was triggered`;
if (argsString === '') {
message += '.';
} else {
message += ` with arguments {${argsString}}.`;
}
addNotification(message);
};
const execute = async (event: React.FormEvent) => {
event.preventDefault();
runMethod(name, parentPath, {});
triggerNotification();
const args = {};
Object.keys(props.parameters).forEach(
(name) => (args[name] = event.target[name].value)
);
emit_update(name, parentPath, { args: args }, (ack) => {
// Update the functionCalls state with the new call if we get an acknowledge msg
if (ack !== undefined) {
setFunctionCalls((prevCalls) => [...prevCalls, { name, args, result: ack }]);
}
});
triggerNotification(args);
};
useEffect(() => {
renderCount.current++;
const args = Object.entries(props.parameters).map(([name, type], index) => {
const form_name = `${name} (${type})`;
return (
<InputGroup key={index}>
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
<Form.Control type="text" name={name} />
</InputGroup>
);
});
return (
<div className="component methodComponent" id={id}>
<div
className="align-items-center methodComponent"
id={parentPath.concat('.' + name)}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<p>Render count: {renderCount.current}</p>
)}
<Form onSubmit={execute} ref={formRef}>
<Button className="component" variant="primary" type="submit">
{`${displayName} `}
<DocStringComponent docString={docString} />
</Button>
<h5 onClick={() => setHideOutput(!hideOutput)} style={{ cursor: 'pointer' }}>
Function: {name}
<DocStringComponent docString={docString} />
</h5>
<Form onSubmit={execute}>
{args}
<div>
<Button variant="primary" type="submit">
Execute
</Button>
</div>
</Form>
<Collapse in={!hideOutput}>
<div id="function-output">
{functionCalls.map((call, index) => (
<div key={index}>
<div style={{ color: 'grey', fontSize: 'small' }}>
{Object.entries(call.args)
.map(([key, val]) => `${key}=${JSON.stringify(val)}`)
.join(', ') +
' => ' +
JSON.stringify(call.result)}
</div>
</div>
))}
</div>
</Collapse>
</div>
);
});

View File

@@ -1,71 +1,73 @@
import React from 'react';
import { ToastContainer, Toast } from 'react-bootstrap';
export type LevelName = 'CRITICAL' | 'ERROR' | 'WARNING' | 'INFO' | 'DEBUG';
export type Notification = {
id: number;
timeStamp: string;
message: string;
levelname: LevelName;
time: string;
text: string;
};
type NotificationProps = {
showNotification: boolean;
notifications: Notification[];
exceptions: Notification[];
removeNotificationById: (id: number) => void;
removeExceptionById: (id: number) => void;
};
export const Notifications = React.memo((props: NotificationProps) => {
const { showNotification, notifications, removeNotificationById } = props;
const {
showNotification,
notifications,
exceptions,
removeExceptionById,
removeNotificationById
} = props;
return (
<ToastContainer className="navbarOffset toastContainer" position="top-end">
{notifications.map((notification) => {
// Determine if the toast should be shown
const shouldShow =
notification.levelname === 'ERROR' ||
notification.levelname === 'CRITICAL' ||
(showNotification &&
['WARNING', 'INFO', 'DEBUG'].includes(notification.levelname));
if (!shouldShow) {
return null;
}
return (
<ToastContainer
className="navbarOffset toastContainer"
position="top-end"
style={{ position: 'fixed' }}>
{showNotification &&
notifications.map((notification) => (
<Toast
className={notification.levelname.toLowerCase() + 'Toast'}
className="notificationToast"
key={notification.id}
onClose={() => removeNotificationById(notification.id)}
onClick={() => removeNotificationById(notification.id)}
onClick={() => {
removeNotificationById(notification.id);
}}
onMouseLeave={() => {
if (notification.levelname !== 'ERROR') {
removeNotificationById(notification.id);
}
removeNotificationById(notification.id);
}}
show={true}
autohide={
notification.levelname === 'WARNING' ||
notification.levelname === 'INFO' ||
notification.levelname === 'DEBUG'
}
delay={
notification.levelname === 'WARNING' ||
notification.levelname === 'INFO' ||
notification.levelname === 'DEBUG'
? 2000
: undefined
}>
<Toast.Header
closeButton={false}
className={notification.levelname.toLowerCase() + 'Toast text-right'}>
<strong className="me-auto">{notification.levelname}</strong>
<small>{notification.timeStamp}</small>
autohide={true}
delay={2000}>
<Toast.Header closeButton={false} className="notificationToast text-right">
<strong className="me-auto">Notification</strong>
<small>{notification.time}</small>
</Toast.Header>
<Toast.Body>{notification.message}</Toast.Body>
<Toast.Body>{notification.text}</Toast.Body>
</Toast>
);
})}
))}
{exceptions.map((exception) => (
<Toast
className="exceptionToast"
key={exception.id}
onClose={() => removeExceptionById(exception.id)}
onClick={() => {
removeExceptionById(exception.id);
}}
show={true}
autohide={false}>
<Toast.Header closeButton className="exceptionToast text-right">
<strong className="me-auto">Exception</strong>
<small>{exception.time}</small>
</Toast.Header>
<Toast.Body>{exception.text}</Toast.Body>
</Toast>
))}
</ToastContainer>
);
});

View File

@@ -1,35 +1,12 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from 'react-bootstrap';
import { emit_update } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
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;
type NumberComponentProps = {
interface NumberComponentProps {
name: string;
type: 'float' | 'int';
parentPath?: string;
@@ -38,24 +15,23 @@ type NumberComponentProps = {
docString: string;
isInstantUpdate: boolean;
unit?: string;
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
showName?: boolean;
customEmitUpdate?: (
name: string,
parent_path: string,
value: number,
callback?: (ack: unknown) => void
) => void;
displayName?: string;
id: string;
};
addNotification: (string) => void;
}
// TODO: highlight the digit that is being changed by setting both selectionStart and
// selectionEnd
const handleArrowKey = (
key: string,
value: string,
selectionStart: number
// selectionEnd: number
selectionStart: number,
selectionEnd: number
) => {
// Split the input value into the integer part and decimal part
const parts = value.split('.');
@@ -132,57 +108,88 @@ const handleDeleteKey = (
return { value, selectionStart };
};
const handleNumericKey = (
key: string,
value: string,
selectionStart: number,
selectionEnd: number
) => {
// Check if a number key or a decimal point key is pressed
if (key === '.' && value.includes('.')) {
// Check if value already contains a decimal. If so, ignore input.
console.warn('Invalid input! Ignoring...');
return { value, selectionStart };
}
let newValue = value;
// Add the new key at the cursor's position
if (selectionEnd > selectionStart) {
// If there is a selection, replace it with the key
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
} else {
// otherwise, append the key after the selection start
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
}
return { value: newValue, selectionStart: selectionStart + 1 };
};
export const NumberComponent = React.memo((props: NumberComponentProps) => {
const {
name,
value,
parentPath,
readOnly,
type,
docString,
isInstantUpdate,
unit,
addNotification,
changeCallback = () => {},
displayName,
id
addNotification
} = props;
// Whether to show the name infront of the component (false if used with a slider)
const showName = props.showName !== undefined ? props.showName : true;
// If emitUpdate is passed, use this instead of the emit_update from the socket
// Also used when used with a slider
const emitUpdate =
props.customEmitUpdate !== undefined ? props.customEmitUpdate : emit_update;
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(value.toString());
const renderCount = useRef(0);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
const [inputString, setInputString] = useState(props.value.toString());
useEffect(() => {
renderCount.current++;
// Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(
parentPath.concat(name)
)[0] as HTMLInputElement;
if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange(cursorPosition, cursorPosition);
}
});
useEffect(() => {
// Parse the input string to a number for comparison
const numericInputString =
props.type === 'int' ? parseInt(inputString) : parseFloat(inputString);
// Only update the inputString if it's different from the prop value
if (props.value !== numericInputString) {
setInputString(props.value.toString());
}
// emitting notification
let notificationMsg = `${parentPath}.${name} changed to ${props.value}`;
if (unit === undefined) {
notificationMsg += '.';
} else {
notificationMsg += ` ${unit}.`;
}
addNotification(notificationMsg);
}, [props.value]);
const handleNumericKey = (
key: string,
value: string,
selectionStart: number,
selectionEnd: number
) => {
// Check if a number key or a decimal point key is pressed
if (key === '.' && (value.includes('.') || props.type === 'int')) {
// Check if value already contains a decimal. If so, ignore input.
// eslint-disable-next-line no-console
console.warn('Invalid input! Ignoring...');
return { value, selectionStart };
}
let newValue = value;
// Add the new key at the cursor's position
if (selectionEnd > selectionStart) {
// If there is a selection, replace it with the key
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
} else {
// otherwise, append the key after the selection start
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
}
return { value: newValue, selectionStart: selectionStart + 1 };
};
const handleKeyDown = (event) => {
const { key, target } = event;
if (
@@ -207,16 +214,6 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Select everything when pressing Ctrl + a
target.setSelectionRange(0, target.value.length);
return;
} else if (key === '-') {
if (selectionStart === 0 && !value.startsWith('-')) {
newValue = '-' + value;
selectionStart++;
} else if (value.startsWith('-') && selectionStart === 1) {
newValue = value.substring(1); // remove minus sign
selectionStart--;
} else {
return; // Ignore "-" pressed in other positions
}
} else if (!isNaN(key) && key !== ' ') {
// Check if a number key or a decimal point key is pressed
({ value: newValue, selectionStart } = handleNumericKey(
@@ -225,7 +222,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
selectionStart,
selectionEnd
));
} else if (key === '.' && type === 'float') {
} else if (key === '.') {
({ value: newValue, selectionStart } = handleNumericKey(
key,
value,
@@ -236,8 +233,8 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
({ value: newValue, selectionStart } = handleArrowKey(
key,
value,
selectionStart
// selectionEnd
selectionStart,
selectionEnd
));
} else if (key === 'Backspace') {
({ value: newValue, selectionStart } = handleBackspaceKey(
@@ -252,7 +249,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
selectionEnd
));
} else if (key === 'Enter' && !isInstantUpdate) {
changeCallback(Number(newValue));
emitUpdate(name, parentPath, Number(newValue));
return;
} else {
console.debug(key);
@@ -261,7 +258,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Update the input value and maintain the cursor position
if (isInstantUpdate) {
changeCallback(Number(newValue));
emitUpdate(name, parentPath, Number(newValue));
}
setInputString(newValue);
@@ -273,59 +270,31 @@ 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
changeCallback(Number(inputString));
emitUpdate(name, parentPath, Number(inputString));
}
};
useEffect(() => {
// Parse the input string to a number for comparison
const numericInputString =
type === 'int' ? parseInt(inputString) : parseFloat(inputString);
// Only update the inputString if it's different from the prop value
if (value !== numericInputString) {
setInputString(value.toString());
}
// emitting notification
let notificationMsg = `${fullAccessPath} changed to ${props.value}`;
if (unit === undefined) {
notificationMsg += '.';
} else {
notificationMsg += ` ${unit}.`;
}
addNotification(notificationMsg);
}, [value]);
useEffect(() => {
// Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(name)[0] as HTMLInputElement;
if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange(cursorPosition, cursorPosition);
}
});
return (
<div className="component numberComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<div className="numberComponent" id={parentPath.concat('.' + name)}>
{process.env.NODE_ENV === 'development' && showName && (
<p>Render count: {renderCount.current}</p>
)}
<InputGroup>
{displayName && (
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
)}
<Form.Control
type="text"
value={inputString}
disabled={readOnly}
name={name}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
/>
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
</InputGroup>
<DocStringComponent docString={docString} />
<div className="d-flex">
<InputGroup>
{showName && <InputGroup.Text>{name}</InputGroup.Text>}
<Form.Control
type="text"
value={inputString}
disabled={readOnly}
name={parentPath.concat(name)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
/>
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
</InputGroup>
</div>
</div>
);
});

View File

@@ -1,34 +1,31 @@
import React, { useEffect, useRef, useState } from 'react';
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
import { emit_update } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { Slider } from '@mui/material';
import { NumberComponent, NumberObject } from './NumberComponent';
import { LevelName } from './NotificationsComponent';
import { NumberComponent } from './NumberComponent';
type SliderComponentProps = {
interface SliderComponentProps {
name: string;
min: NumberObject;
max: NumberObject;
min: number;
max: number;
parentPath?: string;
value: NumberObject;
value: number;
readOnly: boolean;
docString: string;
stepSize: NumberObject;
stepSize: number;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
addNotification: (string) => void;
}
export const SliderComponent = React.memo((props: SliderComponentProps) => {
const renderCount = useRef(0);
const [open, setOpen] = useState(false);
useEffect(() => {
renderCount.current++;
});
const {
name,
parentPath,
@@ -36,97 +33,99 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
min,
max,
stepSize,
readOnly,
docString,
isInstantUpdate,
addNotification,
changeCallback = () => {},
displayName,
id
addNotification
} = props;
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
addNotification(`${fullAccessPath} changed to ${value.value}.`);
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
useEffect(() => {
addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
addNotification(`${parentPath}.${name}.min changed to ${min}.`);
}, [props.min]);
useEffect(() => {
addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
addNotification(`${parentPath}.${name}.max changed to ${max}.`);
}, [props.max]);
useEffect(() => {
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
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
) => {
emit_update(
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];
}
changeCallback(newNumber, `${name}.value`);
emitSliderUpdate(name, parentPath, newNumber);
};
const handleValueChange = (newValue: number, valueType: string) => {
changeCallback(newValue, `${name}.${valueType}`);
};
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;
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;
}
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="component sliderComponent" id={id}>
<div className="sliderComponent" id={parentPath.concat('.' + name)}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
<Row>
<Col xs="auto" xl="auto">
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
<InputGroup.Text>{name}</InputGroup.Text>
</Col>
<Col xs="5" xl>
<Slider
style={{ margin: '0px 0px 10px 0px' }}
aria-label="Always visible"
// valueLabelDisplay="on"
disabled={valueReadOnly}
value={valueMagnitude}
disabled={readOnly}
value={value}
onChange={(event, newNumber) => handleOnChange(event, newNumber)}
min={minMagnitude}
max={maxMagnitude}
step={stepSizeMagnitude}
min={min}
max={max}
step={stepSize}
marks={[
{ value: minMagnitude, label: `${minMagnitude}` },
{ value: maxMagnitude, label: `${maxMagnitude}` }
{ value: min, label: `${min}` },
{ value: max, label: `${max}` }
]}
/>
</Col>
@@ -134,20 +133,18 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<NumberComponent
isInstantUpdate={isInstantUpdate}
parentPath={parentPath}
name={`${name}.value`}
name={name}
docString=""
readOnly={valueReadOnly}
readOnly={readOnly}
type="float"
value={valueMagnitude}
unit={valueUnit}
addNotification={() => {}}
changeCallback={(value) => changeCallback(value, name + '.value')}
id={id + '-value'}
value={value}
showName={false}
customEmitUpdate={emitSliderUpdate}
addNotification={() => null}
/>
</Col>
<Col xs="auto">
<ToggleButton
id={`button-${id}`}
onClick={() => setOpen(!open)}
type="checkbox"
checked={open}
@@ -177,8 +174,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Min Value</Form.Label>
<Form.Control
type="number"
value={minMagnitude}
disabled={minReadOnly}
value={min}
onChange={(e) => handleValueChange(Number(e.target.value), 'min')}
/>
</Col>
@@ -187,8 +183,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Max Value</Form.Label>
<Form.Control
type="number"
value={maxMagnitude}
disabled={maxReadOnly}
value={max}
onChange={(e) => handleValueChange(Number(e.target.value), 'max')}
/>
</Col>
@@ -197,9 +192,8 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Step Size</Form.Label>
<Form.Control
type="number"
value={stepSizeMagnitude}
disabled={stepSizeReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), 'step_size')}
value={stepSize}
onChange={(e) => handleValueChange(Number(e.target.value), 'stepSize')}
/>
</Col>
</Row>

View File

@@ -1,46 +1,27 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from 'react-bootstrap';
import { emit_update } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { LevelName } from './NotificationsComponent';
// TODO: add button functionality
type StringComponentProps = {
interface StringComponentProps {
name: string;
parentPath?: string;
value: string;
readOnly: boolean;
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
displayName: string;
id: string;
};
addNotification: (string) => void;
}
export const StringComponent = React.memo((props: StringComponentProps) => {
const {
name,
readOnly,
docString,
isInstantUpdate,
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
const { name, parentPath, readOnly, docString, isInstantUpdate, addNotification } =
props;
const renderCount = useRef(0);
const [inputString, setInputString] = useState(props.value);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
useEffect(() => {
renderCount.current++;
@@ -51,44 +32,41 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
if (props.value !== inputString) {
setInputString(props.value);
}
addNotification(`${fullAccessPath} changed to ${props.value}.`);
addNotification(`${parentPath}.${name} changed to ${props.value}.`);
}, [props.value]);
const handleChange = (event) => {
setInputString(event.target.value);
if (isInstantUpdate) {
changeCallback(event.target.value);
emit_update(name, parentPath, event.target.value);
}
};
const handleKeyDown = (event) => {
if (event.key === 'Enter' && !isInstantUpdate) {
changeCallback(inputString);
event.preventDefault();
emit_update(name, parentPath, inputString);
}
};
const handleBlur = () => {
if (!isInstantUpdate) {
changeCallback(inputString);
emit_update(name, parentPath, inputString);
}
};
return (
<div className="component stringComponent" id={id}>
<div className={'stringComponent'} id={parentPath.concat(name)}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
<InputGroup>
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
<InputGroup.Text>{name}</InputGroup.Text>
<Form.Control
type="text"
name={name}
value={inputString}
disabled={readOnly}
name={name}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}

View File

@@ -9,28 +9,15 @@ console.debug('Websocket: ', URL);
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
export const setAttribute = (
export const emit_update = (
name: string,
parentPath: string,
value: unknown,
callback?: (ack: unknown) => void
) => {
if (callback) {
socket.emit('set_attribute', { name, parent_path: parentPath, value }, callback);
socket.emit('frontend_update', { name, parent_path: parentPath, value }, callback);
} else {
socket.emit('set_attribute', { name, parent_path: parentPath, value });
}
};
export const runMethod = (
name: string,
parentPath: string,
kwargs: Record<string, unknown>,
callback?: (ack: unknown) => void
) => {
if (callback) {
socket.emit('run_method', { name, parent_path: parentPath, kwargs }, callback);
} else {
socket.emit('run_method', { name, parent_path: parentPath, kwargs });
socket.emit('frontend_update', { name, parent_path: parentPath, value });
}
};

View File

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

View File

@@ -1,16 +0,0 @@
export function getIdFromFullAccessPath(fullAccessPath: string) {
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, '-');
// Remove any trailing dashes
id = id.replace(/-+$/, '');
return id;
} else {
return 'main';
}
}

View File

@@ -1,42 +0,0 @@
site_name: pydase Documentation
repo_url: https://github.com/tiqi-group/pydase
edit_uri: blob/docs/docs/
nav:
- Home: index.md
- Getting Started: getting-started.md
- User Guide:
- Components Guide: user-guide/Components.md
- Developer Guide:
- Developer Guide: dev-guide/README.md
- API Reference: dev-guide/api.md
- Adding Components: dev-guide/Adding_Components.md
- Observer Pattern Implementation: dev-guide/Observer_Pattern_Implementation.md # <-- New section
- About:
- Release Notes: about/release-notes.md
- Contributing: about/contributing.md
- License: about/license.md
theme: readthedocs
extra_css:
- css/extra.css
markdown_extensions:
- smarty
- toc:
permalink: true
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.snippets
- pymdownx.superfences
# - pymdownx.highlight:
# - pymdownx.inlinehilite
plugins:
- include-markdown
- search
- mkdocstrings
watch:
- src/pydase

1755
poetry.lock generated

File diff suppressed because it is too large Load Diff

3
poetry.toml Normal file
View File

@@ -0,0 +1,3 @@
[virtualenvs]
in-project = true
prefer-active-python = true

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydase"
version = "0.7.0"
version = "0.1.0"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md"
@@ -8,97 +8,71 @@ packages = [{ include = "pydase", from = "src" }]
[tool.poetry.dependencies]
python = "^3.10"
python = "^3.9"
rpyc = "^5.3.1"
fastapi = "^0.108.0"
uvicorn = "^0.27.0"
loguru = "^0.7.0"
fastapi = "^0.100.0"
uvicorn = "^0.22.0"
toml = "^0.10.2"
python-socketio = "^5.8.0"
websockets = "^11.0.3"
confz = "^2.0.0"
pint = "^0.22"
pillow = "^10.0.0"
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
types-toml = "^0.10.8.6"
pytest = "^7.4.0"
pytest-cov = "^4.1.0"
mypy = "^1.4.1"
black = "^23.1.0"
isort = "^5.12.0"
flake8 = "^5.0.4"
flake8-use-fstring = "^1.4"
flake8-functions = "^0.0.7"
flake8-comprehensions = "^3.11.1"
flake8-pep585 = "^0.1.7"
flake8-pep604 = "^0.1.0"
flake8-eradicate = "^1.4.0"
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
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.2"
mkdocs-include-markdown-plugin = "^3.9.1"
mkdocstrings = "^0.22.0"
pymdown-extensions = "^10.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
target-version = "py310" # Always generate Python 3.10-compatible code
extend-exclude = [
"docs", "frontend"
]
[tool.ruff.lint]
select = [
"ASYNC", # flake8-async
"C4", # flake8-comprehensions
"C901", # mccabe complex-structure
"E", # pycodestyle errors
"ERA", # eradicate
"F", # pyflakes
"FLY", # flynt
"G", # flake8-logging-format
"I", # isort
"ICN", # flake8-import-conventions
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"N", # pep8-naming
"NPY", # NumPy-specific rules
"PERF", # perflint
"PIE", # flake8-pie
"PL", # pylint
"PYI", # flake8-pyi
"Q", # flake8-quotes
"RET", # flake8-return
"RUF", # Ruff-specific rules
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
"TCH", # flake8-type-checking
"UP", # pyupgrade
"YTT", # flake8-2020
"W", # pycodestyle warnings
]
ignore = [
"RUF006", # asyncio-dangling-task
"PERF203", # try-except-in-loop
]
[tool.ruff.lint.mccabe]
max-complexity = 7
[tool.pyright]
include = ["src/pydase"]
include = ["src/pydase", "tests"]
exclude = ["**/node_modules", "**/__pycache__", "docs", "frontend"]
venvPath = "."
venv = ".venv"
typeCheckingMode = "basic"
reportUnknownMemberType = true
[tool.black]
line-length = 88
exclude = '''
/(
\.git
| \.mypy_cache
| \.tox
| venv
| \.venv
| _build
| buck-out
| build
| dist
)/
'''
[tool.isort]
profile = "black"
[tool.mypy]
mypy_path = "src/"
show_error_codes = true
disallow_untyped_defs = true
disallow_untyped_calls = true
disallow_incomplete_defs = true
disallow_any_generics = true
check_untyped_defs = true
ignore_missing_imports = false

View File

@@ -27,14 +27,10 @@ print(my_service.voltage.value) # Output: 5
```
"""
from pydase.components.coloured_enum import ColouredEnum
from pydase.components.device_connection import DeviceConnection
from pydase.components.image import Image
from pydase.components.number_slider import NumberSlider
__all__ = [
"NumberSlider",
"Image",
"ColouredEnum",
"DeviceConnection",
]

View File

@@ -1,59 +0,0 @@
from enum import Enum
class ColouredEnum(Enum):
"""
Represents a UI element that can display colour-coded text based on its value.
This class extends the standard Enum but requires its values to be valid CSS
colour codes. Supported colour formats include:
- Hexadecimal colours
- Hexadecimal colours with transparency
- RGB colours
- RGBA colours
- HSL colours
- HSLA colours
- Predefined/Cross-browser colour names
Refer to the this website for more details on colour formats:
(https://www.w3schools.com/cssref/css_colours_legal.php)
The behavior of this component in the UI depends on how it's defined in the data
service:
- As property with a setter or as attribute: Renders as a dropdown menu,
allowing users to select and change its value from the frontend.
- As property without a setter: Displays as a coloured box with the key of the
`ColouredEnum` as text inside, serving as a visual indicator without user
interaction.
Example:
--------
```python
import pydase.components as pyc
import pydase
class MyStatus(pyc.ColouredEnum):
PENDING = "#FFA500" # Orange
RUNNING = "#0000FF80" # Transparent Blue
PAUSED = "rgb(169, 169, 169)" # Dark Gray
RETRYING = "rgba(255, 255, 0, 0.3)" # Transparent Yellow
COMPLETED = "hsl(120, 100%, 50%)" # Green
FAILED = "hsla(0, 100%, 50%, 0.7)" # Transparent Red
CANCELLED = "SlateGray" # Slate Gray
class StatusExample(pydase.DataService):
_status = MyStatus.RUNNING
@property
def status(self) -> MyStatus:
return self._status
@status.setter
def status(self, value: MyStatus) -> None:
# Custom logic here...
self._status = value
# Example usage:
my_service = StatusExample()
my_service.status = MyStatus.FAILED
```
"""

View File

@@ -1,77 +0,0 @@
import asyncio
import pydase
class DeviceConnection(pydase.DataService):
"""
Base class for device connection management within the pydase framework.
This class serves as the foundation for subclasses that manage connections to
specific devices. It implements automatic reconnection logic that periodically
checks the device's availability and attempts to reconnect if the connection is
lost. The frequency of these checks is controlled by the `_reconnection_wait_time`
attribute.
Subclassing
-----------
Users should primarily override the `connect` method to establish a connection
to the device. This method should update the `self._connected` attribute to reflect
the connection status:
>>> class MyDeviceConnection(DeviceConnection):
... def connect(self) -> None:
... # Implementation to connect to the device
... # Update self._connected to `True` if connection is successful,
... # `False` otherwise
... ...
Optionally, if additional logic is needed to determine the connection status,
the `connected` property can also be overridden:
>>> class MyDeviceConnection(DeviceConnection):
... @property
... def connected(self) -> bool:
... # Custom logic to determine connection status
... return some_custom_condition
...
Frontend Representation
-----------------------
In the frontend, this class is represented without directly exposing the `connect`
method and `connected` attribute. Instead, user-defined attributes, methods, and
properties are displayed. When `self.connected` is `False`, the frontend component
shows an overlay that allows manual triggering of the `connect()` method. This
overlay disappears once the connection is successfully re-established.
"""
def __init__(self) -> None:
super().__init__()
self._connected = False
self._autostart_tasks["_handle_connection"] = () # type: ignore
self._reconnection_wait_time = 10.0
def connect(self) -> None:
"""Tries connecting to the device and changes `self._connected` status
accordingly. This method is called every `self._reconnection_wait_time` seconds
when `self.connected` is False. Users should override this method to implement
device-specific connection logic.
"""
@property
def connected(self) -> bool:
"""Indicates if the device is currently connected or was recently connected.
Users may override this property to incorporate custom logic for determining
the connection status.
"""
return self._connected
async def _handle_connection(self) -> None:
"""Automatically tries reconnecting to the device if it is not connected.
This method leverages the `connect` method and the `connected` property to
manage the connection status.
"""
while True:
if not self.connected:
self.connect()
await asyncio.sleep(self._reconnection_wait_time)

View File

@@ -1,27 +1,25 @@
import base64
import io
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional, Union
from urllib.request import urlopen
import PIL.Image # type: ignore[import-untyped]
import PIL.Image
from loguru import logger
from pydase.data_service.data_service import DataService
if TYPE_CHECKING:
from matplotlib.figure import Figure
logger = logging.getLogger(__name__)
class Image(DataService):
def __init__(
self,
) -> None:
super().__init__()
self._value: str = ""
self._format: str = ""
super().__init__()
@property
def value(self) -> str:
@@ -31,21 +29,21 @@ class Image(DataService):
def format(self) -> str:
return self._format
def load_from_path(self, path: Path | str) -> None:
def load_from_path(self, path: Union[Path, str]) -> None:
with PIL.Image.open(path) as image:
self._load_from_pil(image)
self._load_from_PIL(image)
def load_from_matplotlib_figure(self, fig: "Figure", format_: str = "png") -> None:
buffer = io.BytesIO()
fig.savefig(buffer, format=format_)
fig.savefig(buffer, format=format_) # type: ignore
value_ = base64.b64encode(buffer.getvalue())
self._load_from_base64(value_, format_)
def load_from_url(self, url: str) -> None:
image = PIL.Image.open(urlopen(url))
self._load_from_pil(image)
self._load_from_PIL(image)
def load_from_base64(self, value_: bytes, format_: str | None = None) -> None:
def load_from_base64(self, value_: bytes, format_: Optional[str] = None) -> None:
if format_ is None:
format_ = self._get_image_format_from_bytes(value_)
if format_ is None:
@@ -56,11 +54,11 @@ class Image(DataService):
self._load_from_base64(value_, format_)
def _load_from_base64(self, value_: bytes, format_: str) -> None:
value = value_.decode("utf-8")
value = value_.decode("utf-8") if isinstance(value_, bytes) else value_
self._value = value
self._format = format_
def _load_from_pil(self, image: PIL.Image.Image) -> None:
def _load_from_PIL(self, image: PIL.Image.Image) -> None:
if image.format is not None:
format_ = image.format
buffer = io.BytesIO()
@@ -70,7 +68,7 @@ class Image(DataService):
else:
logger.error("Image format is 'None'. Skipping...")
def _get_image_format_from_bytes(self, value_: bytes) -> str | None:
def _get_image_format_from_bytes(self, value_: bytes) -> Union[str, None]:
image_data = base64.b64decode(value_)
# Create a writable memory buffer for the image
image_buffer = io.BytesIO(image_data)

View File

@@ -1,10 +1,9 @@
import logging
from typing import Any
from typing import Any, Literal, Union
from loguru import logger
from pydase.data_service.data_service import DataService
logger = logging.getLogger(__name__)
class NumberSlider(DataService):
"""
@@ -13,68 +12,23 @@ class NumberSlider(DataService):
Parameters:
-----------
value (float, optional):
value (float | int, optional):
The initial value of the slider. Defaults to 0.
min (float, optional):
The minimum value of the slider. Defaults to 0.
max (float, optional):
The maximum value of the slider. Defaults to 100.
step_size (float, optional):
step_size (float | int, optional):
The increment/decrement step size of the slider. Defaults to 1.0.
type (Literal["int"] | Literal["float"], optional):
The type of the slider value. Determines if the value is an integer or float.
Defaults to "float".
Example:
--------
```python
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()
class MyService(DataService):
voltage = NumberSlider(1, 0, 10, 0.1, "int")
# Modifying or accessing the voltage value:
my_service = MyService()
@@ -85,37 +39,28 @@ class NumberSlider(DataService):
def __init__(
self,
value: Any = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
value: Union[float, int] = 0,
min: float = 0.0,
max: float = 100.0,
step_size: Union[float, int] = 1.0,
type: Union[Literal["int"], Literal["float"]] = "float",
) -> None:
if type not in {"float", "int"}:
logger.error(f"Unknown type '{type}'. Using 'float'.")
type = "float"
self._type = type
self.step_size = step_size
self.value = value
self.min = min
self.max = max
super().__init__()
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
@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
return super().__setattr__(name, value)

View File

@@ -1,24 +1,9 @@
from pathlib import Path
from typing import Literal
from typing import Literal, Union
from confz import BaseConfig, EnvSource
class OperationMode(BaseConfig): # type: ignore[misc]
environment: Literal["development", "production"] = "development"
class OperationMode(BaseConfig): # type: ignore
environment: Union[Literal["development"], Literal["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(allow_all=True, prefix="SERVICE_", file=".env")
class WebServerConfig(BaseConfig): # type: ignore[misc]
generate_web_settings: bool = False
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])

View File

@@ -1,15 +1,16 @@
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING, Any
from pydase.observer_pattern.observable.observable import Observable
if TYPE_CHECKING:
from pydase.data_service.data_service import DataService
from pydase.data_service.task_manager import TaskManager
from .callback_manager import CallbackManager
from .data_service import DataService
from .task_manager import TaskManager
class AbstractDataService(Observable):
class AbstractDataService(ABC):
__root__: DataService
_task_manager: TaskManager
_callback_manager: CallbackManager
_autostart_tasks: dict[str, tuple[Any]]

View File

@@ -0,0 +1,400 @@
from __future__ import annotations
import inspect
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Union
from loguru import logger
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.utils.helpers import get_class_and_instance_attributes
from .data_service_list import DataServiceList
if TYPE_CHECKING:
from .data_service import DataService
class CallbackManager:
_notification_callbacks: list[Callable[[str, str, Any], Any]] = []
"""
A list of callback functions that are executed when a change occurs in the
DataService instance. These functions are intended to handle or respond to these
changes in some way, such as emitting a socket.io message to the frontend.
Each function in this list should be a callable that accepts three parameters:
- parent_path (str): The path to the parent of the attribute that was changed.
- name (str): The name of the attribute that was changed.
- value (Any): The new value of the attribute.
A callback function can be added to this list using the add_notification_callback
method. Whenever a change in the DataService instance occurs (or in its nested
DataService or DataServiceList instances), the emit_notification method is invoked,
which in turn calls all the callback functions in _notification_callbacks with the
appropriate arguments.
This implementation follows the observer pattern, with the DataService instance as
the "subject" and the callback functions as the "observers".
"""
_list_mapping: dict[int, DataServiceList] = {}
"""
A dictionary mapping the id of the original lists to the corresponding
DataServiceList instances.
This is used to ensure that all references to the same list within the DataService
object point to the same DataServiceList, so that any modifications to that list can
be tracked consistently. The keys of the dictionary are the ids of the original
lists, and the values are the DataServiceList instances that wrap these lists.
"""
def __init__(self, service: DataService) -> None:
self.callbacks: set[Callable[[str, Any], None]] = set()
self.service = service
def _register_list_change_callbacks( # noqa: C901
self, obj: "AbstractDataService", parent_path: str
) -> None:
"""
This method ensures that notifications are emitted whenever a list attribute of
a DataService instance changes. These notifications pertain solely to the list
item changes, not to changes in attributes of objects within the list.
The method works by converting all list attributes (both at the class and
instance levels) into DataServiceList objects. Each DataServiceList is then
assigned a callback that is triggered whenever an item in the list is updated.
The callback emits a notification, but only if the DataService instance was the
root instance when the callback was registered.
This method operates recursively, processing the input object and all nested
attributes that are instances of DataService. While navigating the structure,
it constructs a path for each attribute that traces back to the root. This path
is included in any emitted notifications to facilitate identification of the
source of a change.
Parameters:
-----------
obj: DataService
The target object to be processed. All list attributes (and those of its
nested DataService attributes) will be converted into DataServiceList
objects.
parent_path: str
The access path for the parent object. Used to construct the full access
path for the notifications.
"""
# Convert all list attributes (both class and instance) to DataServiceList
attrs = get_class_and_instance_attributes(obj)
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, AbstractDataService):
new_path = f"{parent_path}.{attr_name}"
self._register_list_change_callbacks(attr_value, new_path)
elif isinstance(attr_value, list):
# Create callback for current attr_name
# Default arguments solve the late binding problem by capturing the
# value at the time the lambda is defined, not when it is called. This
# prevents attr_name from being overwritten in the next loop iteration.
callback = (
lambda index, value, attr_name=attr_name: self.service._callback_manager.emit_notification(
parent_path=parent_path,
name=f"{attr_name}[{index}]",
value=value,
)
if self.service == self.service.__root__
else None
)
# Check if attr_value is already a DataServiceList or in the mapping
if isinstance(attr_value, DataServiceList):
attr_value.add_callback(callback)
continue
if id(attr_value) in self._list_mapping:
notifying_list = self._list_mapping[id(attr_value)]
notifying_list.add_callback(callback)
else:
notifying_list = DataServiceList(attr_value, callback=[callback])
self._list_mapping[id(attr_value)] = notifying_list
setattr(obj, attr_name, notifying_list)
# recursively add callbacks to list attributes of DataService instances
for i, item in enumerate(attr_value):
if isinstance(item, AbstractDataService):
new_path = f"{parent_path}.{attr_name}[{i}]"
self._register_list_change_callbacks(item, new_path)
def _register_DataService_instance_callbacks(
self, obj: "AbstractDataService", parent_path: str
) -> None:
"""
This function is a key part of the observer pattern implemented by the
DataService class.
Its purpose is to allow the system to automatically send out notifications
whenever an attribute of a DataService instance is updated, which is especially
useful when the DataService instance is part of a nested structure.
It works by recursively registering callbacks for a given DataService instance
and all of its nested attributes. Each callback is responsible for emitting a
notification when the attribute it is attached to is modified.
This function ensures that only the root DataService instance (the one directly
exposed to the user or another system via rpyc) emits notifications.
Each notification contains a 'parent_path' that traces the attribute's location
within the nested DataService structure, starting from the root. This makes it
easier for observers to determine exactly where a change has occurred.
Parameters:
-----------
obj: DataService
The target object on which callbacks are to be registered.
parent_path: str
The access path for the parent object. This is used to construct the full
access path for the notifications.
"""
# Create and register a callback for the object
# only emit the notification when the call was registered by the root object
callback: Callable[[str, Any], None] = (
lambda name, value: obj._callback_manager.emit_notification(
parent_path=parent_path, name=name, value=value
)
if self.service == obj.__root__
and not name.startswith("_") # we are only interested in public attributes
and not isinstance(
getattr(type(obj), name, None), property
) # exlude proerty notifications -> those are handled in separate callbacks
else None
)
obj._callback_manager.callbacks.add(callback)
# Recursively register callbacks for all nested attributes of the object
attrs = get_class_and_instance_attributes(obj)
for nested_attr_name, nested_attr in attrs.items():
if isinstance(nested_attr, DataServiceList):
self._register_list_callbacks(
nested_attr, parent_path, nested_attr_name
)
elif isinstance(nested_attr, AbstractDataService):
self._register_service_callbacks(
nested_attr, parent_path, nested_attr_name
)
def _register_list_callbacks(
self, nested_attr: list[Any], parent_path: str, attr_name: str
) -> None:
"""Handles registration of callbacks for list attributes"""
for i, list_item in enumerate(nested_attr):
if isinstance(list_item, AbstractDataService):
self._register_service_callbacks(
list_item, parent_path, f"{attr_name}[{i}]"
)
def _register_service_callbacks(
self, nested_attr: "AbstractDataService", parent_path: str, attr_name: str
) -> None:
"""Handles registration of callbacks for DataService attributes"""
# as the DataService is an attribute of self, change the root object
# use the dictionary to not trigger callbacks on initialised objects
nested_attr.__dict__["__root__"] = self.service.__root__
new_path = f"{parent_path}.{attr_name}"
self._register_DataService_instance_callbacks(nested_attr, new_path)
def __register_recursive_parameter_callback(
self,
obj: Union["AbstractDataService", DataServiceList],
callback: Callable[[Union[str, int], Any], None],
) -> None:
"""
Register callback to a DataService or DataServiceList instance and its nested
instances.
For a DataService, this method traverses its attributes and recursively adds the
callback for nested DataService or DataServiceList instances. For a
DataServiceList,
the callback is also triggered when an item gets reassigned.
"""
if isinstance(obj, DataServiceList):
# emits callback when item in list gets reassigned
obj.add_callback(callback=callback)
obj_list: Union[DataServiceList, list[AbstractDataService]] = obj
else:
obj_list = [obj]
# this enables notifications when a class instance was changed (-> item is
# changed, not reassigned)
for item in obj_list:
if isinstance(item, AbstractDataService):
item._callback_manager.callbacks.add(callback)
for attr_name in set(dir(item)) - set(dir(object)) - {"__root__"}:
attr_value = getattr(item, attr_name)
if isinstance(attr_value, (AbstractDataService, DataServiceList)):
self.__register_recursive_parameter_callback(
attr_value, callback
)
def _register_property_callbacks( # noqa: C901
self,
obj: "AbstractDataService",
parent_path: str,
) -> None:
"""
Register callbacks to notify when properties or their dependencies change.
This method cycles through all attributes (both class and instance level) of the
input `obj`. For each attribute that is a property, it identifies dependencies
used in the getter method and creates a callback for each one.
The method is recursive for attributes that are of type DataService or
DataServiceList. It attaches the callback directly to DataServiceList items or
propagates it through nested DataService instances.
"""
attrs = get_class_and_instance_attributes(obj)
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, AbstractDataService):
self._register_property_callbacks(
attr_value, parent_path=f"{parent_path}.{attr_name}"
)
elif isinstance(attr_value, DataServiceList):
for i, item in enumerate(attr_value):
if isinstance(item, AbstractDataService):
self._register_property_callbacks(
item, parent_path=f"{parent_path}.{attr_name}[{i}]"
)
if isinstance(attr_value, property):
dependencies = attr_value.fget.__code__.co_names # type: ignore
source_code_string = inspect.getsource(attr_value.fget) # type: ignore
for dependency in dependencies:
# check if the dependencies are attributes of obj
# This doesn't have to be the case like, for example, here:
# >>> @property
# >>> def power(self) -> float:
# >>> return self.class_attr.voltage * self.current
#
# The dependencies for this property are:
# > ('class_attr', 'voltage', 'current')
if f"self.{dependency}" not in source_code_string:
continue
# use `obj` instead of `type(obj)` to get DataServiceList
# instead of list
dependency_value = getattr(obj, dependency)
if isinstance(
dependency_value, (DataServiceList, AbstractDataService)
):
callback = (
lambda name, value, dependent_attr=attr_name: obj._callback_manager.emit_notification(
parent_path=parent_path,
name=dependent_attr,
value=getattr(obj, dependent_attr),
)
if self.service == obj.__root__
else None
)
self.__register_recursive_parameter_callback(
dependency_value,
callback=callback,
)
else:
callback = (
lambda name, _, dep_attr=attr_name, dep=dependency: obj._callback_manager.emit_notification( # type: ignore
parent_path=parent_path,
name=dep_attr,
value=getattr(obj, dep_attr),
)
if name == dep and self.service == obj.__root__
else None
)
# Add to callbacks
obj._callback_manager.callbacks.add(callback)
def _register_start_stop_task_callbacks(
self, obj: "AbstractDataService", parent_path: str
) -> None:
"""
This function registers callbacks for start and stop methods of async functions.
These callbacks are stored in the '_task_status_change_callbacks' attribute and
are called when the status of a task changes.
Parameters:
-----------
obj: AbstractDataService
The target object on which callbacks are to be registered.
parent_path: str
The access path for the parent object. This is used to construct the full
access path for the notifications.
"""
# Create and register a callback for the object
# only emit the notification when the call was registered by the root object
callback: Callable[[str, Union[dict[str, Any], None]], None] = (
lambda name, status: obj._callback_manager.emit_notification(
parent_path=parent_path, name=name, value=status
)
if self.service == obj.__root__
and not name.startswith("_") # we are only interested in public attributes
else None
)
obj._task_manager.task_status_change_callbacks.append(callback)
# Recursively register callbacks for all nested attributes of the object
attrs: dict[str, Any] = get_class_and_instance_attributes(obj)
for nested_attr_name, nested_attr in attrs.items():
if isinstance(nested_attr, AbstractDataService):
self._register_start_stop_task_callbacks(
nested_attr, parent_path=f"{parent_path}.{nested_attr_name}"
)
def register_callbacks(self) -> None:
self._register_list_change_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
self._register_DataService_instance_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
self._register_property_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
self._register_start_stop_task_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
def emit_notification(self, parent_path: str, name: str, value: Any) -> None:
logger.debug(f"{parent_path}.{name} changed to {value}!")
for callback in self._notification_callbacks:
try:
callback(parent_path, name, value)
except Exception as e:
logger.error(e)
def add_notification_callback(
self, callback: Callable[[str, str, Any], None]
) -> None:
"""
Adds a new notification callback function to the list of callbacks.
This function is intended to be used for registering a function that will be
called whenever a the value of an attribute changes.
Args:
callback (Callable[[str, str, Any], None]): The callback function to
register.
It should accept three parameters:
- parent_path (str): The parent path of the parameter.
- name (str): The name of the changed parameter.
- value (Any): The value of the parameter.
"""
self._notification_callbacks.append(callback)

View File

@@ -1,27 +1,32 @@
import asyncio
import inspect
import logging
import json
import os
from enum import Enum
from typing import Any, get_type_hints
from typing import Any, Optional, cast, get_type_hints
import rpyc # type: ignore[import-untyped]
import rpyc
from loguru import logger
import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.callback_manager import CallbackManager
from pydase.data_service.task_manager import TaskManager
from pydase.observer_pattern.observable.observable import (
Observable,
)
from pydase.utils.helpers import (
convert_arguments_to_hinted_types,
generate_paths_from_DataService_dict,
get_class_and_instance_attributes,
get_component_class_names,
get_nested_value_from_DataService_by_path_and_key,
get_object_attr_from_path,
is_property_attribute,
parse_list_attr_and_index,
update_value_if_changed,
)
from pydase.utils.serializer import (
Serializer,
from pydase.utils.warnings import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
logger = logging.getLogger(__name__)
def process_callable_attribute(attr: Any, args: dict[str, Any]) -> Any:
converted_args_or_error_msg = convert_arguments_to_hinted_types(
@@ -35,75 +40,73 @@ def process_callable_attribute(attr: Any, args: dict[str, Any]) -> Any:
class DataService(rpyc.Service, AbstractDataService):
def __init__(self, **kwargs: Any) -> None:
super().__init__()
def __init__(self, filename: Optional[str] = None) -> None:
self._callback_manager: CallbackManager = CallbackManager(self)
self._task_manager = TaskManager(self)
if not hasattr(self, "_autostart_tasks"):
self._autostart_tasks = {}
self.__root__: "DataService" = self
"""Keep track of the root object. This helps to filter the emission of
notifications. This overwrite the TaksManager's __root__ attribute."""
self._filename: Optional[str] = filename
self._callback_manager.register_callbacks()
self.__check_instance_classes()
self._initialised = True
self._load_values_from_json()
def __setattr__(self, __name: str, __value: Any) -> None:
# Check and warn for unexpected type changes in attributes
self._warn_on_type_change(__name, __value)
# converting attributes that are not properties
if not isinstance(getattr(type(self), __name, None), property):
current_value = getattr(self, __name, None)
# parse ints into floats if current value is a float
if isinstance(current_value, float) and isinstance(__value, int):
__value = float(__value)
# every class defined by the user should inherit from DataService if it is
# assigned to a public attribute
if not __name.startswith("_") and not inspect.isfunction(__value):
self.__warn_if_not_observable(__value)
if isinstance(current_value, u.Quantity):
__value = u.convert_to_quantity(__value, str(current_value.u))
# Set the attribute
super().__setattr__(__name, __value)
def _warn_on_type_change(self, attr_name: str, new_value: Any) -> None:
if is_property_attribute(self, attr_name):
return
current_value = getattr(self, attr_name, None)
if self._is_unexpected_type_change(current_value, new_value):
if self.__dict__.get("_initialised") and not __name == "_initialised":
for callback in self._callback_manager.callbacks:
callback(__name, __value)
elif __name.startswith(f"_{self.__class__.__name__}__"):
logger.warning(
"Type of '%s' changed from '%s' to '%s'. This may have unwanted "
"side effects! Consider setting it to '%s' directly.",
attr_name,
type(current_value).__name__,
type(new_value).__name__,
type(current_value).__name__,
)
def _is_unexpected_type_change(self, current_value: Any, new_value: Any) -> bool:
return (
isinstance(current_value, float)
and not isinstance(new_value, float)
or (
isinstance(current_value, u.Quantity)
and not isinstance(new_value, u.Quantity)
)
)
def __warn_if_not_observable(self, __value: Any) -> None:
value_class = __value if inspect.isclass(__value) else __value.__class__
if not issubclass(
value_class,
(int | float | bool | str | list | Enum | u.Quantity | Observable),
):
logger.warning(
"Class '%s' does not inherit from DataService. This may lead to"
" unexpected behaviour!",
value_class.__name__,
f"Warning: You should not set private but rather protected attributes! "
f"Use {__name.replace(f'_{self.__class__.__name__}__', '_')} instead "
f"of {__name.replace(f'_{self.__class__.__name__}__', '__')}."
)
def __check_instance_classes(self) -> None:
for attr_name, attr_value in get_class_and_instance_attributes(self).items():
# every class defined by the user should inherit from DataService if it is
# assigned to a public attribute
if (
not attr_name.startswith("_")
and not inspect.isfunction(attr_value)
and not isinstance(attr_value, property)
):
self.__warn_if_not_observable(attr_value)
# every class defined by the user should inherit from DataService
if not attr_name.startswith("_DataService__"):
warn_if_instance_class_does_not_inherit_from_DataService(attr_value)
def __set_attribute_based_on_type( # noqa:CFQ002
self,
target_obj: Any,
attr_name: str,
attr: Any,
value: Any,
index: Optional[int],
path_list: list[str],
) -> None:
if isinstance(attr, Enum):
update_value_if_changed(target_obj, attr_name, attr.__class__[value])
elif isinstance(attr, list) and index is not None:
update_value_if_changed(attr, index, value)
elif isinstance(attr, DataService) and isinstance(value, dict):
for key, v in value.items():
self.update_DataService_attribute([*path_list, attr_name], key, v)
elif callable(attr):
process_callable_attribute(attr, value["args"])
else:
update_value_if_changed(target_obj, attr_name, value)
def _rpyc_getattr(self, name: str) -> Any:
if name.startswith("_"):
@@ -125,7 +128,68 @@ class DataService(rpyc.Service, AbstractDataService):
# allow all other attributes
setattr(self, name, value)
def serialize(self) -> dict[str, dict[str, Any]]:
def _load_values_from_json(self) -> None:
if self._filename is not None:
# Check if the file specified by the filename exists
if os.path.exists(self._filename):
with open(self._filename, "r") as f:
# Load JSON data from file and update class attributes with these
# values
self.load_DataService_from_JSON(cast(dict[str, Any], json.load(f)))
def write_to_file(self) -> None:
"""
Serialize the DataService instance and write it to a JSON file.
Args:
filename (str): The name of the file to write to.
"""
if self._filename is not None:
with open(self._filename, "w") as f:
json.dump(self.serialize(), f, indent=4)
else:
logger.error(
f"Class {self.__class__.__name__} was not initialised with a filename. "
'Skipping "write_to_file"...'
)
def load_DataService_from_JSON(self, json_dict: dict[str, Any]) -> None:
# Traverse the serialized representation and set the attributes of the class
serialized_class = self.serialize()
for path in generate_paths_from_DataService_dict(json_dict):
value = get_nested_value_from_DataService_by_path_and_key(
json_dict, path=path
)
value_type = get_nested_value_from_DataService_by_path_and_key(
json_dict, path=path, key="type"
)
class_value_type = get_nested_value_from_DataService_by_path_and_key(
serialized_class, path=path, key="type"
)
if class_value_type == value_type:
class_attr_is_read_only = (
get_nested_value_from_DataService_by_path_and_key(
serialized_class, path=path, key="readonly"
)
)
if class_attr_is_read_only:
logger.debug(
f'Attribute "{path}" is read-only. Ignoring value from JSON '
"file..."
)
continue
# Split the path into parts
parts = path.split(".")
attr_name = parts[-1]
self.update_DataService_attribute(parts[:-1], attr_name, value)
else:
logger.info(
f'Attribute type of "{path}" changed from "{value_type}" to '
f'"{class_value_type}". Ignoring value from JSON file...'
)
def serialize(self) -> dict[str, dict[str, Any]]: # noqa
"""
Serializes the instance into a dictionary, preserving the structure of the
instance.
@@ -134,12 +198,183 @@ class DataService(rpyc.Service, AbstractDataService):
value, readonly status, and documentation if any in the resulting dictionary.
Attributes and methods starting with an underscore are ignored.
For nested DataService instances, the method serializes recursively.
For attributes, methods, and properties unique to the class (not inherited from
the base class), the method uses the format "<prefix>.<key>" for keys in the
dictionary. If no prefix is provided, the key format is simply "<key>".
For nested DataService instances, the method serializes recursively and appends
the key of the nested instance to the prefix in the format "<prefix>.<key>".
For attributes of type list, each item in the list is serialized individually.
If an item in the list is an instance of DataService, it is serialized
recursively.
recursively with its key in the format "<prefix>.<key>.<item_id>", where
"item_id" is the id of the item itself.
Args:
prefix (str, optional): The prefix for each key in the serialized
dictionary. This is mainly used when this method is called recursively to
maintain the structure of nested instances.
Returns:
dict: The serialized instance.
"""
return Serializer.serialize_object(self)
result: dict[str, dict[str, Any]] = {}
# Get the dictionary of the base class
base_set = set(type(super()).__dict__)
# Get the dictionary of the derived class
derived_set = set(type(self).__dict__)
# Get the difference between the two dictionaries
derived_only_set = derived_set - base_set
instance_dict = set(self.__dict__)
# Merge the class and instance dictionaries
merged_set = derived_only_set | instance_dict
def get_attribute_doc(attr: Any) -> Optional[str]:
"""This function takes an input attribute attr and returns its documentation
string if it's different from the documentation of its type, otherwise,
it returns None.
"""
attr_doc = inspect.getdoc(attr)
attr_class_doc = inspect.getdoc(type(attr))
if attr_class_doc != attr_doc:
return attr_doc
else:
return None
# Iterate over attributes, properties, class attributes, and methods
for key in sorted(merged_set):
if key.startswith("_"):
continue # Skip attributes that start with underscore
# Skip keys that start with "start_" or "stop_" and end with an async method
# name
if (key.startswith("start_") or key.startswith("stop_")) and key.split(
"_", 1
)[1] in {
name
for name, _ in inspect.getmembers(
self, predicate=inspect.iscoroutinefunction
)
}:
continue
# Get the value of the current attribute or method
value = getattr(self, key)
if isinstance(value, DataService):
result[key] = {
"type": type(value).__name__
if type(value).__name__ in get_component_class_names()
else "DataService",
"value": value.serialize(),
"readonly": False,
"doc": get_attribute_doc(value),
}
elif isinstance(value, list):
result[key] = {
"type": "list",
"value": [
{
"type": type(item).__name__
if not isinstance(item, DataService)
or type(item).__name__ in get_component_class_names()
else "DataService",
"value": item.serialize()
if isinstance(item, DataService)
else item,
"readonly": False,
"doc": get_attribute_doc(value),
}
for item in value
],
"readonly": False,
}
elif inspect.isfunction(value) or inspect.ismethod(value):
sig = inspect.signature(value)
# Store parameters and their anotations in a dictionary
parameters: dict[str, Optional[str]] = {}
for k, v in sig.parameters.items():
annotation = v.annotation
if annotation is not inspect._empty:
if isinstance(annotation, type):
# Handle regular types
parameters[k] = annotation.__name__
else:
parameters[k] = str(annotation)
else:
parameters[k] = None
running_task_info = None
if (
key in self._task_manager.tasks
): # If there's a running task for this method
task_info = self._task_manager.tasks[key]
running_task_info = task_info["kwargs"]
result[key] = {
"type": "method",
"async": asyncio.iscoroutinefunction(value),
"parameters": parameters,
"doc": get_attribute_doc(value),
"readonly": True,
"value": running_task_info,
}
elif isinstance(getattr(self.__class__, key, None), property):
prop: property = getattr(self.__class__, key)
result[key] = {
"type": type(value).__name__,
"value": value
if not isinstance(value, u.Quantity)
else {"magnitude": value.m, "unit": str(value.u)},
"readonly": prop.fset is None,
"doc": get_attribute_doc(prop),
}
elif isinstance(value, Enum):
result[key] = {
"type": "Enum",
"value": value.name,
"enum": {
name: member.value
for name, member in value.__class__.__members__.items()
},
"readonly": False,
"doc": get_attribute_doc(value),
}
else:
result[key] = {
"type": type(value).__name__,
"value": value
if not isinstance(value, u.Quantity)
else {"magnitude": value.m, "unit": str(value.u)},
"readonly": False,
"doc": get_attribute_doc(value),
}
return result
def update_DataService_attribute(
self,
path_list: list[str],
attr_name: str,
value: Any,
) -> None:
# If attr_name corresponds to a list entry, extract the attr_name and the index
attr_name, index = parse_list_attr_and_index(attr_name)
# Traverse the object according to the path parts
target_obj = get_object_attr_from_path(self, path_list)
# If the attribute is a property, change it using the setter without getting the
# property value (would otherwise be bad for expensive getter methods)
if is_property_attribute(target_obj, attr_name):
setattr(target_obj, attr_name, value)
return
attr = get_object_attr_from_path(target_obj, [attr_name])
if attr is None:
return
self.__set_attribute_based_on_type(
target_obj, attr_name, attr, value, index, path_list
)

View File

@@ -1,39 +0,0 @@
import logging
from typing import TYPE_CHECKING, Any
from pydase.utils.serializer import (
SerializationPathError,
SerializationValueError,
get_nested_dict_by_path,
set_nested_value_by_path,
)
if TYPE_CHECKING:
from pydase import DataService
logger = logging.getLogger(__name__)
class DataServiceCache:
def __init__(self, service: "DataService") -> None:
self._cache: dict[str, Any] = {}
self.service = service
self._initialize_cache()
@property
def cache(self) -> dict[str, Any]:
return self._cache
def _initialize_cache(self) -> None:
"""Initializes the cache and sets up the callback."""
logger.debug("Initializing cache.")
self._cache = self.service.serialize()
def update_cache(self, full_access_path: str, value: Any) -> None:
set_nested_value_by_path(self._cache["value"], full_access_path, value)
def get_value_dict_from_cache(self, full_access_path: str) -> dict[str, Any]:
try:
return get_nested_dict_by_path(self._cache["value"], full_access_path)
except (SerializationPathError, SerializationValueError, KeyError):
return {}

View File

@@ -0,0 +1,63 @@
from collections.abc import Callable
from typing import Any, Union
from pydase.utils.warnings import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
class DataServiceList(list):
"""
DataServiceList is a list with additional functionality to trigger callbacks
whenever an item is set. This can be used to track changes in the list items.
The class takes the same arguments as the list superclass during initialization,
with an additional optional 'callback' argument that is a list of functions.
These callbacks are stored and executed whenever an item in the DataServiceList
is set via the __setitem__ method. The callbacks receive the index of the changed
item and its new value as arguments.
The original list that is passed during initialization is kept as a private
attribute to prevent it from being garbage collected.
Additional callbacks can be added after initialization using the `add_callback`
method.
Attributes:
callbacks (list):
List of callback functions to be executed on item set.
"""
def __init__(
self,
*args: list[Any],
callback: Union[list[Callable[[int, Any], None]], None] = None,
**kwargs: Any,
) -> None:
self.callbacks: list[Callable[[int, Any], None]] = []
if isinstance(callback, list):
self.callbacks = callback
for item in args[0]:
warn_if_instance_class_does_not_inherit_from_DataService(item)
# prevent gc to delete the passed list by keeping a reference
self._original_list = args[0]
super().__init__(*args, **kwargs) # type: ignore
def __setitem__(self, key: int, value: Any) -> None: # type: ignore
super().__setitem__(key, value) # type: ignore
for callback in self.callbacks:
callback(key, value)
def add_callback(self, callback: Callable[[int, Any], None]) -> None:
"""
Add a new callback function to be executed on item set.
Args:
callback (Callable[[int, Any], None]): Callback function that takes two
arguments - index of the changed item and its new value.
"""
self.callbacks.append(callback)

View File

@@ -1,114 +0,0 @@
import logging
from collections.abc import Callable
from copy import deepcopy
from typing import Any
from pydase.data_service.state_manager import StateManager
from pydase.observer_pattern.observable.observable_object import ObservableObject
from pydase.observer_pattern.observer.property_observer import (
PropertyObserver,
)
from pydase.utils.helpers import get_object_attr_from_path_list
from pydase.utils.serializer import dump
logger = logging.getLogger(__name__)
class DataServiceObserver(PropertyObserver):
def __init__(self, state_manager: StateManager) -> None:
self.state_manager = state_manager
self._notification_callbacks: list[
Callable[[str, Any, dict[str, Any]], None]
] = []
super().__init__(state_manager.service)
def on_change(self, full_access_path: str, value: Any) -> None:
if any(
full_access_path.startswith(changing_attribute)
and full_access_path != changing_attribute
for changing_attribute in self.changing_attributes
):
return
cached_value_dict = deepcopy(
self.state_manager._data_service_cache.get_value_dict_from_cache(
full_access_path
)
)
cached_value = cached_value_dict.get("value")
if cached_value != dump(value)["value"] and all(
part[0] != "_" for part in full_access_path.split(".")
):
logger.debug("'%s' changed to '%s'", full_access_path, value)
self._update_cache_value(full_access_path, value, cached_value_dict)
for callback in self._notification_callbacks:
callback(full_access_path, value, cached_value_dict)
if isinstance(value, ObservableObject):
self._update_property_deps_dict()
self._notify_dependent_property_changes(full_access_path)
def _update_cache_value(
self, full_access_path: str, value: Any, cached_value_dict: dict[str, Any]
) -> None:
value_dict = dump(value)
if cached_value_dict != {}:
if (
cached_value_dict["type"] != "method"
and cached_value_dict["type"] != value_dict["type"]
):
logger.warning(
"Type of '%s' changed from '%s' to '%s'. This could have unwanted "
"side effects! Consider setting it to '%s' directly.",
full_access_path,
cached_value_dict["type"],
value_dict["type"],
cached_value_dict["type"],
)
self.state_manager._data_service_cache.update_cache(
full_access_path,
value,
)
def _notify_dependent_property_changes(self, changed_attr_path: str) -> None:
changed_props = self.property_deps_dict.get(changed_attr_path, [])
for prop in changed_props:
# only notify about changing attribute if it is not currently being
# "changed" e.g. when calling the getter of a property within another
# property
if prop not in self.changing_attributes:
self._notify_changed(
prop,
get_object_attr_from_path_list(self.observable, prop.split(".")),
)
def add_notification_callback(
self, callback: Callable[[str, Any, dict[str, Any]], None]
) -> None:
"""
Registers a callback function to be invoked upon attribute changes in the
observed object.
This method allows for the addition of custom callback functions that will be
executed whenever there is a change in the value of an observed attribute. The
callback function is called with detailed information about the change, enabling
external logic to respond to specific state changes within the observable
object.
Args:
callback (Callable[[str, Any, dict[str, Any]]): The callback function to be
registered. The function should have the following signature:
- full_access_path (str): The full dot-notation access path of the
changed attribute. This path indicates the location of the changed
attribute within the observable object's structure.
- value (Any): The new value of the changed attribute.
- cached_value_dict (dict[str, Any]): A dictionary representing the
cached state of the attribute prior to the change. This can be useful
for understanding the nature of the change and for historical
comparison.
"""
self._notification_callbacks.append(callback)

View File

@@ -1,283 +0,0 @@
import json
import logging
import os
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
import pydase.units as u
from pydase.data_service.data_service_cache import DataServiceCache
from pydase.utils.helpers import (
get_object_attr_from_path_list,
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:
from pydase import DataService
logger = logging.getLogger(__name__)
def load_state(func: Callable[..., Any]) -> Callable[..., Any]:
"""This function should be used as a decorator on property setters to indicate that
the value should be loaded from the JSON file.
Example:
>>> class Service(pydase.DataService):
... _name = "Service"
...
... @property
... def name(self) -> str:
... return self._name
...
... @name.setter
... @load_state
... def name(self, value: str) -> None:
... self._name = value
"""
func._load_state = True # type: ignore[attr-defined]
return func
def has_load_state_decorator(prop: property) -> bool:
"""Determines if the property's setter method is decorated with the `@load_state`
decorator.
"""
try:
return prop.fset._load_state # type: ignore[union-attr]
except AttributeError:
return False
class StateManager:
"""
Manages the state of a DataService instance, serving as both a cache and a
persistence layer. It is designed to provide quick access to the latest known state
for newly connecting web clients without the need for expensive property accesses
that may involve complex calculations or I/O operations.
The StateManager listens for state change notifications from the DataService's
callback manager and updates its cache accordingly. This cache does not always
reflect the most current complex property states but rather retains the value from
the last known state, optimizing for performance and reducing the load on the
system.
While the StateManager ensures that the cached state is as up-to-date as possible,
it does not autonomously update complex properties of the DataService. Such
properties must be updated programmatically, for instance, by invoking specific
tasks or methods that trigger the necessary operations to refresh their state.
The cached state maintained by the StateManager is particularly useful for web
clients that connect to the system and need immediate access to the current state of
the DataService. By avoiding direct and potentially costly property accesses, the
StateManager provides a snapshot of the DataService's state that is sufficiently
accurate for initial rendering and interaction.
Attributes:
cache (dict[str, Any]):
A dictionary cache of the DataService's state.
filename (str):
The file name used for storing the DataService's state.
service (DataService):
The DataService instance whose state is being managed.
Note:
The StateManager's cache updates are triggered by notifications and do not
include autonomous updates of complex DataService properties, which must be
managed programmatically. The cache serves the purpose of providing immediate
state information to web clients, reflecting the state after the last property
update.
"""
def __init__(
self, service: "DataService", filename: str | Path | None = None
) -> None:
self.filename = getattr(service, "_filename", None)
if filename is not None:
if self.filename is not None:
logger.warning(
"Overwriting filename '%s' with '%s'.", self.filename, filename
)
self.filename = filename
self.service = service
self._data_service_cache = DataServiceCache(self.service)
@property
def cache(self) -> dict[str, Any]:
"""Returns the cached DataService state."""
return self._data_service_cache.cache
def save_state(self) -> None:
"""
Saves the DataService's current state to a JSON file defined by `self.filename`.
Logs an error if `self.filename` is not set.
"""
if self.filename is not None:
with open(self.filename, "w") as f:
json.dump(self.cache["value"], f, indent=4)
else:
logger.info(
"State manager was not initialised with a filename. Skipping "
"'save_state'..."
)
def load_state(self) -> None:
"""
Loads the DataService's state from a JSON file defined by `self.filename`.
Updates the service's attributes, respecting type and read-only constraints.
"""
# Traverse the serialized representation and set the attributes of the class
json_dict = self._get_state_dict_from_json_file()
if json_dict == {}:
logger.debug("Could not load the service state.")
return
for path in generate_serialized_data_paths(json_dict):
nested_json_dict = get_nested_dict_by_path(json_dict, path)
nested_class_dict = self._data_service_cache.get_value_dict_from_cache(path)
value, value_type = nested_json_dict["value"], nested_json_dict["type"]
class_attr_value_type = nested_class_dict.get("type", None)
if class_attr_value_type == value_type:
if self.__is_loadable_state_attribute(path):
self.set_service_attribute_value_by_path(path, value)
else:
logger.info(
"Attribute type of '%s' changed from '%s' to "
"'%s'. Ignoring value from JSON file...",
path,
value_type,
class_attr_value_type,
)
def _get_state_dict_from_json_file(self) -> dict[str, Any]:
if self.filename is not None and os.path.exists(self.filename):
with open(self.filename) as f:
# Load JSON data from file and update class attributes with these
# values
return cast(dict[str, Any], json.load(f))
return {}
def set_service_attribute_value_by_path(
self,
path: str,
value: Any,
) -> None:
"""
Sets the value of an attribute in the service managed by the `StateManager`
given its path as a dot-separated string.
This method updates the attribute specified by 'path' with 'value' only if the
attribute is not read-only and the new value differs from the current one.
It also handles type-specific conversions for the new value before setting it.
Args:
path: A dot-separated string indicating the hierarchical path to the
attribute.
value: The new value to set for the attribute.
"""
current_value_dict = get_nested_dict_by_path(self.cache["value"], path)
# This will also filter out methods as they are 'read-only'
if current_value_dict["readonly"]:
logger.debug("Attribute '%s' is read-only. Ignoring new value...", path)
return
converted_value = self.__convert_value_if_needed(value, current_value_dict)
# only set value when it has changed
if self.__attr_value_has_changed(converted_value, current_value_dict["value"]):
self.__update_attribute_by_path(path, converted_value)
else:
logger.debug("Value of attribute '%s' has not changed...", path)
def __attr_value_has_changed(self, value_object: Any, current_value: Any) -> bool:
"""Check if the serialized value of `value_object` differs from `current_value`.
The method serializes `value_object` to compare it, which is mainly
necessary for handling Quantity objects.
"""
return dump(value_object)["value"] != current_value
def __convert_value_if_needed(
self, value: Any, current_value_dict: dict[str, Any]
) -> Any:
if current_value_dict["type"] == "Quantity":
return u.convert_to_quantity(value, current_value_dict["value"]["unit"])
if current_value_dict["type"] == "float" and not isinstance(value, float):
return float(value)
return value
def __update_attribute_by_path(self, path: str, value: Any) -> None:
parent_path_list, attr_name = path.split(".")[:-1], path.split(".")[-1]
# If attr_name corresponds to a list entry, extract the attr_name and the
# index
attr_name, index = parse_list_attr_and_index(attr_name)
# Update path to reflect the attribute without list indices
path = ".".join([*parent_path_list, attr_name])
attr_cache_type = get_nested_dict_by_path(self.cache["value"], path)["type"]
# Traverse the object according to the path parts
target_obj = get_object_attr_from_path_list(self.service, parent_path_list)
if attr_cache_type in ("ColouredEnum", "Enum"):
enum_attr = get_object_attr_from_path_list(target_obj, [attr_name])
setattr(target_obj, attr_name, enum_attr.__class__[value])
elif attr_cache_type == "list":
list_obj = get_object_attr_from_path_list(target_obj, [attr_name])
list_obj[index] = value
else:
setattr(target_obj, attr_name, value)
def __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.
For properties, it verifies the presence of the '@load_state' decorator. Regular
attributes default to being loadable.
"""
parent_object = get_object_attr_from_path_list(
self.service, full_access_path.split(".")[:-1]
)
attr_name = full_access_path.split(".")[-1]
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(
"Property '%s' has no '@load_state' decorator. "
"Ignoring value from JSON file...",
attr_name,
)
return has_decorator
cached_serialization_dict = get_nested_dict_by_path(
self.cache["value"], full_access_path
)
if cached_serialization_dict["value"] == "method":
return False
# nested objects cannot be loaded
return not serialized_dict_is_nested_object(cached_serialization_dict)

View File

@@ -2,31 +2,19 @@ from __future__ import annotations
import asyncio
import inspect
import logging
from enum import Enum
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING, Any, TypedDict, Union
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.utils.helpers import (
function_has_arguments,
get_class_and_instance_attributes,
is_property_attribute,
)
from loguru import logger
if TYPE_CHECKING:
from collections.abc import Callable
from .data_service import DataService
logger = logging.getLogger(__name__)
class TaskDefinitionError(Exception):
pass
class TaskStatus(Enum):
RUNNING = "running"
class TaskDict(TypedDict):
task: asyncio.Task[None]
kwargs: dict[str, Any]
class TaskManager:
@@ -85,42 +73,113 @@ class TaskManager:
def __init__(self, service: DataService) -> None:
self.service = service
self._loop = asyncio.get_event_loop()
self.tasks: dict[str, asyncio.Task[None]] = {}
self.tasks: dict[str, TaskDict] = {}
"""A dictionary to keep track of running tasks. The keys are the names of the
tasks and the values are TaskDict instances which include the task itself and
its kwargs.
"""
self.task_status_change_callbacks: list[
Callable[[str, Union[dict[str, Any], None]], Any]
] = []
"""A list of callback functions to be invoked when the status of a task (start
or stop) changes."""
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: # noqa: C901
# inspect the methods of the class
for name, method in inspect.getmembers(
self.service, predicate=inspect.iscoroutinefunction
):
def _set_start_and_stop_for_async_methods(self) -> None:
for name in dir(self.service):
# circumvents calling properties
if is_property_attribute(self.service, name):
continue
@wraps(method)
def start_task(*args: Any, **kwargs: Any) -> None:
def task_done_callback(task: asyncio.Task, name: str) -> None:
"""Handles tasks that have finished.
method = getattr(self.service, name)
if inspect.iscoroutinefunction(method):
if function_has_arguments(method):
raise TaskDefinitionError(
"Asynchronous functions (tasks) should be defined without "
f"arguments. The task '{method.__name__}' has at least one "
"argument. Please remove the argument(s) from this function to "
"use it."
Removes a task from the tasks dictionary, calls the defined
callbacks, and logs and re-raises exceptions."""
# removing the finished task from the tasks i
self.tasks.pop(name, None)
# emit the notification that the task was stopped
for callback in self.task_status_change_callbacks:
callback(name, None)
exception = task.exception()
if exception is not None:
# Handle the exception, or you can re-raise it.
logger.error(
f"Task '{name}' encountered an exception: "
f"{type(exception).__name__}: {exception}"
)
raise exception
async def task(*args: Any, **kwargs: Any) -> None:
try:
await method(*args, **kwargs)
except asyncio.CancelledError:
print(f"Task {name} was cancelled")
if not self.tasks.get(name):
# Get the signature of the coroutine method to start
sig = inspect.signature(method)
# Create a list of the parameter names from the method signature.
parameter_names = list(sig.parameters.keys())
# Extend the list of positional arguments with None values to match
# the length of the parameter names list. This is done to ensure
# that zip can pair each parameter name with a corresponding value.
args_padded = list(args) + [None] * (
len(parameter_names) - len(args)
)
# create start and stop methods for each coroutine
setattr(
self.service, f"start_{name}", self._make_start_task(name, method)
)
setattr(self.service, f"stop_{name}", self._make_stop_task(name))
# Create a dictionary of keyword arguments by pairing the parameter
# names with the values in 'args_padded'. Then merge this dictionary
# with the 'kwargs' dictionary. If a parameter is specified in both
# 'args_padded' and 'kwargs', the value from 'kwargs' is used.
kwargs_updated = {
**dict(zip(parameter_names, args_padded)),
**kwargs,
}
def _initiate_task_startup(self) -> None:
# creating the task and adding the task_done_callback which checks
# if an exception has occured during the task execution
task_object = self._loop.create_task(task(*args, **kwargs))
task_object.add_done_callback(
lambda task: task_done_callback(task, name)
)
# Store the task and its arguments in the '__tasks' dictionary. The
# key is the name of the method, and the value is a dictionary
# containing the task object and the updated keyword arguments.
self.tasks[name] = {
"task": task_object,
"kwargs": kwargs_updated,
}
# emit the notification that the task was started
for callback in self.task_status_change_callbacks:
callback(name, kwargs_updated)
else:
logger.error(f"Task `{name}` is already running!")
def stop_task() -> None:
# cancel the task
task = self.tasks.get(name, None)
if task is not None:
self._loop.call_soon_threadsafe(task["task"].cancel)
# create start and stop methods for each coroutine
setattr(self.service, f"start_{name}", start_task)
setattr(self.service, f"stop_{name}", stop_task)
def start_autostart_tasks(self) -> None:
if self.service._autostart_tasks is not None:
for service_name, args in self.service._autostart_tasks.items():
start_method = getattr(self.service, f"start_{service_name}", None)
@@ -128,101 +187,5 @@ class TaskManager:
start_method(*args)
else:
logger.warning(
"No start method found for service '%s'", service_name
f"No start method found for service '{service_name}'"
)
def start_autostart_tasks(self) -> None:
self._initiate_task_startup()
attrs = get_class_and_instance_attributes(self.service)
for attr_value in attrs.values():
if isinstance(attr_value, AbstractDataService):
attr_value._task_manager.start_autostart_tasks()
elif isinstance(attr_value, list):
for item in attr_value:
if isinstance(item, AbstractDataService):
item._task_manager.start_autostart_tasks()
def _make_stop_task(self, name: str) -> Callable[..., Any]:
"""
Factory function to create a 'stop_task' function for a running task.
The generated function cancels the associated asyncio task using 'name' for
identification, ensuring proper cleanup. Avoids closure and late binding issues.
Args:
name (str): The name of the coroutine task, used for its identification.
"""
def stop_task() -> None:
# cancel the task
task = self.tasks.get(name, None)
if task is not None:
self._loop.call_soon_threadsafe(task.cancel)
return stop_task
def _make_start_task(
self, name: str, method: Callable[..., Any]
) -> Callable[..., Any]:
"""
Factory function to create a 'start_task' function for a coroutine.
The generated function starts the coroutine as an asyncio task, handling
registration and monitoring.
It uses 'name' and 'method' to avoid the closure and late binding issue.
Args:
name (str): The name of the coroutine, used for task management.
method (callable): The coroutine to be turned into an asyncio task.
"""
def start_task() -> None:
def task_done_callback(task: asyncio.Task[None], name: str) -> None:
"""Handles tasks that have finished.
Removes a task from the tasks dictionary, calls the defined
callbacks, and logs and re-raises exceptions."""
# removing the finished task from the tasks i
self.tasks.pop(name, None)
# emit the notification that the task was stopped
self.service._notify_changed(name, None)
exception = task.exception()
if exception is not None:
# Handle the exception, or you can re-raise it.
logger.error(
"Task '%s' encountered an exception: %s: %s",
name,
type(exception).__name__,
exception,
)
raise exception
async def task() -> None:
try:
await method()
except asyncio.CancelledError:
logger.info("Task '%s' was cancelled", name)
if not self.tasks.get(name):
# creating the task and adding the task_done_callback which checks
# if an exception has occured during the task execution
task_object = self._loop.create_task(task())
task_object.add_done_callback(
lambda task: task_done_callback(task, name)
)
# Store the task and its arguments in the '__tasks' dictionary. The
# key is the name of the method, and the value is a dictionary
# containing the task object and the updated keyword arguments.
self.tasks[name] = task_object
# emit the notification that the task was started
self.service._notify_changed(name, TaskStatus.RUNNING)
else:
logger.error("Task '%s' is already running!", name)
return start_task

View File

@@ -1,13 +1,13 @@
{
"files": {
"main.css": "/static/css/main.7ef670d5.css",
"main.js": "/static/js/main.ce19efa0.js",
"main.css": "/static/css/main.398bc7f8.css",
"main.js": "/static/js/main.c348625e.js",
"index.html": "/index.html",
"main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
"main.ce19efa0.js.map": "/static/js/main.ce19efa0.js.map"
"main.398bc7f8.css.map": "/static/css/main.398bc7f8.css.map",
"main.c348625e.js.map": "/static/js/main.c348625e.js.map"
},
"entrypoints": [
"static/css/main.7ef670d5.css",
"static/js/main.ce19efa0.js"
"static/css/main.398bc7f8.css",
"static/js/main.c348625e.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.ce19efa0.js"></script><link href="/static/css/main.7ef670d5.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><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.c348625e.js"></script><link href="/static/css/main.398bc7f8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,8 @@
http://jedwatson.github.io/classnames
*/
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @license React
* react-dom.production.min.js
@@ -43,3 +45,11 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @mui/styled-engine v5.13.2
*
* @license MIT
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

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 +0,0 @@
from pydase.observer_pattern.observable.observable import Observable
__all__ = ["Observable"]

View File

@@ -1,71 +0,0 @@
import logging
from typing import Any
from pydase.observer_pattern.observable.observable_object import ObservableObject
from pydase.utils.helpers import is_property_attribute
logger = logging.getLogger(__name__)
class Observable(ObservableObject):
def __init__(self) -> None:
super().__init__()
class_attrs = {
k: type(self).__dict__[k]
for k in set(type(self).__dict__)
- set(Observable.__dict__)
- set(self.__dict__)
}
for name, value in class_attrs.items():
if isinstance(value, property) or callable(value):
continue
self.__dict__[name] = self._initialise_new_objects(name, value)
def __setattr__(self, name: str, value: Any) -> None:
if not hasattr(self, "_observers") and name != "_observers":
logger.warning(
"Ensure that super().__init__() is called at the start of the '%s' "
"constructor! Failing to do so may lead to unexpected behavior.",
type(self).__name__,
)
self._observers = {}
value = self._handle_observable_setattr(name, value)
super().__setattr__(name, value)
self._notify_changed(name, value)
def __getattribute__(self, name: str) -> Any:
if is_property_attribute(self, name):
self._notify_change_start(name)
value = super().__getattribute__(name)
if is_property_attribute(self, name):
self._notify_changed(name, value)
return value
def _handle_observable_setattr(self, name: str, value: Any) -> Any:
if name == "_observers":
return value
self._remove_observer_if_observable(name)
value = self._initialise_new_objects(name, value)
self._notify_change_start(name)
return value
def _remove_observer_if_observable(self, name: str) -> None:
if not is_property_attribute(self, name):
current_value = getattr(self, name, None)
if isinstance(current_value, ObservableObject):
current_value._remove_observer(self, name)
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
if observer_attr_name != "":
return f"{observer_attr_name}.{instance_attr_name}"
return instance_attr_name

View File

@@ -1,264 +0,0 @@
import logging
from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, ClassVar, SupportsIndex
if TYPE_CHECKING:
from pydase.observer_pattern.observer.observer import Observer
logger = logging.getLogger(__name__)
class ObservableObject(ABC):
_list_mapping: ClassVar[dict[int, "_ObservableList"]] = {}
_dict_mapping: ClassVar[dict[int, "_ObservableDict"]] = {}
def __init__(self) -> None:
if not hasattr(self, "_observers"):
self._observers: dict[str, list["ObservableObject | Observer"]] = {}
def add_observer(
self, observer: "ObservableObject | Observer", attr_name: str = ""
) -> None:
if attr_name not in self._observers:
self._observers[attr_name] = []
if observer not in self._observers[attr_name]:
self._observers[attr_name].append(observer)
def _remove_observer(self, observer: "ObservableObject", attribute: str) -> None:
if attribute in self._observers:
self._observers[attribute].remove(observer)
@abstractmethod
def _remove_observer_if_observable(self, name: str) -> None:
"""Removes the current object as an observer from an observable attribute.
This method is called before an attribute of the observable object is
changed. If the current value of the attribute is an instance of
`ObservableObject`, this method removes the current object from its list
of observers. This is a crucial step to avoid unwanted notifications from
the old value of the attribute.
"""
def _notify_changed(self, changed_attribute: str, value: Any) -> None:
"""Notifies all observers about changes to an attribute.
This method iterates through all observers registered for the object and
invokes their notification method. It is called whenever an attribute of
the observable object is changed.
Args:
changed_attribute (str): The name of the changed attribute.
value (Any): The value that the attribute was set to.
"""
for attr_name, observer_list in self._observers.items():
for observer in observer_list:
extendend_attr_path = self._construct_extended_attr_path(
attr_name, changed_attribute
)
observer._notify_changed(extendend_attr_path, value)
def _notify_change_start(self, changing_attribute: str) -> None:
"""Notify observers that an attribute or item change process has started.
This method is called at the start of the process of modifying an attribute in
the observed `Observable` object. It registers the attribute as currently
undergoing a change. This registration helps in managing and tracking changes as
they occur, especially in scenarios where the order of changes or their state
during the transition is significant.
Args:
changing_attribute (str): The name of the attribute that is starting to
change. This is typically the full access path of the attribute in the
`Observable`.
value (Any): The value that the attribute is being set to.
"""
for attr_name, observer_list in self._observers.items():
for observer in observer_list:
extended_attr_path = self._construct_extended_attr_path(
attr_name, changing_attribute
)
observer._notify_change_start(extended_attr_path)
def _initialise_new_objects(self, attr_name_or_key: Any, value: Any) -> Any:
new_value = value
if isinstance(value, list):
if id(value) in self._list_mapping:
# If the list `value` was already referenced somewhere else
new_value = self._list_mapping[id(value)]
else:
# convert the builtin list into a ObservableList
new_value = _ObservableList(original_list=value)
self._list_mapping[id(value)] = new_value
elif isinstance(value, dict):
if id(value) in self._dict_mapping:
# If the list `value` was already referenced somewhere else
new_value = self._dict_mapping[id(value)]
else:
# convert the builtin list into a ObservableList
new_value = _ObservableDict(original_dict=value)
self._dict_mapping[id(value)] = new_value
if isinstance(new_value, ObservableObject):
new_value.add_observer(self, str(attr_name_or_key))
return new_value
@abstractmethod
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
"""
Constructs the extended attribute path for notification purposes, which is used
in the observer pattern to specify the full path of an observed attribute.
This abstract method is implemented by the classes inheriting from
`ObservableObject`.
Args:
observer_attr_name (str): The name of the attribute in the observer that
holds a reference to the instance. Equals `""` if observer itself is of type
`Observer`.
instance_attr_name (str): The name of the attribute within the instance that
has changed.
Returns:
str: The constructed extended attribute path.
"""
class _ObservableList(ObservableObject, list[Any]):
def __init__(
self,
original_list: list[Any],
) -> None:
self._original_list = original_list
ObservableObject.__init__(self)
list.__init__(self, self._original_list)
for i, item in enumerate(self._original_list):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
if hasattr(self, "_observers"):
self._remove_observer_if_observable(f"[{key}]")
value = self._initialise_new_objects(f"[{key}]", value)
self._notify_change_start(f"[{key}]")
super().__setitem__(key, value)
self._notify_changed(f"[{key}]", value)
def append(self, __object: Any) -> None:
self._notify_change_start("")
self._initialise_new_objects(f"[{len(self)}]", __object)
super().append(__object)
self._notify_changed("", self)
def clear(self) -> None:
self._remove_self_from_observables()
super().clear()
self._notify_changed("", self)
def extend(self, __iterable: Iterable[Any]) -> None:
self._remove_self_from_observables()
try:
super().extend(__iterable)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
def insert(self, __index: SupportsIndex, __object: Any) -> None:
self._remove_self_from_observables()
try:
super().insert(__index, __object)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
def pop(self, __index: SupportsIndex = -1) -> Any:
self._remove_self_from_observables()
try:
popped_item = super().pop(__index)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
return popped_item
def remove(self, __value: Any) -> None:
self._remove_self_from_observables()
try:
super().remove(__value)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
def _remove_self_from_observables(self) -> None:
for i in range(len(self)):
self._remove_observer_if_observable(f"[{i}]")
def _remove_observer_if_observable(self, name: str) -> None:
key = int(name[1:-1])
current_value = self.__getitem__(key)
if isinstance(current_value, ObservableObject):
current_value._remove_observer(self, name)
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
if observer_attr_name != "":
return f"{observer_attr_name}{instance_attr_name}"
return instance_attr_name
class _ObservableDict(dict[str, Any], ObservableObject):
def __init__(
self,
original_dict: dict[str, Any],
) -> None:
self._original_dict = original_dict
ObservableObject.__init__(self)
dict.__init__(self)
for key, value in self._original_dict.items():
super().__setitem__(key, self._initialise_new_objects(f"['{key}']", value))
def __setitem__(self, key: str, value: Any) -> None:
if not isinstance(key, str):
logger.warning("Converting non-string dictionary key %s to string.", key)
key = str(key)
if hasattr(self, "_observers"):
self._remove_observer_if_observable(f"['{key}']")
value = self._initialise_new_objects(key, value)
self._notify_change_start(f"['{key}']")
super().__setitem__(key, value)
self._notify_changed(f"['{key}']", value)
def _remove_observer_if_observable(self, name: str) -> None:
key = name[2:-2]
current_value = self.get(key, None)
if isinstance(current_value, ObservableObject):
current_value._remove_observer(self, name)
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
if observer_attr_name != "":
return f"{observer_attr_name}{instance_attr_name}"
return instance_attr_name

View File

@@ -1,7 +0,0 @@
from pydase.observer_pattern.observer.observer import Observer
from pydase.observer_pattern.observer.property_observer import PropertyObserver
__all__ = [
"Observer",
"PropertyObserver",
]

View File

@@ -1,31 +0,0 @@
import logging
from abc import ABC, abstractmethod
from typing import Any
from pydase.observer_pattern.observable import Observable
logger = logging.getLogger(__name__)
class Observer(ABC):
def __init__(self, observable: Observable) -> None:
self.observable = observable
self.observable.add_observer(self)
self.changing_attributes: list[str] = []
def _notify_changed(self, changed_attribute: str, value: Any) -> None:
self.on_change(full_access_path=changed_attribute, value=value)
if changed_attribute in self.changing_attributes:
self.changing_attributes.remove(changed_attribute)
def _notify_change_start(self, changing_attribute: str) -> None:
self.changing_attributes.append(changing_attribute)
self.on_change_start(changing_attribute)
@abstractmethod
def on_change(self, full_access_path: str, value: Any) -> None:
...
def on_change_start(self, full_access_path: str) -> None:
return

View File

@@ -1,95 +0,0 @@
import inspect
import logging
import re
from typing import Any
from pydase.observer_pattern.observable.observable import Observable
from pydase.observer_pattern.observer.observer import Observer
logger = logging.getLogger(__name__)
def reverse_dict(original_dict: dict[str, list[str]]) -> dict[str, list[str]]:
reversed_dict: dict[str, list[str]] = {
value: [] for values in original_dict.values() for value in values
}
for key, values in original_dict.items():
for value in values:
reversed_dict[value].append(key)
return reversed_dict
def get_property_dependencies(prop: property, prefix: str = "") -> list[str]:
source_code_string = inspect.getsource(prop.fget) # type: ignore[arg-type]
pattern = r"self\.([^\s\{\}]+)"
matches = re.findall(pattern, source_code_string)
return [prefix + match for match in matches if "(" not in match]
class PropertyObserver(Observer):
def __init__(self, observable: Observable) -> None:
super().__init__(observable)
self._update_property_deps_dict()
def _update_property_deps_dict(self) -> None:
self.property_deps_dict = reverse_dict(
self._get_properties_and_their_dependencies(self.observable)
)
def _get_properties_and_their_dependencies(
self, obj: Observable, prefix: str = ""
) -> dict[str, list[str]]:
deps: dict[str, Any] = {}
self._process_observable_properties(obj, deps, prefix)
self._process_nested_observables_properties(obj, deps, prefix)
return deps
def _process_observable_properties(
self, obj: Observable, deps: dict[str, Any], prefix: str
) -> None:
for k, value in vars(type(obj)).items():
prefix = (
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
)
key = f"{prefix}{k}"
if isinstance(value, property):
deps[key] = get_property_dependencies(value, prefix)
def _process_nested_observables_properties(
self, obj: Observable, deps: dict[str, Any], prefix: str
) -> None:
for k, value in vars(obj).items():
prefix = (
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
)
parent_path = f"{prefix}{k}"
if isinstance(value, Observable):
new_prefix = f"{parent_path}."
deps.update(
self._get_properties_and_their_dependencies(value, new_prefix)
)
elif isinstance(value, list | dict):
self._process_collection_item_properties(value, deps, parent_path)
def _process_collection_item_properties(
self,
collection: list[Any] | dict[str, Any],
deps: dict[str, Any],
parent_path: str,
) -> None:
if isinstance(collection, list):
for i, item in enumerate(collection):
if isinstance(item, Observable):
new_prefix = f"{parent_path}[{i}]"
deps.update(
self._get_properties_and_their_dependencies(item, new_prefix)
)
elif isinstance(collection, dict):
for key, val in collection.items():
if isinstance(val, Observable):
new_prefix = f"{parent_path}['{key}']"
deps.update(
self._get_properties_and_their_dependencies(val, new_prefix)
)

View File

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

View File

@@ -1,23 +1,25 @@
import asyncio
import logging
import os
import signal
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from enum import Enum
from types import FrameType
from typing import Any, Protocol, TypedDict
from typing import Any, Optional, Protocol, TypedDict, Union
from rpyc import ThreadedServer # type: ignore[import-untyped]
import uvicorn
from loguru import logger
from rpyc import (
ForkingServer, # can be used for multiprocessing, e.g. a database interface server
)
from rpyc import ThreadedServer
from uvicorn.server import HANDLED_SIGNALS
import pydase.units as u
from pydase import DataService
from pydase.config import ServiceConfig
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.server.web_server import WebServer
from pydase.version import __version__
logger = logging.getLogger(__name__)
from .web_server import WebAPI
class AdditionalServerProtocol(Protocol):
@@ -26,31 +28,31 @@ class AdditionalServerProtocol(Protocol):
This protocol sets the standard for how additional servers should be implemented
to ensure compatibility with the main Server class. The protocol requires that
any server implementing it should have an __init__ method for initialization and a
serve method for starting the server.
any server implementing it should have an __init__ method for initialization, a
serve method for starting the server, and an install_signal_handlers method for
setting up signal handlers.
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.
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.
**kwargs: Any
Any additional parameters required for initializing the server. These parameters
are specific to the server's implementation.
"""
def __init__(
self,
data_service_observer: DataServiceObserver,
host: str,
port: int,
**kwargs: Any,
self, service: DataService, port: int, host: str, **kwargs: Any
) -> None:
...
@@ -58,6 +60,13 @@ class AdditionalServerProtocol(Protocol):
"""Starts the server. This method should be implemented as an asynchronous
method, which means that it should be able to run concurrently with other tasks.
"""
...
def install_signal_handlers(self) -> None:
"""Sets up signal handlers for the server. This method is used to define how the
server should respond to various system signals, such as SIGINT and SIGTERM.
"""
...
class AdditionalServer(TypedDict):
@@ -79,119 +88,138 @@ class Server:
"""
The `Server` class provides a flexible server implementation for the `DataService`.
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.
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.
use_forking_server: bool
Whether to use ForkingServer for multiprocessing (e.g. for a database interface
server). Default is False.
web_settings: dict[str, Any]
Additional settings for the web server. Default is {} (an empty dictionary).
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:
Here's an example of how you might define an additional server:
- 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:
>>> 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...
>>> class MyCustomServer:
... def __init__(
... self, service: DataService, port: int, host: str, **kwargs: Any
... ):
... self.service = 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
def __init__( # noqa: CFQ002
self,
service: DataService,
host: str = "0.0.0.0",
rpc_port: int = ServiceConfig().rpc_port,
web_port: int = ServiceConfig().web_port,
rpc_port: int = 18871,
web_port: int = 8001,
enable_rpc: bool = True,
enable_web: bool = True,
filename: str | Path | None = None,
additional_servers: list[AdditionalServer] | None = None,
use_forking_server: bool = False,
web_settings: dict[str, Any] = {},
additional_servers: list[AdditionalServer] = [],
**kwargs: Any,
) -> None:
if additional_servers is None:
additional_servers = []
self._service = service
self._host = host
self._rpc_port = rpc_port
self._web_port = web_port
self._enable_rpc = enable_rpc
self._enable_web = enable_web
self._web_settings = web_settings
self._kwargs = kwargs
self._loop: asyncio.AbstractEventLoop
self._rpc_server_type = ForkingServer if use_forking_server else ThreadedServer
self._additional_servers = additional_servers
self.should_exit = False
self.servers: dict[str, asyncio.Future[Any]] = {}
self.executor: ThreadPoolExecutor | None = None
self._state_manager = StateManager(self._service, filename)
self._observer = DataServiceObserver(self._state_manager)
self._state_manager.load_state()
self.executor: Union[ThreadPoolExecutor, None] = None
self._info: dict[str, Any] = {
"name": self._service.get_service_name(),
"version": __version__,
"rpc_port": self._rpc_port,
"web_port": self._web_port,
"enable_rpc": self._enable_rpc,
"enable_web": self._enable_web,
"web_settings": self._web_settings,
"additional_servers": [],
**kwargs,
}
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.
"""
asyncio.run(self.serve())
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
async def serve(self) -> None:
process_id = os.getpid()
logger.info("Started server process [%s]", process_id)
logger.info(f"Started server process [{process_id}]")
await self.startup()
if self.should_exit:
@@ -199,9 +227,9 @@ class Server:
await self.main_loop()
await self.shutdown()
logger.info("Finished server process [%s]", process_id)
logger.info(f"Finished server process [{process_id}]")
async def startup(self) -> None:
async def startup(self) -> None: # noqa: C901
self._loop = asyncio.get_running_loop()
self._loop.set_exception_handler(self.custom_exception_handler)
self.install_signal_handlers()
@@ -209,7 +237,7 @@ class Server:
if self._enable_rpc:
self.executor = ThreadPoolExecutor()
self._rpc_server = ThreadedServer(
self._rpc_server = self._rpc_server_type(
self._service,
port=self._rpc_port,
protocol_config={
@@ -223,26 +251,80 @@ class Server:
self.servers["rpyc"] = future_or_task
for server in self._additional_servers:
addin_server = server["server"](
data_service_observer=self._observer,
host=self._host,
self._service,
port=server["port"],
host=self._host,
info=self._info,
**server["kwargs"],
)
try:
addin_server.install_signal_handlers = lambda: None # type: ignore
except Exception:
logger.debug(
"Additional server does not have a method called "
"'install_signal_handlers'."
)
server_name = (
addin_server.__module__ + "." + addin_server.__class__.__name__
)
self._info["additional_servers"].append(
{
"name": server_name,
"port": server["port"],
"host": self._host,
**server["kwargs"],
}
)
future_or_task = self._loop.create_task(addin_server.serve())
self.servers[server_name] = future_or_task
if self._enable_web:
self._web_server = WebServer(
data_service_observer=self._observer,
host=self._host,
port=self._web_port,
self._wapi: WebAPI = WebAPI(
service=self._service,
info=self._info,
**self._kwargs,
)
future_or_task = self._loop.create_task(self._web_server.serve())
web_server = uvicorn.Server(
uvicorn.Config(
self._wapi.fastapi_app, host=self._host, port=self._web_port
)
)
def sio_callback(parent_path: str, name: str, value: Any) -> None:
# TODO: an error happens when an attribute is set to a list
# > File "/usr/lib64/python3.11/json/encoder.py", line 180, in default
# > raise TypeError(f'Object of type {o.__class__.__name__} '
# > TypeError: Object of type list is not JSON serializable
notify_value = value
if isinstance(value, Enum):
notify_value = value.name
if isinstance(value, u.Quantity):
notify_value = {"magnitude": value.m, "unit": str(value.u)}
async def notify() -> None:
try:
await self._wapi.sio.emit( # type: ignore
"notify",
{
"data": {
"parent_path": parent_path,
"name": name,
"value": notify_value,
}
},
)
except Exception as e:
logger.warning(f"Failed to send notification: {e}")
self._loop.create_task(notify())
self._service._callback_manager.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
future_or_task = self._loop.create_task(web_server.serve())
self.servers["web"] = future_or_task
async def main_loop(self) -> None:
@@ -252,8 +334,9 @@ class Server:
async def shutdown(self) -> None:
logger.info("Shutting down")
logger.info("Saving data to %s.", self._state_manager.filename)
self._state_manager.save_state()
logger.info(f"Saving data to {self._service._filename}.")
if self._service._filename is not None:
self._service.write_to_file()
await self.__cancel_servers()
await self.__cancel_tasks()
@@ -268,9 +351,9 @@ class Server:
try:
await task
except asyncio.CancelledError:
logger.debug("Cancelled '%s' server.", server_name)
logger.debug(f"Cancelled {server_name} server.")
except Exception as e:
logger.warning("Unexpected exception: %s", e)
logger.warning(f"Unexpected exception: {e}.")
async def __cancel_tasks(self) -> None:
for task in asyncio.all_tasks(self._loop):
@@ -278,27 +361,29 @@ class Server:
try:
await task
except asyncio.CancelledError:
logger.debug("Cancelled task '%s'.", task.get_coro())
logger.debug(f"Cancelled task {task.get_coro()}.")
except Exception as e:
logger.exception("Unexpected exception: %s", e)
logger.warning(f"Unexpected exception: {e}.")
def install_signal_handlers(self) -> None:
if threading.current_thread() is not threading.main_thread():
# Signals can only be listened to from the main thread.
return
for sig in HANDLED_SIGNALS:
signal.signal(sig, self.handle_exit)
try:
for sig in HANDLED_SIGNALS:
self._loop.add_signal_handler(sig, self.handle_exit, sig, None)
except NotImplementedError:
# Windows
for sig in HANDLED_SIGNALS:
signal.signal(sig, self.handle_exit)
def handle_exit(self, sig: int = 0, frame: FrameType | None = None) -> None:
def handle_exit(self, sig: int = 0, frame: Optional[FrameType] = None) -> None:
logger.info("Handling exit")
if self.should_exit and sig == signal.SIGINT:
logger.warning("Received signal '%s', forcing exit...", sig)
os._exit(1)
self.force_exit = True
else:
self.should_exit = True
logger.warning(
"Received signal '%s', exiting... (CTRL+C to force quit)", sig
)
def custom_exception_handler(
self, loop: asyncio.AbstractEventLoop, context: dict[str, Any]
@@ -315,7 +400,7 @@ class Server:
async def emit_exception() -> None:
try:
await self._web_server._sio.emit(
await self._wapi.sio.emit( # type: ignore
"exception",
{
"data": {
@@ -325,7 +410,7 @@ class Server:
},
)
except Exception as e:
logger.exception("Failed to send notification: %s", e)
logger.warning(f"Failed to send notification: {e}")
loop.create_task(emit_exception())
else:

View File

@@ -0,0 +1,141 @@
from pathlib import Path
from typing import Any, TypedDict, Union
import socketio
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from loguru import logger
from pydase import DataService
from pydase.version import __version__
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 WebAPI:
__sio_app: socketio.ASGIApp
__fastapi_app: FastAPI
def __init__( # noqa: CFQ002
self,
service: DataService,
frontend: Union[str, Path, None] = None,
css: Union[str, Path, None] = None,
enable_CORS: bool = True,
info: dict[str, Any] = {},
*args: Any,
**kwargs: Any,
):
self.service = service
self.frontend = frontend
self.css = css
self.enable_CORS = enable_CORS
self.info = info
self.args = args
self.kwargs = kwargs
self.setup_socketio()
self.setup_fastapi_app()
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 # type: ignore
def frontend_update(sid: str, data: UpdateDict) -> Any:
logger.debug(f"Received frontend update: {data}")
path_list, attr_name = data["parent_path"].split("."), data["name"]
path_list.remove("DataService") # always at the start, does not do anything
return self.service.update_DataService_attribute(
path_list=path_list, attr_name=attr_name, value=data["value"]
)
self.__sio = sio
self.__sio_app = socketio.ASGIApp(self.__sio)
def setup_fastapi_app(self) -> None: # noqa: CFQ004
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", include_in_schema=False)
@app.get("/version")
def version() -> str:
return __version__
@app.get("/name")
def name() -> str:
return self.service.get_service_name()
@app.get("/info")
def info() -> dict[str, Any]:
return self.info
@app.get("/service-properties")
def service_properties() -> dict[str, Any]:
return self.service.serialize()
app.mount(
"/",
StaticFiles(
directory=Path(__file__).parent.parent / "frontend",
html=True,
),
)
self.__fastapi_app = app
def add_endpoint(self, name: str) -> None:
# your endpoint creation code
pass
def get_custom_openapi(self) -> None:
# your custom openapi generation code
pass
@property
def sio(self) -> socketio.AsyncServer:
return self.__sio
@property
def fastapi_app(self) -> FastAPI:
return self.__fastapi_app

View File

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

View File

@@ -1,149 +0,0 @@
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

@@ -1,185 +0,0 @@
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["value"]):
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

@@ -1,21 +1,19 @@
from typing import TypedDict
from typing import TypedDict, Union
import pint
from pint import Quantity
units: pint.UnitRegistry = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
units = pint.UnitRegistry()
units.default_format = "~P" # pretty and short format
Quantity = pint.Quantity
Unit = units.Unit
class QuantityDict(TypedDict):
magnitude: int | float
magnitude: Union[int, float]
unit: str
def convert_to_quantity(
value: QuantityDict | float | Quantity, unit: str = ""
value: Union[QuantityDict, float, int, Quantity], unit: str = ""
) -> Quantity:
"""
Convert a given value into a pint.Quantity object with the specified unit.
@@ -47,10 +45,10 @@ def convert_to_quantity(
will be unitless.
"""
if isinstance(value, int | float):
quantity = float(value) * Unit(unit)
if isinstance(value, (int, float)):
quantity = float(value) * units(unit)
elif isinstance(value, dict):
quantity = float(value["magnitude"]) * Unit(value["unit"])
quantity = float(value["magnitude"]) * units(value["unit"])
else:
quantity = value
return quantity
return quantity # type: ignore

View File

@@ -1,27 +0,0 @@
from collections.abc import Callable
from typing import Any
from pydase.utils.helpers import function_has_arguments
class FunctionDefinitionError(Exception):
pass
def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator to mark a DataService method for frontend rendering. Ensures that the
method does not contain arguments, as they are not supported for frontend rendering.
"""
if function_has_arguments(func):
raise FunctionDefinitionError(
"The @frontend decorator requires functions without arguments. Function "
f"'{func.__name__}' has at least one argument. "
"Please remove the argument(s) from this function to use it with the "
"@frontend decorator."
)
# Mark the function for frontend display.
func._display_in_frontend = True # type: ignore
return func

View File

@@ -1,23 +1,13 @@
import inspect
import logging
from collections.abc import Callable
import re
from itertools import chain
from typing import Any
from typing import Any, Optional, Union, cast
logger = logging.getLogger(__name__)
from loguru import logger
STANDARD_TYPES = ("int", "float", "bool", "str", "Enum", "NoneType", "Quantity")
def get_attribute_doc(attr: Any) -> str | None:
"""This function takes an input attribute attr and returns its documentation
string if it's different from the documentation of its type, otherwise,
it returns None.
"""
attr_doc = inspect.getdoc(attr)
attr_class_doc = inspect.getdoc(type(attr))
return attr_doc if attr_class_doc != attr_doc else None
def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
def get_class_and_instance_attributes(obj: Any) -> dict[str, Any]:
"""Dictionary containing all attributes (both instance and class level) of a
given object.
@@ -27,10 +17,12 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
loops.
"""
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
attrs = dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
attrs.pop("__root__")
return attrs
def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
def get_object_attr_from_path(target_obj: Any, path: list[str]) -> Any:
"""
Traverse the object tree according to the given path.
@@ -58,14 +50,223 @@ def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
target_obj = getattr(target_obj, part)
except AttributeError:
# The attribute doesn't exist
logger.debug("Attribute % does not exist in the object.", part)
logger.debug(f"Attribute {part} does not exist in the object.")
return None
return target_obj
def generate_paths_from_DataService_dict(
data: dict, parent_path: str = ""
) -> list[str]:
"""
Recursively generate paths from a dictionary representing a DataService object.
This function traverses through a nested dictionary, which is typically obtained
from serializing a DataService object. The function generates a list where each
element is a string representing the path to each terminal value in the original
dictionary.
The paths are represented as strings, with dots ('.') denoting nesting levels and
square brackets ('[]') denoting list indices.
Args:
data (dict): The input dictionary to generate paths from. This is typically
obtained from serializing a DataService object.
parent_path (str, optional): The current path up to the current level of
recursion. Defaults to ''.
Returns:
list[str]: A list with paths as elements.
Note:
The function ignores keys whose "type" is "method", as these represent methods
of the DataService object and not its state.
Example:
-------
>>> {
... "attr1": {"type": "int", "value": 10},
... "attr2": {
... "type": "list",
... "value": [{"type": "int", "value": 1}, {"type": "int", "value": 2}],
... },
... "add": {
... "type": "method",
... "async": False,
... "parameters": {"a": "float", "b": "int"},
... "doc": "Returns the sum of the numbers a and b.",
... },
... }
>>> print(generate_paths_from_DataService_dict(nested_dict))
[attr1, attr2[0], attr2[1]]
"""
paths = []
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_paths_from_DataService_dict(value["value"], new_path)) # type: ignore
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( # type: ignore
generate_paths_from_DataService_dict(
item["value"], indexed_key_path
)
)
else:
paths.append(indexed_key_path) # type: ignore
else:
paths.append(new_path) # type: ignore
return paths
def extract_dict_or_list_entry(
data: dict[str, Any], key: str
) -> Union[dict[str, Any], None]:
"""
Extract a nested dictionary or list entry based on the provided key.
Given a dictionary and a key, this function retrieves the corresponding nested
dictionary or list entry. If the key includes an index in the format "[<index>]",
the function assumes that the corresponding entry in the dictionary is a list, and
it will attempt to retrieve the indexed item from that list.
Args:
data (dict): The input dictionary containing nested dictionaries or lists.
key (str): The key specifying the desired entry within the dictionary. The key
can be a regular dictionary key or can include an index in the format
"[<index>]" to retrieve an item from a nested list.
Returns:
dict | None: The nested dictionary or list item found for the given key. If the
key is invalid, or if the specified index is out of bounds for a list, it
returns None.
Example:
>>> data = {
... "attr1": [
... {"type": "int", "value": 10}, {"type": "string", "value": "hello"}
... ],
... "attr2": {
... "type": "MyClass",
... "value": {"sub_attr": {"type": "float", "value": 20.5}}
... }
... }
>>> extract_dict_or_list_entry(data, "attr1[1]")
{"type": "string", "value": "hello"}
>>> extract_dict_or_list_entry(data, "attr2")
{"type": "MyClass", "value": {"sub_attr": {"type": "float", "value": 20.5}}}
"""
attr_name = key
index: Optional[int] = None
# Check if the key contains an index part like '[<index>]'
if "[" in key and key.endswith("]"):
attr_name, index_part = key.split("[", 1)
index_part = index_part.rstrip("]") # remove the closing bracket
# Convert the index part to an integer
if index_part.isdigit():
index = int(index_part)
else:
logger.error(f"Invalid index format in key: {key}")
current_data: Union[dict[str, Any], list[dict[str, Any]], None] = data.get(
attr_name, None
)
if not isinstance(current_data, dict):
# key does not exist in dictionary, e.g. when class does not have this
# attribute
return None
if isinstance(current_data["value"], list):
current_data = current_data["value"]
if index is not None and 0 <= index < len(current_data):
current_data = current_data[index]
else:
return None
# When the attribute is a class instance, the attributes are nested in the
# "value" key
if current_data["type"] not in STANDARD_TYPES:
current_data = cast(dict[str, Any], current_data.get("value", None)) # type: ignore
assert isinstance(current_data, dict)
return current_data
def get_nested_value_from_DataService_by_path_and_key(
data: dict[str, Any], path: str, key: str = "value"
) -> Any:
"""
Get the value associated with a specific key from a dictionary given a path.
This function traverses the dictionary according to the path provided and
returns the value associated with the specified key at that path. The path is
a string with dots connecting the levels and brackets indicating list indices.
The function can handle complex dictionaries where data is nested within different
types of objects. It checks the type of each object it encounters and correctly
descends into the object if it is not a standard type (i.e., int, float, bool, str,
Enum).
Args:
data (dict): The input dictionary to get the value from.
path (str): The path to the value in the dictionary.
key (str, optional): The key associated with the value to be returned.
Default is "value".
Returns:
Any: The value associated with the specified key at the given path in the
dictionary.
Examples:
Let's consider the following dictionary:
>>> data = {
>>> "attr1": {"type": "int", "value": 10},
>>> "attr2": {
"type": "MyClass",
"value": {"attr3": {"type": "float", "value": 20.5}}
}
>>> }
The function can be used to get the value of 'attr1' as follows:
>>> get_nested_value_by_path_and_key(data, "attr1")
10
It can also be used to get the value of 'attr3', which is nested within 'attr2',
as follows:
>>> get_nested_value_by_path_and_key(data, "attr2.attr3", "type")
float
"""
# Split the path into parts
parts: list[str] = re.split(r"\.", path) # Split by '.'
current_data: Union[dict[str, Any], None] = data
for part in parts:
if current_data is None:
return
current_data = extract_dict_or_list_entry(current_data, part)
if isinstance(current_data, dict):
return current_data.get(key, None)
def convert_arguments_to_hinted_types(
args: dict[str, Any], type_hints: dict[str, Any]
) -> dict[str, Any] | str:
) -> Union[dict[str, Any], str]:
"""
Convert the given arguments to their types hinted in the type_hints dictionary.
@@ -107,7 +308,7 @@ def convert_arguments_to_hinted_types(
def update_value_if_changed(
target: Any, attr_name_or_index: str | int, new_value: Any
target: Any, attr_name_or_index: Union[str, int], new_value: Any
) -> None:
"""
Updates the value of an attribute or a list element on a target object if the new
@@ -140,86 +341,62 @@ def update_value_if_changed(
if getattr(target, attr_name_or_index) != new_value:
setattr(target, attr_name_or_index, new_value)
else:
logger.error("Incompatible arguments: %s, %s.", target, attr_name_or_index)
logger.error(f"Incompatible arguments: {target}, {attr_name_or_index}.")
def parse_list_attr_and_index(attr_string: str) -> tuple[str, int | None]:
def parse_list_attr_and_index(attr_string: str) -> tuple[str, Optional[int]]:
"""
Parses an attribute string and extracts a potential list attribute name and its
index.
Logs an error if the index is not a valid digit.
Args:
attr_string (str):
The attribute string to parse. Can be a regular attribute name (e.g.,
'attr_name') or a list attribute with an index (e.g., 'list_attr[2]').
This function examines the provided attribute string. If the string contains square
brackets, it assumes that it's a list attribute and the string within brackets is
the index of an element. It then returns the attribute name and the index as an
integer. If no brackets are present, the function assumes it's a regular attribute
and returns the attribute name and None as the index.
Parameters:
-----------
attr_string: str
The attribute string to parse. Can be a regular attribute name (e.g.
'attr_name') or a list attribute with an index (e.g. 'list_attr[2]').
Returns:
tuple[str, Optional[int]]:
A tuple containing the attribute name as a string and the index as an
integer if present, otherwise None.
--------
tuple: (str, Optional[int])
A tuple containing the attribute name as a string and the index as an integer if
present, otherwise None.
Examples:
>>> parse_attribute_and_index('list_attr[2]')
('list_attr', 2)
>>> parse_attribute_and_index('attr_name')
('attr_name', None)
Example:
--------
>>> parse_list_attr_and_index('list_attr[2]')
('list_attr', 2)
>>> parse_list_attr_and_index('attr_name')
('attr_name', None)
"""
index = None
attr_name = attr_string
if "[" in attr_string and attr_string.endswith("]"):
attr_name, index_part = attr_string.split("[", 1)
index_part = index_part.rstrip("]")
if index_part.isdigit():
index = int(index_part)
else:
logger.error("Invalid index format in key: %s", attr_name)
index = None
if "[" in attr_string and "]" in attr_string:
attr_name, idx = attr_string[:-1].split("[")
index = int(idx)
return attr_name, index
def get_component_classes() -> list[type]:
def get_component_class_names() -> list[str]:
"""
Returns references to the component classes in a list.
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
"""
import pydase.components
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")
return pydase.components.__all__
def is_property_attribute(target_obj: Any, attr_name: str) -> bool:
return isinstance(getattr(type(target_obj), attr_name, None), property)
def function_has_arguments(func: Callable[..., Any]) -> bool:
sig = inspect.signature(func)
parameters = dict(sig.parameters)
# Remove 'self' parameter for instance methods.
parameters.pop("self", None)
# Check if there are any parameters left which would indicate additional arguments.
if len(parameters) > 0:
return True
return False
def render_in_frontend(func: Callable[..., Any]) -> bool:
"""Determines if the method should be rendered in the frontend.
It checks if the "@frontend" decorator was used or the method is a coroutine."""
if inspect.iscoroutinefunction(func):
return True
try:
return func._display_in_frontend # type: ignore
except AttributeError:
return False

Some files were not shown because too many files have changed in this diff Show More