104 Commits

Author SHA1 Message Date
Mose Müller
77802da417 Merge pull request #166 from tiqi-group/fix/logging_settings
fix: removes basic configuration of logging system in task module
2024-09-25 19:50:10 +02:00
Mose Müller
3e21858cb7 removes basic configuration of logging system in task module 2024-09-25 19:48:32 +02:00
Mose Müller
2003f28fd1 Merge pull request #165 from tiqi-group/fix/client_usage_in_jupyter_notebook
Fix: client usage in jupyter notebook
2024-09-25 19:41:42 +02:00
Mose Müller
172b50bf77 updates to version v0.10.5 2024-09-25 19:38:28 +02:00
Mose Müller
ec5694fedf no need to check for RuntimeError as the loop is always new 2024-09-25 19:38:09 +02:00
Mose Müller
968f774092 always create a new event loop in the client and pass it to a new thread 2024-09-25 19:37:33 +02:00
Mose Müller
757dc9aa3c Merge pull request #163 from tiqi-group/fix/removes_PerInstanceTaskDescriptor_warning
fix: removes inheritance warning for descriptors
2024-09-23 13:35:44 +02:00
Mose Müller
3d938562a6 updates to version v0.10.4 2024-09-23 13:35:33 +02:00
Mose Müller
964a62d4b4 removes inheritance warning for descriptors 2024-09-23 13:33:13 +02:00
Mose Müller
99aa38fcfe chore: removes unused code 2024-09-23 13:28:08 +02:00
Mose Müller
5658514c8a Merge pull request #162 from tiqi-group/fix/normalize_full_access_path
Fix: normalize full access path (for dict keys)
2024-09-23 13:23:11 +02:00
Mose Müller
109ee7d5e1 updates version to v0.10.3 2024-09-23 13:16:50 +02:00
Mose Müller
f4fa02fe11 adds test enuring dict keys can be encoded with both single and double quotes 2024-09-23 13:16:50 +02:00
Mose Müller
487ef504a8 normalizes full access path strings containing dict keys with double quotes
Full access paths containing stringed dictionary keys are sent with
double quotes from the frontend. The quotes have to be changed to single
quotes s.t. the comparison with the property dependency dictionary
works.
2024-09-23 13:16:49 +02:00
Mose Müller
c98e407ed7 Merge pull request #161 from tiqi-group/160-control-of-tasks-in-instances-derived-from-same-class-only-controls-task-of-first-instance
fix: controlling tasks of instances derived from same class
2024-09-23 12:54:38 +02:00
Mose Müller
6b6ce1d43f adds test checking for multiple instances of a class containing a task 2024-09-23 09:44:43 +02:00
Mose Müller
e491ac7458 observable does not have to initialise descriptor objects anymore
The task decorator is not returning a Task object directly anymore, but
rather a descriptor which is returning the task. This is where the task
is initialised and this does not have to be done in the observable base
class, any more.
2024-09-23 09:27:15 +02:00
Mose Müller
e9d8cbafc2 adds PerInstanceTaskDescriptor class managing task objects for service class instances
When defining a task on a DataService class, formerly a task object was
created which replaced the decorated method as a class attribute. This
caused errors when using multiple instances of that class as each
instance was referring to the same task.
This descriptor class now handles the tasks per instance of the service
class.
2024-09-23 09:21:04 +02:00
Mose Müller
aa705592b2 removes code from Task meant to bind the passed function to the containing class instance
The task object will only receive bound methods, so there is no need to
keep the descriptor functionality anymore.
2024-09-23 09:15:42 +02:00
Mose Müller
008e1262bb updates PropertyObserver to support descriptors returning observables
If a class attribute is a descriptor, get its value before checking if
it is an Observable.
2024-09-23 08:57:00 +02:00
Mose Müller
91a71ad004 updates is_descriptor to exclude false positives for methods, functions and builtins 2024-09-23 08:55:44 +02:00
Mose Müller
bbf479a440 Merge pull request #159 from tiqi-group/158-defining-task-without-autostart-fails
fix: defining task without autostart fails
2024-09-21 11:43:31 +02:00
Mose Müller
983d392ba8 properly handle Task objects in autostart method
Tasks that are not autostart or are already running were passed to
autostart_nested_services. This caused the recursion as tasks have a
__self__ attribute pointing to the containing service.
2024-09-21 11:40:25 +02:00
Mose Müller
56dd9dd8aa adapts autostart to support nested lists in dicts and vice versa 2024-09-21 09:12:01 +02:00
Mose Müller
20028c379d test: updates task tests 2024-09-21 09:04:04 +02:00
Mose Müller
e48046795e updates version to v0.10.2 2024-09-21 08:37:34 +02:00
Mose Müller
1ac9e45c73 test: updates task test to catch recursion when defining without autostart 2024-09-21 08:36:47 +02:00
Mose Müller
488415436c fixes recursion when defining task without autostart 2024-09-21 08:32:54 +02:00
Mose Müller
d7c5c2cd6e updates to version v0.10.1 2024-09-17 16:52:39 +02:00
Mose Müller
5388fd0d2b Merge pull request #157 from tiqi-group/fix/handle_minus_sign_input
fix: correctly handling minus sign input in NumberComponent
2024-09-17 16:52:08 +02:00
Mose Müller
e74b5c773a npm run build 2024-09-17 16:51:00 +02:00
Mose Müller
bb6cd159f1 frontend: refactoring minus sign handling in NumberComponent 2024-09-17 16:48:40 +02:00
Mose Müller
4a09f02882 docs: updates Readme
Trying to clarify the usage of ports in both server and clients.
2024-09-17 07:23:45 +02:00
Mose Müller
9180bb1d9e Merge pull request #150 from tiqi-group/feat/task_decorator
Feat: Replace implicit async function tasks with task decorator
2024-09-16 15:51:52 +02:00
Mose Müller
ece68b4b99 docs: updates Task documentation
- updates Tasks.md
- updates docstrings
- adds api section
2024-09-16 15:30:47 +02:00
Mose Müller
0c95b5e3cb frontend: removes AsyncMethodComponent (replaced by Task) 2024-09-16 14:22:29 +02:00
Mose Müller
0450bb1570 updates version to v0.10.0 2024-09-16 14:18:10 +02:00
Mose Müller
2f5a640c4c chore: refactored task autostart 2024-09-16 14:17:20 +02:00
Mose Müller
78964be506 adds serialization and deserialization support for task objects 2024-09-16 13:58:58 +02:00
Mose Müller
fbdf6de63c npm run build 2024-09-16 13:58:16 +02:00
Mose Müller
9b04dcd41e frontend: ass Task component 2024-09-16 13:46:07 +02:00
Mose Müller
32e36d4962 adds task tests 2024-09-16 07:53:46 +02:00
Mose Müller
62f28f79db adds list and dictionary entries to task autostart 2024-09-16 07:53:44 +02:00
Mose Müller
e88965b69d fixes device connection test 2024-09-13 16:09:39 +02:00
Mose Müller
e422d627af adds docstring to autostart method 2024-09-13 16:07:30 +02:00
Mose Müller
2e31ebb7d9 fixes or removes task-related tests 2024-09-13 16:07:29 +02:00
Mose Müller
71adc8bea2 adds autostart to server 2024-09-13 12:37:29 +02:00
Mose Müller
bfa0acedab moves autostart from Task to separate autostart submodule 2024-09-13 12:37:18 +02:00
Mose Müller
416b9ee815 removes part of serializer for serializing start and stop methods of async methods 2024-09-13 11:27:30 +02:00
Mose Müller
d1d2ac2614 fixing circular import 2024-09-13 11:27:30 +02:00
Mose Müller
fa35fa53e2 removes TaskManager 2024-09-13 11:27:30 +02:00
Mose Müller
c0e5a77d6f simplifies @task decorator (updates types), moves task logic into Task's run_task() 2024-09-13 11:27:30 +02:00
Mose Müller
96cc7b31b4 updates documentation 2024-09-13 11:27:30 +02:00
Mose Müller
0d6d312f68 chore: fixes type hints for python 3.10 2024-09-13 11:27:30 +02:00
Mose Müller
be3011c565 adapt device connection component to use @task decorator 2024-09-13 11:27:30 +02:00
Mose Müller
09fae01985 adds warning when _bound_func has not been bound yet
This might arise when calling the start method of a task which is part of a class
that has not been instantiated yet.
2024-09-13 11:27:30 +02:00
Mose Müller
12c0c9763d delay task setup until called from class instance containing the task 2024-09-13 11:27:30 +02:00
Mose Müller
15322b742d using explicit loop to create task even if loop is not running yet 2024-09-13 11:27:30 +02:00
Mose Müller
85d6229aa6 updates DataService import to avoid circular import 2024-09-13 11:27:30 +02:00
Mose Müller
083fab0a29 Carefully setting up asyncio event loop 2024-09-13 11:27:30 +02:00
Mose Müller
2a1aff589d properly binding task method 2024-09-13 11:27:30 +02:00
Mose Müller
3cd7198747 task can only wrap async functions without arguments 2024-09-13 11:27:30 +02:00
Mose Müller
1e02f12794 adds autostart flag to task 2024-09-13 11:27:30 +02:00
Mose Müller
e4a3cf341f task can receive bound and unbound functions now 2024-09-13 11:27:30 +02:00
Mose Müller
7ddcd97f68 fixing ruff and mypy errors 2024-09-13 11:27:30 +02:00
Mose Müller
80da96657c tasks: don't start another task when it is already running 2024-09-13 11:27:30 +02:00
Mose Müller
861e89f37a task: using functools to get correct func name 2024-09-13 11:27:30 +02:00
Mose Müller
c00cf9a6ff updating property dependencies in PropertyObserver
As Task objects have to be class attributes, I have to loop through class attributes, as well
when calculating nested observables properties.
2024-09-13 11:27:30 +02:00
Mose Müller
ed7f3d8509 dont make descriptors attributes of the instance -> would loose functionality 2024-09-13 11:27:30 +02:00
Mose Müller
456090fee9 adds is_descriptor helper method 2024-09-13 11:27:30 +02:00
Mose Müller
e69ef376ae replaces some code with helper function 2024-09-13 11:27:30 +02:00
Mose Müller
5f78771f66 tasks: need to bind method as soon as instance is passed to __get__
I cannot keep a reference to the parent class as the Task class is a DataService, as well.
2024-09-13 11:27:30 +02:00
Mose Müller
09ceae90ec tasks: only care about async methods right now 2024-09-13 11:27:30 +02:00
Mose Müller
c34351270c feat: first Task implementation 2024-09-13 11:27:29 +02:00
Mose Müller
743c18bdd7 fix: need to compare with serialized value (for enums) 2024-09-13 11:27:29 +02:00
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
60 changed files with 2070 additions and 1589 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

