29 Commits

Author SHA1 Message Date
Mose Müller
12d7ddab08 updates to version v0.9.1 2024-08-29 08:57:45 +02:00
Mose Müller
e40646c664 Merge pull request #153 from tiqi-group/feat/overwritable_sio_client_manager
adds overwritable sio client_manager
2024-08-29 08:56:58 +02:00
Mose Müller
ab9b4257f2 adds overwritable sio client_manager 2024-08-28 12:37:56 +02:00
Mose Müller
a2effca2b0 fixes ruff errors 2024-08-20 13:14:03 +02:00
Mose Müller
f76703340c Merge pull request #156 from tiqi-group/docs
Updates Docs
2024-08-20 13:01:17 +02:00
Mose Müller
dbc1fa00f7 adds autogenerated api documentation 2024-08-20 12:03:08 +02:00
Mose Müller
4ecc1a191f renames main.md to README.md 2024-08-20 11:50:27 +02:00
Mose Müller
4f8e3f845c fixes relative links 2024-08-20 11:50:27 +02:00
Mose Müller
132856a8f0 updates mkdocstrings dependency (adds python extra)
updates requirements.txt
2024-08-20 11:50:27 +02:00
Mose Müller
b1f75bb786 makes handle_server_shutdown a protected method 2024-08-20 11:50:27 +02:00
Mose Müller
0011a0f92e fix: uses logger instead of logging in sio events 2024-08-20 08:30:13 +02:00
Mose Müller
b7ab364aab adds "testing" operation mode 2024-08-20 08:29:54 +02:00
Mose Müller
52e4647433 Merge pull request #155 from tiqi-group/docs
Updating Docs
2024-08-19 16:35:40 +02:00
Mose Müller
b2b3d426ed updates license 2024-08-19 16:11:26 +02:00
Mose Müller
7ae3ff504d reference link to license 2024-08-19 16:03:37 +02:00
Mose Müller
50f3686c12 moves "Understanding Units" to docs 2024-08-19 15:56:57 +02:00
Mose Müller
b0c3c4cad9 moves "Validating Property Setters" to docs 2024-08-19 15:52:08 +02:00
Mose Müller
9b8279da85 moving "Understanding Tasks" into docs 2024-08-19 15:41:19 +02:00
Mose Müller
97e21b2ea8 docs: more reference links 2024-08-19 15:34:09 +02:00
Mose Müller
fb75de5b51 adds service persistence page to mkdocs.yml 2024-08-19 15:19:46 +02:00
Mose Müller
3eb9c6476b replaces inline links with reference links (can be overwritten in docs) 2024-08-19 15:17:31 +02:00
Mose Müller
c7ec929d05 moves state persistence section into docs, restructuring docs 2024-08-19 14:45:56 +02:00
Mose Müller
ca19fcc63f updates Readme (moving components guide to docs, removing TOC, updated features list,...) 2024-08-19 14:18:28 +02:00
Mose Müller
7904d0d7d9 updates Readme introduction 2024-08-19 13:19:30 +02:00
Mose Müller
8526e74aa7 Merge pull request #154 from tiqi-group/fixci-github-release
CI: fixing github-release ci job
2024-08-19 10:08:12 +02:00
Mose Müller
6e16d84ba4 fixes python sigstore action 2024-08-19 10:01:33 +02:00
Mose Müller
6765246231 fixing ruff formatting error 2024-08-19 09:53:54 +02:00
Mose Müller
f50976358b Fixes python-package workflow 2024-08-19 09:52:54 +02:00
Mose Müller
aa37fa8533 Removes ruff github action with explicit steps 2024-08-19 09:40:34 +02:00
35 changed files with 1184 additions and 1034 deletions

View File