758
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,30 @@ class Device(DataService):
if __name__ == "__main__": if __name__ == "__main__":
service = Device() service = Device()
Server(service).run() pydase.Server(service=service, web_port=8001).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, web_port=8001).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). The port number for the web server can
be customised in the server constructor or through environment variables and defaults
to `8001`.
### Accessing the Web Interface ### Accessing the Web Interface
@@ -170,7 +147,7 @@ import pydase
# Replace the hostname and port with the IP address and the port of the machine where # Replace the hostname and port with the IP address and the port of the machine where
# the service is running, respectively # the service is running, respectively
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy client_proxy = pydase.Client(url="ws://<ip_addr>:<web_port>").proxy
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy # if your service uses ssl-encryption # client_proxy = pydase.Client(url="wss://your-domain.ch").proxy # if your service uses ssl-encryption
# After the connection, interact with the service attributes as if they were local # After the connection, interact with the service attributes as if they were local
@@ -183,7 +160,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.
@@ -196,644 +173,14 @@ import json
import requests import requests
response = requests.get( response = requests.get(
"http://<hostname>:<port>/api/v1/get_value?access_path=<full_access_path>" "http://<hostname>:<web_port>/api/v1/get_value?access_path=<full_access_path>"
) )
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 +270,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,45 @@
::: 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.task
handler: python
options:
inherited_members: false
show_submodules: true
::: 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.

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

@@ -0,0 +1,38 @@
# Understanding Tasks
In `pydase`, a task is defined as an asynchronous function without arguments that is decorated with the `@task` decorator and 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.
`pydase` allows you to control task execution via both the frontend and Python clients and can automatically start tasks upon initialization of the service. By using the `@task` decorator with the `autostart=True` argument in your service class, `pydase` will automatically start these tasks when the server is started. Here's an example:
```python
import pydase
from pydase.task.decorator import task
class SensorService(pydase.DataService):
def __init__(self):
super().__init__()
self.readout_frequency = 1.0
def _process_data(self, data: ...) -> None:
...
def _read_from_sensor(self) -> Any:
...
@task(autostart=True)
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 decorating it with `@task(autostart=True)`, it will automatically start running when `pydase.Server(service).run()` is executed.
The `@task` decorator replaces the function with a task object that has `start()` and `stop()` methods. This means you can control the task execution directly using these methods. For instance, you can manually start or stop the task by calling `service.read_sensor_data.start()` and `service.read_sensor_data.stop()`, respectively.

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

@@ -4,7 +4,6 @@ import { NumberComponent, NumberObject } from "./NumberComponent";
import { SliderComponent } from "./SliderComponent"; import { SliderComponent } from "./SliderComponent";
import { EnumComponent } from "./EnumComponent"; import { EnumComponent } from "./EnumComponent";
import { MethodComponent } from "./MethodComponent"; import { MethodComponent } from "./MethodComponent";
import { AsyncMethodComponent } from "./AsyncMethodComponent";
import { StringComponent } from "./StringComponent"; import { StringComponent } from "./StringComponent";
import { ListComponent } from "./ListComponent"; import { ListComponent } from "./ListComponent";
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent"; import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
@@ -17,6 +16,7 @@ import { updateValue } from "../socket";
import { DictComponent } from "./DictComponent"; import { DictComponent } from "./DictComponent";
import { parseFullAccessPath } from "../utils/stateUtils"; import { parseFullAccessPath } from "../utils/stateUtils";
import { SerializedEnum, SerializedObject } from "../types/SerializedObject"; import { SerializedEnum, SerializedObject } from "../types/SerializedObject";
import { TaskComponent, TaskStatus } from "./TaskComponent";
interface GenericComponentProps { interface GenericComponentProps {
attribute: SerializedObject; attribute: SerializedObject;
@@ -144,30 +144,16 @@ export const GenericComponent = React.memo(
/> />
); );
} else if (attribute.type === "method") { } else if (attribute.type === "method") {
if (!attribute.async) { return (
return ( <MethodComponent
<MethodComponent fullAccessPath={fullAccessPath}
fullAccessPath={fullAccessPath} docString={attribute.doc}
docString={attribute.doc} addNotification={addNotification}
addNotification={addNotification} displayName={displayName}
displayName={displayName} id={id}
id={id} render={attribute.frontend_render}
render={attribute.frontend_render} />
/> );
);
} else {
return (
<AsyncMethodComponent
fullAccessPath={fullAccessPath}
docString={attribute.doc}
value={attribute.value as "RUNNING" | null}
addNotification={addNotification}
displayName={displayName}
id={id}
render={attribute.frontend_render}
/>
);
}
} else if (attribute.type === "str") { } else if (attribute.type === "str") {
return ( return (
<StringComponent <StringComponent
@@ -182,6 +168,17 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type == "Task") {
return (
<TaskComponent
fullAccessPath={fullAccessPath}
docString={attribute.doc}
status={attribute.value["status"].value as TaskStatus}
addNotification={addNotification}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === "DataService") { } else if (attribute.type === "DataService") {
return ( return (
<DataServiceComponent <DataServiceComponent

View File

@@ -132,6 +132,8 @@ const handleNumericKey = (
selectionStart: number, selectionStart: number,
selectionEnd: number, selectionEnd: number,
) => { ) => {
let newValue = value;
// Check if a number key or a decimal point key is pressed // Check if a number key or a decimal point key is pressed
if (key === "." && value.includes(".")) { if (key === "." && value.includes(".")) {
// Check if value already contains a decimal. If so, ignore input. // Check if value already contains a decimal. If so, ignore input.
@@ -139,14 +141,34 @@ const handleNumericKey = (
return { value, selectionStart }; return { value, selectionStart };
} }
let newValue = value; // Handle minus sign input
if (key === "-") {
if (selectionStart === 0 && selectionEnd > selectionStart) {
// Replace selection with minus if selection starts at 0
newValue = "-" + value.slice(selectionEnd);
selectionStart = 1;
} else if (selectionStart === 0 && !value.startsWith("-")) {
// Add minus at the beginning if it doesn't exist
newValue = "-" + value;
selectionStart = 1;
} else if (
(selectionStart === 0 || selectionStart === 1) &&
value.startsWith("-")
) {
// Remove minus if it exists
newValue = value.slice(1);
selectionStart = 0;
}
return { value: newValue, selectionStart };
}
// Add the new key at the cursor's position // Add the new key at the cursor's position
if (selectionEnd > selectionStart) { if (selectionEnd > selectionStart) {
// If there is a selection, replace it with the key // If there is a selection, replace it with the key
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd); newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
} else { } else {
// otherwise, append the key after the selection start // Otherwise, insert the key at the cursor position
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart); newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
} }
@@ -201,17 +223,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Select everything when pressing Ctrl + a // Select everything when pressing Ctrl + a
inputTarget.setSelectionRange(0, value.length); inputTarget.setSelectionRange(0, value.length);
return; return;
} else if (key === "-") { } else if ((key >= "0" && key <= "9") || key === "-") {
if (selectionStart === 0 && !value.startsWith("-")) {
newValue = "-" + value;
selectionStart++;
} else if (value.startsWith("-") && selectionStart === 1) {
newValue = value.substring(1); // remove minus sign
selectionStart--;
} else {
return; // Ignore "-" pressed in other positions
}
} else if (key >= "0" && key <= "9") {
// Check if a number key or a decimal point key is pressed // Check if a number key or a decimal point key is pressed
({ value: newValue, selectionStart } = handleNumericKey( ({ value: newValue, selectionStart } = handleNumericKey(
key, key,

View File

@@ -5,67 +5,51 @@ import { DocStringComponent } from "./DocStringComponent";
import { LevelName } from "./NotificationsComponent"; import { LevelName } from "./NotificationsComponent";
import useRenderCount from "../hooks/useRenderCount"; import useRenderCount from "../hooks/useRenderCount";
interface AsyncMethodProps { export type TaskStatus = "RUNNING" | "NOT_RUNNING";
interface TaskProps {
fullAccessPath: string; fullAccessPath: string;
value: "RUNNING" | null;
docString: string | null; docString: string | null;
hideOutput?: boolean; status: TaskStatus;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string; displayName: string;
id: string; id: string;
render: boolean;
} }
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => { export const TaskComponent = React.memo((props: TaskProps) => {
const { const { fullAccessPath, docString, status, addNotification, displayName, id } = props;
fullAccessPath,
docString,
value: runningTask,
addNotification,
displayName,
id,
} = props;
// Conditional rendering based on the 'render' prop.
if (!props.render) {
return null;
}
const renderCount = useRenderCount(); const renderCount = useRenderCount();
const formRef = useRef(null); const formRef = useRef(null);
const [spinning, setSpinning] = useState(false); const [spinning, setSpinning] = useState(false);
const name = fullAccessPath.split(".").at(-1)!;
const parentPath = fullAccessPath.slice(0, -(name.length + 1));
useEffect(() => { useEffect(() => {
let message: string; let message: string;
if (runningTask === null) { if (status === "RUNNING") {
message = `${fullAccessPath} task was stopped.`;
} else {
message = `${fullAccessPath} was started.`; message = `${fullAccessPath} was started.`;
} else {
message = `${fullAccessPath} was stopped.`;
} }
addNotification(message); addNotification(message);
setSpinning(false); setSpinning(false);
}, [props.value]); }, [status]);
const execute = async (event: React.FormEvent) => { const execute = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
let method_name: string;
if (runningTask !== undefined && runningTask !== null) { const method_name = status == "RUNNING" ? "stop" : "start";
method_name = `stop_${name}`;
} else {
method_name = `start_${name}`;
}
const accessPath = [parentPath, method_name].filter((element) => element).join("."); const accessPath = [fullAccessPath, method_name]
.filter((element) => element)
.join(".");
setSpinning(true); setSpinning(true);
runMethod(accessPath); runMethod(accessPath);
}; };
return ( return (
<div className="component asyncMethodComponent" id={id}> <div className="component taskComponent" id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
<Form onSubmit={execute} ref={formRef}> <Form onSubmit={execute} ref={formRef}>
<InputGroup> <InputGroup>
@@ -76,7 +60,7 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
<Button id={`button-${id}`} type="submit"> <Button id={`button-${id}`} type="submit">
{spinning ? ( {spinning ? (
<Spinner size="sm" role="status" aria-hidden="true" /> <Spinner size="sm" role="status" aria-hidden="true" />
) : runningTask === "RUNNING" ? ( ) : status === "RUNNING" ? (
"Stop " "Stop "
) : ( ) : (
"Start " "Start "
@@ -88,4 +72,4 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
); );
}); });
AsyncMethodComponent.displayName = "AsyncMethodComponent"; TaskComponent.displayName = "TaskComponent";

View File

@@ -77,7 +77,12 @@ type SerializedException = SerializedObjectBase & {
type: "Exception"; type: "Exception";
}; };
type DataServiceTypes = "DataService" | "Image" | "NumberSlider" | "DeviceConnection"; type DataServiceTypes =
| "DataService"
| "Image"
| "NumberSlider"
| "DeviceConnection"
| "Task";
type SerializedDataService = SerializedObjectBase & { type SerializedDataService = SerializedObjectBase & {
name: string; name: string;

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.10.5"
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

@@ -31,10 +31,7 @@ class NotifyDict(TypedDict):
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None: def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: loop.run_forever()
loop.run_forever()
except RuntimeError:
logger.debug("Tried starting even loop, but it is running already")
class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection): class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
@@ -43,10 +40,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,26 +51,27 @@ 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__(
self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop
) -> None: ) -> None:
super().__init__() super().__init__()
pydase.components.DeviceConnection.__init__(self)
self._initialise(sio_client=sio_client, loop=loop) self._initialise(sio_client=sio_client, loop=loop)
@@ -84,19 +82,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 +107,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

@@ -351,7 +351,7 @@ class ProxyLoader:
) -> Any: ) -> Any:
# Custom types like Components or DataService classes # Custom types like Components or DataService classes
component_class = cast( component_class = cast(
type, Deserializer.get_component_class(serialized_object["type"]) type, Deserializer.get_service_base_class(serialized_object["type"])
) )
class_bases = ( class_bases = (
ProxyClassMixin, ProxyClassMixin,

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

@@ -1,6 +1,7 @@
import asyncio import asyncio
import pydase.data_service import pydase.data_service
import pydase.task.decorator
class DeviceConnection(pydase.data_service.DataService): class DeviceConnection(pydase.data_service.DataService):
@@ -19,22 +20,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
----------------------- -----------------------
@@ -48,7 +53,6 @@ class DeviceConnection(pydase.data_service.DataService):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._connected = False self._connected = False
self._autostart_tasks["_handle_connection"] = () # type: ignore
self._reconnection_wait_time = 10.0 self._reconnection_wait_time = 10.0
def connect(self) -> None: def connect(self) -> None:
@@ -66,6 +70,7 @@ class DeviceConnection(pydase.data_service.DataService):
""" """
return self._connected return self._connected
@pydase.task.decorator.task(autostart=True)
async def _handle_connection(self) -> None: async def _handle_connection(self) -> None:
"""Automatically tries reconnecting to the device if it is not connected. """Automatically tries reconnecting to the device if it is not connected.
This method leverages the `connect` method and the `connected` property to This method leverages the `connect` method and the `connected` property to

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

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

View File

@@ -5,12 +5,12 @@ from typing import Any
import pydase.units as u import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.task_manager import TaskManager
from pydase.observer_pattern.observable.observable import ( from pydase.observer_pattern.observable.observable import (
Observable, Observable,
) )
from pydase.utils.helpers import ( from pydase.utils.helpers import (
get_class_and_instance_attributes, get_class_and_instance_attributes,
is_descriptor,
is_property_attribute, is_property_attribute,
) )
from pydase.utils.serialization.serializer import ( from pydase.utils.serialization.serializer import (
@@ -24,11 +24,6 @@ logger = logging.getLogger(__name__)
class DataService(AbstractDataService): class DataService(AbstractDataService):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._task_manager = TaskManager(self)
if not hasattr(self, "_autostart_tasks"):
self._autostart_tasks = {}
self.__check_instance_classes() self.__check_instance_classes()
def __setattr__(self, __name: str, __value: Any) -> None: def __setattr__(self, __name: str, __value: Any) -> None:
@@ -74,7 +69,7 @@ class DataService(AbstractDataService):
if not issubclass( if not issubclass(
value_class, value_class,
(int | float | bool | str | list | dict | Enum | u.Quantity | Observable), (int | float | bool | str | list | dict | Enum | u.Quantity | Observable),
): ) and not is_descriptor(__value):
logger.warning( logger.warning(
"Class '%s' does not inherit from DataService. This may lead to" "Class '%s' does not inherit from DataService. This may lead to"
" unexpected behaviour!", " unexpected behaviour!",

View File

@@ -8,7 +8,10 @@ from pydase.observer_pattern.observable.observable_object import ObservableObjec
from pydase.observer_pattern.observer.property_observer import ( from pydase.observer_pattern.observer.property_observer import (
PropertyObserver, PropertyObserver,
) )
from pydase.utils.helpers import get_object_attr_from_path from pydase.utils.helpers import (
get_object_attr_from_path,
normalize_full_access_path_string,
)
from pydase.utils.serialization.serializer import ( from pydase.utils.serialization.serializer import (
SerializationPathError, SerializationPathError,
SerializedObject, SerializedObject,
@@ -53,7 +56,7 @@ class DataServiceObserver(PropertyObserver):
cached_value = cached_value_dict.get("value") cached_value = cached_value_dict.get("value")
if ( if (
all(part[0] != "_" for part in full_access_path.split(".")) all(part[0] != "_" for part in full_access_path.split("."))
and cached_value != value and cached_value != dump(value)["value"]
): ):
logger.debug("'%s' changed to '%s'", full_access_path, value) logger.debug("'%s' changed to '%s'", full_access_path, value)
@@ -99,7 +102,8 @@ class DataServiceObserver(PropertyObserver):
) )
def _notify_dependent_property_changes(self, changed_attr_path: str) -> None: def _notify_dependent_property_changes(self, changed_attr_path: str) -> None:
changed_props = self.property_deps_dict.get(changed_attr_path, []) normalized_attr_path = normalize_full_access_path_string(changed_attr_path)
changed_props = self.property_deps_dict.get(normalized_attr_path, [])
for prop in changed_props: for prop in changed_props:
# only notify about changing attribute if it is not currently being # only notify about changing attribute if it is not currently being
# "changed" e.g. when calling the getter of a property within another # "changed" e.g. when calling the getter of a property within another
@@ -124,8 +128,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

@@ -1,225 +0,0 @@
from __future__ import annotations
import asyncio
import inspect
import logging
from enum import Enum
from typing import TYPE_CHECKING, Any
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.utils.helpers import (
function_has_arguments,
get_class_and_instance_attributes,
is_property_attribute,
)
if TYPE_CHECKING:
from collections.abc import Callable
from .data_service import DataService
logger = logging.getLogger(__name__)
class TaskStatus(Enum):
RUNNING = "running"
class TaskManager:
"""
The TaskManager class is a utility designed to manage asynchronous tasks. It
provides functionality for starting, stopping, and tracking these tasks. The class
is primarily used by the DataService class to manage its tasks.
A task in TaskManager is any asynchronous function. To add a task, you simply need
to define an async function within your class that extends TaskManager. For example:
```python
class MyService(DataService):
async def my_task(self):
# Your task implementation here
pass
```
With the above definition, TaskManager automatically creates `start_my_task` and
`stop_my_task` methods that can be used to control the task.
TaskManager also supports auto-starting tasks. If there are tasks that should start
running as soon as an instance of your class is created, you can define them in
`self._autostart_tasks` in your class constructor (__init__ method). Here's how:
```python
class MyService(DataService):
def __init__(self):
self._autostart_tasks = {
"my_task": (*args) # Replace with actual arguments
}
self.wait_time = 1
super().__init__()
async def my_task(self, *args):
while True:
# Your task implementation here
await asyncio.sleep(self.wait_time)
```
In the above example, `my_task` will start running as soon as
`_start_autostart_tasks` is called which is done when the DataService instance is
passed to the `pydase.Server` class.
The responsibilities of the TaskManager class are:
- Track all running tasks: Keeps track of all the tasks that are currently running.
This allows for monitoring of task statuses and for making sure tasks do not
overlap.
- Provide the ability to start and stop tasks: Automatically creates methods to
start and stop each task.
- Emit notifications when the status of a task changes: Has a built-in mechanism for
emitting notifications when a task starts or stops. This is used to update the user
interfaces, but can also be used to write logs, etc.
"""
def __init__(self, service: DataService) -> None:
self.service = service
self.tasks: dict[str, asyncio.Task[None]] = {}
"""A dictionary to keep track of running tasks. The keys are the names of the
tasks and the values are TaskDict instances which include the task itself and
its kwargs.
"""
self._set_start_and_stop_for_async_methods()
@property
def _loop(self) -> asyncio.AbstractEventLoop:
return asyncio.get_running_loop()
def _set_start_and_stop_for_async_methods(self) -> None:
for name in dir(self.service):
# circumvents calling properties
if is_property_attribute(self.service, name):
continue
method = getattr(self.service, name)
if inspect.iscoroutinefunction(method):
if function_has_arguments(method):
logger.info(
"Async function %a is defined with at least one argument. If "
"you want to use it as a task, remove the argument(s) from the "
"function definition.",
method.__name__,
)
continue
# create start and stop methods for each coroutine
setattr(
self.service, f"start_{name}", self._make_start_task(name, method)
)
setattr(self.service, f"stop_{name}", self._make_stop_task(name))
def _initiate_task_startup(self) -> None:
if self.service._autostart_tasks is not None:
for service_name, args in self.service._autostart_tasks.items():
start_method = getattr(self.service, f"start_{service_name}", None)
if start_method is not None and callable(start_method):
start_method(*args)
else:
logger.warning(
"No start method found for service '%s'", service_name
)
def start_autostart_tasks(self) -> None:
self._initiate_task_startup()
attrs = get_class_and_instance_attributes(self.service)
for attr_value in attrs.values():
if isinstance(attr_value, AbstractDataService):
attr_value._task_manager.start_autostart_tasks()
elif isinstance(attr_value, list):
for item in attr_value:
if isinstance(item, AbstractDataService):
item._task_manager.start_autostart_tasks()
def _make_stop_task(self, name: str) -> Callable[..., Any]:
"""
Factory function to create a 'stop_task' function for a running task.
The generated function cancels the associated asyncio task using 'name' for
identification, ensuring proper cleanup. Avoids closure and late binding issues.
Args:
name (str): The name of the coroutine task, used for its identification.
"""
def stop_task() -> None:
# cancel the task
task = self.tasks.get(name, None)
if task is not None:
self._loop.call_soon_threadsafe(task.cancel)
return stop_task
def _make_start_task(
self, name: str, method: Callable[..., Any]
) -> Callable[..., Any]:
"""
Factory function to create a 'start_task' function for a coroutine.
The generated function starts the coroutine as an asyncio task, handling
registration and monitoring.
It uses 'name' and 'method' to avoid the closure and late binding issue.
Args:
name (str): The name of the coroutine, used for task management.
method (callable): The coroutine to be turned into an asyncio task.
"""
def start_task() -> None:
def task_done_callback(task: asyncio.Task[None], name: str) -> None:
"""Handles tasks that have finished.
Removes a task from the tasks dictionary, calls the defined
callbacks, and logs and re-raises exceptions."""
# removing the finished task from the tasks i
self.tasks.pop(name, None)
# emit the notification that the task was stopped
self.service._notify_changed(name, None)
exception = task.exception()
if exception is not None:
# Handle the exception, or you can re-raise it.
logger.error(
"Task '%s' encountered an exception: %s: %s",
name,
type(exception).__name__,
exception,
)
raise exception
async def task() -> None:
try:
await method()
except asyncio.CancelledError:
logger.info("Task '%s' was cancelled", name)
if not self.tasks.get(name):
# creating the task and adding the task_done_callback which checks
# if an exception has occured during the task execution
task_object = self._loop.create_task(task())
task_object.add_done_callback(
lambda task: task_done_callback(task, name)
)
# Store the task and its arguments in the '__tasks' dictionary. The
# key is the name of the method, and the value is a dictionary
# containing the task object and the updated keyword arguments.
self.tasks[name] = task_object
# emit the notification that the task was started
self.service._notify_changed(name, TaskStatus.RUNNING)
else:
logger.error("Task '%s' is already running!", name)
return start_task

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="description" content="Web site displaying a pydase UI." /> <meta name="description" content="Web site displaying a pydase UI." />
<script type="module" crossorigin src="/assets/index-D7tStNHJ.js"></script> <script type="module" crossorigin src="/assets/index-BjsjosWf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css"> <link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
</head> </head>

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

@@ -6,7 +6,7 @@ from pydase.observer_pattern.observable.decorators import (
has_validate_set_decorator, has_validate_set_decorator,
) )
from pydase.observer_pattern.observable.observable_object import ObservableObject from pydase.observer_pattern.observable.observable_object import ObservableObject
from pydase.utils.helpers import is_property_attribute from pydase.utils.helpers import is_descriptor, is_property_attribute
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,7 +22,9 @@ class Observable(ObservableObject):
- {"__annotations__"} - {"__annotations__"}
} }
for name, value in class_attrs.items(): for name, value in class_attrs.items():
if isinstance(value, property) or callable(value): if isinstance(value, property) or callable(value) or is_descriptor(value):
# Properties, methods and descriptors have to be stored as class
# attributes to work properly. So don't make it an instance attribute.
continue continue
self.__dict__[name] = self._initialise_new_objects(name, value) self.__dict__[name] = self._initialise_new_objects(name, value)

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

@@ -5,6 +5,7 @@ from typing import Any
from pydase.observer_pattern.observable.observable import Observable from pydase.observer_pattern.observable.observable import Observable
from pydase.observer_pattern.observer.observer import Observer from pydase.observer_pattern.observer.observer import Observer
from pydase.utils.helpers import is_descriptor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,18 +61,28 @@ class PropertyObserver(Observer):
def _process_nested_observables_properties( def _process_nested_observables_properties(
self, obj: Observable, deps: dict[str, Any], prefix: str self, obj: Observable, deps: dict[str, Any], prefix: str
) -> None: ) -> None:
for k, value in vars(obj).items(): for k, value in {**vars(type(obj)), **vars(obj)}.items():
actual_value = value
prefix = ( prefix = (
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
) )
parent_path = f"{prefix}{k}" parent_path = f"{prefix}{k}"
if isinstance(value, Observable):
# Get value from descriptor
if not isinstance(value, property) and is_descriptor(value):
actual_value = getattr(obj, k)
if isinstance(actual_value, Observable):
new_prefix = f"{parent_path}." new_prefix = f"{parent_path}."
deps.update( deps.update(
self._get_properties_and_their_dependencies(value, new_prefix) self._get_properties_and_their_dependencies(
actual_value, new_prefix
)
) )
elif isinstance(value, list | dict): elif isinstance(value, list | dict):
self._process_collection_item_properties(value, deps, parent_path) self._process_collection_item_properties(
actual_value, deps, parent_path
)
def _process_collection_item_properties( def _process_collection_item_properties(
self, self,

View File

@@ -13,6 +13,8 @@ from pydase.config import ServiceConfig
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager
from pydase.server.web_server import WebServer from pydase.server.web_server import WebServer
from pydase.task.autostart import autostart_service_tasks
from pydase.utils.helpers import current_event_loop_exists
HANDLED_SIGNALS = ( HANDLED_SIGNALS = (
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C. signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
@@ -35,18 +37,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 +66,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 +84,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 +137,8 @@ class Server:
) )
server.run() server.run()
``` ```
**kwargs: Any **kwargs:
Additional keyword arguments. Additional keyword arguments.
""" """
def __init__( # noqa: PLR0913 def __init__( # noqa: PLR0913
@@ -166,13 +158,18 @@ class Server:
self._web_port = web_port self._web_port = web_port
self._enable_web = enable_web self._enable_web = enable_web
self._kwargs = kwargs self._kwargs = kwargs
self._loop: asyncio.AbstractEventLoop
self._additional_servers = additional_servers self._additional_servers = additional_servers
self.should_exit = False self.should_exit = False
self.servers: dict[str, asyncio.Future[Any]] = {} self.servers: dict[str, asyncio.Future[Any]] = {}
self._state_manager = StateManager(self._service, filename) self._state_manager = StateManager(self._service, filename)
self._observer = DataServiceObserver(self._state_manager) self._observer = DataServiceObserver(self._state_manager)
self._state_manager.load_state() self._state_manager.load_state()
autostart_service_tasks(self._service)
if not current_event_loop_exists():
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
else:
self._loop = asyncio.get_event_loop()
def run(self) -> None: def run(self) -> None:
""" """
@@ -180,7 +177,7 @@ class Server:
This method should be called to start the server after it's been instantiated. This method should be called to start the server after it's been instantiated.
""" """
asyncio.run(self.serve()) self._loop.run_until_complete(self.serve())
async def serve(self) -> None: async def serve(self) -> None:
process_id = os.getpid() process_id = os.getpid()
@@ -196,10 +193,8 @@ class Server:
logger.info("Finished server process [%s]", process_id) logger.info("Finished server process [%s]", process_id)
async def startup(self) -> None: async def startup(self) -> None:
self._loop = asyncio.get_running_loop()
self._loop.set_exception_handler(self.custom_exception_handler) self._loop.set_exception_handler(self.custom_exception_handler)
self.install_signal_handlers() self.install_signal_handlers()
self._service._task_manager.start_autostart_tasks()
for server in self._additional_servers: for server in self._additional_servers:
addin_server = server["server"]( addin_server = server["server"](
@@ -214,7 +209,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 +220,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

View File

@@ -0,0 +1,46 @@
from typing import Any
import pydase.data_service.data_service
import pydase.task.task
from pydase.task.task_status import TaskStatus
from pydase.utils.helpers import is_property_attribute
def autostart_service_tasks(
service: pydase.data_service.data_service.DataService,
) -> None:
"""Starts the service tasks defined with the `autostart` keyword argument.
This method goes through the attributes of the passed service and its nested
[`DataService`][pydase.DataService] instances and calls the start method on
autostart-tasks.
"""
for attr in dir(service):
if is_property_attribute(service, attr) or attr in {
"_observers",
"__dict__",
}: # prevent eval of property attrs and recursion
continue
val = getattr(service, attr)
if isinstance(val, pydase.task.task.Task):
if val.autostart and val.status == TaskStatus.NOT_RUNNING:
val.start()
else:
continue
else:
autostart_nested_service_tasks(val)
def autostart_nested_service_tasks(
service: pydase.data_service.data_service.DataService | list[Any] | dict[Any, Any],
) -> None:
if isinstance(service, pydase.DataService):
autostart_service_tasks(service)
elif isinstance(service, list):
for entry in service:
autostart_nested_service_tasks(entry)
elif isinstance(service, dict):
for entry in service.values():
autostart_nested_service_tasks(entry)

View File

@@ -0,0 +1,145 @@
import logging
from collections.abc import Callable, Coroutine
from typing import Any, Generic, TypeVar, overload
from pydase.data_service.data_service import DataService
from pydase.task.task import Task
logger = logging.getLogger(__name__)
R = TypeVar("R")
class PerInstanceTaskDescriptor(Generic[R]):
"""
A descriptor class that provides a unique [`Task`][pydase.task.task.Task] object
for each instance of a [`DataService`][pydase.data_service.data_service.DataService]
class.
The `PerInstanceTaskDescriptor` is used to transform an asynchronous function into a
task that is managed independently for each instance of a `DataService` subclass.
This allows tasks to be initialized, started, and stopped on a per-instance basis,
providing better control over task execution within the service.
The `PerInstanceTaskDescriptor` is not intended to be used directly. Instead, it is
used internally by the `@task` decorator to manage task objects for each instance of
the service class.
"""
def __init__(
self,
func: Callable[[Any], Coroutine[None, None, R]]
| Callable[[], Coroutine[None, None, R]],
autostart: bool = False,
) -> None:
self.__func = func
self.__autostart = autostart
self.__task_instances: dict[object, Task[R]] = {}
def __set_name__(self, owner: type[DataService], name: str) -> None:
"""Stores the name of the task within the owning class. This method is called
automatically when the descriptor is assigned to a class attribute.
"""
self.__task_name = name
@overload
def __get__(
self, instance: None, owner: type[DataService]
) -> "PerInstanceTaskDescriptor[R]":
"""Returns the descriptor itself when accessed through the class."""
@overload
def __get__(self, instance: DataService, owner: type[DataService]) -> Task[R]:
"""Returns the `Task` object associated with the specific `DataService`
instance.
If no task exists for the instance, a new `Task` object is created and stored
in the `__task_instances` dictionary.
"""
def __get__(
self, instance: DataService | None, owner: type[DataService]
) -> "Task[R] | PerInstanceTaskDescriptor[R]":
if instance is None:
return self
# Create a new Task object for this instance, using the function's name.
if instance not in self.__task_instances:
self.__task_instances[instance] = instance._initialise_new_objects(
self.__task_name,
Task(self.__func.__get__(instance, owner), autostart=self.__autostart),
)
return self.__task_instances[instance]
def task(
*, autostart: bool = False
) -> Callable[
[
Callable[[Any], Coroutine[None, None, R]]
| Callable[[], Coroutine[None, None, R]]
],
PerInstanceTaskDescriptor[R],
]:
"""
A decorator to define an asynchronous function as a per-instance task within a
[`DataService`][pydase.DataService] class.
This decorator transforms an asynchronous function into a
[`Task`][pydase.task.task.Task] object that is unique to each instance of the
`DataService` class. The resulting `Task` object provides methods like `start()`
and `stop()` to control the execution of the task, and manages the task's lifecycle
independently for each instance of the service.
The decorator is particularly useful for defining tasks that need to run
periodically or perform asynchronous operations, such as polling data sources,
updating databases, or any recurring job that should be managed within the context
of a `DataService`.
time.
Args:
autostart:
If set to True, the task will automatically start when the service is
initialized. Defaults to False.
Returns:
A decorator that wraps an asynchronous function in a
[`PerInstanceTaskDescriptor`][pydase.task.decorator.PerInstanceTaskDescriptor]
object, which, when accessed, provides an instance-specific
[`Task`][pydase.task.task.Task] object.
Example:
```python
import asyncio
import pydase
from pydase.task.decorator import task
class MyService(pydase.DataService):
@task(autostart=True)
async def my_task(self) -> None:
while True:
# Perform some periodic work
await asyncio.sleep(1)
if __name__ == "__main__":
service = MyService()
pydase.Server(service=service).run()
```
In this example, `my_task` is defined as a task using the `@task` decorator, and
it will start automatically when the service is initialized because
`autostart=True` is set. You can manually start or stop the task using
`service.my_task.start()` and `service.my_task.stop()`, respectively.
"""
def decorator(
func: Callable[[Any], Coroutine[None, None, R]]
| Callable[[], Coroutine[None, None, R]],
) -> PerInstanceTaskDescriptor[R]:
return PerInstanceTaskDescriptor(func, autostart=autostart)
return decorator

148
src/pydase/task/task.py Normal file
View File

@@ -0,0 +1,148 @@
import asyncio
import inspect
import logging
from collections.abc import Callable, Coroutine
from typing import (
Generic,
TypeVar,
)
import pydase.data_service.data_service
from pydase.task.task_status import TaskStatus
from pydase.utils.helpers import current_event_loop_exists
logger = logging.getLogger(__name__)
R = TypeVar("R")
class Task(pydase.data_service.data_service.DataService, Generic[R]):
"""A class representing a task within the `pydase` framework.
The `Task` class wraps an asynchronous function and provides methods to manage its
lifecycle, such as `start()` and `stop()`. It is typically used to perform periodic
or recurring jobs in a [`DataService`][pydase.DataService], like reading
sensor data, updating databases, or executing other background tasks.
When a function is decorated with the [`@task`][pydase.task.decorator.task]
decorator, it is replaced by a `Task` instance that controls the execution of the
original function.
Args:
func:
The asynchronous function that this task wraps. It must be a coroutine
without arguments.
autostart:
If set to True, the task will automatically start when the service is
initialized. Defaults to False.
Example:
```python
import asyncio
import pydase
from pydase.task.decorator import task
class MyService(pydase.DataService):
@task(autostart=True)
async def my_task(self) -> None:
while True:
# Perform some periodic work
await asyncio.sleep(1)
if __name__ == "__main__":
service = MyService()
pydase.Server(service=service).run()
```
In this example, `my_task` is defined as a task using the `@task` decorator, and
it will start automatically when the service is initialized because
`autostart=True` is set. You can manually start or stop the task using
`service.my_task.start()` and `service.my_task.stop()`, respectively.
"""
def __init__(
self,
func: Callable[[], Coroutine[None, None, R | None]],
*,
autostart: bool = False,
) -> None:
super().__init__()
self._autostart = autostart
self._func_name = func.__name__
self._func = func
self._task: asyncio.Task[R | None] | None = None
self._status = TaskStatus.NOT_RUNNING
self._result: R | None = None
if not current_event_loop_exists():
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
else:
self._loop = asyncio.get_event_loop()
@property
def autostart(self) -> bool:
"""Defines if the task should be started automatically when the
[`Server`][pydase.Server] starts."""
return self._autostart
@property
def status(self) -> TaskStatus:
"""Returns the current status of the task."""
return self._status
def start(self) -> None:
"""Starts the asynchronous task if it is not already running."""
if self._task:
return
def task_done_callback(task: asyncio.Task[R | None]) -> None:
"""Handles tasks that have finished.
Updates the task status, calls the defined callbacks, and logs and re-raises
exceptions.
"""
self._task = None
self._status = TaskStatus.NOT_RUNNING
exception = task.exception()
if exception is not None:
# Handle the exception, or you can re-raise it.
logger.error(
"Task '%s' encountered an exception: %s: %s",
self._func_name,
type(exception).__name__,
exception,
)
raise exception
self._result = task.result()
async def run_task() -> R | None:
if inspect.iscoroutinefunction(self._func):
logger.info("Starting task %r", self._func_name)
self._status = TaskStatus.RUNNING
res: Coroutine[None, None, R | None] = self._func()
try:
return await res
except asyncio.CancelledError:
logger.info("Task '%s' was cancelled", self._func_name)
return None
logger.warning(
"Cannot start task %r. Function has not been bound yet", self._func_name
)
return None
logger.info("Creating task %r", self._func_name)
self._task = self._loop.create_task(run_task())
self._task.add_done_callback(task_done_callback)
def stop(self) -> None:
"""Stops the running asynchronous task by cancelling it."""
if self._task:
self._task.cancel()

View File

@@ -0,0 +1,8 @@
import enum
class TaskStatus(enum.Enum):
"""Possible statuses of a [`Task`][pydase.task.task.Task]."""
RUNNING = "running"
NOT_RUNNING = "not_running"

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

@@ -114,8 +114,6 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
If an attribute exists at both the instance and class level,the value from the If an attribute exists at both the instance and class level,the value from the
instance attribute takes precedence. instance attribute takes precedence.
The __root__ object is removed as this will lead to endless recursion in the for
loops.
""" """
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items())) return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
@@ -162,6 +160,12 @@ def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
return get_object_by_path_parts(target_obj, path_parts) return get_object_by_path_parts(target_obj, path_parts)
def get_task_class() -> type:
from pydase.task.task import Task
return Task
def get_component_classes() -> list[type]: def get_component_classes() -> list[type]:
""" """
Returns references to the component classes in a list. Returns references to the component classes in a list.
@@ -196,3 +200,48 @@ def function_has_arguments(func: Callable[..., Any]) -> bool:
# Check if there are any parameters left which would indicate additional arguments. # Check if there are any parameters left which would indicate additional arguments.
return len(parameters) > 0 return len(parameters) > 0
def is_descriptor(obj: object) -> bool:
"""Check if an object is a descriptor."""
# Exclude functions, methods, builtins and properties
if (
inspect.isfunction(obj)
or inspect.ismethod(obj)
or inspect.isbuiltin(obj)
or isinstance(obj, property)
):
return False
# Check if it has any descriptor methods
return any(hasattr(obj, method) for method in ("__get__", "__set__", "__delete__"))
def current_event_loop_exists() -> bool:
"""Check if an event loop has been set."""
import asyncio
return asyncio.get_event_loop_policy()._local._loop is not None # type: ignore
def normalize_full_access_path_string(s: str) -> str:
"""Normalizes a string representing a full access path by converting double quotes
to single quotes.
This function is useful for ensuring consistency in strings that represent access
paths containing dictionary keys, by replacing all double quotes (`"`) with single
quotes (`'`).
Args:
s (str): The input string to be normalized.
Returns:
A new string with all double quotes replaced by single quotes.
Example:
>>> normalize_full_access_path_string('dictionary["first"].my_task')
"dictionary['first'].my_task"
"""
return s.replace('"', "'")

View File

@@ -6,7 +6,9 @@ from typing import TYPE_CHECKING, Any, NoReturn, cast
import pydase import pydase
import pydase.components import pydase.components
import pydase.units as u import pydase.units as u
from pydase.utils.helpers import get_component_classes from pydase.utils.helpers import (
get_component_classes,
)
from pydase.utils.serialization.types import ( from pydase.utils.serialization.types import (
SerializedDatetime, SerializedDatetime,
SerializedException, SerializedException,
@@ -23,6 +25,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,
@@ -48,9 +51,9 @@ class Deserializer:
return handler(serialized_object) return handler(serialized_object)
# Custom types like Components or DataService classes # Custom types like Components or DataService classes
component_class = cls.get_component_class(serialized_object["type"]) service_base_class = cls.get_service_base_class(serialized_object["type"])
if component_class: if service_base_class:
return cls.deserialize_component_type(serialized_object, component_class) return cls.deserialize_data_service(serialized_object, service_base_class)
return None return None
@@ -109,11 +112,11 @@ class Deserializer:
raise exception(serialized_object["value"]) raise exception(serialized_object["value"])
@staticmethod @staticmethod
def get_component_class(type_name: str | None) -> type | None: def get_service_base_class(type_name: str | None) -> type | None:
for component_class in get_component_classes(): for component_class in get_component_classes():
if type_name == component_class.__name__: if type_name == component_class.__name__:
return component_class return component_class
if type_name == "DataService": if type_name in ("DataService", "Task"):
import pydase import pydase
return pydase.DataService return pydase.DataService
@@ -136,7 +139,7 @@ class Deserializer:
return property(get, set) return property(get, set)
@classmethod @classmethod
def deserialize_component_type( def deserialize_data_service(
cls, serialized_object: SerializedObject, base_class: type cls, serialized_object: SerializedObject, base_class: type
) -> Any: ) -> Any:
def create_proxy_class(serialized_object: SerializedObject) -> type: def create_proxy_class(serialized_object: SerializedObject) -> type:
@@ -159,4 +162,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

@@ -9,12 +9,14 @@ from typing import TYPE_CHECKING, Any, Literal, cast
import pydase.units as u import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.task_manager import TaskStatus from pydase.task.task_status import TaskStatus
from pydase.utils.decorators import render_in_frontend from pydase.utils.decorators import render_in_frontend
from pydase.utils.helpers import ( from pydase.utils.helpers import (
get_attribute_doc, get_attribute_doc,
get_component_classes, get_component_classes,
get_data_service_class_reference, get_data_service_class_reference,
get_task_class,
is_property_attribute,
parse_full_access_path, parse_full_access_path,
parse_serialized_key, parse_serialized_key,
) )
@@ -52,8 +54,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):
@@ -261,6 +282,10 @@ class Serializer:
if component_base_cls: if component_base_cls:
obj_type = component_base_cls.__name__ # type: ignore obj_type = component_base_cls.__name__ # type: ignore
elif isinstance(obj, get_task_class()):
# Check if obj is a pydase task
obj_type = "Task"
# Get the set of DataService class attributes # Get the set of DataService class attributes
data_service_attr_set = set(dir(get_data_service_class_reference())) data_service_attr_set = set(dir(get_data_service_class_reference()))
# Get the set of the object attributes # Get the set of the object attributes
@@ -275,29 +300,15 @@ class Serializer:
if key.startswith("_"): if key.startswith("_"):
continue # Skip attributes that start with underscore continue # Skip attributes that start with underscore
# Skip keys that start with "start_" or "stop_" and end with an async
# method name
if key.startswith(("start_", "stop_")) and key.split("_", 1)[1] in {
name
for name, _ in inspect.getmembers(
obj, predicate=inspect.iscoroutinefunction
)
}:
continue
val = getattr(obj, key) val = getattr(obj, key)
path = f"{access_path}.{key}" if access_path else key path = f"{access_path}.{key}" if access_path else key
serialized_object = cls.serialize_object(val, access_path=path) serialized_object = cls.serialize_object(val, access_path=path)
# If there's a running task for this method
if serialized_object["type"] == "method" and key in obj._task_manager.tasks:
serialized_object["value"] = TaskStatus.RUNNING.name
value[key] = serialized_object value[key] = serialized_object
# If the DataService attribute is a property # If the DataService attribute is a property
if isinstance(getattr(obj.__class__, key, None), property): if is_property_attribute(obj, key):
prop: property = getattr(obj.__class__, key) prop: property = getattr(obj.__class__, key)
value[key]["readonly"] = prop.fset is None value[key]["readonly"] = prop.fset is None
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
@@ -313,6 +324,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 +345,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 +359,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 +463,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 +508,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 +554,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 +577,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

@@ -98,7 +98,9 @@ class SerializedException(SerializedObjectBase):
type: Literal["Exception"] type: Literal["Exception"]
DataServiceTypes = Literal["DataService", "Image", "NumberSlider", "DeviceConnection"] DataServiceTypes = Literal[
"DataService", "Image", "NumberSlider", "DeviceConnection", "Task"
]
class SerializedDataService(SerializedObjectBase): class SerializedDataService(SerializedObjectBase):
@@ -123,3 +125,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
"""