@@ -70,9 +70,9 @@ jobs:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
- name: Sign the dists with Sigstore - name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@v1.2.3 uses: sigstore/gh-action-sigstore-python@v3.0.0
with: with:
inputs: >- inputs: |
./dist/*.tar.gz ./dist/*.tar.gz
./dist/*.whl ./dist/*.whl
- name: Upload artifact signatures to GitHub Release - name: Upload artifact signatures to GitHub Release
@@ -85,27 +85,3 @@ jobs:
gh release upload gh release upload
'${{ github.ref_name }}' dist/** '${{ github.ref_name }}' dist/**
--repo '${{ github.repository }}' --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/

View File

@@ -20,9 +20,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: chartboost/ruff-action@v1
with:
src: "./src"
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
@@ -32,6 +29,12 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install poetry python -m pip install poetry
poetry install --with dev poetry install --with dev
- name: Check with ruff
run: |
poetry run ruff check src
- name: Check formatting with ruff
run: |
poetry run ruff format --check src
- name: Test with pytest - name: Test with pytest
run: | run: |
poetry run pytest poetry run pytest

View File

@@ -1,6 +1,4 @@
MIT License Copyright (c) 2023-2024 Mose Müller <mosemueller@gmail.com>
Copyright (c) 2023 Mose Müller <mosemueller@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

751
README.md
View File

@@ -1,62 +1,34 @@
<!--introduction-start-->
# pydase <!-- omit from toc --> # pydase <!-- omit from toc -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Version](https://img.shields.io/pypi/v/pydase?style=flat)](https://pypi.org/project/pydase/)
[![Documentation Status](https://readthedocs.org/projects/pydase/badge/?version=latest)](https://pydase.readthedocs.io/en/latest/?badge=latest) [![Python Versions](https://img.shields.io/pypi/pyversions/pydase)](https://pypi.org/project/pydase/)
[![Documentation Status](https://readthedocs.org/projects/pydase/badge/?version=stable)](https://pydase.readthedocs.io/en/stable/)
[![License: MIT](https://img.shields.io/github/license/tiqi-group/pydase)][License]
`pydase` is a Python library designed to streamline the creation of services that interface with devices and data. It offers a unified API, simplifying the process of data querying and device interaction. Whether you're managing lab sensors, network devices, or any abstract data entity, `pydase` facilitates rapid service development and deployment. `pydase` is a Python library that simplifies the creation of remote control interfaces for Python objects. It exposes the public attributes of a user-defined class via a [Socket.IO](https://python-socketio.readthedocs.io/en/stable/) web server, ensuring they are always in sync with the service state. You can interact with these attributes using an RPC client, a RESTful API, or a web browser. The web browser frontend is auto-generated, displaying components that correspond to each public attribute of the class for direct interaction.
`pydase` implements an [observer pattern][Observer Pattern] to provide the real-time updates, ensuring that changes to the class attributes are reflected across all clients.
- [Features](#features) Whether you're managing lab sensors, network devices, or any abstract data entity, `pydase` facilitates service development and deployment.
- [Installation](#installation)
- [Usage](#usage)
- [Defining a DataService](#defining-a-dataservice)
- [Running the Server](#running-the-server)
- [Accessing the Web Interface](#accessing-the-web-interface)
- [Connecting to the Service via Python Client](#connecting-to-the-service-via-python-client)
- [Tab Completion Support](#tab-completion-support)
- [Integration within Another Service](#integration-within-another-service)
- [RESTful API](#restful-api)
- [Understanding the Component System](#understanding-the-component-system)
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
- [Method Components](#method-components)
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
- [`DeviceConnection`](#deviceconnection)
- [Customizing Connection Logic](#customizing-connection-logic)
- [Reconnection Interval](#reconnection-interval)
- [`Image`](#image)
- [`NumberSlider`](#numberslider)
- [`ColouredEnum`](#colouredenum)
- [Extending with New Components](#extending-with-new-components)
- [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)
- [Using `validate_set` to Validate Property Setters](#using-validate_set-to-validate-property-setters)
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
- [Customizing the Web Interface](#customizing-the-web-interface)
- [Logging in pydase](#logging-in-pydase)
- [Changing the Log Level](#changing-the-log-level)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
## Features ## Features
<!-- no toc --> <!-- no toc -->
- [Simple service definition through class-based interface](#defining-a-dataService) - [Simple service definition through class-based interface][Defining DataService]
- [Integrated web interface for interactive access and control of your service](#accessing-the-web-interface) - [Auto-generated web interface for interactive access and control of your service][Web Interface Access]
- [Support for programmatic control and interaction with your service](#connecting-to-the-service-via-python-client) - [Python RPC client][Short RPC Client]
- [Component system bridging Python backend with frontend visual representation](#understanding-the-component-system) - [Customizable web interface][Customizing Web Interface]
- [Customizable styling for the web interface](#customizing-web-interface-style) - [Saving and restoring the service state][Service Persistence]
- [Saving and restoring the service state for service persistence](#understanding-service-persistence) - [Automated task management with built-in start/stop controls and optional autostart][Task Management]
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase) - [Support for units][Units]
- [Support for units](#understanding-units-in-pydase) - [Validating Property Setters][Property Validation]
- [Validating Property Setters](#using-validate_set-to-validate-property-setters)
<!-- Support for additional servers for specific use-cases --> <!--introduction-end-->
<!--getting-started-start-->
## Installation ## Installation
<!--installation-start-->
Install `pydase` using [`poetry`](https://python-poetry.org/): Install `pydase` using [`poetry`](https://python-poetry.org/):
@@ -70,26 +42,27 @@ or `pip`:
pip install pydase pip install pydase
``` ```
<!--installation-end-->
## Usage ## 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 `pydase.Client` or through the web interface. Using `pydase` involves three main steps: defining a `pydase.DataService` subclass, running the server, and then connecting to the service either programmatically using `pydase.Client` or through the web interface.
### Defining a DataService ### Defining a DataService
To use pydase, you'll first need to create a class that inherits from `DataService`. This class represents your custom data service, which will be exposed via a web server. Your class can implement class / instance attributes and synchronous and asynchronous tasks. To use `pydase`, you'll first need to create a class that inherits from `pydase.DataService`.
This class represents your custom service, which will be exposed via a web server.<br>
Your class can implement synchronous and asynchronous methods, some [built-in types](https://docs.python.org/3/library/stdtypes.html) (like `int`, `float`, `str`, `bool`, `list` or `dict`) and [other components][Custom Components] as attributes.
For more information, please refer to the [components guide][Components].
Here's an example: Here's an example:
```python ```python
from pydase import DataService, Server import pydase
from pydase.utils.decorators import frontend from pydase.utils.decorators import frontend
class Device(DataService): class Device(pydase.DataService):
_current = 0.0 _current = 0.0
_voltage = 0.0 _voltage = 0.0
_power = False _power = False
@@ -132,26 +105,27 @@ class Device(DataService):
if __name__ == "__main__": if __name__ == "__main__":
service = Device() service = Device()
Server(service).run() pydase.Server(service=service).run()
``` ```
In the above example, we define a Device class that extends DataService. We define a few properties (current, voltage, power) and their getter and setter methods. In the above example, we define a `Device` class that inherits from `pydase.DataService`.
We define a few properties (current, voltage, power) and their getter and setter methods.
### Running the Server ### Running the Server
Once your DataService is defined, you can create an instance of it and run the server: Once your service class is defined, you can create an instance of it and run the server:
```python ```python
from pydase import Server import pydase
# ... defining the Device class ... # ... defining the Device class ...
if __name__ == "__main__": if __name__ == "__main__":
service = Device() service = Device()
Server(service).run() pydase.Server(service=service).run()
``` ```
This will start the server, making your Device service accessible on [http://localhost:8001](http://localhost:8001). This will start the server, making your `Device` service accessible on [http://localhost:8001](http://localhost:8001).
### Accessing the Web Interface ### Accessing the Web Interface
@@ -183,7 +157,7 @@ The proxy acts as a local representative of the remote service, enabling straigh
The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object. The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object.
The RPC client also supports tab completion support in the interpreter, can be used as a context manager and integrates very well with other pydase services. For more information, please refer to the [documentation](https://pydase.readthedocs.io/en/latest/user-guide/interaction/main/#python-client). The RPC client also supports tab completion support in the interpreter, can be used as a context manager and integrates very well with other pydase services. For more information, please refer to the [documentation][Python RPC Client].
### RESTful API ### RESTful API
The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes. The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes.
@@ -201,639 +175,9 @@ response = requests.get(
serialized_value = json.loads(response.text) serialized_value = json.loads(response.text)
``` ```
For more information, see [here](https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#restful-api). For more information, see [here][RESTful API].
<!--usage-end--> <!--getting-started-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.
- `dict`: Each key-value pair displayed individually, named after the dictionary attribute and its key. **Note** that the dictionary keys must be strings.
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
### Method Components
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)
**Note** that each enumeration name and value must be unique.
This means that you should use different colour formats when you want to use a colour multiple times.
#### Extending with New Components
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
<!-- 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.
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.
Here's an example:
```python
from pydase import DataService, Server
class Device(DataService):
# ... defining the Device class ...
if __name__ == "__main__":
service = Device()
Server(service, filename="device_state.json").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.
### 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.
## 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.
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. One core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and python clients, giving you flexible and powerful control over your service's operation.
Another powerful feature of `pydase` is its ability to automatically start tasks upon initialization of the service. By specifying the tasks and their arguments in the `_autostart_tasks` dictionary in your service class's `__init__` method, `pydase` will automatically start these tasks when the server is started. Here's an example:
```python
from pydase import DataService, Server
class SensorService(DataService):
def __init__(self):
super().__init__()
self.readout_frequency = 1.0
self._autostart_tasks["read_sensor_data"] = ()
def _process_data(self, data: ...) -> None:
...
def _read_from_sensor(self) -> Any:
...
async def read_sensor_data(self):
while True:
data = self._read_from_sensor()
self._process_data(data) # Process the data as needed
await asyncio.sleep(self.readout_frequency)
if __name__ == "__main__":
service = SensorService()
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.
## 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.
Here's an example:
```python
from typing import Any
import pydase.units as u
from pydase import DataService, Server
class ServiceClass(DataService):
voltage = 1.0 * u.units.V
_current: u.Quantity = 1.0 * u.units.mA
@property
def current(self) -> u.Quantity:
return self._current
@current.setter
def current(self, value: u.Quantity) -> None:
self._current = value
if __name__ == "__main__":
service = ServiceClass()
service.voltage = 10.0 * u.units.V
service.current = 1.5 * u.units.mA
Server(service).run()
```
In the frontend, quantities are rendered as floats, with the unit displayed as additional text. This allows you to maintain a clear and consistent representation of physical quantities across both the backend and frontend of your service.
![Web interface with rendered units](./docs/images/Units_App.png)
Should you need to access the magnitude or the unit of a quantity, you can use the `.m` attribute or the `.u` attribute of the variable, respectively. For example, this could be necessary to set the periodicity of a task:
```python
import asyncio
from pydase import DataService, Server
import pydase.units as u
class ServiceClass(DataService):
readout_wait_time = 1.0 * u.units.ms
async def read_sensor_data(self):
while True:
print("Reading out sensor ...")
await asyncio.sleep(self.readout_wait_time.to("s").m)
if __name__ == "__main__":
service = ServiceClass()
Server(service).run()
```
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
## Using `validate_set` to Validate Property Setters
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
If the value is not within the precision boundary after this time, an exception is raised.
The `precision` argument defines the acceptable deviation from the desired value.
If `precision` is `None`, the value must be exact.
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
Heres how to use the `validate_set` decorator in a `DataService` class:
```python
import pydase
from pydase.observer_pattern.observable.decorators import validate_set
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._device = RemoteDevice() # dummy class
@property
def value(self) -> float:
# Implement how to get the value from the remote device...
return self._device.value
@value.setter
@validate_set(timeout=1.0, precision=1e-5)
def value(self, value: float) -> None:
# Implement how to set the value on the remote device...
self._device.value = value
if __name__ == "__main__":
pydase.Server(Service()).run()
```
## Configuring pydase via Environment Variables ## Configuring pydase via Environment Variables
@@ -923,12 +267,27 @@ You have two primary ways to adjust the log levels in `pydase`:
## Documentation ## 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](https://pydase.readthedocs.io/en/stable/) for more information.
## Contributing ## 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](https://pydase.readthedocs.io/en/stable/about/contributing/) for details on how to contribute.
## License ## 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].
[License]: ./LICENSE
[Observer Pattern]: https://pydase.readthedocs.io/en/docs/dev-guide/Observer_Pattern_Implementation/
[Service Persistence]: https://pydase.readthedocs.io/en/stable/user-guide/Service_Persistence
[Defining DataService]: #defining-a-dataService
[Web Interface Access]: #accessing-the-web-interface
[Short RPC Client]: #connecting-to-the-service-via-python-rpc-client
[Customizing Web Interface]: #customizing-the-web-interface
[Task Management]: https://pydase.readthedocs.io/en/stable/user-guide/Tasks/
[Units]: https://pydase.readthedocs.io/en/stable/user-guide/Understanding-Units/
[Property Validation]: https://pydase.readthedocs.io/en/stable/user-guide/Validating-Property-Setters/
[Custom Components]: https://pydase.readthedocs.io/en/stable/user-guide/Components/#custom-components-pydasecomponents
[Components]: https://pydase.readthedocs.io/en/stable/user-guide/Components/
[RESTful API]: https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#restful-api
[Python RPC Client]: https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#python-rpc-client

View File

@@ -2,7 +2,7 @@
## Overview ## 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. The [Observer Pattern](https://en.wikipedia.org/wiki/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 ## How it Works

View File

@@ -0,0 +1,39 @@
::: pydase.data_service
handler: python
::: pydase.server.server
handler: python
::: pydase.server.web_server
handler: python
::: pydase.client
handler: python
::: pydase.components
handler: python
::: pydase.utils.serialization.serializer
handler: python
::: pydase.utils.serialization.deserializer
handler: python
options:
show_root_heading: true
show_root_toc_entry: false
show_symbol_type_heading: true
show_symbol_type_toc: true
::: pydase.utils.serialization.types
handler: python
::: pydase.utils.decorators
handler: python
options:
filters: ["!render_in_frontend"]
::: pydase.units
handler: python
::: pydase.config
handler: python

View File

@@ -1,14 +1,11 @@
# Getting Started # Getting Started
## Installation
{% {%
include-markdown "../README.md" include-markdown "../README.md"
start="<!--installation-start-->" start="<!--getting-started-start-->"
end="<!--installation-end-->" end="<!--getting-started-end-->"
%} %}
## Usage [RESTful API]: ./user-guide/interaction/README.md#restful-api
{% [Python RPC Client]: ./user-guide/interaction/README.md#python-rpc-client
include-markdown "../README.md" [Custom Components]: ./user-guide/Components.md#custom-components-pydasecomponents
start="<!--usage-start-->" [Components]: ./user-guide/Components.md
end="<!--usage-end-->"
%}

View File

@@ -1 +1,16 @@
{% include-markdown "../README.md" %} {%
include-markdown "../README.md"
start="<!--introduction-start-->"
end="<!--introduction-end-->"
%}
[License]: ./about/license.md
[Observer Pattern]: ./dev-guide/Observer_Pattern_Implementation.md
[Service Persistence]: ./user-guide/Service_Persistence.md
[Defining DataService]: ./getting-started.md#defining-a-dataservice
[Web Interface Access]: ./getting-started.md#accessing-the-web-interface
[Short RPC Client]: ./getting-started.md#connecting-to-the-service-via-python-rpc-client
[Customizing Web Interface]: ./user-guide/interaction/README.md#customization-options
[Task Management]: ./user-guide/Tasks.md
[Units]: ./user-guide/Understanding-Units.md
[Property Validation]: ./user-guide/Validating-Property-Setters.md

View File

@@ -5,6 +5,7 @@ charset-normalizer==3.3.2 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0" 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" colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0"
ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0" ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
griffe==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
idna==3.7 ; python_version >= "3.10" and python_version < "4.0" idna==3.7 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0" jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0"
markdown==3.6 ; python_version >= "3.10" and python_version < "4.0" markdown==3.6 ; python_version >= "3.10" and python_version < "4.0"
@@ -14,10 +15,12 @@ mkdocs-autorefs==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-get-deps==0.2.0 ; python_version >= "3.10" and python_version < "4.0" mkdocs-get-deps==0.2.0 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-include-markdown-plugin==3.9.1 ; python_version >= "3.10" and python_version < "4.0" mkdocs-include-markdown-plugin==3.9.1 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-material-extensions==1.3.1 ; python_version >= "3.10" and python_version < "4.0" mkdocs-material-extensions==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-material==9.5.30 ; python_version >= "3.10" and python_version < "4.0" mkdocs-material==9.5.31 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-swagger-ui-tag==0.6.10 ; python_version >= "3.10" and python_version < "4.0" mkdocs-swagger-ui-tag==0.6.10 ; python_version >= "3.10" and python_version < "4.0"
mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0" mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings==0.22.0 ; python_version >= "3.10" and python_version < "4.0" mkdocstrings-python==1.10.8 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings==0.25.2 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings[python]==0.25.2 ; python_version >= "3.10" and python_version < "4.0"
packaging==24.1 ; python_version >= "3.10" and python_version < "4.0" packaging==24.1 ; python_version >= "3.10" and python_version < "4.0"
paginate==0.5.6 ; python_version >= "3.10" and python_version < "4.0" paginate==0.5.6 ; python_version >= "3.10" and python_version < "4.0"
pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0" pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0"

View File

@@ -1,6 +1,435 @@
# Components Guide # Components Guide
{%
include-markdown "../../README.md" 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:
start="<!-- Component User Guide Start -->"
end="<!-- Component User Guide End -->" ## 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.
- `dict`: Each key-value pair displayed individually, named after the dictionary attribute and its key. **Note** that the dictionary keys must be strings.
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
## Method Components
Within the `DataService` class of `pydase`, only methods devoid of arguments can be represented in the frontend, classified into two distinct categories
1. [**Tasks**](./Tasks.md): 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](../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](../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](../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](../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](../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.md) 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](../images/ColouredEnum_component.png)
**Note** that each enumeration name and value must be unique.
This means that you should use different colour formats when you want to use a colour multiple times.
### Extending with New Components
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.

View File

@@ -0,0 +1,49 @@
# 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.
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.
Here's an example:
```python
import pydase
class Device(pydase.DataService):
# ... defining the Device class ...
if __name__ == "__main__":
service = Device()
pydase.Server(service=service, filename="device_state.json").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.
## 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
import pydase
from pydase.data_service.state_manager import load_state
class Device(pydase.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.

39
docs/user-guide/Tasks.md Normal file
View File

@@ -0,0 +1,39 @@
# Understanding Tasks
In `pydase`, a task is defined as an asynchronous function without arguments contained in a class that inherits from `pydase.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.
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:
```python
import pydase
class SensorService(pydase.DataService):
def __init__(self):
super().__init__()
self.readout_frequency = 1.0
self._autostart_tasks["read_sensor_data"] = ()
def _process_data(self, data: ...) -> None:
...
def _read_from_sensor(self) -> Any:
...
async def read_sensor_data(self):
while True:
data = self._read_from_sensor()
self._process_data(data) # Process the data as needed
await asyncio.sleep(self.readout_frequency)
if __name__ == "__main__":
service = SensorService()
pydase.Server(service=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 `pydase.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.

View File

@@ -0,0 +1,64 @@
# Understanding Units
`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 `pydase.DataService` subclass using the `pydase.units` module.
Here's an example:
```python
from typing import Any
import pydase
import pydase.units as u
class ServiceClass(pydase.DataService):
voltage = 1.0 * u.units.V
_current: u.Quantity = 1.0 * u.units.mA
@property
def current(self) -> u.Quantity:
return self._current
@current.setter
def current(self, value: u.Quantity) -> None:
self._current = value
if __name__ == "__main__":
service = ServiceClass()
service.voltage = 10.0 * u.units.V
service.current = 1.5 * u.units.mA
pydase.Server(service=service).run()
```
In the frontend, quantities are rendered as floats, with the unit displayed as additional text. This allows you to maintain a clear and consistent representation of physical quantities across both the backend and frontend of your service.
![Web interface with rendered units](../images/Units_App.png)
Should you need to access the magnitude or the unit of a quantity, you can use the `.m` attribute or the `.u` attribute of the variable, respectively. For example, this could be necessary to set the periodicity of a task:
```python
import asyncio
import pydase
import pydase.units as u
class ServiceClass(pydase.DataService):
readout_wait_time = 1.0 * u.units.ms
async def read_sensor_data(self):
while True:
print("Reading out sensor ...")
await asyncio.sleep(self.readout_wait_time.to("s").m)
if __name__ == "__main__":
service = ServiceClass()
pydase.Server(service=service).run()
```
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).

View File

@@ -0,0 +1,38 @@
# Using `validate_set` to Validate Property Setters
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
If the value is not within the precision boundary after this time, an exception is raised.
The `precision` argument defines the acceptable deviation from the desired value.
If `precision` is `None`, the value must be exact.
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
Heres how to use the `validate_set` decorator in a `DataService` class:
```python
import pydase
from pydase.observer_pattern.observable.decorators import validate_set
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._device = RemoteDevice() # dummy class
@property
def value(self) -> float:
# Implement how to get the value from the remote device...
return self._device.value
@value.setter
@validate_set(timeout=1.0, precision=1e-5)
def value(self, value: float) -> None:
# Implement how to set the value on the remote device...
self._device.value = value
if __name__ == "__main__":
pydase.Server(service=Service()).run()
```

View File

@@ -6,7 +6,11 @@ nav:
- Getting Started: getting-started.md - Getting Started: getting-started.md
- User Guide: - User Guide:
- Components Guide: user-guide/Components.md - Components Guide: user-guide/Components.md
- Interacting with pydase Services: user-guide/interaction/main.md - Interacting with pydase Services: user-guide/interaction/README.md
- Achieving Service Persistence: user-guide/Service_Persistence.md
- Understanding Tasks: user-guide/Tasks.md
- Understanding Units: user-guide/Understanding-Units.md
- Validating Property Setters: user-guide/Validating-Property-Setters.md
- Developer Guide: - Developer Guide:
- Developer Guide: dev-guide/README.md - Developer Guide: dev-guide/README.md
- API Reference: dev-guide/api.md - API Reference: dev-guide/api.md
@@ -40,10 +44,35 @@ markdown_extensions:
plugins: plugins:
- include-markdown - include-markdown
- search - search
- mkdocstrings - mkdocstrings:
- swagger-ui-tag handlers:
python:
paths: [src] # search packages in the src folder
import:
- https://docs.python.org/3/objects.inv
- https://docs.pydantic.dev/latest/objects.inv
- https://confz.readthedocs.io/en/latest/objects.inv
options:
show_source: true
inherited_members: true
merge_init_into_class: true
show_signature_annotations: true
signature_crossrefs: true
separate_signature: true
docstring_options:
ignore_init_summary: true
# docstring_section_style: list
heading_level: 2
parameter_headings: true
show_root_heading: true
show_root_full_path: true
show_symbol_type_heading: true
show_symbol_type_toc: true
# summary: true
unwrap_annotated: true
- swagger-ui-tag
watch: watch:
- src/pydase - src/pydase

44
poetry.lock generated
View File

@@ -769,6 +769,20 @@ python-dateutil = ">=2.8.1"
[package.extras] [package.extras]
dev = ["flake8", "markdown", "twine", "wheel"] dev = ["flake8", "markdown", "twine", "wheel"]
[[package]]
name = "griffe"
version = "1.1.0"
description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
optional = false
python-versions = ">=3.8"
files = [
{file = "griffe-1.1.0-py3-none-any.whl", hash = "sha256:38ccc5721571c95ae427123074cf0dc0d36bce7c9701ab2ada9fe0566ff50c10"},
{file = "griffe-1.1.0.tar.gz", hash = "sha256:c6328cbdec0d449549c1cc332f59227cd5603f903479d73e4425d828b782ffc3"},
]
[package.dependencies]
colorama = ">=0.4"
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.14.0" version = "0.14.0"
@@ -1212,21 +1226,24 @@ beautifulsoup4 = ">=4.11.1"
[[package]] [[package]]
name = "mkdocstrings" name = "mkdocstrings"
version = "0.22.0" version = "0.25.2"
description = "Automatic documentation from sources, for MkDocs." description = "Automatic documentation from sources, for MkDocs."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "mkdocstrings-0.22.0-py3-none-any.whl", hash = "sha256:2d4095d461554ff6a778fdabdca3c00c468c2f1459d469f7a7f622a2b23212ba"}, {file = "mkdocstrings-0.25.2-py3-none-any.whl", hash = "sha256:9e2cda5e2e12db8bb98d21e3410f3f27f8faab685a24b03b06ba7daa5b92abfc"},
{file = "mkdocstrings-0.22.0.tar.gz", hash = "sha256:82a33b94150ebb3d4b5c73bab4598c3e21468c79ec072eff6931c8f3bfc38256"}, {file = "mkdocstrings-0.25.2.tar.gz", hash = "sha256:5cf57ad7f61e8be3111a2458b4e49c2029c9cb35525393b179f9c916ca8042dc"},
] ]
[package.dependencies] [package.dependencies]
click = ">=7.0"
Jinja2 = ">=2.11.1" Jinja2 = ">=2.11.1"
Markdown = ">=3.3" Markdown = ">=3.3"
MarkupSafe = ">=1.1" MarkupSafe = ">=1.1"
mkdocs = ">=1.2" mkdocs = ">=1.4"
mkdocs-autorefs = ">=0.3.1" mkdocs-autorefs = ">=0.3.1"
mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""}
platformdirs = ">=2.2.0"
pymdown-extensions = ">=6.3" pymdown-extensions = ">=6.3"
[package.extras] [package.extras]
@@ -1234,6 +1251,21 @@ crystal = ["mkdocstrings-crystal (>=0.3.4)"]
python = ["mkdocstrings-python (>=0.5.2)"] python = ["mkdocstrings-python (>=0.5.2)"]
python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
[[package]]
name = "mkdocstrings-python"
version = "1.10.8"
description = "A Python handler for mkdocstrings."
optional = false
python-versions = ">=3.8"
files = [
{file = "mkdocstrings_python-1.10.8-py3-none-any.whl", hash = "sha256:bb12e76c8b071686617f824029cb1dfe0e9afe89f27fb3ad9a27f95f054dcd89"},
{file = "mkdocstrings_python-1.10.8.tar.gz", hash = "sha256:5856a59cbebbb8deb133224a540de1ff60bded25e54d8beacc375bb133d39016"},
]
[package.dependencies]
griffe = ">=0.49"
mkdocstrings = ">=0.25"
[[package]] [[package]]
name = "multidict" name = "multidict"
version = "6.0.5" version = "6.0.5"
@@ -2464,4 +2496,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "54e25a68577a912301aa9125e3f3de545e03a199a79b2153c106285b92febbba" content-hash = "7131eddc2065147a18c145bb6da09492f03eb7fe050e968109cecb6044d17ed6"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "pydase" name = "pydase"
version = "0.9.0" version = "0.9.1"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases." 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>"] authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md" readme = "README.md"
@@ -38,7 +38,7 @@ optional = true
[tool.poetry.group.docs.dependencies] [tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5.30" mkdocs-material = "^9.5.30"
mkdocs-include-markdown-plugin = "^3.9.1" mkdocs-include-markdown-plugin = "^3.9.1"
mkdocstrings = "^0.22.0" mkdocstrings = {extras = ["python"], version = "^0.25.2"}
pymdown-extensions = "^10.1" pymdown-extensions = "^10.1"
mkdocs-swagger-ui-tag = "^0.6.10" mkdocs-swagger-ui-tag = "^0.6.10"

View File

@@ -43,10 +43,10 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
via a socket.io client in an asyncio environment. via a socket.io client in an asyncio environment.
Args: Args:
sio_client (socketio.AsyncClient): sio_client:
The socket.io client instance used for asynchronous communication with the The socket.io client instance used for asynchronous communication with the
pydase service server. pydase service server.
loop (asyncio.AbstractEventLoop): loop:
The event loop in which the client operations are managed and executed. The event loop in which the client operations are managed and executed.
This class is used to create a proxy object that behaves like a local representation This class is used to create a proxy object that behaves like a local representation
@@ -54,20 +54,20 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
while actually communicating over network protocols. while actually communicating over network protocols.
It can also be used as an attribute of a pydase service itself, e.g. It can also be used as an attribute of a pydase service itself, e.g.
```python ```python
import pydase import pydase
class MyService(pydase.DataService): class MyService(pydase.DataService):
proxy = pydase.Client( proxy = pydase.Client(
hostname="...", port=8001, block_until_connected=False hostname="...", port=8001, block_until_connected=False
).proxy ).proxy
if __name__ == "__main__": if __name__ == "__main__":
service = MyService() service = MyService()
server = pydase.Server(service, web_port=8002).run() server = pydase.Server(service, web_port=8002).run()
``` ```
""" """
def __init__( def __init__(
@@ -84,19 +84,16 @@ class Client:
connection, disconnection, and updates, and ensures that the proxy object is connection, disconnection, and updates, and ensures that the proxy object is
up-to-date with the server state. up-to-date with the server state.
Attributes:
proxy (ProxyClass):
A proxy object representing the remote service, facilitating interaction as
if it were local.
Args: Args:
url (str): url:
The URL of the pydase Socket.IO server. This should always contain the The URL of the pydase Socket.IO server. This should always contain the
protocol and the hostname. protocol and the hostname.
Examples: Examples:
- wss://my-service.example.com # for secure connections, use wss
- ws://localhost:8001 - `wss://my-service.example.com` # for secure connections, use wss
block_until_connected (bool): - `ws://localhost:8001`
block_until_connected:
If set to True, the constructor will block until the connection to the If set to True, the constructor will block until the connection to the
service has been established. This is useful for ensuring the client is service has been established. This is useful for ensuring the client is
ready to use immediately after instantiation. Default is True. ready to use immediately after instantiation. Default is True.
@@ -112,6 +109,8 @@ class Client:
self._sio = socketio.AsyncClient() self._sio = socketio.AsyncClient()
self._loop = asyncio.new_event_loop() self._loop = asyncio.new_event_loop()
self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop) self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop)
"""A proxy object representing the remote service, facilitating interaction as
if it were local."""
self._thread = threading.Thread( self._thread = threading.Thread(
target=asyncio_loop_thread, args=(self._loop,), daemon=True target=asyncio_loop_thread, args=(self._loop,), daemon=True
) )

View File

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

View File

@@ -19,22 +19,26 @@ class DeviceConnection(pydase.data_service.DataService):
to the device. This method should update the `self._connected` attribute to reflect to the device. This method should update the `self._connected` attribute to reflect
the connection status: the connection status:
>>> class MyDeviceConnection(DeviceConnection): ```python
... def connect(self) -> None: class MyDeviceConnection(DeviceConnection):
... # Implementation to connect to the device def connect(self) -> None:
... # Update self._connected to `True` if connection is successful, # Implementation to connect to the device
... # `False` otherwise # Update self._connected to `True` if connection is successful,
... ... # `False` otherwise
...
```
Optionally, if additional logic is needed to determine the connection status, Optionally, if additional logic is needed to determine the connection status,
the `connected` property can also be overridden: the `connected` property can also be overridden:
>>> class MyDeviceConnection(DeviceConnection): ```python
... @property class MyDeviceConnection(DeviceConnection):
... def connected(self) -> bool: @property
... # Custom logic to determine connection status def connected(self) -> bool:
... return some_custom_condition # Custom logic to determine connection status
... return some_custom_condition
```
Frontend Representation Frontend Representation
----------------------- -----------------------

View File

@@ -11,76 +11,74 @@ class NumberSlider(DataService):
This class models a UI slider for a data service, allowing for adjustments of a This class models a UI slider for a data service, allowing for adjustments of a
parameter within a specified range and increments. parameter within a specified range and increments.
Parameters: Args:
----------- value:
value (float, optional): The initial value of the slider. Defaults to 0.
The initial value of the slider. Defaults to 0. min_:
min (float, optional): The minimum value of the slider. Defaults to 0.
The minimum value of the slider. Defaults to 0. max_:
max (float, optional): The maximum value of the slider. Defaults to 100.
The maximum value of the slider. Defaults to 100. step_size:
step_size (float, optional): The increment/decrement step size of the slider. Defaults to 1.0.
The increment/decrement step size of the slider. Defaults to 1.0.
Example: Example:
-------- ```python
```python class MySlider(pydase.components.NumberSlider):
class MySlider(pydase.components.NumberSlider): def __init__(
def __init__( self,
self, value: float = 0.0,
value: float = 0.0, min_: float = 0.0,
min_: float = 0.0, max_: float = 100.0,
max_: float = 100.0, step_size: float = 1.0,
step_size: float = 1.0, ) -> None:
) -> None: super().__init__(value, min_, max_, step_size)
super().__init__(value, min_, max_, step_size)
@property @property
def min(self) -> float: def min(self) -> float:
return self._min return self._min
@min.setter @min.setter
def min(self, value: float) -> None: def min(self, value: float) -> None:
self._min = value self._min = value
@property @property
def max(self) -> float: def max(self) -> float:
return self._max return self._max
@max.setter @max.setter
def max(self, value: float) -> None: def max(self, value: float) -> None:
self._max = value self._max = value
@property @property
def step_size(self) -> float: def step_size(self) -> float:
return self._step_size return self._step_size
@step_size.setter @step_size.setter
def step_size(self, value: float) -> None: def step_size(self, value: float) -> None:
self._step_size = value self._step_size = value
@property @property
def value(self) -> float: def value(self) -> float:
return self._value return self._value
@value.setter @value.setter
def value(self, value: float) -> None: def value(self, value: float) -> None:
if value < self._min or value > self._max: if value < self._min or value > self._max:
raise ValueError( raise ValueError(
"Value is either below allowed min or above max value." "Value is either below allowed min or above max value."
) )
self._value = value self._value = value
class MyService(pydase.DataService): class MyService(pydase.DataService):
def __init__(self) -> None: def __init__(self) -> None:
self.voltage = MyService() self.voltage = MyService()
# Modifying or accessing the voltage value: # Modifying or accessing the voltage value:
my_service = MyService() my_service = MyService()
my_service.voltage.value = 5 my_service.voltage.value = 5
print(my_service.voltage.value) # Output: 5 print(my_service.voltage.value) # Output: 5
``` ```
""" """
def __init__( def __init__(

View File

@@ -5,19 +5,31 @@ from confz import BaseConfig, EnvSource
class OperationMode(BaseConfig): # type: ignore[misc] class OperationMode(BaseConfig): # type: ignore[misc]
environment: Literal["development", "production"] = "development" environment: Literal["testing", "development", "production"] = "development"
"""The service's operation mode."""
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"]) CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
class ServiceConfig(BaseConfig): # type: ignore[misc] class ServiceConfig(BaseConfig): # type: ignore[misc]
"""Service configuration.
Variables can be set through environment variables prefixed with `SERVICE_` or an
`.env` file containing those variables.
"""
config_dir: Path = Path("config") config_dir: Path = Path("config")
"""Configuration directory"""
web_port: int = 8001 web_port: int = 8001
"""Web server port"""
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env") CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
class WebServerConfig(BaseConfig): # type: ignore[misc] class WebServerConfig(BaseConfig): # type: ignore[misc]
"""The service's web server configuration."""
generate_web_settings: bool = False generate_web_settings: bool = False
"""Should generate web_settings.json file"""
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"]) CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])

View File

@@ -124,8 +124,10 @@ class DataServiceObserver(PropertyObserver):
object. object.
Args: Args:
callback (Callable[[str, Any, dict[str, Any]]): The callback function to be callback:
registered. The function should have the following signature: 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 - full_access_path (str): The full dot-notation access path of the
changed attribute. This path indicates the location of the changed changed attribute. This path indicates the location of the changed
attribute within the observable object's structure. attribute within the observable object's structure.

View File

@@ -33,17 +33,19 @@ def load_state(func: Callable[..., Any]) -> Callable[..., Any]:
the value should be loaded from the JSON file. the value should be loaded from the JSON file.
Example: Example:
>>> class Service(pydase.DataService): ```python
... _name = "Service" class Service(pydase.DataService):
... _name = "Service"
... @property
... def name(self) -> str: @property
... return self._name def name(self) -> str:
... return self._name
... @name.setter
... @load_state @name.setter
... def name(self, value: str) -> None: @load_state
... self._name = value def name(self, value: str) -> None:
self._name = value
```
""" """
func._load_state = True # type: ignore[attr-defined] func._load_state = True # type: ignore[attr-defined]
@@ -85,13 +87,11 @@ class StateManager:
StateManager provides a snapshot of the DataService's state that is sufficiently StateManager provides a snapshot of the DataService's state that is sufficiently
accurate for initial rendering and interaction. accurate for initial rendering and interaction.
Attributes: Args:
cache (dict[str, Any]): service:
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. The DataService instance whose state is being managed.
filename:
The file name used for storing the DataService's state.
Note: Note:
The StateManager's cache updates are triggered by notifications and do not The StateManager's cache updates are triggered by notifications and do not
@@ -200,9 +200,11 @@ class StateManager:
It also handles type-specific conversions for the new value before setting it. It also handles type-specific conversions for the new value before setting it.
Args: Args:
path: A dot-separated string indicating the hierarchical path to the path:
A dot-separated string indicating the hierarchical path to the
attribute. attribute.
value: The new value to set for the attribute. serialized_value:
The serialized representation of the new value to set for the attribute.
""" """
try: try:

View File

@@ -17,10 +17,10 @@ def validate_set(
getter and check against the desired value. getter and check against the desired value.
Args: Args:
timeout (float): timeout:
The maximum time (in seconds) to wait for the value to be within the The maximum time (in seconds) to wait for the value to be within the
precision boundary. precision boundary.
precision (float | None): precision:
The acceptable deviation from the desired value. If None, the value must be The acceptable deviation from the desired value. If None, the value must be
exact. exact.
""" """
@@ -44,13 +44,11 @@ def has_validate_set_decorator(prop: property) -> bool:
Checks if a property setter has been decorated with the `validate_set` decorator. Checks if a property setter has been decorated with the `validate_set` decorator.
Args: Args:
prop (property): prop:
The property to check. The property to check.
Returns: Returns:
bool: True if the property setter has the `validate_set` decorator, False otherwise.
True if the property setter has the `validate_set` decorator, False
otherwise.
""" """
property_setter = prop.fset property_setter = prop.fset
@@ -68,11 +66,11 @@ def _validate_value_was_correctly_set(
specified `precision` and time `timeout`. specified `precision` and time `timeout`.
Args: Args:
obj (Observable): obj:
The instance of the class containing the property. The instance of the class containing the property.
name (str): name:
The name of the property to validate. The name of the property to validate.
value (Any): value:
The desired value to check against. The desired value to check against.
Raises: Raises:

View File

@@ -24,8 +24,7 @@ class Observer(ABC):
self.on_change_start(changing_attribute) self.on_change_start(changing_attribute)
@abstractmethod @abstractmethod
def on_change(self, full_access_path: str, value: Any) -> None: def on_change(self, full_access_path: str, value: Any) -> None: ...
...
def on_change_start(self, full_access_path: str) -> None: def on_change_start(self, full_access_path: str) -> None:
return return

View File

@@ -35,18 +35,18 @@ class AdditionalServerProtocol(Protocol):
Args: Args:
data_service_observer: data_service_observer:
Observer for the DataService, handling state updates and communication to Observer for the DataService, handling state updates and communication to
connected clients through injected callbacks. Can be utilized to access the connected clients through injected callbacks. Can be utilized to access the
service and state manager, and to add custom state-update callbacks. service and state manager, and to add custom state-update callbacks.
host: host:
Hostname or IP address where the server is accessible. Commonly '0.0.0.0' to Hostname or IP address where the server is accessible. Commonly '0.0.0.0' to
bind to all network interfaces. bind to all network interfaces.
port: port:
Port number on which the server listens. Typically in the range 1024-65535 Port number on which the server listens. Typically in the range 1024-65535
(non-standard ports). (non-standard ports).
**kwargs: **kwargs:
Any additional parameters required for initializing the server. These Any additional parameters required for initializing the server. These
parameters are specific to the server's implementation. parameters are specific to the server's implementation.
""" """
def __init__( def __init__(
@@ -64,18 +64,17 @@ class AdditionalServerProtocol(Protocol):
class AdditionalServer(TypedDict): class AdditionalServer(TypedDict):
""" """A TypedDict that represents the configuration for an additional server to be run
A TypedDict that represents the configuration for an additional server to be run
alongside the main server. alongside the main server.
This class is used to specify the server type, the port on which the server should
run, and any additional keyword arguments that should be passed to the server when
it's instantiated.
""" """
server: type[AdditionalServerProtocol] server: type[AdditionalServerProtocol]
"""Server adhering to the
[`AdditionalServerProtocol`][pydase.server.server.AdditionalServerProtocol]."""
port: int port: int
"""Port on which the server should run."""
kwargs: dict[str, Any] kwargs: dict[str, Any]
"""Additional keyword arguments that will be passed to the server's constructor """
class Server: class Server:
@@ -83,29 +82,20 @@ class Server:
The `Server` class provides a flexible server implementation for the `DataService`. The `Server` class provides a flexible server implementation for the `DataService`.
Args: Args:
service: DataService service:
The DataService instance that this server will manage. The DataService instance that this server will manage.
host: str host:
The host address for the server. Default is '0.0.0.0', which means all The host address for the server. Defaults to `'0.0.0.0'`, which means all
available network interfaces. available network interfaces.
web_port: int web_port:
The port number for the web server. Default is The port number for the web server. Defaults to
`pydase.config.ServiceConfig().web_port`. [`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port].
enable_web: bool enable_web:
Whether to enable the web server. Default is True. Whether to enable the web server.
filename: str | Path | None filename:
Filename of the file managing the service state persistence. Filename of the file managing the service state persistence.
Defaults to None. additional_servers:
additional_servers : list[AdditionalServer] A list of additional servers to run alongside the main server.
A list of additional servers to run alongside the main server. Each entry in
the list should be a dictionary with the following structure:
- server: A class that adheres to the AdditionalServerProtocol. This
class should have an `__init__` method that accepts the DataService
instance, port, host, and optional keyword arguments, and a `serve`
method that is a coroutine responsible for starting the server.
- port: The port on which the additional server will be running.
- kwargs: A dictionary containing additional keyword arguments that will
be passed to the server's `__init__` method.
Here's an example of how you might define an additional server: Here's an example of how you might define an additional server:
@@ -145,8 +135,8 @@ class Server:
) )
server.run() server.run()
``` ```
**kwargs: Any **kwargs:
Additional keyword arguments. Additional keyword arguments.
""" """
def __init__( # noqa: PLR0913 def __init__( # noqa: PLR0913
@@ -214,7 +204,7 @@ class Server:
) )
server_task = self._loop.create_task(addin_server.serve()) server_task = self._loop.create_task(addin_server.serve())
server_task.add_done_callback(self.handle_server_shutdown) server_task.add_done_callback(self._handle_server_shutdown)
self.servers[server_name] = server_task self.servers[server_name] = server_task
if self._enable_web: if self._enable_web:
self._web_server = WebServer( self._web_server = WebServer(
@@ -225,10 +215,10 @@ class Server:
) )
server_task = self._loop.create_task(self._web_server.serve()) server_task = self._loop.create_task(self._web_server.serve())
server_task.add_done_callback(self.handle_server_shutdown) server_task.add_done_callback(self._handle_server_shutdown)
self.servers["web"] = server_task self.servers["web"] = server_task
def handle_server_shutdown(self, task: asyncio.Task[Any]) -> None: def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None:
"""Handle server shutdown. If the service should exit, do nothing. Else, make """Handle server shutdown. If the service should exit, do nothing. Else, make
the service exit.""" the service exit."""

View File

@@ -23,6 +23,7 @@ logger = logging.getLogger(__name__)
# These functions can be monkey-patched by other libraries at runtime # These functions can be monkey-patched by other libraries at runtime
dump = pydase.utils.serialization.serializer.dump dump = pydase.utils.serialization.serializer.dump
sio_client_manager = None
class UpdateDict(TypedDict): class UpdateDict(TypedDict):
@@ -54,12 +55,15 @@ class RunMethodDict(TypedDict):
exposed DataService. exposed DataService.
Attributes: Attributes:
name (str): The name of the method to be run. name:
parent_path (str): The access path for the parent object of the method to be The name of the method to be run.
run. This is used to construct the full access path for the method. For parent_path:
example, for an method with access path 'attr1.list_attr[0].method_name', The access path for the parent object of the method to be run. This is used
'attr1.list_attr[0]' would be the parent_path. to construct the full access path for the method. For example, for an method
kwargs (dict[str, Any]): The arguments passed to the method. with access path 'attr1.list_attr[0].method_name', 'attr1.list_attr[0]'
would be the parent_path.
kwargs:
The arguments passed to the method.
""" """
name: str name: str
@@ -76,23 +80,30 @@ def setup_sio_server(
Sets up and configures a Socket.IO asynchronous server. Sets up and configures a Socket.IO asynchronous server.
Args: Args:
observer (DataServiceObserver): observer:
The observer managing state updates and communication. The observer managing state updates and communication.
enable_cors (bool): enable_cors:
Flag indicating whether CORS should be enabled for the server. Flag indicating whether CORS should be enabled for the server.
loop (asyncio.AbstractEventLoop): loop:
The event loop in which the server will run. The event loop in which the server will run.
Returns: Returns:
socketio.AsyncServer: The configured Socket.IO asynchronous server. The configured Socket.IO asynchronous server.
""" """
state_manager = observer.state_manager state_manager = observer.state_manager
if enable_cors: if enable_cors:
sio = socketio.AsyncServer(async_mode="aiohttp", cors_allowed_origins="*") sio = socketio.AsyncServer(
async_mode="aiohttp",
cors_allowed_origins="*",
client_manager=sio_client_manager,
)
else: else:
sio = socketio.AsyncServer(async_mode="aiohttp") sio = socketio.AsyncServer(
async_mode="aiohttp",
client_manager=sio_client_manager,
)
setup_sio_events(sio, state_manager) setup_sio_events(sio, state_manager)
setup_logging_handler(sio) setup_logging_handler(sio)
@@ -127,15 +138,15 @@ def setup_sio_server(
def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) -> None: # noqa: C901 def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) -> None: # noqa: C901
@sio.event # type: ignore @sio.event # type: ignore
async def connect(sid: str, environ: Any) -> None: async def connect(sid: str, environ: Any) -> None:
logging.debug("Client [%s] connected", click.style(str(sid), fg="cyan")) logger.debug("Client [%s] connected", click.style(str(sid), fg="cyan"))
@sio.event # type: ignore @sio.event # type: ignore
async def disconnect(sid: str) -> None: async def disconnect(sid: str) -> None:
logging.debug("Client [%s] disconnected", click.style(str(sid), fg="cyan")) logger.debug("Client [%s] disconnected", click.style(str(sid), fg="cyan"))
@sio.event # type: ignore @sio.event # type: ignore
async def service_serialization(sid: str) -> SerializedObject: async def service_serialization(sid: str) -> SerializedObject:
logging.debug( logger.debug(
"Client [%s] requested service serialization", "Client [%s] requested service serialization",
click.style(str(sid), fg="cyan"), click.style(str(sid), fg="cyan"),
) )

View File

@@ -25,41 +25,51 @@ API_VERSION = "v1"
class WebServer: class WebServer:
""" """
Represents a web server that adheres to the AdditionalServerProtocol, designed to Represents a web server that adheres to the
work with a DataService instance. This server facilitates client-server [`AdditionalServerProtocol`][pydase.server.server.AdditionalServerProtocol],
communication and state management through web protocols and socket connections. designed to work with a [`DataService`][pydase.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 The WebServer class initializes and manages a web server environment aiohttp and
and Socket.IO, allowing for HTTP and WebSocket communications. It incorporates CORS Socket.IO, allowing for HTTP and Socket.IO communications. It incorporates CORS
(Cross-Origin Resource Sharing) support, custom CSS, and serves a frontend static (Cross-Origin Resource Sharing) support, custom CSS, and serves a static files
files directory. It also initializes web server settings based on configuration directory. It also initializes web server settings based on configuration files or
files or generates default settings if necessary. generates default settings if necessary.
Configuration for the web server (like service configuration directory and whether Configuration for the web server (like service configuration directory and whether
to generate new web settings) is determined in the following order of precedence: to generate new web settings) is determined in the following order of precedence:
1. Values provided directly to the constructor. 1. Values provided directly to the constructor.
2. Environment variable settings (via configuration classes like 2. Environment variable settings (via configuration classes like
`pydase.config.ServiceConfig` and `pydase.config.WebServerConfig`). [`ServiceConfig`][pydase.config.ServiceConfig] and
[`WebServerConfig`][pydase.config.WebServerConfig]).
3. Default values defined in the configuration classes. 3. Default values defined in the configuration classes.
Args: Args:
data_service_observer (DataServiceObserver): Observer for the DataService, data_service_observer:
handling state updates and communication to connected clients. Observer for the [`DataService`][pydase.DataService], handling state updates
host (str): Hostname or IP address where the server is accessible. Commonly and communication to connected clients.
'0.0.0.0' to bind to all network interfaces. host:
port (int): Port number on which the server listens. Typically in the range Hostname or IP address where the server is accessible. Commonly '0.0.0.0'
1024-65535 (non-standard ports). to bind to all network interfaces.
css (str | Path | None, optional): Path to a custom CSS file for styling the port:
frontend. If None, no custom styles are applied. Defaults to None. Port number on which the server listens. Typically in the range 1024-65535
enable_cors (bool, optional): Flag to enable or disable CORS policy. When True, (non-standard ports).
CORS is enabled, allowing cross-origin requests. Defaults to True. css:
config_dir (Path | None, optional): Path to the configuration Path to a custom CSS file for styling the frontend. If None, no custom
directory where the web settings will be stored. Defaults to styles are applied. Defaults to None.
`pydase.config.ServiceConfig().config_dir`. enable_cors:
generate_new_web_settings (bool | None, optional): Flag to enable or disable Flag to enable or disable CORS policy. When True, CORS is enabled, allowing
generation of new web settings if the configuration file is missing. Defaults cross-origin requests. Defaults to True.
to `pydase.config.WebServerConfig().generate_new_web_settings`. config_dir:
**kwargs (Any): Additional unused keyword arguments. Path to the configuration directory where the web settings will be stored.
Defaults to
[`ServiceConfig().config_dir`][pydase.config.ServiceConfig.config_dir].
generate_web_settings:
Flag to enable or disable generation of new web settings if the
configuration file is missing. Defaults to
[`WebServerConfig().generate_web_settings`][pydase.config.WebServerConfig.generate_web_settings].
""" """
def __init__( # noqa: PLR0913 def __init__( # noqa: PLR0913

View File

@@ -21,18 +21,20 @@ def convert_to_quantity(
Convert a given value into a pint.Quantity object with the specified unit. Convert a given value into a pint.Quantity object with the specified unit.
Args: Args:
value (QuantityDict | float | int | Quantity): value:
The value to be converted into a Quantity object. The value to be converted into a Quantity object.
- If value is a float or int, it will be directly converted to the specified - If value is a float or int, it will be directly converted to the specified
unit. unit.
- If value is a dict, it must have keys 'magnitude' and 'unit' to represent - If value is a dict, it must have keys 'magnitude' and 'unit' to represent
the value and unit. the value and unit.
- If value is a Quantity object, it will remain unchanged.\n - If value is a Quantity object, it will remain unchanged.\n
unit (str, optional): The target unit for conversion. If empty and value is not unit:
a Quantity object, it will assume a unitless quantity. The target unit for conversion. If empty and value is not a Quantity object,
it will assume a unitless quantity.
Returns: Returns:
Quantity: The converted value as a pint.Quantity object with the specified unit. The converted value as a pint.Quantity object with the specified unit.
Examples: Examples:
>>> convert_to_quantity(5, 'm') >>> convert_to_quantity(5, 'm')
@@ -42,9 +44,9 @@ def convert_to_quantity(
>>> convert_to_quantity(10.0 * u.units.V) >>> convert_to_quantity(10.0 * u.units.V)
<Quantity(10.0, 'volt')> <Quantity(10.0, 'volt')>
Notes: Note:
- If unit is not provided and value is a float or int, the resulting Quantity If unit is not provided and value is a float or int, the resulting Quantity will
will be unitless. be unitless.
""" """
if isinstance(value, int | float): if isinstance(value, int | float):

View File

@@ -10,9 +10,9 @@ class FunctionDefinitionError(Exception):
def frontend(func: Callable[..., Any]) -> Callable[..., Any]: def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
""" """Decorator to mark a [`DataService`][pydase.DataService] method for frontend
Decorator to mark a DataService method for frontend rendering. Ensures that the rendering. Ensures that the method does not contain arguments, as they are not
method does not contain arguments, as they are not supported for frontend rendering. supported for frontend rendering.
""" """
if function_has_arguments(func): if function_has_arguments(func):

View File

@@ -23,6 +23,7 @@ logger = logging.getLogger(__name__)
class Deserializer: class Deserializer:
@classmethod @classmethod
def deserialize(cls, serialized_object: SerializedObject) -> Any: def deserialize(cls, serialized_object: SerializedObject) -> Any:
"""Deserialize `serialized_object` (a `dict`) to a Python object."""
type_handler: dict[str | None, None | Callable[..., Any]] = { type_handler: dict[str | None, None | Callable[..., Any]] = {
None: None, None: None,
"int": cls.deserialize_primitive, "int": cls.deserialize_primitive,
@@ -159,4 +160,5 @@ class Deserializer:
def loads(serialized_object: SerializedObject) -> Any: def loads(serialized_object: SerializedObject) -> Any:
"""Deserialize `serialized_object` (a `dict`) to a Python object."""
return Deserializer.deserialize(serialized_object) return Deserializer.deserialize(serialized_object)

View File

@@ -52,8 +52,27 @@ class SerializationPathError(Exception):
class Serializer: class Serializer:
"""Serializes objects into
[`SerializedObject`][pydase.utils.serialization.types.SerializedObject]
representations.
"""
@classmethod @classmethod
def serialize_object(cls, obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901 def serialize_object(cls, obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
"""Serialize `obj` to a
[`SerializedObject`][pydase.utils.serialization.types.SerializedObject].
Args:
obj:
Object to be serialized.
access_path:
String corresponding to the full access path of the object. This will be
prepended to the full_access_path in the SerializedObject entries.
Returns:
Dictionary representation of `obj`.
"""
result: SerializedObject result: SerializedObject
if isinstance(obj, Exception): if isinstance(obj, Exception):
@@ -313,6 +332,19 @@ class Serializer:
def dump(obj: Any) -> SerializedObject: def dump(obj: Any) -> SerializedObject:
"""Serialize `obj` to a
[`SerializedObject`][pydase.utils.serialization.types.SerializedObject].
The [`Serializer`][pydase.utils.serialization.serializer.Serializer] is used for
encoding.
Args:
obj:
Object to be serialized.
Returns:
Dictionary representation of `obj`.
"""
return Serializer.serialize_object(obj) return Serializer.serialize_object(obj)
@@ -321,12 +353,13 @@ def set_nested_value_by_path(
) -> None: ) -> None:
""" """
Set a value in a nested dictionary structure, which conforms to the serialization Set a value in a nested dictionary structure, which conforms to the serialization
format used by `pydase.utils.serializer.Serializer`, using a dot-notation path. format used by [`Serializer`][pydase.utils.serialization.serializer.Serializer],
using a dot-notation path.
Args: Args:
serialization_dict: serialization_dict:
The base dictionary representing data serialized with The base dictionary representing data serialized with
`pydase.utils.serializer.Serializer`. [`Serializer`][pydase.utils.serialization.serializer.Serializer].
path: path:
The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to
set the value. set the value.
@@ -334,8 +367,8 @@ def set_nested_value_by_path(
The new value to set at the specified path. The new value to set at the specified path.
Note: Note:
- If the index equals the length of the list, the function will append the If the index equals the length of the list, the function will append the
serialized representation of the 'value' to the list. serialized representation of the 'value' to the list.
""" """
path_parts = parse_full_access_path(path) path_parts = parse_full_access_path(path)
@@ -438,26 +471,24 @@ def get_container_item_by_key(
) -> SerializedObject: ) -> SerializedObject:
""" """
Retrieve an item from a container specified by the passed key. Add an item to the Retrieve an item from a container specified by the passed key. Add an item to the
container if allow_append is set to True. container if `allow_append` is set to `True`.
If specified keys or indexes do not exist, the function can append new elements to If specified keys or indexes do not exist, the function can append new elements to
dictionaries and to lists if `allow_append` is True and the missing element is dictionaries and to lists if `allow_append` is True and the missing element is
exactly the next sequential index (for lists). exactly the next sequential index (for lists).
Args: Args:
container: dict[str, SerializedObject] | list[SerializedObject] container:
The container representing serialized data. The container representing serialized data.
key: str key:
The key name representing the attribute in the dictionary, which may include The key name representing the attribute in the dictionary, which may include
direct keys or indexes (e.g., 'attr_name', '["key"]' or '[0]'). direct keys or indexes (e.g., 'attr_name', '["key"]' or '[0]').
allow_append: bool allow_append:
Flag to allow appending a new entry if the specified index is out of range Flag to allow appending a new entry if the specified index is out of range
by exactly one position. by exactly one position.
Returns: Returns:
SerializedObject The dictionary or list item corresponding to the specified attribute and index.
The dictionary or list item corresponding to the specified attribute and
index.
Raises: Raises:
SerializationPathError: SerializationPathError:
@@ -485,13 +516,12 @@ def get_data_paths_from_serialized_object( # noqa: C901
Recursively extracts full access paths from a serialized object. Recursively extracts full access paths from a serialized object.
Args: Args:
serialized_obj (SerializedObject): serialized_obj:
The dictionary representing the serialization of an object. Produced by The dictionary representing the serialization of an object. Produced by
`pydase.utils.serializer.Serializer`. `pydase.utils.serializer.Serializer`.
Returns: Returns:
list[str]: A list of strings, each representing a full access path in the serialized
A list of strings, each representing a full access path in the serialized
object. object.
""" """
@@ -532,12 +562,11 @@ def generate_serialized_data_paths(
Recursively extracts full access paths from a serialized DataService class instance. Recursively extracts full access paths from a serialized DataService class instance.
Args: Args:
data (dict[str, SerializedObject]): data:
The value of the "value" key of a serialized DataService class instance. The value of the "value" key of a serialized DataService class instance.
Returns: Returns:
list[str]: A list of strings, each representing a full access path in the serialized
A list of strings, each representing a full access path in the serialized
object. object.
""" """
@@ -556,3 +585,6 @@ def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool:
# We are excluding Quantity here as the value corresponding to the "value" key is # We are excluding Quantity here as the value corresponding to the "value" key is
# a dictionary of the form {"magnitude": ..., "unit": ...} # a dictionary of the form {"magnitude": ..., "unit": ...}
return serialized_dict["type"] != "Quantity" and (isinstance(value, dict | list)) return serialized_dict["type"] != "Quantity" and (isinstance(value, dict | list))
__all__ = ["Serializer", "dump"]

View File

@@ -123,3 +123,21 @@ SerializedObject = (
| SerializedQuantity | SerializedQuantity
| SerializedNoValue | SerializedNoValue
) )
"""
This type can be any of the following:
- SerializedBool
- SerializedFloat
- SerializedInteger
- SerializedString
- SerializedDatetime
- SerializedList
- SerializedDict
- SerializedNoneType
- SerializedMethod
- SerializedException
- SerializedDataService
- SerializedEnum
- SerializedQuantity
- SerializedNoValue
"""