View File

@@ -3,6 +3,7 @@ import asyncio
import pydase import pydase
import pydase.components.device_connection import pydase.components.device_connection
import pytest import pytest
from pydase.task.autostart import autostart_service_tasks
from pytest import LogCaptureFixture from pytest import LogCaptureFixture
@@ -19,10 +20,9 @@ async def test_reconnection(caplog: LogCaptureFixture) -> None:
self._connected = True self._connected = True
service_instance = MyService() service_instance = MyService()
autostart_service_tasks(service_instance)
assert service_instance._connected is False assert service_instance._connected is False
service_instance._task_manager.start_autostart_tasks()
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
assert service_instance._connected is True assert service_instance._connected is True

View File

@@ -36,8 +36,7 @@ def test_unexpected_type_change_warning(caplog: LogCaptureFixture) -> None:
def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None: def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
class SubService(DataService): class SubService(DataService): ...
...
class SomeEnum(Enum): class SomeEnum(Enum):
HI = 0 HI = 0
@@ -57,11 +56,9 @@ def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
def name(self) -> str: def name(self) -> str:
return self._name return self._name
def some_method(self) -> None: def some_method(self) -> None: ...
...
async def some_task(self) -> None: async def some_task(self) -> None: ...
...
ServiceClass() ServiceClass()
@@ -129,17 +126,12 @@ def test_exposing_methods(caplog: LogCaptureFixture) -> None:
return "some method" return "some method"
class ClassWithTask(pydase.DataService): class ClassWithTask(pydase.DataService):
async def some_task(self, sleep_time: int) -> None: @frontend
pass def some_method(self) -> str:
return "some method"
ClassWithTask() ClassWithTask()
assert (
"Async function 'some_task' is defined with at least one argument. If you want "
"to use it as a task, remove the argument(s) from the function definition."
in caplog.text
)
def test_dynamically_added_attribute(caplog: LogCaptureFixture) -> None: def test_dynamically_added_attribute(caplog: LogCaptureFixture) -> None:
class MyService(DataService): class MyService(DataService):

View File

@@ -1,7 +1,6 @@
import logging import logging
import pydase import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager
@@ -33,35 +32,3 @@ def test_nested_attributes_cache_callback() -> None:
] ]
== "Ciao" == "Ciao"
) )
@pytest.mark.asyncio(scope="function")
async def test_task_status_update() -> None:
class ServiceClass(pydase.DataService):
name = "World"
async def my_method(self) -> None:
pass
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert (
state_manager.cache_manager.get_value_dict_from_cache("my_method")["type"]
== "method"
)
assert (
state_manager.cache_manager.get_value_dict_from_cache("my_method")["value"]
is None
)
service_instance.start_my_method() # type: ignore
assert (
state_manager.cache_manager.get_value_dict_from_cache("my_method")["type"]
== "method"
)
assert (
state_manager.cache_manager.get_value_dict_from_cache("my_method")["value"]
== "RUNNING"
)

View File

@@ -5,7 +5,7 @@ import pydase
import pytest import pytest
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager
from pydase.utils.serialization.serializer import SerializationError from pydase.utils.serialization.serializer import SerializationError, dump
logger = logging.getLogger() logger = logging.getLogger()
@@ -146,3 +146,41 @@ def test_private_attribute_does_not_have_to_be_serializable() -> None:
service_instance.change_publ_attr() service_instance.change_publ_attr()
service_instance.change_priv_attr() service_instance.change_priv_attr()
def test_normalized_attr_path_in_dependent_property_changes(
caplog: pytest.LogCaptureFixture,
) -> None:
class SubService(pydase.DataService):
_prop = 10.0
@property
def prop(self) -> float:
return self._prop
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.service_dict = {"one": SubService()}
service_instance = MyService()
state_manager = StateManager(service=service_instance)
observer = DataServiceObserver(state_manager=state_manager)
assert observer.property_deps_dict["service_dict['one']._prop"] == [
"service_dict['one'].prop"
]
# We can use dict key path encoded with double quotes
state_manager.set_service_attribute_value_by_path(
'service_dict["one"]._prop', dump(11.0)
)
assert service_instance.service_dict["one"].prop == 11.0
assert "'service_dict[\"one\"].prop' changed to '11.0'" in caplog.text
# We can use dict key path encoded with single quotes
state_manager.set_service_attribute_value_by_path(
"service_dict['one']._prop", dump(12.0)
)
assert service_instance.service_dict["one"].prop == 12.0
assert "'service_dict[\"one\"].prop' changed to '12.0'" in caplog.text

View File

@@ -1,135 +0,0 @@
import asyncio
import logging
import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
logger = logging.getLogger("pydase")
@pytest.mark.asyncio(scope="function")
async def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._autostart_tasks = { # type: ignore
"my_task": (), # type: ignore
"my_other_task": (), # type: ignore
}
async def my_task(self) -> None:
logger.info("Triggered task.")
async def my_other_task(self) -> None:
logger.info("Triggered other task.")
# Your test code here
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks()
assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "'my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
@pytest.mark.asyncio(scope="function")
async def test_DataService_subclass_autostart_task_callback(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._autostart_tasks = { # type: ignore
"my_task": (),
"my_other_task": (),
}
async def my_task(self) -> None:
logger.info("Triggered task.")
async def my_other_task(self) -> None:
logger.info("Triggered other task.")
class MyService(pydase.DataService):
sub_service = MySubService()
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks()
assert "'sub_service.my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "'sub_service.my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
@pytest.mark.asyncio(scope="function")
async def test_DataService_subclass_list_autostart_task_callback(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._autostart_tasks = { # type: ignore
"my_task": (),
"my_other_task": (),
}
async def my_task(self) -> None:
logger.info("Triggered task.")
async def my_other_task(self) -> None:
logger.info("Triggered other task.")
class MyService(pydase.DataService):
sub_services_list = [MySubService() for i in range(2)]
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks()
assert (
"'sub_services_list[0].my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
)
assert (
"'sub_services_list[0].my_other_task' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
assert (
"'sub_services_list[1].my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
)
assert (
"'sub_services_list[1].my_other_task' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
@pytest.mark.asyncio(scope="function")
async def test_start_and_stop_task_methods(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
async def my_task(self) -> None:
while True:
logger.debug("Logging message")
await asyncio.sleep(0.1)
# Your test code here
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.start_my_task()
await asyncio.sleep(0.01)
assert "'my_task' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "Logging message" in caplog.text
caplog.clear()
service_instance.stop_my_task()
await asyncio.sleep(0.01)
assert "Task 'my_task' was cancelled" in caplog.text

291
tests/task/test_task.py Normal file
View File

@@ -0,0 +1,291 @@
import asyncio
import logging
import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.task.autostart import autostart_service_tasks
from pydase.task.decorator import task
from pydase.task.task_status import TaskStatus
from pytest import LogCaptureFixture
logger = logging.getLogger("pydase")
@pytest.mark.asyncio(scope="function")
async def test_start_and_stop_task(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
@task()
async def my_task(self) -> None:
logger.info("Triggered task.")
while True:
await asyncio.sleep(1)
# Your test code here
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
autostart_service_tasks(service_instance)
await asyncio.sleep(0.1)
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
service_instance.my_task.start()
await asyncio.sleep(0.1)
assert service_instance.my_task.status == TaskStatus.RUNNING
assert "'my_task.status' changed to 'TaskStatus.RUNNING'" in caplog.text
assert "Triggered task." in caplog.text
caplog.clear()
service_instance.my_task.stop()
await asyncio.sleep(0.1)
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
assert "Task 'my_task' was cancelled" in caplog.text
@pytest.mark.asyncio(scope="function")
async def test_autostart_task(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
@task(autostart=True)
async def my_task(self) -> None:
logger.info("Triggered task.")
while True:
await asyncio.sleep(1)
# Your test code here
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
autostart_service_tasks(service_instance)
await asyncio.sleep(0.1)
assert service_instance.my_task.status == TaskStatus.RUNNING
assert "'my_task.status' changed to 'TaskStatus.RUNNING'" in caplog.text
@pytest.mark.asyncio(scope="function")
async def test_nested_list_autostart_task(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
@task(autostart=True)
async def my_task(self) -> None:
logger.info("Triggered task.")
while True:
await asyncio.sleep(1)
class MyService(pydase.DataService):
sub_services_list = [MySubService() for i in range(2)]
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
autostart_service_tasks(service_instance)
await asyncio.sleep(0.1)
assert service_instance.sub_services_list[0].my_task.status == TaskStatus.RUNNING
assert service_instance.sub_services_list[1].my_task.status == TaskStatus.RUNNING
assert (
"'sub_services_list[0].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
assert (
"'sub_services_list[1].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
@pytest.mark.asyncio(scope="function")
async def test_nested_dict_autostart_task(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
@task(autostart=True)
async def my_task(self) -> None:
logger.info("Triggered task.")
while True:
await asyncio.sleep(1)
class MyService(pydase.DataService):
sub_services_dict = {"first": MySubService(), "second": MySubService()}
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
autostart_service_tasks(service_instance)
await asyncio.sleep(0.1)
assert (
service_instance.sub_services_dict["first"].my_task.status == TaskStatus.RUNNING
)
assert (
service_instance.sub_services_dict["second"].my_task.status
== TaskStatus.RUNNING
)
assert (
"'sub_services_dict[\"first\"].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
assert (
"'sub_services_dict[\"second\"].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
@pytest.mark.asyncio(scope="function")
async def test_manual_start_with_multiple_service_instances(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
@task()
async def my_task(self) -> None:
logger.info("Triggered task.")
while True:
await asyncio.sleep(1)
class MyService(pydase.DataService):
sub_services_list = [MySubService() for i in range(2)]
sub_services_dict = {"first": MySubService(), "second": MySubService()}
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
autostart_service_tasks(service_instance)
await asyncio.sleep(0.1)
assert (
service_instance.sub_services_list[0].my_task.status == TaskStatus.NOT_RUNNING
)
assert (
service_instance.sub_services_list[1].my_task.status == TaskStatus.NOT_RUNNING
)
assert (
service_instance.sub_services_dict["first"].my_task.status
== TaskStatus.NOT_RUNNING
)
assert (
service_instance.sub_services_dict["second"].my_task.status
== TaskStatus.NOT_RUNNING
)
service_instance.sub_services_list[0].my_task.start()
await asyncio.sleep(0.01)
assert service_instance.sub_services_list[0].my_task.status == TaskStatus.RUNNING
assert (
"'sub_services_list[0].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
assert (
"'sub_services_list[1].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_dict[\"first\"].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_dict[\"second\"].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
service_instance.sub_services_list[0].my_task.stop()
await asyncio.sleep(0.01)
assert "Task 'my_task' was cancelled" in caplog.text
caplog.clear()
service_instance.sub_services_list[1].my_task.start()
await asyncio.sleep(0.01)
assert service_instance.sub_services_list[1].my_task.status == TaskStatus.RUNNING
assert (
"'sub_services_list[0].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_list[1].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
assert (
"'sub_services_dict[\"first\"].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_dict[\"second\"].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
service_instance.sub_services_list[1].my_task.stop()
await asyncio.sleep(0.01)
assert "Task 'my_task' was cancelled" in caplog.text
caplog.clear()
service_instance.sub_services_dict["first"].my_task.start()
await asyncio.sleep(0.01)
assert (
service_instance.sub_services_dict["first"].my_task.status == TaskStatus.RUNNING
)
assert (
"'sub_services_list[0].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_list[1].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_dict[\"first\"].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
assert (
"'sub_services_dict[\"second\"].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
service_instance.sub_services_dict["first"].my_task.stop()
await asyncio.sleep(0.01)
assert "Task 'my_task' was cancelled" in caplog.text
caplog.clear()
service_instance.sub_services_dict["second"].my_task.start()
await asyncio.sleep(0.01)
assert (
service_instance.sub_services_dict["second"].my_task.status
== TaskStatus.RUNNING
)
assert (
"'sub_services_list[0].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_list[1].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_dict[\"first\"].my_task.status' changed to 'TaskStatus.RUNNING'"
not in caplog.text
)
assert (
"'sub_services_dict[\"second\"].my_task.status' changed to 'TaskStatus.RUNNING'"
in caplog.text
)
service_instance.sub_services_dict["second"].my_task.stop()
await asyncio.sleep(0.01)
assert "Task 'my_task' was cancelled" in caplog.text

View File

@@ -1,4 +1,3 @@
import asyncio
import enum import enum
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -8,7 +7,7 @@ import pydase
import pydase.units as u import pydase.units as u
import pytest import pytest
from pydase.components.coloured_enum import ColouredEnum from pydase.components.coloured_enum import ColouredEnum
from pydase.data_service.task_manager import TaskStatus from pydase.task.task_status import TaskStatus
from pydase.utils.decorators import frontend from pydase.utils.decorators import frontend
from pydase.utils.serialization.serializer import ( from pydase.utils.serialization.serializer import (
SerializationPathError, SerializationPathError,
@@ -214,11 +213,9 @@ async def test_method_serialization() -> None:
return "some method" return "some method"
async def some_task(self) -> None: async def some_task(self) -> None:
while True: pass
await asyncio.sleep(10)
instance = ClassWithMethod() instance = ClassWithMethod()
instance.start_some_task() # type: ignore
assert dump(instance)["value"] == { assert dump(instance)["value"] == {
"some_method": { "some_method": {
@@ -234,7 +231,7 @@ async def test_method_serialization() -> None:
"some_task": { "some_task": {
"full_access_path": "some_task", "full_access_path": "some_task",
"type": "method", "type": "method",
"value": TaskStatus.RUNNING.name, "value": None,
"readonly": True, "readonly": True,
"doc": None, "doc": None,
"async": True, "async": True,