Compare commits

..

No commits in common. "main" and "v0.8.3" have entirely different histories.
main ... v0.8.3

157 changed files with 23555 additions and 16411 deletions

View File

@ -18,10 +18,7 @@ Provide steps to reproduce the behaviour, including a minimal code snippet (if a
## Expected behaviour ## Expected behaviour
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
## Actual behaviour ## Screenshot/Video
Describe what you see instead of the expected behaviour.
### Screenshot/Video
If applicable, add visual content that helps explain your problem. If applicable, add visual content that helps explain your problem.
## Additional context ## Additional context

View File

@ -22,7 +22,7 @@ jobs:
- name: Build a binary wheel and a source tarball - name: Build a binary wheel and a source tarball
run: python3 -m build run: python3 -m build
- name: Store the distribution packages - name: Store the distribution packages
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
@ -44,7 +44,7 @@ jobs:
steps: steps:
- name: Download all the dists - name: Download all the dists
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
name: python-package-distributions name: python-package-distributions
path: dist/ path: dist/
@ -65,14 +65,14 @@ jobs:
steps: steps:
- name: Download all the dists - name: Download all the dists
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
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@v3.0.0 uses: sigstore/gh-action-sigstore-python@v1.2.3
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,3 +85,27 @@ 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

@ -16,25 +16,22 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12"] python-version: ["3.10", "3.11"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- 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@v3
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
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 --all-extras 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

2
.vscode/launch.json vendored
View File

@ -25,7 +25,7 @@
"type": "firefox", "type": "firefox",
"request": "launch", "request": "launch",
"name": "react: firefox", "name": "react: firefox",
"url": "http://localhost:5173", "url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/frontend" "webRoot": "${workspaceFolder}/frontend"
} }
] ]

View File

@ -1,4 +1,6 @@
Copyright (c) 2023-2024 Mose Müller <mosemueller@gmail.com> MIT License
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

894
README.md

File diff suppressed because it is too large Load Diff

View File

@ -111,7 +111,7 @@ Write the React component code, following the structure and patterns used in exi
For example, for the `Image` component, a template could look like this: For example, for the `Image` component, a template could look like this:
```ts ```tsx
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from 'react-bootstrap'; import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent'; import { DocStringComponent } from './DocStringComponent';
@ -203,7 +203,8 @@ There are two different events a component might want to trigger: updating an at
For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend: For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```ts title="frontend/src/components/ButtonComponent.tsx" ```tsx
// file: frontend/src/components/ButtonComponent.tsx
// ... (import statements) // ... (import statements)
type ButtonComponentProps = { type ButtonComponentProps = {
@ -248,7 +249,7 @@ There are two different events a component might want to trigger: updating an at
To see how to use the `MethodComponent` in your component, have a look at the `DeviceConnection.tsx` file. Here is an example that demonstrates the usage of the `runMethod` function (also, have a look at the `MethodComponent.tsx` file): To see how to use the `MethodComponent` in your component, have a look at the `DeviceConnection.tsx` file. Here is an example that demonstrates the usage of the `runMethod` function (also, have a look at the `MethodComponent.tsx` file):
```ts title="frontend/src/components/_YourComponent_.tsx" ```tsx
import { runMethod } from '../socket'; import { runMethod } from '../socket';
// ... (other imports) // ... (other imports)
@ -286,7 +287,9 @@ The `GenericComponent` is responsible for rendering different types of component
At the beginning of the `GenericComponent` file, import the newly created `ImageComponent`: At the beginning of the `GenericComponent` file, import the newly created `ImageComponent`:
```ts title="frontend/src/components/GenericComponent.tsx" ```tsx
// file: frontend/src/components/GenericComponent.tsx
import { ImageComponent } from './ImageComponent'; import { ImageComponent } from './ImageComponent';
``` ```
@ -296,7 +299,7 @@ Update the `AttributeType` type definition to include the new type for the `Imag
For example, if the new attribute type is `'Image'` (which should correspond to the name of the backend component class), you can add it to the union: For example, if the new attribute type is `'Image'` (which should correspond to the name of the backend component class), you can add it to the union:
```ts ```tsx
type AttributeType = type AttributeType =
| 'str' | 'str'
| 'bool' | 'bool'
@ -315,7 +318,7 @@ type AttributeType =
Inside the `GenericComponent` function, add a new conditional branch to render the `ImageComponent` when the attribute type is `'Image'`: Inside the `GenericComponent` function, add a new conditional branch to render the `ImageComponent` when the attribute type is `'Image'`:
```ts ```tsx
} else if (attribute.type === 'Image') { } else if (attribute.type === 'Image') {
return ( return (
<ImageComponent <ImageComponent
@ -345,7 +348,7 @@ For example, updating an `Image` component corresponds to setting a very long st
To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this: To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this:
```ts ```tsx
const fullAccessPath = [parentPath, name].filter((element) => element).join('.'); const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => { useEffect(() => {

View File

@ -2,7 +2,7 @@
## Overview ## Overview
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. The Observer Pattern is a fundamental design pattern in the `pydase` package, serving as the central communication mechanism for state updates to clients connected to a service.
## How it Works ## How it Works

View File

@ -1,57 +0,0 @@
::: pydase.data_service
handler: python
::: pydase.data_service.data_service_cache
handler: python
::: pydase.data_service.data_service_observer
handler: python
::: pydase.data_service.state_manager
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.utils.logging
handler: python
::: pydase.units
handler: python
::: pydase.config
handler: python

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="300.000000pt" height="319.000000pt" viewBox="0 0 300.000000 319.000000" preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.10, written by Peter Selinger 2001-2011
</metadata>
<g transform="translate(0.000000,319.000000) scale(0.050000,-0.050000)" fill="#000000" stroke="none">
<path d="M3177 6315 c-73 -26 -181 -83 -240 -128 -87 -67 -137 -88 -270 -115 -1259 -251 -2314 -1289 -2589 -2550 -380 -1734 1006 -3502 2746 -3502 1092 0 1819 261 2376 852 1117 1187 1046 2893 -171 4102 l-265 263 107 71 c65 43 127 106 160 161 68 116 87 115 287 -19 279 -187 300 -77 30 157 l-58 51 115 116 c149 152 167 320 22 199 -224 -185 -335 -226 -354 -131 -34 168 -137 227 -683 390 l-380 114 -350 7 c-326 8 -359 5 -483 -38z m1193 -245 c505 -152 550 -179 550 -322 0 -95 -184 -206 -559 -337 -556 -193 -887 -224 -1121 -104 -71 37 -173 89 -224 115 -221 112 -188 499 57 673 129 91 215 106 577 98 l340 -7 380 -116z m-1647 -319 c-8 -214 19 -324 119 -480 33 -53 57 -98 54 -100 -3 -2 -127 -48 -276 -100 -789 -280 -1197 -648 -1468 -1325 -250 -626 -230 -1189 69 -1886 56 -132 112 -304 130 -400 66 -348 238 -672 518 -975 150 -162 145 -163 -142 -18 -751 378 -1266 1020 -1501 1873 -52 189 -51 877 2 1120 230 1058 1019 1971 2012 2329 129 46 450 147 480 150 6 1 7 -84 3 -188z m2304 -993 c914 -980 1033 -2150 325 -3215 -572 -860 -1720 -1295 -2645 -1002 -560 178 -831 366 -986 683 -223 458 -232 753 -33 1064 175 273 284 290 1082 163 853 -135 1190 -74 1545 280 91 90 165 157 165 148 0 -244 -303 -619 -632 -782 l-174 -86 -374 -11 c-447 -12 -521 -40 -624 -238 -142 -271 -52 -462 244 -518 216 -42 300 -46 464 -24 1202 161 1849 1357 1347 2490 -29 66 -75 226 -101 356 -48 244 -131 451 -249 622 l-61 89 235 80 c306 104 276 110 472 -99z m-772 -195 c280 -415 191 -1010 -208 -1383 -252 -236 -463 -295 -1137 -322 -822 -32 -1036 -94 -1249 -361 -107 -134 -113 -133 -82 7 172 759 472 1031 1191 1078 240 16 342 31 410 61 363 159 379 624 29 795 -99 49 -122 41 451 160 553 116 490 120 595 -35z m-1895 -84 c39 -11 192 -47 340 -80 518 -114 681 -237 592 -446 -67 -156 -155 -191 -550 -215 -782 -47 -1105 -339 -1352 -1226 -37 -131 -53 -128 -89 18 -134 554 57 1165 509 1623 309 313 404 369 550 326z m2342 -1942 c-167 -657 -704 -1119 -1359 -1169 -320 -24 -563 50 -563 173 0 188 127 259 508 282 802 48 1231 374 1375 1048 60 282 66 286 73 41 4 -166 -4 -255 -34 -375z"/>
<path d="M3858 5922 c-62 -62 -78 -92 -78 -151 0 -307 422 -382 501 -88 70 262 -231 432 -423 239z m245 -95 c45 -41 48 -113 6 -156 -43 -42 -101 -39 -149 9 -97 97 41 239 143 147z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

View File

@ -1,153 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
width="588px"
height="626px"
viewBox="0 0 588 626"
preserveAspectRatio="xMidYMid meet"
id="svg184"
sodipodi:docname="pydase-logo-colour-3.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
inkscape:export-filename="pydase-logo-colour-3.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs184" />
<sodipodi:namedview
id="namedview184"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.70710678"
inkscape:cx="48.083261"
inkscape:cy="74.953318"
inkscape:window-width="2048"
inkscape:window-height="1243"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg184"
showgrid="false" />
<g
fill="#041b31"
id="g1"
style="display:inline"
inkscape:label="Contour">
<path
d="m 249,624.5 c -0.8,-0.2 -4.9,-0.8 -9,-1.5 -23.8,-3.7 -65.4,-19 -91,-33.5 C 115.5,570.6 81,540.3 58.3,510 41.3,487.2 23.6,454.3 16.2,431.5 8.8,408.8 8.3,406.8 4.9,387.5 1.9,370.5 1.8,368 1.6,342 1.5,313.2 1.4,314 7.1,282.6 18.3,221.6 48.7,167 100.4,115.5 116.6,99.3 126.7,90.8 142.5,80.1 158.5,69.3 182.9,56 199.5,49 210.6,44.4 240.6,34.4 252,31.5 c 7.3,-1.8 22.4,-4.5 25.5,-4.5 0.2,0 2.7,-2.1 5.7,-4.6 C 301.8,6.5 318.4,1 348,0.9 c 17.1,0 36.4,1.4 46,3.2 3,0.6 14.7,4 26,7.4 11.3,3.5 27.3,8.2 35.5,10.4 17.5,4.8 27.3,9.3 33.4,15.3 5.5,5.5 8.1,10.7 8.8,17.4 0.3,3 0.9,5.4 1.4,5.4 4,0 19.5,-9.6 30.7,-19 8.1,-6.9 9.3,-6.9 11.3,-0.1 2,6.6 -0.6,10 -19,25.9 l -3.5,2.9 10.6,10.4 c 13.4,13.2 17.8,21.1 12.4,22.5 -2.9,0.7 -4.8,-0.3 -15.2,-7.8 C 516.1,87.4 503.2,80 500.5,80 c -1.6,0 -2.9,1.5 -5,6.1 -3.8,7.9 -13.7,17.7 -22.6,22.4 l -6.8,3.6 4.7,4.2 c 18.1,16.2 30.1,28 40.8,40 15.1,16.9 22.8,27 32.1,42.4 6.9,11.4 22.2,41.2 23.8,46.3 0.4,1.4 1.6,4.3 2.6,6.5 4.9,10.7 10.9,34.8 14.6,58.5 2.7,17.9 2.5,58.7 -0.5,77.8 -5.3,33.5 -9.2,47.1 -21.3,73.7 -12.6,27.8 -24.1,46.3 -40.8,65.6 -19.2,22.3 -38.5,39.4 -60.5,53.8 -10.2,6.6 -43.5,23 -54.7,26.9 -16.2,5.7 -44,11 -69.1,13.2 -6.9,0.6 -17.5,1.7 -23.5,2.5 -9.4,1.3 -59.9,2 -65.3,1 z m 99.5,-135.4 c 36.7,-9.2 67.4,-29.4 87.4,-57.6 7.2,-10.3 17.8,-31.2 21.6,-42.9 5.7,-17.8 7,-26.5 7,-48.3 0,-18 -0.4,-22.7 -2,-21.2 -0.2,0.3 -1.1,5 -2,10.4 -5.4,34.9 -14.4,55.5 -32.5,74.8 -16.6,17.7 -36.73987,31.75263 -59.4,38.2 -7.25764,2.06498 -18.96791,3.46589 -37.2,4.4 -35.48106,1.81785 -36.6,1.6 -43.6,5.3 -12.5,6.7 -18.3,17.8 -14.4,27.3 2,4.7 6.3,7.1 17.1,9.5 12.5,2.8 13.8,2.9 33,2.5 12.8,-0.3 19,-0.8 25,-2.4 z M 134.4,385.8 c 0.8,-2.9 2.5,-8.9 3.6,-13.3 7.9,-29.5 14.4,-45.5 25.2,-62 7.4,-11.4 12,-16.1 27,-27.5 8.1,-6.1 13.6,-9.4 23.3,-13.6 18.4,-8.1 23.2,-9 48.5,-9.8 36.8,-1.2 44.6,-2.8 53.9,-11.2 9.4,-8.5 10.8,-20 3.7,-30.6 -7.7,-11.7 -15.4,-15.1 -50.6,-22.2 -24.8,-5.1 -30,-6.3 -40.9,-9.7 l -7.3,-2.3 -5.5,2.9 c -9.6,5 -25.36942,18.22759 -38.5,31.3 -19.59963,19.51281 -30.17386,36.16842 -42.7,67.6 -4.80076,12.04646 -7.8,26.5 -9.2,37.8 -1.6,13.7 -0.7,38.8 2,50.6 2.7,12.1 4.2,17.2 5.2,17.2 0.4,0 1.4,-2.4 2.3,-5.2 z"
id="path1"
sodipodi:nodetypes="ccccccccccccscccccccscccccccsccccccccccccccccccccscccsscccccccccccccccccssccsc"
style="fill:#041b31;fill-opacity:1" />
</g>
<g
fill="#003051"
id="g84"
style="display:inline"
inkscape:label="Very Dark Blue">
<path
d="M 230.4,602 C 175.34835,591.74645 169.18046,579.19949 127.38046,537.39949 126.28656,507.06066 124.35047,466.6837 125.4,421 c 3.1,7.5 6.91046,19.16537 8.35973,29.56569 3.51031,25.1907 16.4289,65.12981 36.44027,90.93431 22.43047,28.92391 69.16433,55.53771 88.55235,64.93033 C 249.09029,604.75095 241.4,604.1 230.4,602 Z"
id="path70"
sodipodi:nodetypes="cccsacc" />
<path
d="m 319.4,193.4 c -9.8,-5.8 -14.5,-7.1 -48.4,-14 -18.7,-3.7 -29,-4.8 -29,-6.5 0,-1.7 4.92805,-2.87104 12.5,-5.4 12.8566,-4.29398 19.24892,-5.98769 27.1,-7.9 24.01253,-5.84879 36.7,-8.7 48.4,-10.5 25.2,-4 35.7,-5.4 42.5,-5.5 6.2,-0.1 7.9,0.3 14.6,3.6 9.7,4.8 15.5,10 26.3,24 -32.58707,9.22703 -69.37398,17.37018 -94,22.2 z"
id="path77"
sodipodi:nodetypes="ccsssccccc" />
</g>
<g
fill="#033f64"
id="g97"
style="display:inline"
inkscape:label="Dark Blue">
<path
d="m 152.17414,396.63217 c 0.38601,-2.81096 5.82243,-25.08009 21.18483,-38.15736 33.76966,-28.74649 155.07007,-22.31003 192.71893,-28.8897 C 388.43397,313.23279 413.02792,214.49976 425.1,189.5 c 7.4,15 16.15078,54.97811 10.64936,81.97944 -4.26433,20.9296 -15.49967,42.2641 -32.45863,55.24972 -23.8158,18.23596 -36.39069,23.58236 -86.79073,23.77084 -83.29996,0.31152 -95.44833,-4.42471 -136.27417,16.21161 -12.20115,6.16734 -21.45976,18.1207 -28.05169,29.92056 z"
id="path118"
sodipodi:nodetypes="csccaasac"
style="display:inline;fill:#18759e;fill-opacity:1;fill-rule:nonzero" />
<path
d="M 183.5,588.1 C 115.8931,558.47699 107.64772,492.94457 88.1,430.2335 79,400.6335 76.84251,387.87492 75,366.15 c -1.824643,-21.51425 -3.417479,-43.86578 2.1,-64.7404 8.432657,-31.90379 27.29188,-60.49473 46.1,-87.6096 11.8141,-17.03188 24.95272,-33.78473 41.4,-46.4 13.29518,-10.19757 29.7308,-15.48328 44.9,-22.6 23.68008,-11.10966 63.61618,-31.81861 71.93442,-31.35243 3.81558,6.62743 29.05267,18.5147 28.43398,19.68762 0.31235,2.20322 -15.49372,-1.71368 -93.0684,32.46481 -30.64541,13.50201 -57.7,42.3 -74.5,67.4 -13.2,19.7 -23.8,43.8 -29.8,67.5 -5.2,20.6 -5.8,26.4 -5.2,45.7 0.8,25.7 4.5,42 15.4,68.8 l 5.5,13.5 0.3,13 c 0.1,7.1 0.6,15.1 1,17.6 0.4,2.6 1.31647,9.84975 0.81647,10.14975 -1.3,0.8 -0.71647,10.65025 1.78353,20.75025 2.9,11.9 13.6,43.4 17,50.1 9.51543,25.08025 19.6983,31.17451 34.4,48 z"
id="path92"
sodipodi:nodetypes="ccaaaaaccsccccccccccc"
style="fill:#18759e;fill-opacity:1" />
<path
d="M 336.53336,126.11775 C 326.2422,124.21015 287.27262,118.19694 281.1,72.4 398.98512,97.839775 428.5705,92.736362 481.94363,60.277903 c 0.3,15.65 -0.24934,17.091747 -5.11226,23.440508 -12.11958,15.82266 -34.57733,20.119399 -53.08407,27.518149 -15.89858,6.35605 -32.39842,11.77707 -49.33154,14.31356 -12.48954,1.87087 -28.16017,2.36977 -37.8824,0.56763 z"
id="path121"
sodipodi:nodetypes="sccaaas"
style="display:inline;fill:#18759e;fill-opacity:1" />
</g>
<g
fill="#c88700"
id="g133"
style="display:inline"
inkscape:label="Orange">
<path
d="M387.4 69.6 c-2.7 -2.7 -3.4 -4.2 -3.4 -7.4 0 -4.7 2.9 -8.8 7.6 -10.8 5.2 -2.2 7.3 -1.7 11.5 2.5 5.2 5.1 5.4 10.3 0.8 15.6 -2.8 3.1 -3.6 3.5 -8.1 3.5 -4.4 0 -5.4 -0.4 -8.4 -3.4z"
id="path125" />
<path
d="m 319.5,603.3 c -20.3,-1 -47.80327,-8.953 -69.9,-18.6 -12.64521,-5.52065 -23.8619,-13.95619 -35,-22.1 -5.09897,-3.72819 -9.99476,-7.77262 -14.5,-12.2 -8.10524,-7.96518 -17.7,-18.1 -22.4,-25.7 -13.9,-22.6 -23.4,-49.7 -26.7,-76.3 -1,-7.8 -0.9,-10.1 0.5,-15.5 3.5,-13.8 17.6,-39 26.3,-47.1 2.7,-2.6 8.1,-6.2 11.9,-8.1 8.6,-4.4 24.6,-9.3 33.8,-10.4 7.3,-0.9 66.1,-0.8 73,0.1 2.2,0.3 13.7,0.8 25.7,1.2 22.9,0.7 34.8,-0.2 49.2,-3.5 0,0 49.54914,-16.12943 68.7,-52.4 l 3.8,-7.2 0.1,6 c 0,8.5 -4.5,35.3 -7.5,44.2 -5.06001,15.02512 -12.78595,28.02413 -23.26042,39.12091 -9.81203,10.39498 -22.03592,19.12073 -36.73958,26.27909 -17.6,8.5 -16.2,8.2 -52,8.4 -30.6,0.1 -32.3,0.2 -37.6,2.3 -16.6,6.6 -26.4,18.6 -29.5,36.3 -1.6,8.9 -1.1,16.5 1.1,20.9 1.8,3.3 8.2,9.4 12.2,11.4 4.3,2.1 18.7,5.2 31.3,6.7 20.6,2.4 50,-1.8 71.5,-10.1 22.9,-8.9 41.8,-21.2 59,-38.4 18.5,-18.5 31.2,-39.3 39.5,-64.5 12.2,-37.2 12.4,-66.6 0.5,-107.7 -3.2,-11.2 -4.6,-14.9 -12,-30.8 -2.7,-6 -4.1,-11.8 -7,-30.5 -0.9,-5.7 -2.6,-13.8 -3.6,-18 -2.3,-9 -12.8,-31.1 -18.8,-39.6 -5.9,-8.4 -18.1,-21.5 -25.2,-27.1 -3.3,-2.6 -5.6,-5.1 -5.2,-5.5 0.4,-0.4 5.1,-1.9 10.3,-3.3 17.7,-5 26.1,-7.9 29.6,-10.2 1.9,-1.3 4.3,-2.4 5.2,-2.4 5,0.1 36,27 53.9,46.9 46.2,51.1 71.3,114.2 71.3,178.9 0,60.4 -17.3,114.5 -51.4,160.6 -14.1,19.3 -42.2,45.5 -64.6,60.6 -12.3,8.3 -21.8,13.2 -36.1,18.9 -40.2,15.9 -63.3,20.2 -99.4,18.4 z"
id="path131"
sodipodi:nodetypes="caaacccccccccccccsccccccccscccccccsccccscccc" />
</g>
<g
fill="#38b3d3"
id="g162"
style="display:inline"
inkscape:label="Blue">
<path
d="m 152.17414,396.63217 c -2.38519,-1.38132 9.27416,-52.79756 19.37815,-68.90898 16.15254,-24.81116 34.25689,-40.51929 62.0508,-48.64318 22.03094,-6.43944 64.62509,-4.00901 74.27424,-7.22545 5.13056,-1.80515 13.30143,-6.84069 18.81201,-11.68624 5.89061,-5.1305 11.1162,-14.91656 12.63633,-23.84749 1.71019,-9.88108 -0.47111,-21.90723 -6.47249,-30.32083 -2.85318,-4 -7.4141,-11.9156 -29.3718,-19.44781 19.92351,-5.56647 53.71798,-12.0993 80.70491,-17.53799 7.71528,-1.55487 13.91102,-2.63422 23.21371,-4.3142 21.30966,22.8642 21.21637,35.77338 26.93252,55.92264 0.0584,24.34066 -3.50141,45.36921 -16.09946,65.67248 -23.04998,44.93326 -65.30711,57.83541 -113.96611,59.38228 -72.68272,0.94776 -90.43688,2.59826 -116.76278,14.72068 -22.87446,10.53312 -33.71226,36.8281 -35.33003,36.23409 z"
id="path159"
sodipodi:nodetypes="csscccscacssssc"
style="display:inline;stroke-width:0.999987" />
<path
d="M 59.627728,486.61872 C 26.249201,426.79436 20.062286,396.1054 18.4,359.3 17.560667,340.71596 17.7,316.6 19.4,303.5 23.8,271.6 35.4,236 51.1,206 75.3,160.1 119.7,111.9 162.1,85.7 194.42327,64.457719 225.27293,54.821946 268,43 c -4.38883,35.093545 0.24301,53.781332 18.43033,75.35581 -16.19179,5.17933 -38.68025,13.24334 -44.53566,15.38169 -16.14313,5.89535 -49.89323,20.65189 -79.79467,47.7625 -27.4732,24.909 -59.81413,81.60725 -65.712627,143.66935 -4.157076,43.73944 6.451807,84.86847 34.031537,142.43409 3.43378,24.64602 9.97891,73.87903 71.35443,127.63575 C 125.61659,570.1535 67.391777,500.53423 59.627728,486.61872 Z"
id="path160"
sodipodi:nodetypes="sscccccsssccs"
style="display:inline" />
<path
d="m 332,111.5 c -7.6,-1.9 -19.1,-6.8 -24.2,-10.3 -5.6,-3.9 -14.42556,-11.925563 -21.72556,-10.225563 -0.59944,-1.638563 -2.45486,-5.992204 -3.00412,-8.525 C 277.37032,64.949437 281.9,46.6 294.8,33.2 c 6.5,-6.8 14.5,-10.9 27.7,-14.4 7,-1.9 10.6,-2.1 29,-2.1 28.2,0.1 42.1,2.5 71.2,12.3 6.8,2.2 19.1,5.8 27.4,8 16.6,4.4 23.6,7.6 28,12.9 2.6,3.2 3.87429,4.2 3.87429,11.2 v 7.7 L 473.8,73.7 c -4,2.8 -9.8,6.4 -12.8,8.1 -15.5,8.6 -69.4,26.1 -91.5,29.7 -11,1.8 -30.1,1.8 -37.5,0 z m 74.6,-27.4 c 8,-3.6 13.4,-13.3 13.4,-24 0,-7.1 -2.5,-12.5 -7.8,-17.3 -6.2,-5.6 -15.4,-7.3 -24.6,-4.6 -5.8,1.7 -14.1,10.2 -15.6,15.9 -3.2,11.9 3.1,25.6 14,30.3 4.9,2.1 15.5,2 20.6,-0.3 z"
id="path162"
sodipodi:nodetypes="ccccccccscsccccccsccccc" />
</g>
<g
fill="#fccd00"
id="g165"
style="display:inline"
inkscape:label="Yellow">
<path
d="M 290.57843,579.73223 C 262.53343,574.09041 238.11479,563.08508 212.75,550.7 189.86762,538.42339 184.68162,535.3415 175.4,519.55 c -7.00993,-11.92651 -30.58414,-55.74044 -23.8,-86.25 4.0198,-18.07777 14.86881,-43.99552 38.1,-55.6 16.46843,-0.10091 32.45479,1.52207 48.61284,3.12963 26.00767,2.58749 51.5763,9.85418 77.70491,10.47812 23.17389,0.55338 47.87531,2.89829 69.28278,-5.99304 22.20756,-9.22363 37.89511,-23.97358 55.12824,-46.53102 -2.5563,14.26912 -7.95593,45.65799 -44.98524,71.69133 -11.14814,7.83767 -23.62107,14.42481 -36.84575,17.7139 -10.72566,2.66757 -18.69625,1.20562 -33.13151,1.30575 C 310.59858,429.5978 291.1,429.3 281.1,434.3 c -12.2,6 -20.6,17.5 -23.7,32.3 -3.2,15.3 0.11875,24.31875 9.51875,31.11875 4.9,3.6 9.48125,5.48125 25.58125,8.38125 10.2,1.8 14.5,2 29,1.6 19.3,-0.6 27.7,-2.1 45,-7.8 65,-21.6 108.32042,-74.69846 114.2483,-146.4 0.5433,-6.57154 0.51635,-11.00098 0.35824,-16.5 -0.12685,-4.41201 -0.53376,-8.81617 -1.04757,-13.2 -0.31035,-2.64783 -0.73303,-5.28343 -1.22803,-7.90303 -1.04804,-5.54641 -2.17688,-11.08849 -3.68486,-16.52789 -3.8173,-13.76923 -7.04718,-27.944 -13.54608,-40.66908 -8.57845,-16.79692 -6.03317,-32.79012 -12.7776,-53.20969 -5.4006,-16.35095 -14.13511,-31.22562 -25.45092,-47.68672 9.20262,-3.00968 42.04296,-13.97755 50.15501,-17.80255 10.28756,9.39474 26.84483,25.52589 38.78601,40.81146 30.4959,39.03695 51.65187,83.78847 56.2875,132.1875 4.21372,43.99397 -0.37701,62.58021 -7.1,82.25 -6.8,20.7 -14.2,35.95 -22.6,53.65 -14.8,30.9 -37.8,59.1 -65.1,79.7 -34.6,26.2 -53.59209,36.03122 -84.7,43.9 -28.19212,7.13123 -69.76059,13.01808 -98.52157,7.23223 z"
id="path163"
sodipodi:nodetypes="sssscaaacsasccccccsaaaassccsscccss"
style="display:inline" />
<path
d="M 391.3,71.5 C 387.8,70 384,64.8 384,61.4 c 0,-2.7 3.4,-7 7,-8.9 4.9,-2.5 7.8,-1.9 12.2,2.5 3.6,3.5 4,4.4 3.5,7.5 -0.7,4.5 -3.5,8.2 -7.1,9.5 -3.7,1.3 -4.4,1.2 -8.3,-0.5 z"
id="path165" />
</g>
<g
fill="#fafcfc"
id="g183"
inkscape:label="White"
style="display:inline">
<path
d="M 292.22204,510.87608 C 280.22101,508.20541 268.81402,500.34672 263.69227,494.9842 275.64093,505.5687 304.1,508.3 321.5,507.7 c 21.55,-2.225 49.37501,-6.43114 86.62589,-28.91732 22.61919,-13.65389 51.87112,-50.42418 60.53015,-75.76929 6.66561,-19.51032 10.07957,-35.4123 12.39396,-53.90714 3.1459,18.64649 1.15198,36.57617 -1.3,46.46875 -2.9,11.1 -6.35,24.125 -11.95,34.225 -8.3,15.1 -27.2,38.1 -39.1,47.8 -25.5,20.5 -61.64365,33.01311 -92.85,36.3 -15.06775,1.58705 -35.15198,-1.1377 -43.62796,-3.02392 z"
id="path174"
sodipodi:nodetypes="sccssccccss" />
<path
d="M 28.4,416.5 C 15.349709,374.67557 18.014551,365.86291 17.43688,340.1625 17.048048,322.86353 19.119484,305.4699 22.5,288.5 c 10.62259,-53.3245 29.9,-91.9 61.3,-131 11,-13.7 40.9,-44 52.7,-53.5 C 166.2,80.3 209,59.4 252,47.5 c 8.5,-2.3 15.6,-4.2 15.7,-4.1 0.1,0.1 -0.4,3.8 -1.2,8.1 -0.8,4.4 -1.4,8.1 -1.5,8.3 0,0.1 -0.8,0.2 -1.9,0.2 -1,0 -6.3,1.4 -11.7,3 -41.6,12.8 -72.7,28.3 -103.6,51.7 -24.8,18.7 -39.9,34 -59.6,60 C 63.3,207.6 42.3,251 34.6,285.5 29.2,309.4 26.825886,327.09972 25.755456,348.16934 24.598916,370.93392 24.8,389.7 28.4,416.5 Z"
id="path178"
sodipodi:nodetypes="cascccsccsccccac" />
<path
d="m 208.22773,289.96967 c 9.51882,-5.66851 21.67237,-10.67386 30.98163,-12.63033 5.43202,-1.14162 18.645,-2.6057 32.04905,-3.10711 14.85841,-0.5558 26.43935,0.0727 34.62618,-2.66291 17.29397,-5.77872 28.56982,-17.26767 32.18039,-30.34042 1.49085,-5.3979 2.16985,-10.98219 1.55113,-16.06452 -0.70068,-5.7556 -3.89365,-15.38399 -6.46854,-18.70034 7.65573,3.55244 13.50421,17.23897 13.20338,31.10442 -0.37371,17.22406 -13.0606,32.1577 -24.74645,38.26377 -9.47406,4.95038 -29.08518,7.77124 -44.57677,8.07938 -10.95355,0.21788 -20.76029,0.67236 -31.82773,2.18839 -11.53232,1.57971 -30.58589,8.52074 -45.60676,17.46672 -7.81866,4.65656 -18.21827,12.44919 -21.26902,14.46609 4.45077,-6.22439 16.85283,-20.2914 29.90351,-28.06314 z"
id="path181"
sodipodi:nodetypes="sssssscssssscs" />
<path
d="m 282.3,76.8 c -1.6,-2.7 -0.6,-19.1 1.6,-25.2 4.3,-12 13.6,-22.7 23.4,-27.1 12.6,-5.5 18.3,-6.7 36.2,-7.2 29.7,-0.9 49.3,2 77,11.3 7.2,2.4 19.8,6.1 28.2,8.3 19.3,5.1 26.3,8.5 30.5,14.9 1.6,2.4 2.5,8.2 1.3,8.2 -0.3,0 -2.6,-1.3 -5.2,-2.9 C 470.5,54.2 463.8,51.9 442,46 435.7,44.2 426,41.1 420.5,39 415,36.9 408.9,34.7 407,34.1 c -12,-3.7 -49.7,-5.9 -71.3,-4.2 -11,0.8 -13.8,1.4 -19.4,4.1 -12.9,6 -24.1,20.5 -27.6,35.9 -1.7,7.2 -4.3,10.1 -6.4,6.9 z"
id="path183" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,145 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="84.373627mm"
height="29.06181mm"
viewBox="0 0 84.373627 29.06181"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="logo-with-text.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.6080267"
inkscape:cx="230.09568"
inkscape:cy="46.019136"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-27.646074,-133.9691)"><g
id="g2"
transform="matrix(0.04656788,0,0,0.04656788,27.572788,133.92718)"
style="stroke-width:5.68167"><g
fill="#041b31"
id="g1"
style="display:inline;stroke-width:5.68167"
inkscape:label="Contour"><path
d="m 249,624.5 c -0.8,-0.2 -4.9,-0.8 -9,-1.5 -23.8,-3.7 -65.4,-19 -91,-33.5 C 115.5,570.6 81,540.3 58.3,510 41.3,487.2 23.6,454.3 16.2,431.5 8.8,408.8 8.3,406.8 4.9,387.5 1.9,370.5 1.8,368 1.6,342 1.5,313.2 1.4,314 7.1,282.6 18.3,221.6 48.7,167 100.4,115.5 116.6,99.3 126.7,90.8 142.5,80.1 158.5,69.3 182.9,56 199.5,49 210.6,44.4 240.6,34.4 252,31.5 c 7.3,-1.8 22.4,-4.5 25.5,-4.5 0.2,0 2.7,-2.1 5.7,-4.6 C 301.8,6.5 318.4,1 348,0.9 c 17.1,0 36.4,1.4 46,3.2 3,0.6 14.7,4 26,7.4 11.3,3.5 27.3,8.2 35.5,10.4 17.5,4.8 27.3,9.3 33.4,15.3 5.5,5.5 8.1,10.7 8.8,17.4 0.3,3 0.9,5.4 1.4,5.4 4,0 19.5,-9.6 30.7,-19 8.1,-6.9 9.3,-6.9 11.3,-0.1 2,6.6 -0.6,10 -19,25.9 l -3.5,2.9 10.6,10.4 c 13.4,13.2 17.8,21.1 12.4,22.5 -2.9,0.7 -4.8,-0.3 -15.2,-7.8 C 516.1,87.4 503.2,80 500.5,80 c -1.6,0 -2.9,1.5 -5,6.1 -3.8,7.9 -13.7,17.7 -22.6,22.4 l -6.8,3.6 4.7,4.2 c 18.1,16.2 30.1,28 40.8,40 15.1,16.9 22.8,27 32.1,42.4 6.9,11.4 22.2,41.2 23.8,46.3 0.4,1.4 1.6,4.3 2.6,6.5 4.9,10.7 10.9,34.8 14.6,58.5 2.7,17.9 2.5,58.7 -0.5,77.8 -5.3,33.5 -9.2,47.1 -21.3,73.7 -12.6,27.8 -24.1,46.3 -40.8,65.6 -19.2,22.3 -38.5,39.4 -60.5,53.8 -10.2,6.6 -43.5,23 -54.7,26.9 -16.2,5.7 -44,11 -69.1,13.2 -6.9,0.6 -17.5,1.7 -23.5,2.5 -9.4,1.3 -59.9,2 -65.3,1 z m 99.5,-135.4 c 36.7,-9.2 67.4,-29.4 87.4,-57.6 7.2,-10.3 17.8,-31.2 21.6,-42.9 5.7,-17.8 7,-26.5 7,-48.3 0,-18 -0.4,-22.7 -2,-21.2 -0.2,0.3 -1.1,5 -2,10.4 -5.4,34.9 -14.4,55.5 -32.5,74.8 -16.6,17.7 -36.73987,31.75263 -59.4,38.2 -7.25764,2.06498 -18.96791,3.46589 -37.2,4.4 -35.48106,1.81785 -36.6,1.6 -43.6,5.3 -12.5,6.7 -18.3,17.8 -14.4,27.3 2,4.7 6.3,7.1 17.1,9.5 12.5,2.8 13.8,2.9 33,2.5 12.8,-0.3 19,-0.8 25,-2.4 z M 134.4,385.8 c 0.8,-2.9 2.5,-8.9 3.6,-13.3 7.9,-29.5 14.4,-45.5 25.2,-62 7.4,-11.4 12,-16.1 27,-27.5 8.1,-6.1 13.6,-9.4 23.3,-13.6 18.4,-8.1 23.2,-9 48.5,-9.8 36.8,-1.2 44.6,-2.8 53.9,-11.2 9.4,-8.5 10.8,-20 3.7,-30.6 -7.7,-11.7 -15.4,-15.1 -50.6,-22.2 -24.8,-5.1 -30,-6.3 -40.9,-9.7 l -7.3,-2.3 -5.5,2.9 c -9.6,5 -25.36942,18.22759 -38.5,31.3 -19.59963,19.51281 -30.17386,36.16842 -42.7,67.6 -4.80076,12.04646 -7.8,26.5 -9.2,37.8 -1.6,13.7 -0.7,38.8 2,50.6 2.7,12.1 4.2,17.2 5.2,17.2 0.4,0 1.4,-2.4 2.3,-5.2 z"
id="path1"
sodipodi:nodetypes="ccccccccccccscccccccscccccccsccccccccccccccccccccscccsscccccccccccccccccssccsc"
style="fill:#041b31;fill-opacity:1;stroke-width:5.68167" /></g><g
fill="#003051"
id="g84"
style="display:inline;stroke-width:5.68167"
inkscape:label="Very Dark Blue"><path
d="M 230.4,602 C 175.34835,591.74645 169.18046,579.19949 127.38046,537.39949 126.28656,507.06066 124.35047,466.6837 125.4,421 c 3.1,7.5 6.91046,19.16537 8.35973,29.56569 3.51031,25.1907 16.4289,65.12981 36.44027,90.93431 22.43047,28.92391 69.16433,55.53771 88.55235,64.93033 C 249.09029,604.75095 241.4,604.1 230.4,602 Z"
id="path70"
sodipodi:nodetypes="cccsacc"
style="stroke-width:5.68167" /><path
d="m 319.4,193.4 c -9.8,-5.8 -14.5,-7.1 -48.4,-14 -18.7,-3.7 -29,-4.8 -29,-6.5 0,-1.7 4.92805,-2.87104 12.5,-5.4 12.8566,-4.29398 19.24892,-5.98769 27.1,-7.9 24.01253,-5.84879 36.7,-8.7 48.4,-10.5 25.2,-4 35.7,-5.4 42.5,-5.5 6.2,-0.1 7.9,0.3 14.6,3.6 9.7,4.8 15.5,10 26.3,24 -32.58707,9.22703 -69.37398,17.37018 -94,22.2 z"
id="path77"
sodipodi:nodetypes="ccsssccccc"
style="stroke-width:5.68167" /></g><g
fill="#033f64"
id="g97"
style="display:inline;stroke-width:5.68167"
inkscape:label="Dark Blue"><path
d="m 152.17414,396.63217 c 0.38601,-2.81096 5.82243,-25.08009 21.18483,-38.15736 33.76966,-28.74649 155.07007,-22.31003 192.71893,-28.8897 C 388.43397,313.23279 413.02792,214.49976 425.1,189.5 c 7.4,15 16.15078,54.97811 10.64936,81.97944 -4.26433,20.9296 -15.49967,42.2641 -32.45863,55.24972 -23.8158,18.23596 -36.39069,23.58236 -86.79073,23.77084 -83.29996,0.31152 -95.44833,-4.42471 -136.27417,16.21161 -12.20115,6.16734 -21.45976,18.1207 -28.05169,29.92056 z"
id="path118"
sodipodi:nodetypes="csccaasac"
style="display:inline;fill:#18759e;fill-opacity:1;fill-rule:nonzero;stroke-width:5.68167" /><path
d="M 183.5,588.1 C 115.8931,558.47699 107.64772,492.94457 88.1,430.2335 79,400.6335 76.84251,387.87492 75,366.15 c -1.824643,-21.51425 -3.417479,-43.86578 2.1,-64.7404 8.432657,-31.90379 27.29188,-60.49473 46.1,-87.6096 11.8141,-17.03188 24.95272,-33.78473 41.4,-46.4 13.29518,-10.19757 29.7308,-15.48328 44.9,-22.6 23.68008,-11.10966 63.61618,-31.81861 71.93442,-31.35243 3.81558,6.62743 29.05267,18.5147 28.43398,19.68762 0.31235,2.20322 -15.49372,-1.71368 -93.0684,32.46481 -30.64541,13.50201 -57.7,42.3 -74.5,67.4 -13.2,19.7 -23.8,43.8 -29.8,67.5 -5.2,20.6 -5.8,26.4 -5.2,45.7 0.8,25.7 4.5,42 15.4,68.8 l 5.5,13.5 0.3,13 c 0.1,7.1 0.6,15.1 1,17.6 0.4,2.6 1.31647,9.84975 0.81647,10.14975 -1.3,0.8 -0.71647,10.65025 1.78353,20.75025 2.9,11.9 13.6,43.4 17,50.1 9.51543,25.08025 19.6983,31.17451 34.4,48 z"
id="path92"
sodipodi:nodetypes="ccaaaaaccsccccccccccc"
style="fill:#18759e;fill-opacity:1;stroke-width:5.68167" /><path
d="M 336.53336,126.11775 C 326.2422,124.21015 287.27262,118.19694 281.1,72.4 398.98512,97.839775 428.5705,92.736362 481.94363,60.277903 c 0.3,15.65 -0.24934,17.091747 -5.11226,23.440508 -12.11958,15.82266 -34.57733,20.119399 -53.08407,27.518149 -15.89858,6.35605 -32.39842,11.77707 -49.33154,14.31356 -12.48954,1.87087 -28.16017,2.36977 -37.8824,0.56763 z"
id="path121"
sodipodi:nodetypes="sccaaas"
style="display:inline;fill:#18759e;fill-opacity:1;stroke-width:5.68167" /></g><g
fill="#c88700"
id="g133"
style="display:inline;stroke-width:5.68167"
inkscape:label="Orange"><path
d="m 387.4,69.6 c -2.7,-2.7 -3.4,-4.2 -3.4,-7.4 0,-4.7 2.9,-8.8 7.6,-10.8 5.2,-2.2 7.3,-1.7 11.5,2.5 5.2,5.1 5.4,10.3 0.8,15.6 -2.8,3.1 -3.6,3.5 -8.1,3.5 -4.4,0 -5.4,-0.4 -8.4,-3.4 z"
id="path125"
style="stroke-width:5.68167" /><path
d="m 319.5,603.3 c -20.3,-1 -47.80327,-8.953 -69.9,-18.6 -12.64521,-5.52065 -23.8619,-13.95619 -35,-22.1 -5.09897,-3.72819 -9.99476,-7.77262 -14.5,-12.2 -8.10524,-7.96518 -17.7,-18.1 -22.4,-25.7 -13.9,-22.6 -23.4,-49.7 -26.7,-76.3 -1,-7.8 -0.9,-10.1 0.5,-15.5 3.5,-13.8 17.6,-39 26.3,-47.1 2.7,-2.6 8.1,-6.2 11.9,-8.1 8.6,-4.4 24.6,-9.3 33.8,-10.4 7.3,-0.9 66.1,-0.8 73,0.1 2.2,0.3 13.7,0.8 25.7,1.2 22.9,0.7 34.8,-0.2 49.2,-3.5 0,0 49.54914,-16.12943 68.7,-52.4 l 3.8,-7.2 0.1,6 c 0,8.5 -4.5,35.3 -7.5,44.2 -5.06001,15.02512 -12.78595,28.02413 -23.26042,39.12091 -9.81203,10.39498 -22.03592,19.12073 -36.73958,26.27909 -17.6,8.5 -16.2,8.2 -52,8.4 -30.6,0.1 -32.3,0.2 -37.6,2.3 -16.6,6.6 -26.4,18.6 -29.5,36.3 -1.6,8.9 -1.1,16.5 1.1,20.9 1.8,3.3 8.2,9.4 12.2,11.4 4.3,2.1 18.7,5.2 31.3,6.7 20.6,2.4 50,-1.8 71.5,-10.1 22.9,-8.9 41.8,-21.2 59,-38.4 18.5,-18.5 31.2,-39.3 39.5,-64.5 12.2,-37.2 12.4,-66.6 0.5,-107.7 -3.2,-11.2 -4.6,-14.9 -12,-30.8 -2.7,-6 -4.1,-11.8 -7,-30.5 -0.9,-5.7 -2.6,-13.8 -3.6,-18 -2.3,-9 -12.8,-31.1 -18.8,-39.6 -5.9,-8.4 -18.1,-21.5 -25.2,-27.1 -3.3,-2.6 -5.6,-5.1 -5.2,-5.5 0.4,-0.4 5.1,-1.9 10.3,-3.3 17.7,-5 26.1,-7.9 29.6,-10.2 1.9,-1.3 4.3,-2.4 5.2,-2.4 5,0.1 36,27 53.9,46.9 46.2,51.1 71.3,114.2 71.3,178.9 0,60.4 -17.3,114.5 -51.4,160.6 -14.1,19.3 -42.2,45.5 -64.6,60.6 -12.3,8.3 -21.8,13.2 -36.1,18.9 -40.2,15.9 -63.3,20.2 -99.4,18.4 z"
id="path131"
sodipodi:nodetypes="caaacccccccccccccsccccccccscccccccsccccscccc"
style="stroke-width:5.68167" /></g><g
fill="#38b3d3"
id="g162"
style="display:inline;stroke-width:5.68167"
inkscape:label="Blue"><path
d="m 152.17414,396.63217 c -2.38519,-1.38132 9.27416,-52.79756 19.37815,-68.90898 16.15254,-24.81116 34.25689,-40.51929 62.0508,-48.64318 22.03094,-6.43944 64.62509,-4.00901 74.27424,-7.22545 5.13056,-1.80515 13.30143,-6.84069 18.81201,-11.68624 5.89061,-5.1305 11.1162,-14.91656 12.63633,-23.84749 1.71019,-9.88108 -0.47111,-21.90723 -6.47249,-30.32083 -2.85318,-4 -7.4141,-11.9156 -29.3718,-19.44781 19.92351,-5.56647 53.71798,-12.0993 80.70491,-17.53799 7.71528,-1.55487 13.91102,-2.63422 23.21371,-4.3142 21.30966,22.8642 21.21637,35.77338 26.93252,55.92264 0.0584,24.34066 -3.50141,45.36921 -16.09946,65.67248 -23.04998,44.93326 -65.30711,57.83541 -113.96611,59.38228 -72.68272,0.94776 -90.43688,2.59826 -116.76278,14.72068 -22.87446,10.53312 -33.71226,36.8281 -35.33003,36.23409 z"
id="path159"
sodipodi:nodetypes="csscccscacssssc"
style="display:inline;stroke-width:5.68161" /><path
d="M 59.627728,486.61872 C 26.249201,426.79436 20.062286,396.1054 18.4,359.3 17.560667,340.71596 17.7,316.6 19.4,303.5 23.8,271.6 35.4,236 51.1,206 75.3,160.1 119.7,111.9 162.1,85.7 194.42327,64.457719 225.27293,54.821946 268,43 c -4.38883,35.093545 0.24301,53.781332 18.43033,75.35581 -16.19179,5.17933 -38.68025,13.24334 -44.53566,15.38169 -16.14313,5.89535 -49.89323,20.65189 -79.79467,47.7625 -27.4732,24.909 -59.81413,81.60725 -65.712627,143.66935 -4.157076,43.73944 6.451807,84.86847 34.031537,142.43409 3.43378,24.64602 9.97891,73.87903 71.35443,127.63575 C 125.61659,570.1535 67.391777,500.53423 59.627728,486.61872 Z"
id="path160"
sodipodi:nodetypes="sscccccsssccs"
style="display:inline;stroke-width:5.68167" /><path
d="m 332,111.5 c -7.6,-1.9 -19.1,-6.8 -24.2,-10.3 -5.6,-3.9 -14.42556,-11.925563 -21.72556,-10.225563 -0.59944,-1.638563 -2.45486,-5.992204 -3.00412,-8.525 C 277.37032,64.949437 281.9,46.6 294.8,33.2 c 6.5,-6.8 14.5,-10.9 27.7,-14.4 7,-1.9 10.6,-2.1 29,-2.1 28.2,0.1 42.1,2.5 71.2,12.3 6.8,2.2 19.1,5.8 27.4,8 16.6,4.4 23.6,7.6 28,12.9 2.6,3.2 3.87429,4.2 3.87429,11.2 v 7.7 L 473.8,73.7 c -4,2.8 -9.8,6.4 -12.8,8.1 -15.5,8.6 -69.4,26.1 -91.5,29.7 -11,1.8 -30.1,1.8 -37.5,0 z m 74.6,-27.4 c 8,-3.6 13.4,-13.3 13.4,-24 0,-7.1 -2.5,-12.5 -7.8,-17.3 -6.2,-5.6 -15.4,-7.3 -24.6,-4.6 -5.8,1.7 -14.1,10.2 -15.6,15.9 -3.2,11.9 3.1,25.6 14,30.3 4.9,2.1 15.5,2 20.6,-0.3 z"
id="path162"
sodipodi:nodetypes="ccccccccscsccccccsccccc"
style="stroke-width:5.68167" /></g><g
fill="#fccd00"
id="g165"
style="display:inline;stroke-width:5.68167"
inkscape:label="Yellow"><path
d="M 290.57843,579.73223 C 262.53343,574.09041 238.11479,563.08508 212.75,550.7 189.86762,538.42339 184.68162,535.3415 175.4,519.55 c -7.00993,-11.92651 -30.58414,-55.74044 -23.8,-86.25 4.0198,-18.07777 14.86881,-43.99552 38.1,-55.6 16.46843,-0.10091 32.45479,1.52207 48.61284,3.12963 26.00767,2.58749 51.5763,9.85418 77.70491,10.47812 23.17389,0.55338 47.87531,2.89829 69.28278,-5.99304 22.20756,-9.22363 37.89511,-23.97358 55.12824,-46.53102 -2.5563,14.26912 -7.95593,45.65799 -44.98524,71.69133 -11.14814,7.83767 -23.62107,14.42481 -36.84575,17.7139 -10.72566,2.66757 -18.69625,1.20562 -33.13151,1.30575 C 310.59858,429.5978 291.1,429.3 281.1,434.3 c -12.2,6 -20.6,17.5 -23.7,32.3 -3.2,15.3 0.11875,24.31875 9.51875,31.11875 4.9,3.6 9.48125,5.48125 25.58125,8.38125 10.2,1.8 14.5,2 29,1.6 19.3,-0.6 27.7,-2.1 45,-7.8 65,-21.6 108.32042,-74.69846 114.2483,-146.4 0.5433,-6.57154 0.51635,-11.00098 0.35824,-16.5 -0.12685,-4.41201 -0.53376,-8.81617 -1.04757,-13.2 -0.31035,-2.64783 -0.73303,-5.28343 -1.22803,-7.90303 -1.04804,-5.54641 -2.17688,-11.08849 -3.68486,-16.52789 -3.8173,-13.76923 -7.04718,-27.944 -13.54608,-40.66908 -8.57845,-16.79692 -6.03317,-32.79012 -12.7776,-53.20969 -5.4006,-16.35095 -14.13511,-31.22562 -25.45092,-47.68672 9.20262,-3.00968 42.04296,-13.97755 50.15501,-17.80255 10.28756,9.39474 26.84483,25.52589 38.78601,40.81146 30.4959,39.03695 51.65187,83.78847 56.2875,132.1875 4.21372,43.99397 -0.37701,62.58021 -7.1,82.25 -6.8,20.7 -14.2,35.95 -22.6,53.65 -14.8,30.9 -37.8,59.1 -65.1,79.7 -34.6,26.2 -53.59209,36.03122 -84.7,43.9 -28.19212,7.13123 -69.76059,13.01808 -98.52157,7.23223 z"
id="path163"
sodipodi:nodetypes="sssscaaacsasccccccsaaaassccsscccss"
style="display:inline;stroke-width:5.68167" /><path
d="M 391.3,71.5 C 387.8,70 384,64.8 384,61.4 c 0,-2.7 3.4,-7 7,-8.9 4.9,-2.5 7.8,-1.9 12.2,2.5 3.6,3.5 4,4.4 3.5,7.5 -0.7,4.5 -3.5,8.2 -7.1,9.5 -3.7,1.3 -4.4,1.2 -8.3,-0.5 z"
id="path165"
style="stroke-width:5.68167" /></g><g
fill="#fafcfc"
id="g183"
inkscape:label="White"
style="display:inline;stroke-width:5.68167"><path
d="M 292.22204,510.87608 C 280.22101,508.20541 268.81402,500.34672 263.69227,494.9842 275.64093,505.5687 304.1,508.3 321.5,507.7 c 21.55,-2.225 49.37501,-6.43114 86.62589,-28.91732 22.61919,-13.65389 51.87112,-50.42418 60.53015,-75.76929 6.66561,-19.51032 10.07957,-35.4123 12.39396,-53.90714 3.1459,18.64649 1.15198,36.57617 -1.3,46.46875 -2.9,11.1 -6.35,24.125 -11.95,34.225 -8.3,15.1 -27.2,38.1 -39.1,47.8 -25.5,20.5 -61.64365,33.01311 -92.85,36.3 -15.06775,1.58705 -35.15198,-1.1377 -43.62796,-3.02392 z"
id="path174"
sodipodi:nodetypes="sccssccccss"
style="stroke-width:5.68167" /><path
d="M 28.4,416.5 C 15.349709,374.67557 18.014551,365.86291 17.43688,340.1625 17.048048,322.86353 19.119484,305.4699 22.5,288.5 c 10.62259,-53.3245 29.9,-91.9 61.3,-131 11,-13.7 40.9,-44 52.7,-53.5 C 166.2,80.3 209,59.4 252,47.5 c 8.5,-2.3 15.6,-4.2 15.7,-4.1 0.1,0.1 -0.4,3.8 -1.2,8.1 -0.8,4.4 -1.4,8.1 -1.5,8.3 0,0.1 -0.8,0.2 -1.9,0.2 -1,0 -6.3,1.4 -11.7,3 -41.6,12.8 -72.7,28.3 -103.6,51.7 -24.8,18.7 -39.9,34 -59.6,60 C 63.3,207.6 42.3,251 34.6,285.5 29.2,309.4 26.825886,327.09972 25.755456,348.16934 24.598916,370.93392 24.8,389.7 28.4,416.5 Z"
id="path178"
sodipodi:nodetypes="cascccsccsccccac"
style="stroke-width:5.68167" /><path
d="m 208.22773,289.96967 c 9.51882,-5.66851 21.67237,-10.67386 30.98163,-12.63033 5.43202,-1.14162 18.645,-2.6057 32.04905,-3.10711 14.85841,-0.5558 26.43935,0.0727 34.62618,-2.66291 17.29397,-5.77872 28.56982,-17.26767 32.18039,-30.34042 1.49085,-5.3979 2.16985,-10.98219 1.55113,-16.06452 -0.70068,-5.7556 -3.89365,-15.38399 -6.46854,-18.70034 7.65573,3.55244 13.50421,17.23897 13.20338,31.10442 -0.37371,17.22406 -13.0606,32.1577 -24.74645,38.26377 -9.47406,4.95038 -29.08518,7.77124 -44.57677,8.07938 -10.95355,0.21788 -20.76029,0.67236 -31.82773,2.18839 -11.53232,1.57971 -30.58589,8.52074 -45.60676,17.46672 -7.81866,4.65656 -18.21827,12.44919 -21.26902,14.46609 4.45077,-6.22439 16.85283,-20.2914 29.90351,-28.06314 z"
id="path181"
sodipodi:nodetypes="sssssscssssscs"
style="stroke-width:5.68167" /><path
d="m 282.3,76.8 c -1.6,-2.7 -0.6,-19.1 1.6,-25.2 4.3,-12 13.6,-22.7 23.4,-27.1 12.6,-5.5 18.3,-6.7 36.2,-7.2 29.7,-0.9 49.3,2 77,11.3 7.2,2.4 19.8,6.1 28.2,8.3 19.3,5.1 26.3,8.5 30.5,14.9 1.6,2.4 2.5,8.2 1.3,8.2 -0.3,0 -2.6,-1.3 -5.2,-2.9 C 470.5,54.2 463.8,51.9 442,46 435.7,44.2 426,41.1 420.5,39 415,36.9 408.9,34.7 407,34.1 c -12,-3.7 -49.7,-5.9 -71.3,-4.2 -11,0.8 -13.8,1.4 -19.4,4.1 -12.9,6 -24.1,20.5 -27.6,35.9 -1.7,7.2 -4.3,10.1 -6.4,6.9 z"
id="path183"
style="stroke-width:5.68167" /></g></g><text
xml:space="preserve"
style="font-size:11.2889px;font-family:Comfortaa;-inkscape-font-specification:Comfortaa;text-align:center;writing-mode:lr-tb;direction:ltr;text-anchor:middle;opacity:0.66761;fill:#083f91;stroke-width:3.307;stroke-linejoin:round;stroke-miterlimit:2.6"
x="91.349724"
y="151.56494"
id="text2"><tspan
sodipodi:role="line"
id="tspan2"
style="font-size:11.2889px;fill:#000000;fill-opacity:1;stroke-width:3.307"
x="91.349724"
y="151.56494">pydase</tspan></text></g></svg>

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,17 +1 @@
{% {% include-markdown "../README.md" %}
include-markdown "../README.md"
start="<!--introduction-start-->"
end="<!--introduction-end-->"
%}
[pydase Banner]: ./images/logo-with-text.png
[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/Auto-generated-Frontend.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

@ -1,38 +1,20 @@
babel==2.15.0 ; python_version >= "3.10" and python_version < "4.0"
beautifulsoup4==4.12.3 ; python_version >= "3.10" and python_version < "4.0"
certifi==2024.7.4 ; python_version >= "3.10" and python_version < "4.0"
charset-normalizer==3.3.2 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0" 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" and platform_system == "Windows"
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" jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
idna==3.7 ; python_version >= "3.10" and python_version < "4.0" markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0" markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
markdown==3.6 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==2.1.5 ; python_version >= "3.10" and python_version < "4.0"
mergedeep==1.3.4 ; python_version >= "3.10" and python_version < "4.0" mergedeep==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-autorefs==1.0.1 ; python_version >= "3.10" and python_version < "4.0" mkdocs-autorefs==0.5.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==1.5.3 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-material==9.5.31 ; python_version >= "3.10" and python_version < "4.0" mkdocstrings==0.22.0 ; 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" packaging==23.1 ; python_version >= "3.10" and python_version < "4.0"
mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0" pathspec==0.11.2 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings-python==1.10.8 ; python_version >= "3.10" and python_version < "4.0" platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings==0.25.2 ; python_version >= "3.10" and python_version < "4.0" pymdown-extensions==10.3 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings[python]==0.25.2 ; python_version >= "3.10" and python_version < "4.0" python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
packaging==24.1 ; python_version >= "3.10" and python_version < "4.0"
paginate==0.5.6 ; python_version >= "3.10" and python_version < "4.0"
pathspec==0.12.1 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==4.2.2 ; python_version >= "3.10" and python_version < "4.0"
pygments==2.18.0 ; python_version >= "3.10" and python_version < "4.0"
pymdown-extensions==10.9 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
pyyaml-env-tag==0.1 ; python_version >= "3.10" and python_version < "4.0" pyyaml-env-tag==0.1 ; python_version >= "3.10" and python_version < "4.0"
pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0" pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
regex==2024.7.24 ; python_version >= "3.10" and python_version < "4.0"
requests==2.32.3 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0" six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
soupsieve==2.5 ; python_version >= "3.10" and python_version < "4.0" watchdog==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
urllib3==2.2.2 ; python_version >= "3.10" and python_version < "4.0"
watchdog==4.0.1 ; python_version >= "3.10" and python_version < "4.0"

View File

@ -1,435 +1,6 @@
# Components Guide # Components Guide
{%
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: include-markdown "../../README.md"
start="<!-- Component User Guide Start -->"
## Built-in Type and Enum Components end="<!-- Component User Guide End -->"
%}
`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

@ -1,211 +0,0 @@
# Configuring `pydase`
## Do I Need to Configure My `pydase` Service?
`pydase` services work out of the box without requiring any configuration. However, you
might want to change some options, such as the web server port or logging level. To
accommodate such customizations, `pydase` allows configuration through environment
variables - avoiding hard-coded settings in your service code.
Why should you avoid hard-coding configurations? Here are two reasons:
1. **Security**:
Protect sensitive information, such as usernames and passwords. By using environment
variables, your service code can remain public while keeping private information
secure.
2. **Reusability**:
Services often need to be reused in different environments. For example, you might
deploy multiple instances of a service (e.g., for different sensors in a lab). By
separating configuration from code, you can adapt the service to new requirements
without modifying its codebase.
Next, well walk you through the environment variables `pydase` supports and provide an
example of how to separate service code from configuration.
## Configuring `pydase` Using Environment Variables
`pydase` provides the following environment variables for customization:
- **`ENVIRONMENT`**:
Defines the operation mode (`"development"` or `"production"`), which influences
behaviour such as logging (see [Logging in pydase](./Logging.md)).
- **`SERVICE_CONFIG_DIR`**:
Specifies the directory for configuration files (e.g., `web_settings.json`). Defaults
to the `config` folder in the service root. Access this programmatically using:
```python
import pydase.config
pydase.config.ServiceConfig().config_dir
```
- **`SERVICE_WEB_PORT`**:
Defines the web servers port. Ensure each service on the same host uses a unique
port. Default: `8001`.
- **`GENERATE_WEB_SETTINGS`**:
When `true`, generates or updates the `web_settings.json` file (see [Tailoring Frontend Component Layout](./interaction/Auto-generated-Frontend.md#tailoring-frontend-component-layout)).
Existing entries are preserved, and new entries are appended.
### Configuring `pydase` via Keyword Arguments
Some settings can also be overridden directly in your service code using keyword
arguments when initializing the server. This allows for flexibility in code-based
configuration:
```python
import pathlib
from pydase import Server
from your_service_module import YourService
server = Server(
YourService(),
web_port=8080, # Overrides SERVICE_WEB_PORT
config_dir=pathlib.Path("custom_config"), # Overrides SERVICE_CONFIG_DIR
generate_web_settings=True # Overrides GENERATE_WEB_SETTINGS
).run()
```
## Separating Service Code from Configuration
To decouple configuration from code, `pydase` utilizes `confz` for configuration
management. Below is an example that demonstrates how to configure a `pydase` service
for a sensor readout application.
### Scenario: Configuring a Sensor Service
Imagine you have multiple sensors distributed across your lab. You need to configure
each service instance with:
1. **Hostname**: The hostname or IP address of the sensor.
2. **Authentication Token**: A token or credentials to authenticate with the sensor.
3. **Readout Interval**: A periodic interval to read sensor data and log it to a
database.
Given the repository structure:
```bash title="Service Repository Structure"
my_sensor
├── pyproject.toml
├── README.md
└── src
└── my_sensor
├── my_sensor.py
├── config.py
├── __init__.py
└── __main__.py
```
Your service might look like this:
### Configuration
Define the configuration using `confz`:
```python title="src/my_sensor/config.py"
import confz
from pydase.config import ServiceConfig
class MySensorConfig(confz.BaseConfig):
instance_name: str
hostname: str
auth_token: str
readout_interval_s: float
CONFIG_SOURCES = confz.FileSource(file=ServiceConfig().config_dir / "config.yaml")
```
This class defines configurable parameters and loads values from a `config.yaml` file
located in the services configuration directory (which is configurable through an
environment variable, see [above](#configuring-pydase-using-environment-variables)).
A sample YAML file might look like this:
```yaml title="config.yaml"
instance_name: my-sensor-service-01
hostname: my-sensor-01.example.com
auth_token: my-secret-authentication-token
readout_interval_s: 5
```
### Service Implementation
Your service implementation might look like this:
```python title="src/my_sensor/my_sensor.py"
import asyncio
import http.client
import json
import logging
from typing import Any
import pydase.components
import pydase.units as u
from pydase.task.decorator import task
from my_sensor.config import MySensorConfig
logger = logging.getLogger(__name__)
class MySensor(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.readout_interval_s: u.Quantity = (
MySensorConfig().readout_interval_s * u.units.s
)
@property
def hostname(self) -> str:
"""Hostname of the sensor. Read-only."""
return MySensorConfig().hostname
def _get_data(self) -> dict[str, Any]:
"""Fetches sensor data via an HTTP GET request. It passes the authentication
token as "Authorization" header."""
connection = http.client.HTTPConnection(self.hostname, timeout=10)
connection.request(
"GET", "/", headers={"Authorization": MySensorConfig().auth_token}
)
response = connection.getresponse()
connection.close()
return json.loads(response.read())
@task(autostart=True)
async def get_and_log_sensor_values(self) -> None:
"""Periodically fetches and logs sensor data."""
while True:
try:
data = self._get_data()
# Write data to database using MySensorConfig().instance_name ...
except Exception as e:
logger.error(
"Error occurred, retrying in %s seconds. Error: %s",
self.readout_interval_s.m,
e,
)
await asyncio.sleep(self.readout_interval_s.m)
```
### Starting the Service
The service is launched via the `__main__.py` entry point:
```python title="src/my_sensor/__main__.py"
import pydase
from my_sensor.my_sensor import MySensor
pydase.Server(MySensor()).run()
```
You can now start the service with:
```bash
python -m my_sensor
```
This approach ensures the service is fully configured via the `config.yaml` file,
separating service logic from configuration.

View File

@ -1,91 +0,0 @@
# Logging in pydase
The `pydase` library organizes its loggers per module, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library. Logs can also include details about client identification based on headers sent by the client or proxy, providing additional context for debugging or auditing.
## Changing the pydase Log Level
You have two primary ways to adjust the log levels in `pydase`:
1. **Directly targeting `pydase` loggers**
You can set the log level for any `pydase` logger directly in your code. This method is useful for fine-tuning logging levels for specific modules within `pydase`. For instance, if you want to change the log level of the main `pydase` logger or target a submodule like `pydase.data_service`, you can do so as follows:
```python
# <your_script.py>
import logging
# Set the log level for the main pydase logger
logging.getLogger("pydase").setLevel(logging.INFO)
# Optionally, target a specific submodule logger
# logging.getLogger("pydase.data_service").setLevel(logging.DEBUG)
# Your logger for the current script
from pydase.utils.logging import configure_logging_with_pydase_formatter
configure_logging_with_pydase_formatter(level=logging.DEBUG)
logger = logging.getLogger(__name__)
logger.debug("My debug message.")
```
This approach allows for specific control over different parts of the `pydase` library, depending on your logging needs.
2. **Using the `ENVIRONMENT` environment variable**
For a more global setting that affects the entire `pydase` library, you can utilize the `ENVIRONMENT` environment variable. Setting this variable to `"production"` will configure all `pydase` loggers to only log messages of level `"INFO"` and above, filtering out more verbose logging. This is particularly useful for production environments where excessive logging can be overwhelming or unnecessary.
```bash
ENVIRONMENT="production" python -m <module_using_pydase>
```
In the absence of this setting, the default behavior is to log everything of level `"DEBUG"` and above, suitable for development environments where more detailed logs are beneficial.
## Client Identification in pydase Logs
The logging system in `pydase` includes information about clients based on headers sent by the client or a proxy. The priority for identifying the client is fixed and as follows:
1. **`Remote-User` Header**: This header is typically set by authentication servers like [Authelia](https://www.authelia.com/). While it can be set manually by users, its primary purpose is to provide client information authenticated through such servers.
2. **`X-Client-ID` Header**: This header is intended for use by Python clients to pass custom client identification information. It acts as a fallback when the `Remote-User` header is not available.
3. **Default Socket.IO Session ID**: If neither of the above headers is present, the system falls back to the default Socket.IO session ID to identify the client.
For example, a log entries might include the following details based on the available headers:
```plaintext
2025-01-20 06:47:50.940 | INFO | pydase.server.web_server.api.v1.application:_get_value:36 - Client [id=This is me!] is getting the value of 'property_attr'
2025-01-20 06:48:13.710 | INFO | pydase.server.web_server.api.v1.application:_get_value:36 - Client [user=Max Muster] is getting the value of 'property_attr'
```
## Configuring Logging in Services
To configure logging in services built with `pydase`, use the helper function [`configure_logging_with_pydase_formatter`][pydase.utils.logging.configure_logging_with_pydase_formatter]. This function sets up a logger with the same formatting used internally by `pydase`, so your service logs match the style and structure of `pydase` logs.
### Example
If your service follows a typical layout like:
```text
└── src
└── my_service
├── __init__.py
└── ...
```
you should call `configure_logging_with_pydase_formatter` inside `src/my_service/__init__.py`. This ensures the logger is configured as soon as your service is imported, and before any log messages are emitted.
```python title="src/my_service/__init__.py"
import sys
from pydase.utils.logging import configure_logging_with_pydase_formatter
configure_logging_with_pydase_formatter(
name="my_service", # Use the package/module name or None for the root logger
level=logging.DEBUG, # Set the desired logging level (defaults to INFO)
stream=sys.stderr # Optional: set the output stream (stderr by default)
)
```
### Notes
- If you pass `name=None`, the root logger will be configured. This affects **all logs** that propagate to the root logger.
- Passing a specific `name` like `"my_service"` allows you to scope the configuration to your service only, which is safer in multi-library environments.
- You can use `sys.stdout` instead of `sys.stderr` if your logs are being captured or processed differently (e.g., in containers or logging systems).

View File

@ -1,66 +0,0 @@
# 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 enable persistence, pass a `filename` keyword argument to the constructor of the [`pydase.Server`][pydase.Server] class. The `filename` specifies the file where the state will be saved:
- If the file **does not exist**, it will be created and populated with the current state when the service shuts down or saves.
- If the file **already exists**, the state manager will **load** the saved values into the service at startup.
Heres an example:
```python
import pydase
class Device(pydase.DataService):
# ... define your service class ...
if __name__ == "__main__":
service = Device()
pydase.Server(service=service, filename="device_state.json").run()
```
In this example, the service state will be automatically loaded from `device_state.json` at startup (if it exists), and saved to the same file periodically and upon shutdown.
## Automatic Periodic State Saving
When a `filename` is provided, `pydase` automatically enables **periodic autosaving** of the service state to that file. This ensures that the current state is regularly persisted, reducing the risk of data loss during unexpected shutdowns.
The autosave happens every 30 seconds by default. You can customize the interval using the `autosave_interval` argument (in seconds):
```python
pydase.Server(
service=service,
filename="device_state.json",
autosave_interval=10.0, # save every 10 seconds
).run()
```
To disable automatic saving, set `autosave_interval` to `None`.
## Controlling Property State Loading with `@load_state`
By default, the state manager only restores values for public attributes of your service (i.e. *it does not restore property values*). If you have properties that you want to control the loading for, you can use the [`@load_state`][pydase.data_service.state_manager.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.
Example:
```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 upon server startup.
**Note**: If the structure of your service class changes between saves, only properties decorated with `@load_state` and unchanged public attributes will be restored safely.

View File

@ -1,82 +0,0 @@
# Understanding Tasks
In `pydase`, a task is defined as an asynchronous function without arguments that is decorated with the [`@task`][pydase.task.decorator.task] decorator and contained in a class that inherits from [`pydase.DataService`][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`][pydase.task.decorator.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.
## Task Lifecycle Control
The [`@task`][pydase.task.decorator.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.
## Advanced Task Options
The [`@task`][pydase.task.decorator.task] decorator supports several options inspired by systemd unit services, allowing fine-grained control over task behavior:
- **`autostart`**: Automatically starts the task when the service initializes. Defaults to `False`.
- **`restart_on_exception`**: Configures whether the task should restart if it exits due to an exception (other than `asyncio.CancelledError`). Defaults to `True`.
- **`restart_sec`**: Specifies the delay (in seconds) before restarting a failed task. Defaults to `1.0`.
- **`start_limit_interval_sec`**: Configures a time window (in seconds) for rate limiting task restarts. If the task restarts more than `start_limit_burst` times within this interval, it will no longer restart. Defaults to `None` (disabled).
- **`start_limit_burst`**: Defines the maximum number of restarts allowed within the interval specified by `start_limit_interval_sec`. Defaults to `3`.
- **`exit_on_failure`**: If set to `True`, the service will exit if the task fails and either `restart_on_exception` is `False` or the start rate limiting is exceeded. Defaults to `False`.
### Example with Advanced Options
Here is an example showcasing advanced task options:
```python
import pydase
from pydase.task.decorator import task
class AdvancedTaskService(pydase.DataService):
def __init__(self):
super().__init__()
@task(
autostart=True,
restart_on_exception=True,
restart_sec=2.0,
start_limit_interval_sec=10.0,
start_limit_burst=5,
exit_on_failure=True,
)
async def critical_task(self):
while True:
raise Exception("Critical failure")
if __name__ == "__main__":
service = AdvancedTaskService()
pydase.Server(service=service).run()
```

View File

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

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

@ -1,59 +0,0 @@
# Deploying Services Behind a Reverse Proxy
In some environments, you may need to deploy your services behind a reverse proxy. Typically, this involves adding a CNAME record for your service that points to the reverse proxy in your DNS server. The proxy then routes requests to the `pydase` backend on the appropriate web server port.
However, in scenarios where you dont control the DNS server, or where adding new CNAME records is time-consuming, `pydase` supports **service multiplexing** using a path prefix. This means multiple services can be hosted on a single CNAME (e.g., `services.example.com`), with each service accessible through a unique path such as `services.example.com/my-service`.
To ensure seamless operation, the reverse proxy must strip the path prefix (e.g., `/my-service`) from the request URL and forward it as the `X-Forwarded-Prefix` header. `pydase` then uses this header to dynamically adjust the frontend paths, ensuring all resources are correctly located.
## Example Deployment with Traefik
Below is an example setup using [Traefik](https://doc.traefik.io/traefik/), a widely-used reverse proxy. This configuration demonstrates how to forward requests for a `pydase` service using a path prefix.
### 1. Reverse Proxy Configuration
Save the following configuration to a file (e.g., `/etc/traefik/dynamic_conf/my-service-config.yml`):
```yaml
http:
routers:
my-service-route:
rule: PathPrefix(`/my-service`)
entryPoints:
- web
service: my-service
middlewares:
- strip-prefix
services:
my-service:
loadBalancer:
servers:
- url: http://127.0.0.1:8001
middlewares:
strip-prefix:
stripprefix:
prefixes: /my-service
```
This configuration:
- Routes requests with the path prefix `/my-service` to the `pydase` backend.
- Strips the prefix (`/my-service`) from the request URL using the `stripprefix` middleware.
- Forwards the stripped prefix as the `X-Forwarded-Prefix` header.
### 2. Static Configuration for Traefik
Ensure Traefik is set up to use the dynamic configuration. Add this to your Traefik static configuration (e.g., `/etc/traefik/traefik.yml`):
```yaml
providers:
file:
filename: /etc/traefik/dynamic_conf/my-service-config.yml
entrypoints:
web:
address: ":80"
```
### 3. Accessing the Service
Once configured, your `pydase` service will be accessible at `http://services.example.com/my-service`. The path prefix will be handled transparently by `pydase`, so you dont need to make any changes to your application code or frontend resources.

View File

@ -1,48 +0,0 @@
# Connecting Through a SOCKS5 Proxy
If your target service is only reachable via an SSH gateway or resides behind a
firewall, you can route your [`pydase.Client`][pydase.Client] connection through a local
SOCKS5 proxy. This is particularly useful in network environments where direct access to
the service is not possible.
## Setting Up a SOCKS5 Proxy
You can create a local [SOCKS5 proxy](https://en.wikipedia.org/wiki/SOCKS) using SSH's
`-D` option:
```bash
ssh -D 2222 user@gateway.example.com
```
This command sets up a SOCKS5 proxy on `localhost:2222`, securely forwarding traffic
over the SSH connection.
## Using the Proxy in Your Python Client
Once the proxy is running, configure the [`pydase.Client`][pydase.Client] to route
traffic through it using the `proxy_url` parameter:
```python
import pydase
client = pydase.Client(
url="ws://target-service:8001",
proxy_url="socks5://localhost:2222"
).proxy
```
* You can also use this setup with `wss://` URLs for encrypted WebSocket connections.
## Installing Required Dependencies
To use this feature, you must install the optional `socks` dependency group, which
includes [`aiohttp_socks`](https://pypi.org/project/aiohttp-socks/):
- `poetry`
```bash
poetry add "pydase[socks]"
```
- `pip`
```bash
pip install "pydase[socks]"
```

View File

@ -1,196 +0,0 @@
# Auto-generated Frontend
`pydase` automatically generates a frontend interface based on your service definition, representing the current state and controls of the service.
It simplifies the process of visualization and control of the data and devices managed by your `pydase` service, making it accessible to both developers and end-users.
Through the integration of Socket.IO, the frontend provides real-time updates, reflecting changes as they occur and allowing for immediate interaction with the backend.
## Accessing the Frontend
You can access the auto-generated frontend by navigating to the hostname of the device the service is hosted on, followed by the exposed port:
```
http://<hostname>:<port>/
```
The frontend uses a component-based approach, representing various data types and control mechanisms as distinct UI components. For more information about this, please refer to [Components Guide](../Components.md).
## Customization Options
`pydase` allows you to enhance the user experience by customizing the web interface's appearance through
1. a custom CSS file, and
2. a custom favicon image, and
3. tailoring the frontend component layout and display style.
For more advanced customization, you can provide a completely custom frontend source.
### Custom CSS Styling
You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
Here's how you can use this feature:
1. Prepare your custom CSS file with the desired styles.
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
```python
from pydase import Server, DataService
class MyService(DataService):
# ... your service definition ...
if __name__ == "__main__":
service = MyService()
server = Server(service, css="path/to/your/custom.css").run()
```
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
### Custom favicon image
You can customize the favicon displayed in the browser tab by providing your own favicon image file during the server initialization.
Here's how you can use this feature:
1. Prepare your custom favicon image (e.g. a `.png` file).
2. Pass the path to your favicon file as the `favicon_path` argument when initializing the `Server` class.
Heres an example:
```python
import pydase
class MyService(pydase.DataService):
# ... your service definition ...
if __name__ == "__main__":
service = MyService()
pydase.Server(service, favicon_path="./my/local/my-favicon.png").run()
```
This will serve the specified image instead of the default `pydase` logo.
### Tailoring Frontend Component Layout
You can customize the display names, visibility, and order of components via the `web_settings.json` file.
Each key in the file corresponds to the full access path of public attributes, properties, and methods of the exposed service, using dot-notation.
- **Custom Display Names**: Modify the `"displayName"` value in the file to change how each component appears in the frontend.
- **Control Component Visibility**: Utilize the `"display"` key-value pair to control whether a component is rendered in the frontend. Set the value to `true` to make the component visible or `false` to hide it.
- **Adjustable Component Order**: The `"displayOrder"` values determine the order of components. Alter these values to rearrange the components as desired. The value defaults to [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER).
The `web_settings.json` file will be stored in the directory specified by the `SERVICE_CONFIG_DIR` environment variable. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](../Configuration.md).
For example, styling the following service
```python
import pydase
class Device(pydase.DataService):
name = "My Device"
temperature = 1.0
power = 1
class Service(pydase.DataService):
device = Device()
state = "RUNNING"
if __name__ == "__main__":
pydase.Server(Service()).run()
```
with the following `web_settings.json`
```json
{
"device": {
"displayName": "My Device",
"displayOrder": 1
},
"device.name": {
"display": false
},
"device.power": {
"displayName": "Power",
"displayOrder": 1
},
"device.temperature": {
"displayName": "Temperature",
"displayOrder": 0
},
"state": {
"displayOrder": 0
}
}
```
looks like this:
![Tailoring frontend component layout](../../images/Tailoring_frontend_component_layout.png)
### Specifying a Custom Frontend Source
To further customize your web interface, you can provide a custom frontend source.
By specifying the `frontend_src` parameter when initializing the server, you can host a tailored frontend application:
```python
from pathlib import Path
import pydase
class MyService(pydase.DataService):
# Service definition
if __name__ == "__main__":
service = MyService()
pydase.Server(
service,
frontend_src=Path("path/to/your/frontend/directory"),
).run()
```
`pydase` expects a directory structured as follows:
```bash title="Frontend directory structure"
<your_frontend_directory>
├── assets
│   └── ...
└── index.html
```
Any CSS, js, image or other files need to be put into the assets folder for the web server to be able to provide access to it.
#### Example: Custom React Frontend
You can use vite to generate a react app template:
```bash
npm create vite@latest my-react-app -- --template react
```
*TODO: Add some useful information here...*
To deploy the custom react frontend, build it with
```bash
npm run build
```
and pass the relative path of the output directory to the `frontend_src` parameter of the `pydase.Server`.
**Note** that you have to make sure that all the generated files (except the `index.html`) are in the `assets` folder. In the react app, you can achieve this by not using the `public` folder, but instead using e.g. `src/assets`.

View File

@ -1,97 +0,0 @@
# Python RPC Client
The [`pydase.Client`][pydase.Client] allows you to connect to a remote `pydase` service using Socket.IO, facilitating interaction with the service as though it were running locally.
## Basic Usage
```python
import pydase
# Replace <ip_addr> and <service_port> with the appropriate values for your service
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy
# For SSL-encrypted services, use the wss protocol
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy
# Interact with the service attributes as if they were local
client_proxy.voltage = 5.0
print(client_proxy.voltage) # Expected output: 5.0
```
This example shows how to set and retrieve the `voltage` attribute through the client proxy.
The proxy acts as a local representation of the remote service, enabling intuitive interaction.
The proxy class automatically synchronizes with the server's attributes and methods, keeping itself up-to-date with any changes. This dynamic synchronization essentially mirrors the server's API, making it feel like you're working with a local object.
### Accessing Services Behind Firewalls or SSH Gateways
If your service is only reachable through a private network or SSH gateway, you can route your connection through a local SOCKS5 proxy using the `proxy_url` parameter.
See [Connecting Through a SOCKS5 Proxy](../advanced/SOCKS-Proxy.md) for details.
## Context Manager Support
You can also use the client within a context manager, which automatically handles connection management (i.e., opening and closing the connection):
```python
import pydase
with pydase.Client(url="ws://localhost:8001") as client:
client.proxy.my_method()
```
Using the context manager ensures that connections are cleanly closed once the block of code finishes executing.
## Tab Completion Support
In interactive environments like Python interpreters or Jupyter notebooks, the proxy supports tab completion. This allows users to explore available methods and attributes.
## Integrating the Client into Another Service
You can integrate a `pydase` client proxy within another service. Here's an example of how to set this up:
```python
import pydase
class MyService(pydase.DataService):
proxy = pydase.Client(
url="ws://<ip_addr>:<service_port>",
block_until_connected=False,
client_id="my_pydase_client_id", # optional, defaults to system hostname
).proxy
# For SSL-encrypted services, use the wss protocol
# proxy = pydase.Client(
# url="wss://your-domain.ch",
# block_until_connected=False,
# client_id="my_pydase_client_id",
# ).proxy
if __name__ == "__main__":
service = MyService()
# Create a server that exposes this service
server = pydase.Server(service, web_port=8002).run()
```
In this example:
- The `MyService` class has a `proxy` attribute that connects to a `pydase` service at `<ip_addr>:<service_port>`.
- By setting `block_until_connected=False`, the service can start without waiting for the connection to succeed.
- The `client_id` is optional. If not specified, it defaults to the system hostname, which will be sent in the `X-Client-Id` HTTP header for logging or authentication on the server side.
## Custom `socketio.AsyncClient` Connection Parameters
You can configure advanced connection options by passing arguments to the underlying [`AsyncClient`][socketio.AsyncClient] via `sio_client_kwargs`. For example:
```python
client = pydase.Client(
url="ws://localhost:8001",
sio_client_kwargs={
"reconnection_attempts": 3,
"reconnection_delay": 2,
"reconnection_delay_max": 10,
}
).proxy
```
In this setup, the client will attempt to reconnect three times, with an initial delay of 2 seconds (each successive attempt doubles this delay) and a maximum delay of 10 seconds between attempts.

View File

@ -1,7 +0,0 @@
# Interacting with `pydase` Services
`pydase` offers multiple ways for users to interact with the services they create.
- [Auto-generated Frontend](./Auto-generated-Frontend.md)
- [RESTful API](./RESTful-API.md)
- [Python Client](./Python-Client.md)

View File

@ -1,22 +0,0 @@
# RESTful API
The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes. This is particularly useful for integrating `pydase` services with other applications or for scripting and automation.
For example, you can get a value like this:
```python
import json
import requests
response = requests.get(
"http://<hostname>:<port>/api/v1/get_value?access_path=<full_access_path>"
)
serialized_value = json.loads(response.text)
```
To help developers understand and utilize the API, we provide an OpenAPI specification. This specification describes the available endpoints and corresponding request/response formats.
## OpenAPI Specification
<swagger-ui src="./openapi.yaml"/>

View File

@ -1,326 +0,0 @@
openapi: 3.1.0
info:
version: 1.0.0
title: pydase API
tags:
- name: /api/v1
description: Version 1
paths:
/api/v1/get_value:
get:
tags:
- /api/v1
summary: Get the value of an existing attribute.
description: Get the value of an existing attribute by full access path.
operationId: getValue
parameters:
- in: query
name: access_path
schema:
type: string
example: device.channel[0].voltage
required: true
description: Full access path of the service attribute.
responses:
'200':
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/SerializedAttribute'
examples:
Exists:
summary: Attribute exists
value:
docs: My documentation string.
full_access_path: device.channel[0].voltage
readonly: false
type: float
value: 12.1
'400':
description: Could not get attribute
content:
application/json:
schema:
$ref: '#/components/schemas/SerializedException'
examples:
Attribute:
summary: Attribute does not exist
value:
docs: null
full_access_path: ""
name: AttributeError
readonly: true
type: Exception
value: "'MyService' object has no attribute 'invalid_attribute'"
List:
summary: List index out of range
value:
docs: null
full_access_path: ""
name: IndexError
readonly: true
type: Exception
value: "list index out of range"
/api/v1/update_value:
put:
tags:
- /api/v1
summary: Update an existing attribute.
description: Update an existing attribute by full access path.
operationId: updateValue
requestBody:
description: Update an existent attribute in the service
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateValue'
required: true
responses:
'200':
description: Successful Operation
'400':
description: Could not Update Attribute
content:
application/json:
schema:
$ref: '#/components/schemas/SerializedException'
examples:
Attribute:
summary: Attribute does not exist
value:
docs: null
full_access_path: ""
name: AttributeError
readonly: true
type: Exception
value: "'MyService' object has no attribute 'invalid_attribute'"
ReadOnly:
summary: Attribute is read-only
value:
docs: null
full_access_path: ""
name: AttributeError
readonly: true
type: Exception
value: "property 'readonly_property' of 'MyService' object has no setter"
List:
summary: List index out of range
value:
docs: null
full_access_path: ""
name: IndexError
readonly: true
type: Exception
value: "list index out of range"
/api/v1/trigger_method:
put:
tags:
- /api/v1
summary: Trigger method.
description: Trigger method with by full access path with provided args and kwargs.
operationId: triggerMethod
requestBody:
description: Update an existent attribute in the service
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerMethod'
required: true
responses:
'200':
description: Successful Operation
content:
application/json:
schema:
$ref: '#/components/schemas/SerializedAttribute'
examples:
NoneReturn:
summary: Function returns None
value:
docs: null
full_access_path: ""
readonly: false
type: "NoneType"
value: null
FloatReturn:
summary: Function returns float
value:
docs: null
full_access_path: ""
readonly: false
type: "float"
value: 23.2
'400':
description: Method does not exist
content:
application/json:
schema:
$ref: '#/components/schemas/SerializedException'
examples:
Args:
summary: Wrong number of arguments
value:
docs: null
full_access_path: ""
name: TypeError
readonly: true
type: Exception
value: "MyService.some_function() takes 1 positional argument but 2 were given"
Attribute:
summary: Attribute does not exist
value:
docs: null
full_access_path: ""
name: AttributeError
readonly: true
type: Exception
value: "'MyService' object has no attribute 'invalid_method'"
List:
summary: List index out of range
value:
docs: null
full_access_path: ""
name: IndexError
readonly: true
type: Exception
value: "list index out of range"
Dict:
summary: Dictionary key does not exist
value:
docs: null
full_access_path: ""
name: KeyError
readonly: true
type: Exception
value: "invalid_key"
components:
schemas:
UpdateValue:
required:
- access_path
- value
type: object
properties:
access_path:
type: string
example: device.channel[0].voltage
value:
$ref: '#/components/schemas/SerializedValue'
TriggerMethod:
required:
- access_path
type: object
properties:
access_path:
type: string
example: device.channel[0].voltage
args:
type: object
required:
- type
- value
- full_access_path
properties:
full_access_path:
type: string
example: ""
type:
type: string
enum:
- list
value:
type: array
items:
$ref: '#/components/schemas/SerializedValue'
kwargs:
type: object
required:
- type
- value
- full_access_path
properties:
full_access_path:
type: string
example: ""
type:
type: string
enum:
- dict
value:
type: object
additionalProperties:
$ref: '#/components/schemas/SerializedValue'
SerializedValue:
required:
- full_access_path
- type
- value
type: object
properties:
docs:
type: string | null
example: null
full_access_path:
type: string
example: ""
readonly:
type: boolean
example: false
type:
type: string
example: float
value:
type: any
example: 22.0
SerializedAttribute:
required:
- full_access_path
- type
- value
type: object
properties:
docs:
type: string | null
example: My documentation string.
full_access_path:
type: string
example: device.channel[0].voltage
readonly:
type: boolean
example: false
type:
type: string
example: float
value:
type: any
example: 22.0
SerializedException:
required:
- full_access_path
- type
- value
type: object
properties:
docs:
type: string | null
example: Raised when the access path does not correspond to a valid attribute.
full_access_path:
type: string
example: ""
name:
type: string
example: SerializationPathError
readonly:
type: boolean
example: true
type:
type: string
example: Exception
value:
type: string
examples:
value:
"Index '2': list index out of range"
some:
"Index '2': list index out of range"

16
frontend/.eslintrc Normal file
View File

@ -0,0 +1,16 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"prettier/prettier": "error"
}
}

38
frontend/.gitignore vendored
View File

@ -1,24 +1,20 @@
# Logs # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
logs
*.log # dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,9 +1,11 @@
{ {
"arrowParens": "always", "arrowParens": "always",
"bracketSameLine": true, "bracketSameLine": true,
"endOfLine": "auto", "endOfLine": "auto",
"semi": true, "semi": true,
"singleQuote": false, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,
"printWidth": 88 "vueIndentScriptAndStyle": true,
"printWidth": 88,
"trailingComma": "none"
} }

View File

@ -1,30 +1,70 @@
# React + TypeScript + Vite # Getting Started with Create React App
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
Currently, two official plugins are available: ## Available Scripts
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh In the project directory, you can run:
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration ### `npm start`
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
- Configure the top-level `parserOptions` property like this: The page will reload when you make changes.\
You may also see any lint errors in the console.
```js ### `npm test`
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` Launches the test runner in the interactive watch mode.\
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

@ -1,24 +0,0 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import reactRecommended from "eslint-plugin-react/configs/recommended.js";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
{
files: ["**/*.{js,jsx,ts,tsx}"],
...reactRecommended,
languageOptions: {
parser: tseslint.parser,
},
rules: {
"prettier/prettier": "error",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"@typescript-eslint/no-empty-function": "off",
},
},
eslintPluginPrettierRecommended,
);

View File

@ -1,24 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site displaying a pydase UI." />
</head>
<script>
// this will be set by the python backend if the service is behind a proxy which strips a prefix. The frontend can use this to build the paths to the resources.
window.__FORWARDED_PREFIX__ = "";
window.__FORWARDED_PROTO__ = "";
</script>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

25416
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +1,58 @@
{ {
"name": "pydase", "name": "pydase",
"private": true, "version": "0.1.0",
"version": "0.1.0", "private": true,
"type": "module", "dependencies": {
"scripts": { "@emotion/react": "^11.11.1",
"dev": "vite", "@emotion/styled": "^11.11.0",
"build": "tsc -b && vite build --emptyOutDir", "@fsouza/prettierd": "^0.25.1",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0", "@mui/material": "^5.14.1",
"preview": "vite preview" "@testing-library/jest-dom": "^5.16.5",
}, "@testing-library/react": "^13.4.0",
"dependencies": { "@testing-library/user-event": "^13.5.0",
"@emotion/styled": "^11.14.0", "bootstrap": "^5.3.0",
"@mui/material": "^5.16.14", "react": "^18.2.0",
"bootstrap": "^5.3.3", "react-bootstrap": "^2.8.0",
"deep-equal": "^2.2.3", "react-bootstrap-icons": "^1.10.3",
"react": "^19.0.0", "react-dom": "^18.2.0",
"react-bootstrap": "^2.10.7", "react-scripts": "5.0.1",
"react-bootstrap-icons": "^1.11.5", "socket.io-client": "^4.7.1",
"socket.io-client": "^4.8.1" "web-vitals": "^3.4.0"
}, },
"devDependencies": { "scripts": {
"@eslint/js": "^9.18.0", "start": "NODE_ENV=development react-scripts start",
"@types/deep-equal": "^1.0.4", "build": "BUILD_PATH='../src/pydase/frontend' react-scripts build",
"@types/eslint__js": "^8.42.3", "test": "react-scripts test",
"@types/node": "^20.17.14", "eject": "react-scripts eject"
"@types/react": "^19.0.7", },
"@types/react-dom": "^19.0.3", "eslintConfig": {
"@typescript-eslint/eslint-plugin": "^7.15.0", "extends": [
"@vitejs/plugin-react-swc": "^3.7.2", "react-app",
"eslint": "^8.57.1", "react-app/jest"
"eslint-config-prettier": "^9.1.0", ]
"eslint-plugin-prettier": "^5.2.3", },
"eslint-plugin-react": "^7.37.4", "browserslist": {
"prettier": "3.3.2", "production": [
"typescript": "^5.7.3", ">0.2%",
"typescript-eslint": "^7.18.0", "not dead",
"vite": "^6.3.5" "not op_mini all"
} ],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.9.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.0.3",
"typescript": "^4.9.0"
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site displaying a pydase UI."
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>pydase App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -1,49 +1,43 @@
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useReducer, useState } from 'react';
import { Navbar, Form, Offcanvas, Container } from "react-bootstrap"; import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
import { authority, socket, forwardedProto } from "./socket"; import { hostname, port, socket } from './socket';
import "./App.css"; import './App.css';
import { import {
Notifications, Notifications,
Notification, Notification,
LevelName, LevelName
} from "./components/NotificationsComponent"; } from './components/NotificationsComponent';
import { ConnectionToast } from "./components/ConnectionToast"; import { ConnectionToast } from './components/ConnectionToast';
import { setNestedValueByPath, State } from "./utils/stateUtils"; import { setNestedValueByPath, State } from './utils/stateUtils';
import { WebSettingsContext, WebSetting } from "./WebSettings"; import { WebSettingsContext, WebSetting } from './WebSettings';
import { GenericComponent } from "./components/GenericComponent"; import { SerializedValue, GenericComponent } from './components/GenericComponent';
import { SerializedObject } from "./types/SerializedObject";
import useLocalStorage from "./hooks/useLocalStorage";
type Action = type Action =
| { type: "SET_DATA"; data: State } | { type: 'SET_DATA'; data: State }
| { | {
type: "UPDATE_ATTRIBUTE"; type: 'UPDATE_ATTRIBUTE';
fullAccessPath: string; fullAccessPath: string;
newValue: SerializedObject; newValue: SerializedValue;
}; };
interface UpdateMessage { type UpdateMessage = {
data: { full_access_path: string; value: SerializedObject }; data: { full_access_path: string; value: SerializedValue };
} };
interface LogMessage { type LogMessage = {
levelname: LevelName; levelname: LevelName;
message: string; message: string;
} };
const reducer = (state: State | null, action: Action): State | null => { const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "SET_DATA": case 'SET_DATA':
return action.data; return action.data;
case "UPDATE_ATTRIBUTE": { case 'UPDATE_ATTRIBUTE': {
if (state === null) { if (state === null) {
return null; return null;
} }
return { return {
...state, ...state,
value: setNestedValueByPath( value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
state.value as Record<string, SerializedObject>,
action.fullAccessPath,
action.newValue,
),
}; };
} }
default: default:
@ -52,76 +46,62 @@ const reducer = (state: State | null, action: Action): State | null => {
}; };
const App = () => { const App = () => {
const [state, dispatch] = useReducer(reducer, null); const [state, dispatch] = useReducer(reducer, null);
const [serviceName, setServiceName] = useState<string | null>(null);
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({}); const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
const [isInstantUpdate, setIsInstantUpdate] = useLocalStorage( const [isInstantUpdate, setIsInstantUpdate] = useState(false);
"isInstantUpdate",
false,
);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showNotification, setShowNotification] = useLocalStorage( const [showNotification, setShowNotification] = useState(false);
"showNotification",
false,
);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [connectionStatus, setConnectionStatus] = useState("connecting"); const [connectionStatus, setConnectionStatus] = useState('connecting');
useEffect(() => { useEffect(() => {
// Allow the user to add a custom css file // Allow the user to add a custom css file
fetch(`${forwardedProto}://${authority}/custom.css`, { credentials: "include" }) fetch(`http://${hostname}:${port}/custom.css`)
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
// If the file exists, create a link element for the custom CSS // If the file exists, create a link element for the custom CSS
const link = document.createElement("link"); const link = document.createElement('link');
link.href = `${forwardedProto}://${authority}/custom.css`; link.href = `http://${hostname}:${port}/custom.css`;
link.type = "text/css"; link.type = 'text/css';
link.rel = "stylesheet"; link.rel = 'stylesheet';
document.head.appendChild(link); document.head.appendChild(link);
} }
}) })
.catch(console.error); // Handle the error appropriately .catch(console.error); // Handle the error appropriately
socket.on("connect", () => { socket.on('connect', () => {
// Fetch data from the API when the client connects // Fetch data from the API when the client connects
fetch(`${forwardedProto}://${authority}/service-properties`, { fetch(`http://${hostname}:${port}/service-properties`)
credentials: "include",
})
.then((response) => response.json()) .then((response) => response.json())
.then((data: State) => { .then((data: State) => dispatch({ type: 'SET_DATA', data }));
dispatch({ type: "SET_DATA", data }); fetch(`http://${hostname}:${port}/web-settings`)
setServiceName(data.name);
document.title = data.name; // Setting browser tab title
});
fetch(`${forwardedProto}://${authority}/web-settings`, { credentials: "include" })
.then((response) => response.json()) .then((response) => response.json())
.then((data: Record<string, WebSetting>) => setWebSettings(data)); .then((data: Record<string, WebSetting>) => setWebSettings(data));
setConnectionStatus("connected"); setConnectionStatus('connected');
}); });
socket.on("disconnect", () => { socket.on('disconnect', () => {
setConnectionStatus("disconnected"); setConnectionStatus('disconnected');
setTimeout(() => { setTimeout(() => {
// Only set "reconnecting" is the state is still "disconnected" // Only set "reconnecting" is the state is still "disconnected"
// E.g. when the client has already reconnected // E.g. when the client has already reconnected
setConnectionStatus((currentState) => setConnectionStatus((currentState) =>
currentState === "disconnected" ? "reconnecting" : currentState, currentState === 'disconnected' ? 'reconnecting' : currentState
); );
}, 2000); }, 2000);
}); });
socket.on("notify", onNotify); socket.on('notify', onNotify);
socket.on("log", onLogMessage); socket.on('log', onLogMessage);
return () => { return () => {
socket.off("notify", onNotify); socket.off('notify', onNotify);
socket.off("log", onLogMessage); socket.off('log', onLogMessage);
}; };
}, []); }, []);
// Adding useCallback to prevent notify to change causing a re-render of all // Adding useCallback to prevent notify to change causing a re-render of all
// components // components
const addNotification = useCallback( const addNotification = useCallback(
(message: string, levelname: LevelName = "DEBUG") => { (message: string, levelname: LevelName = 'DEBUG') => {
// Getting the current time in the required format // Getting the current time in the required format
const timeStamp = new Date().toISOString().substring(11, 19); const timeStamp = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it // Adding an id to the notification to provide a way of removing it
@ -130,15 +110,15 @@ const App = () => {
// Custom logic for notifications // Custom logic for notifications
setNotifications((prevNotifications) => [ setNotifications((prevNotifications) => [
{ levelname, id, message, timeStamp }, { levelname, id, message, timeStamp },
...prevNotifications, ...prevNotifications
]); ]);
}, },
[], []
); );
const removeNotificationById = (id: number) => { const removeNotificationById = (id: number) => {
setNotifications((prevNotifications) => setNotifications((prevNotifications) =>
prevNotifications.filter((n) => n.id !== id), prevNotifications.filter((n) => n.id !== id)
); );
}; };
@ -151,9 +131,9 @@ const App = () => {
// Dispatching the update to the reducer // Dispatching the update to the reducer
dispatch({ dispatch({
type: "UPDATE_ATTRIBUTE", type: 'UPDATE_ATTRIBUTE',
fullAccessPath, fullAccessPath,
newValue, newValue
}); });
} }
@ -169,7 +149,7 @@ const App = () => {
<> <>
<Navbar expand={false} bg="primary" variant="dark" fixed="top"> <Navbar expand={false} bg="primary" variant="dark" fixed="top">
<Container fluid> <Container fluid>
<Navbar.Brand>{serviceName}</Navbar.Brand> <Navbar.Brand>Data Service App</Navbar.Brand>
<Navbar.Toggle aria-controls="offcanvasNavbar" onClick={handleShowSettings} /> <Navbar.Toggle aria-controls="offcanvasNavbar" onClick={handleShowSettings} />
</Container> </Container>
</Navbar> </Navbar>
@ -208,7 +188,7 @@ const App = () => {
<div className="App navbarOffset"> <div className="App navbarOffset">
<WebSettingsContext.Provider value={webSettings}> <WebSettingsContext.Provider value={webSettings}>
<GenericComponent <GenericComponent
attribute={state as SerializedObject} attribute={state as SerializedValue}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
/> />

View File

@ -1,9 +1,9 @@
import { createContext } from "react"; import { createContext } from 'react';
export const WebSettingsContext = createContext<Record<string, WebSetting>>({}); export const WebSettingsContext = createContext<Record<string, WebSetting>>({});
export interface WebSetting { export type WebSetting = {
displayName: string; displayName: string;
display: boolean; display: boolean;
displayOrder: number; index: number;
} };

View File

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

View File

@ -1,21 +1,20 @@
import React, { useEffect } from "react"; import React, { useEffect, useRef } from 'react';
import { ToggleButton } from "react-bootstrap"; import { ToggleButton } from 'react-bootstrap';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import { LevelName } from "./NotificationsComponent"; import { SerializedValue } from './GenericComponent';
import { SerializedObject } from "../types/SerializedObject"; import { LevelName } from './NotificationsComponent';
import useRenderCount from "../hooks/useRenderCount";
interface ButtonComponentProps { type ButtonComponentProps = {
fullAccessPath: string; fullAccessPath: string;
value: boolean; value: boolean;
readOnly: boolean; readOnly: boolean;
docString: string | null; docString: string;
mapping?: [string, string]; // Enforce a tuple of two strings mapping?: [string, string]; // Enforce a tuple of two strings
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void; changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName: string; displayName: string;
id: string; id: string;
} };
export const ButtonComponent = React.memo((props: ButtonComponentProps) => { export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const { const {
@ -26,11 +25,15 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
addNotification, addNotification,
changeCallback = () => {}, changeCallback = () => {},
displayName, displayName,
id, id
} = props; } = props;
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name; // const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
const renderCount = useRenderCount(); const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
});
useEffect(() => { useEffect(() => {
addNotification(`${fullAccessPath} changed to ${value}.`); addNotification(`${fullAccessPath} changed to ${value}.`);
@ -38,22 +41,24 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const setChecked = (checked: boolean) => { const setChecked = (checked: boolean) => {
changeCallback({ changeCallback({
type: "bool", type: 'bool',
value: checked, value: checked,
full_access_path: fullAccessPath, full_access_path: fullAccessPath,
readonly: readOnly, readonly: readOnly,
doc: docString, doc: docString
}); });
}; };
return ( return (
<div className={"component buttonComponent"} id={id}> <div className={'component buttonComponent'} id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<ToggleButton <ToggleButton
id={`toggle-check-${id}`} id={`toggle-check-${id}`}
type="checkbox" type="checkbox"
variant={value ? "success" : "secondary"} variant={value ? 'success' : 'secondary'}
checked={value} checked={value}
value={displayName} value={displayName}
disabled={readOnly} disabled={readOnly}
@ -64,5 +69,3 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
</div> </div>
); );
}); });
ButtonComponent.displayName = "ButtonComponent";

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from 'react';
import { Toast, Button, ToastContainer } from "react-bootstrap"; import { Toast, Button, ToastContainer } from 'react-bootstrap';
interface ConnectionToastProps { type ConnectionToastProps = {
connectionStatus: string; connectionStatus: string;
} };
/** /**
* ConnectionToast Component * ConnectionToast Component
@ -36,31 +36,31 @@ export const ConnectionToast = React.memo(
delay: number | undefined; delay: number | undefined;
} => { } => {
switch (connectionStatus) { switch (connectionStatus) {
case "connecting": case 'connecting':
return { return {
message: "Connecting...", message: 'Connecting...',
bg: "info", bg: 'info',
delay: undefined, delay: undefined
}; };
case "connected": case 'connected':
return { message: "Connected", bg: "success", delay: 1000 }; return { message: 'Connected', bg: 'success', delay: 1000 };
case "disconnected": case 'disconnected':
return { return {
message: "Disconnected", message: 'Disconnected',
bg: "danger", bg: 'danger',
delay: undefined, delay: undefined
}; };
case "reconnecting": case 'reconnecting':
return { return {
message: "Reconnecting...", message: 'Reconnecting...',
bg: "info", bg: 'info',
delay: undefined, delay: undefined
}; };
default: default:
return { return {
message: "", message: '',
bg: "info", bg: 'info',
delay: undefined, delay: undefined
}; };
} }
}; };
@ -82,7 +82,5 @@ export const ConnectionToast = React.memo(
</Toast> </Toast>
</ToastContainer> </ToastContainer>
); );
}, }
); );
ConnectionToast.displayName = "ConnectionToast";

View File

@ -1,41 +1,36 @@
import React from "react"; import { useState } from 'react';
import { Card, Collapse } from "react-bootstrap"; import React from 'react';
import { ChevronDown, ChevronRight } from "react-bootstrap-icons"; import { Card, Collapse } from 'react-bootstrap';
import { GenericComponent } from "./GenericComponent"; import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { LevelName } from "./NotificationsComponent"; import { SerializedValue, GenericComponent } from './GenericComponent';
import { SerializedObject } from "../types/SerializedObject"; import { LevelName } from './NotificationsComponent';
import useLocalStorage from "../hooks/useLocalStorage";
import useSortedEntries from "../hooks/useSortedEntries";
interface DataServiceProps { type DataServiceProps = {
props: DataServiceJSON; props: DataServiceJSON;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string; displayName: string;
id: string; id: string;
} };
export type DataServiceJSON = Record<string, SerializedObject>; export type DataServiceJSON = Record<string, SerializedValue>;
export const DataServiceComponent = React.memo( export const DataServiceComponent = React.memo(
({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => { ({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => {
// Retrieve the initial state from localStorage, default to true if not found const [open, setOpen] = useState(true);
const [open, setOpen] = useLocalStorage(`dataServiceComponent-${id}-open`, true);
const sortedEntries = useSortedEntries(props); if (displayName !== '') {
if (displayName !== "") {
return ( return (
<div className="component dataServiceComponent" id={id}> <div className="component dataServiceComponent" id={id}>
<Card> <Card>
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: "pointer" }}> <Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
{displayName} {open ? <ChevronDown /> : <ChevronRight />} {displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header> </Card.Header>
<Collapse in={open}> <Collapse in={open}>
<Card.Body> <Card.Body>
{sortedEntries.map((value) => ( {Object.entries(props).map(([key, value]) => (
<GenericComponent <GenericComponent
key={value.full_access_path} key={key}
attribute={value} attribute={value}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
@ -49,9 +44,9 @@ export const DataServiceComponent = React.memo(
} else { } else {
return ( return (
<div className="component dataServiceComponent" id={id}> <div className="component dataServiceComponent" id={id}>
{sortedEntries.map((value) => ( {Object.entries(props).map(([key, value]) => (
<GenericComponent <GenericComponent
key={value.full_access_path} key={key}
attribute={value} attribute={value}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
@ -60,7 +55,5 @@ export const DataServiceComponent = React.memo(
</div> </div>
); );
} }
}, }
); );
DataServiceComponent.displayName = "DataServiceComponent";

View File

@ -1,16 +1,16 @@
import React from "react"; import React from 'react';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent"; import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { MethodComponent } from "./MethodComponent"; import { MethodComponent } from './MethodComponent';
interface DeviceConnectionProps { type DeviceConnectionProps = {
fullAccessPath: string; fullAccessPath: string;
props: DataServiceJSON; props: DataServiceJSON;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string; displayName: string;
id: string; id: string;
} };
export const DeviceConnectionComponent = React.memo( export const DeviceConnectionComponent = React.memo(
({ ({
@ -19,7 +19,7 @@ export const DeviceConnectionComponent = React.memo(
isInstantUpdate, isInstantUpdate,
addNotification, addNotification,
displayName, displayName,
id, id
}: DeviceConnectionProps) => { }: DeviceConnectionProps) => {
const { connected, connect, ...updatedProps } = props; const { connected, connect, ...updatedProps } = props;
const connectedVal = connected.value; const connectedVal = connected.value;
@ -29,14 +29,14 @@ export const DeviceConnectionComponent = React.memo(
{!connectedVal && ( {!connectedVal && (
<div className="overlayContent"> <div className="overlayContent">
<div> <div>
{displayName != "" ? displayName : "Device"} is currently not available! {displayName != '' ? displayName : 'Device'} is currently not available!
</div> </div>
<MethodComponent <MethodComponent
fullAccessPath={`${fullAccessPath}.connect`} fullAccessPath={`${fullAccessPath}.connect`}
docString={connect.doc} docString={connect.doc}
addNotification={addNotification} addNotification={addNotification}
displayName={"reconnect"} displayName={'reconnect'}
id={id + "-connect"} id={id + '-connect'}
render={true} render={true}
/> />
</div> </div>
@ -50,7 +50,5 @@ export const DeviceConnectionComponent = React.memo(
/> />
</div> </div>
); );
}, }
); );
DeviceConnectionComponent.displayName = "DeviceConnectionComponent";

View File

@ -1,30 +1,33 @@
import React from "react"; import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import { GenericComponent } from "./GenericComponent"; import { SerializedValue, GenericComponent } from './GenericComponent';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import { SerializedObject } from "../types/SerializedObject";
import useRenderCount from "../hooks/useRenderCount";
import useSortedEntries from "../hooks/useSortedEntries";
interface DictComponentProps { type DictComponentProps = {
value: Record<string, SerializedObject>; value: Record<string, SerializedValue>;
docString: string | null; docString: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
id: string; id: string;
} };
export const DictComponent = React.memo((props: DictComponentProps) => { export const DictComponent = React.memo((props: DictComponentProps) => {
const { docString, isInstantUpdate, addNotification, id } = props; const { value, docString, isInstantUpdate, addNotification, id } = props;
const sortedEntries = useSortedEntries(props.value); const renderCount = useRef(0);
const renderCount = useRenderCount(); const valueArray = Object.values(value);
useEffect(() => {
renderCount.current++;
}, [props]);
return ( return (
<div className={"listComponent"} id={id}> <div className={'listComponent'} id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} /> <DocStringComponent docString={docString} />
{sortedEntries.map((item) => { {valueArray.map((item) => {
return ( return (
<GenericComponent <GenericComponent
key={item.full_access_path} key={item.full_access_path}
@ -37,5 +40,3 @@ export const DictComponent = React.memo((props: DictComponentProps) => {
</div> </div>
); );
}); });
DictComponent.displayName = "DictComponent";

View File

@ -1,9 +1,9 @@
import { Badge, Tooltip, OverlayTrigger } from "react-bootstrap"; import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
import React from "react"; import React from 'react';
interface DocStringProps { type DocStringProps = {
docString?: string | null; docString?: string;
} };
export const DocStringComponent = React.memo((props: DocStringProps) => { export const DocStringComponent = React.memo((props: DocStringProps) => {
const { docString } = props; const { docString } = props;
@ -21,5 +21,3 @@ export const DocStringComponent = React.memo((props: DocStringProps) => {
</OverlayTrigger> </OverlayTrigger>
); );
}); });
DocStringComponent.displayName = "DocStringComponent";

View File

@ -1,40 +1,64 @@
import React, { useEffect } from "react"; import React, { useEffect, useRef, useState } from 'react';
import { InputGroup, Form, Row, Col } from "react-bootstrap"; import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import { LevelName } from "./NotificationsComponent"; import { SerializedValue } from './GenericComponent';
import { SerializedObject, SerializedEnum } from "../types/SerializedObject"; import { LevelName } from './NotificationsComponent';
import { propsAreEqual } from "../utils/propsAreEqual";
import useRenderCount from "../hooks/useRenderCount";
interface EnumComponentProps extends SerializedEnum { export type EnumSerialization = {
type: 'Enum' | 'ColouredEnum';
full_access_path: string;
name: string;
value: string;
readonly: boolean;
doc?: string | null;
enum: Record<string, string>;
};
type EnumComponentProps = {
attribute: EnumSerialization;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string; displayName: string;
id: string; id: string;
changeCallback: (value: SerializedObject, callback?: (ack: unknown) => void) => void; changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
} };
export const EnumComponent = React.memo((props: EnumComponentProps) => { export const EnumComponent = React.memo((props: EnumComponentProps) => {
const { attribute, addNotification, displayName, id } = props;
const { const {
addNotification,
displayName,
id,
value,
full_access_path: fullAccessPath, full_access_path: fullAccessPath,
enum: enumDict, value,
doc: docString, doc: docString,
readonly: readOnly, enum: enumDict,
changeCallback, readonly: readOnly
} = props; } = attribute;
const renderCount = useRenderCount(); let { changeCallback } = props;
if (changeCallback === undefined) {
changeCallback = (value: SerializedValue) => {
setEnumValue(() => {
return String(value.value);
});
};
}
const renderCount = useRef(0);
const [enumValue, setEnumValue] = useState(value);
useEffect(() => { useEffect(() => {
renderCount.current++;
});
useEffect(() => {
setEnumValue(() => {
return value;
});
addNotification(`${fullAccessPath} changed to ${value}.`); addNotification(`${fullAccessPath} changed to ${value}.`);
}, [value]); }, [value]);
return ( return (
<div className={"component enumComponent"} id={id}> <div className={'component enumComponent'} id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<Row> <Row>
<Col className="d-flex align-items-center"> <Col className="d-flex align-items-center">
<InputGroup.Text> <InputGroup.Text>
@ -46,9 +70,11 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
// Display the Form.Control when readOnly is true // Display the Form.Control when readOnly is true
<Form.Control <Form.Control
style={ style={
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {} attribute.type == 'ColouredEnum'
? { backgroundColor: enumDict[enumValue] }
: {}
} }
value={props.type == "ColouredEnum" ? value : enumDict[value]} value={attribute.type == 'ColouredEnum' ? enumValue : enumDict[enumValue]}
name={fullAccessPath} name={fullAccessPath}
disabled={true} disabled={true}
/> />
@ -56,25 +82,27 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
// Display the Form.Select when readOnly is false // Display the Form.Select when readOnly is false
<Form.Select <Form.Select
aria-label="example-select" aria-label="example-select"
value={value} value={enumValue}
name={fullAccessPath} name={fullAccessPath}
style={ style={
props.type == "ColouredEnum" ? { backgroundColor: enumDict[value] } : {} attribute.type == 'ColouredEnum'
? { backgroundColor: enumDict[enumValue] }
: {}
} }
onChange={(event) => onChange={(event) =>
changeCallback({ changeCallback({
type: props.type, type: attribute.type,
name: props.name, name: attribute.name,
enum: enumDict, enum: enumDict,
value: event.target.value, value: event.target.value,
full_access_path: fullAccessPath, full_access_path: fullAccessPath,
readonly: props.readonly, readonly: attribute.readonly,
doc: props.doc, doc: attribute.doc
}) })
}> }>
{Object.entries(enumDict).map(([key, val]) => ( {Object.entries(enumDict).map(([key, val]) => (
<option key={key} value={key}> <option key={key} value={key}>
{props.type == "ColouredEnum" ? key : val} {attribute.type == 'ColouredEnum' ? key : val}
</option> </option>
))} ))}
</Form.Select> </Form.Select>
@ -83,6 +111,4 @@ export const EnumComponent = React.memo((props: EnumComponentProps) => {
</Row> </Row>
</div> </div>
); );
}, propsAreEqual); });
EnumComponent.displayName = "EnumComponent";

View File

@ -1,34 +1,62 @@
import React, { useContext } from "react"; import React, { useContext } from 'react';
import { ButtonComponent } from "./ButtonComponent"; import { ButtonComponent } from './ButtonComponent';
import { NumberComponent, NumberObject } from "./NumberComponent"; import { NumberComponent } from './NumberComponent';
import { SliderComponent } from "./SliderComponent"; import { SliderComponent } from './SliderComponent';
import { EnumComponent } from "./EnumComponent"; import { EnumComponent, EnumSerialization } from './EnumComponent';
import { MethodComponent } from "./MethodComponent"; import { MethodComponent } from './MethodComponent';
import { StringComponent } from "./StringComponent"; import { AsyncMethodComponent } from './AsyncMethodComponent';
import { ListComponent } from "./ListComponent"; import { StringComponent } from './StringComponent';
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent"; import { ListComponent } from './ListComponent';
import { DeviceConnectionComponent } from "./DeviceConnection"; import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { ImageComponent } from "./ImageComponent"; import { DeviceConnectionComponent } from './DeviceConnection';
import { LevelName } from "./NotificationsComponent"; import { ImageComponent } from './ImageComponent';
import { getIdFromFullAccessPath } from "../utils/stringUtils"; import { LevelName } from './NotificationsComponent';
import { WebSettingsContext } from "../WebSettings"; import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { updateValue } from "../socket"; import { WebSettingsContext } from '../WebSettings';
import { DictComponent } from "./DictComponent"; import { updateValue } from '../socket';
import { parseFullAccessPath } from "../utils/stateUtils"; import { DictComponent } from './DictComponent';
import { SerializedEnum, SerializedObject } from "../types/SerializedObject"; import { parseFullAccessPath } from '../utils/stateUtils';
import { TaskComponent, TaskStatus } from "./TaskComponent";
interface GenericComponentProps { type AttributeType =
attribute: SerializedObject; | 'str'
| 'bool'
| 'float'
| 'int'
| 'Quantity'
| 'None'
| 'list'
| 'dict'
| 'method'
| 'DataService'
| 'DeviceConnection'
| 'Enum'
| 'NumberSlider'
| 'Image'
| 'ColouredEnum';
type ValueType = boolean | string | number | Record<string, unknown>;
export type SerializedValue = {
type: AttributeType;
full_access_path: string;
name?: string;
value?: ValueType | ValueType[];
readonly: boolean;
doc?: string | null;
async?: boolean;
frontend_render?: boolean;
enum?: Record<string, string>;
};
type GenericComponentProps = {
attribute: SerializedValue;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
} };
const getPathFromPathParts = (pathParts: string[]): string => { const getPathFromPathParts = (pathParts: string[]): string => {
let path = ""; let path = '';
for (const pathPart of pathParts) { for (const pathPart of pathParts) {
if (!pathPart.startsWith("[") && path !== "") { if (!pathPart.startsWith('[') && path !== '') {
path += "."; path += '.';
} }
path += pathPart; path += pathPart;
} }
@ -41,20 +69,13 @@ const createDisplayNameFromAccessPath = (fullAccessPath: string): string => {
for (let i = parsedFullAccessPath.length - 1; i >= 0; i--) { for (let i = parsedFullAccessPath.length - 1; i >= 0; i--) {
const item = parsedFullAccessPath[i]; const item = parsedFullAccessPath[i];
displayNameParts.unshift(item); displayNameParts.unshift(item);
if (!item.startsWith("[")) { if (!item.startsWith('[')) {
break; break;
} }
} }
return getPathFromPathParts(displayNameParts); return getPathFromPathParts(displayNameParts);
}; };
function changeCallback(
value: SerializedObject,
callback: (ack: unknown) => void = () => {},
) {
updateValue(value, callback);
}
export const GenericComponent = React.memo( export const GenericComponent = React.memo(
({ attribute, isInstantUpdate, addNotification }: GenericComponentProps) => { ({ attribute, isInstantUpdate, addNotification }: GenericComponentProps) => {
const { full_access_path: fullAccessPath } = attribute; const { full_access_path: fullAccessPath } = attribute;
@ -72,7 +93,14 @@ export const GenericComponent = React.memo(
} }
} }
if (attribute.type === "bool") { function changeCallback(
value: SerializedValue,
callback: (ack: unknown) => void = undefined
) {
updateValue(value, callback);
}
if (attribute.type === 'bool') {
return ( return (
<ButtonComponent <ButtonComponent
fullAccessPath={fullAccessPath} fullAccessPath={fullAccessPath}
@ -85,7 +113,7 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type === "float" || attribute.type === "int") { } else if (attribute.type === 'float' || attribute.type === 'int') {
return ( return (
<NumberComponent <NumberComponent
type={attribute.type} type={attribute.type}
@ -100,15 +128,15 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type === "Quantity") { } else if (attribute.type === 'Quantity') {
return ( return (
<NumberComponent <NumberComponent
type="Quantity" type="Quantity"
fullAccessPath={fullAccessPath} fullAccessPath={fullAccessPath}
docString={attribute.doc} docString={attribute.doc}
readOnly={attribute.readonly} readOnly={attribute.readonly}
value={Number(attribute.value["magnitude"])} value={Number(attribute.value['magnitude'])}
unit={attribute.value["unit"]} unit={attribute.value['unit']}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback} changeCallback={changeCallback}
@ -116,16 +144,16 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type === "NumberSlider") { } else if (attribute.type === 'NumberSlider') {
return ( return (
<SliderComponent <SliderComponent
fullAccessPath={fullAccessPath} fullAccessPath={fullAccessPath}
docString={attribute.value["value"].doc} docString={attribute.value['value'].doc}
readOnly={attribute.readonly} readOnly={attribute.readonly}
value={attribute.value["value"] as NumberObject} value={attribute.value['value']}
min={attribute.value["min"] as NumberObject} min={attribute.value['min']}
max={attribute.value["max"] as NumberObject} max={attribute.value['max']}
stepSize={attribute.value["step_size"] as NumberObject} stepSize={attribute.value['step_size']}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback} changeCallback={changeCallback}
@ -133,28 +161,42 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type === "Enum" || attribute.type === "ColouredEnum") { } else if (attribute.type === 'Enum' || attribute.type === 'ColouredEnum') {
return ( return (
<EnumComponent <EnumComponent
{...(attribute as SerializedEnum)} attribute={attribute as EnumSerialization}
addNotification={addNotification} addNotification={addNotification}
changeCallback={changeCallback} changeCallback={changeCallback}
displayName={displayName} displayName={displayName}
id={id} id={id}
/> />
); );
} else if (attribute.type === "method") { } else if (attribute.type === 'method') {
return ( if (!attribute.async) {
<MethodComponent return (
fullAccessPath={fullAccessPath} <MethodComponent
docString={attribute.doc} fullAccessPath={fullAccessPath}
addNotification={addNotification} docString={attribute.doc}
displayName={displayName} addNotification={addNotification}
id={id} displayName={displayName}
render={attribute.frontend_render} id={id}
/> render={attribute.frontend_render}
); />
} else if (attribute.type === "str") { );
} 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') {
return ( return (
<StringComponent <StringComponent
fullAccessPath={fullAccessPath} fullAccessPath={fullAccessPath}
@ -168,18 +210,7 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type == "Task") { } else if (attribute.type === 'DataService') {
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") {
return ( return (
<DataServiceComponent <DataServiceComponent
props={attribute.value as DataServiceJSON} props={attribute.value as DataServiceJSON}
@ -189,7 +220,7 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type === "DeviceConnection") { } else if (attribute.type === 'DeviceConnection') {
return ( return (
<DeviceConnectionComponent <DeviceConnectionComponent
fullAccessPath={fullAccessPath} fullAccessPath={fullAccessPath}
@ -200,42 +231,41 @@ export const GenericComponent = React.memo(
id={id} id={id}
/> />
); );
} else if (attribute.type === "list") { } else if (attribute.type === 'list') {
return ( return (
<ListComponent <ListComponent
value={attribute.value} value={attribute.value as SerializedValue[]}
docString={attribute.doc} docString={attribute.doc}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
id={id} id={id}
/> />
); );
} else if (attribute.type === "dict") { } else if (attribute.type === 'dict') {
return ( return (
<DictComponent <DictComponent
value={attribute.value} value={attribute.value as Record<string, SerializedValue>}
docString={attribute.doc} docString={attribute.doc}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
id={id} id={id}
/> />
); );
} else if (attribute.type === "Image") { } else if (attribute.type === 'Image') {
return ( return (
<ImageComponent <ImageComponent
fullAccessPath={fullAccessPath} fullAccessPath={fullAccessPath}
docString={attribute.value["value"].doc} docString={attribute.value['value'].doc}
displayName={displayName} displayName={displayName}
id={id} id={id}
addNotification={addNotification} addNotification={addNotification}
value={attribute.value["value"]["value"] as string} // Add any other specific props for the ImageComponent here
format={attribute.value["format"]["value"] as string} value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string}
/> />
); );
} else { } else {
return <div key={fullAccessPath}>{fullAccessPath}</div>; return <div key={fullAccessPath}>{fullAccessPath}</div>;
} }
}, }
); );
GenericComponent.displayName = "GenericComponent";

View File

@ -1,27 +1,30 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from "react-bootstrap"; import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from "react-bootstrap-icons"; import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import useRenderCount from "../hooks/useRenderCount";
interface ImageComponentProps { type ImageComponentProps = {
fullAccessPath: string; fullAccessPath: string;
value: string; value: string;
docString: string | null; docString: string;
format: string; format: string;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string; displayName: string;
id: string; id: string;
} };
export const ImageComponent = React.memo((props: ImageComponentProps) => { export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { fullAccessPath, value, docString, format, addNotification, displayName, id } = const { fullAccessPath, value, docString, format, addNotification, displayName, id } =
props; props;
const renderCount = useRenderCount(); const renderCount = useRef(0);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
useEffect(() => {
renderCount.current++;
});
useEffect(() => { useEffect(() => {
addNotification(`${fullAccessPath} changed.`); addNotification(`${fullAccessPath} changed.`);
}, [props.value]); }, [props.value]);
@ -31,7 +34,7 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
<Card> <Card>
<Card.Header <Card.Header
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
style={{ cursor: "pointer" }} // Change cursor style on hover style={{ cursor: 'pointer' }} // Change cursor style on hover
> >
{displayName} {displayName}
<DocStringComponent docString={docString} /> <DocStringComponent docString={docString} />
@ -39,10 +42,10 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
</Card.Header> </Card.Header>
<Collapse in={open}> <Collapse in={open}>
<Card.Body> <Card.Body>
{process.env.NODE_ENV === "development" && ( {process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount}</p> <p>Render count: {renderCount.current}</p>
)} )}
{format === "" && value === "" ? ( {format === '' && value === '' ? (
<p>No image set in the backend.</p> <p>No image set in the backend.</p>
) : ( ) : (
<Image src={`data:image/${format.toLowerCase()};base64,${value}`}></Image> <Image src={`data:image/${format.toLowerCase()};base64,${value}`}></Image>
@ -53,5 +56,3 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
</div> </div>
); );
}); });
ImageComponent.displayName = "ImageComponent";

View File

@ -1,31 +1,32 @@
import React from "react"; import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import { GenericComponent } from "./GenericComponent"; import { SerializedValue, GenericComponent } from './GenericComponent';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import { SerializedObject } from "../types/SerializedObject";
import useRenderCount from "../hooks/useRenderCount";
import useSortedEntries from "../hooks/useSortedEntries";
interface ListComponentProps { type ListComponentProps = {
value: SerializedObject[]; value: SerializedValue[];
docString: string | null; docString: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
id: string; id: string;
} };
export const ListComponent = React.memo((props: ListComponentProps) => { export const ListComponent = React.memo((props: ListComponentProps) => {
const { docString, isInstantUpdate, addNotification, id } = props; const { value, docString, isInstantUpdate, addNotification, id } = props;
const sortedEntries = useSortedEntries(props.value); const renderCount = useRef(0);
const renderCount = useRenderCount(); useEffect(() => {
renderCount.current++;
}, [props]);
return ( return (
<div className={"listComponent"} id={id}> <div className={'listComponent'} id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} /> <DocStringComponent docString={docString} />
{sortedEntries.map((item) => { {value.map((item) => {
return ( return (
<GenericComponent <GenericComponent
key={item.full_access_path} key={item.full_access_path}
@ -38,5 +39,3 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
</div> </div>
); );
}); });
ListComponent.displayName = "ListComponent";

View File

@ -1,19 +1,17 @@
import React, { useRef } from "react"; import React, { useEffect, useRef } from 'react';
import { runMethod } from "../socket"; import { runMethod } from '../socket';
import { Button, Form } from "react-bootstrap"; import { Button, Form } from 'react-bootstrap';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import useRenderCount from "../hooks/useRenderCount";
import { propsAreEqual } from "../utils/propsAreEqual";
interface MethodProps { type MethodProps = {
fullAccessPath: string; fullAccessPath: string;
docString: string | null; docString?: string;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string; displayName: string;
id: string; id: string;
render: boolean; render: boolean;
} };
export const MethodComponent = React.memo((props: MethodProps) => { export const MethodComponent = React.memo((props: MethodProps) => {
const { fullAccessPath, docString, addNotification, displayName, id } = props; const { fullAccessPath, docString, addNotification, displayName, id } = props;
@ -23,7 +21,7 @@ export const MethodComponent = React.memo((props: MethodProps) => {
return null; return null;
} }
const renderCount = useRenderCount(); const renderCount = useRef(0);
const formRef = useRef(null); const formRef = useRef(null);
const triggerNotification = () => { const triggerNotification = () => {
@ -39,9 +37,15 @@ export const MethodComponent = React.memo((props: MethodProps) => {
triggerNotification(); triggerNotification();
}; };
useEffect(() => {
renderCount.current++;
});
return ( return (
<div className="component methodComponent" id={id}> <div className="component methodComponent" id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<Form onSubmit={execute} ref={formRef}> <Form onSubmit={execute} ref={formRef}>
<Button className="component" variant="primary" type="submit"> <Button className="component" variant="primary" type="submit">
{`${displayName} `} {`${displayName} `}
@ -50,6 +54,4 @@ export const MethodComponent = React.memo((props: MethodProps) => {
</Form> </Form>
</div> </div>
); );
}, propsAreEqual); });
MethodComponent.displayName = "MethodComponent";

View File

@ -1,19 +1,19 @@
import React from "react"; import React from 'react';
import { ToastContainer, Toast } from "react-bootstrap"; import { ToastContainer, Toast } from 'react-bootstrap';
export type LevelName = "CRITICAL" | "ERROR" | "WARNING" | "INFO" | "DEBUG"; export type LevelName = 'CRITICAL' | 'ERROR' | 'WARNING' | 'INFO' | 'DEBUG';
export interface Notification { export type Notification = {
id: number; id: number;
timeStamp: string; timeStamp: string;
message: string; message: string;
levelname: LevelName; levelname: LevelName;
} };
interface NotificationProps { type NotificationProps = {
showNotification: boolean; showNotification: boolean;
notifications: Notification[]; notifications: Notification[];
removeNotificationById: (id: number) => void; removeNotificationById: (id: number) => void;
} };
export const Notifications = React.memo((props: NotificationProps) => { export const Notifications = React.memo((props: NotificationProps) => {
const { showNotification, notifications, removeNotificationById } = props; const { showNotification, notifications, removeNotificationById } = props;
@ -23,10 +23,10 @@ export const Notifications = React.memo((props: NotificationProps) => {
{notifications.map((notification) => { {notifications.map((notification) => {
// Determine if the toast should be shown // Determine if the toast should be shown
const shouldShow = const shouldShow =
notification.levelname === "ERROR" || notification.levelname === 'ERROR' ||
notification.levelname === "CRITICAL" || notification.levelname === 'CRITICAL' ||
(showNotification && (showNotification &&
["WARNING", "INFO", "DEBUG"].includes(notification.levelname)); ['WARNING', 'INFO', 'DEBUG'].includes(notification.levelname));
if (!shouldShow) { if (!shouldShow) {
return null; return null;
@ -34,31 +34,31 @@ export const Notifications = React.memo((props: NotificationProps) => {
return ( return (
<Toast <Toast
className={notification.levelname.toLowerCase() + "Toast"} className={notification.levelname.toLowerCase() + 'Toast'}
key={notification.id} key={notification.id}
onClose={() => removeNotificationById(notification.id)} onClose={() => removeNotificationById(notification.id)}
onClick={() => removeNotificationById(notification.id)} onClick={() => removeNotificationById(notification.id)}
onMouseLeave={() => { onMouseLeave={() => {
if (notification.levelname !== "ERROR") { if (notification.levelname !== 'ERROR') {
removeNotificationById(notification.id); removeNotificationById(notification.id);
} }
}} }}
show={true} show={true}
autohide={ autohide={
notification.levelname === "WARNING" || notification.levelname === 'WARNING' ||
notification.levelname === "INFO" || notification.levelname === 'INFO' ||
notification.levelname === "DEBUG" notification.levelname === 'DEBUG'
} }
delay={ delay={
notification.levelname === "WARNING" || notification.levelname === 'WARNING' ||
notification.levelname === "INFO" || notification.levelname === 'INFO' ||
notification.levelname === "DEBUG" notification.levelname === 'DEBUG'
? 2000 ? 2000
: undefined : undefined
}> }>
<Toast.Header <Toast.Header
closeButton={false} closeButton={false}
className={notification.levelname.toLowerCase() + "Toast text-right"}> className={notification.levelname.toLowerCase() + 'Toast text-right'}>
<strong className="me-auto">{notification.levelname}</strong> <strong className="me-auto">{notification.levelname}</strong>
<small>{notification.timeStamp}</small> <small>{notification.timeStamp}</small>
</Toast.Header> </Toast.Header>
@ -69,5 +69,3 @@ export const Notifications = React.memo((props: NotificationProps) => {
</ToastContainer> </ToastContainer>
); );
}); });
Notifications.displayName = "Notifications";

View File

@ -1,58 +1,59 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useState, useRef } from 'react';
import { Form, InputGroup } from "react-bootstrap"; import { Form, InputGroup } from 'react-bootstrap';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import "../App.css"; import '../App.css';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import { SerializedObject } from "../types/SerializedObject"; import { SerializedValue } from './GenericComponent';
import { QuantityMap } from "../types/QuantityMap";
import useRenderCount from "../hooks/useRenderCount";
// TODO: add button functionality // TODO: add button functionality
export interface QuantityObject { export type QuantityObject = {
type: "Quantity"; type: 'Quantity';
readonly: boolean; readonly: boolean;
value: QuantityMap; value: {
doc: string | null; magnitude: number;
} unit: string;
export interface IntObject { };
type: "int"; doc?: string;
};
export type IntObject = {
type: 'int';
readonly: boolean; readonly: boolean;
value: number; value: number;
doc: string | null; doc?: string;
} };
export interface FloatObject { export type FloatObject = {
type: "float"; type: 'float';
readonly: boolean; readonly: boolean;
value: number; value: number;
doc: string | null; doc?: string;
} };
export type NumberObject = IntObject | FloatObject | QuantityObject; export type NumberObject = IntObject | FloatObject | QuantityObject;
interface NumberComponentProps { type NumberComponentProps = {
type: "float" | "int" | "Quantity"; type: 'float' | 'int' | 'Quantity';
fullAccessPath: string; fullAccessPath: string;
value: number; value: number;
readOnly: boolean; readOnly: boolean;
docString: string | null; docString: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
unit?: string; unit?: string;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void; changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName?: string; displayName?: string;
id: string; id: string;
} };
// TODO: highlight the digit that is being changed by setting both selectionStart and // TODO: highlight the digit that is being changed by setting both selectionStart and
// selectionEnd // selectionEnd
const handleArrowKey = ( const handleArrowKey = (
key: string, key: string,
value: string, value: string,
selectionStart: number, selectionStart: number
// selectionEnd: number // selectionEnd: number
) => { ) => {
// Split the input value into the integer part and decimal part // Split the input value into the integer part and decimal part
const parts = value.split("."); const parts = value.split('.');
const beforeDecimalCount = parts[0].length; // Count digits before the decimal const beforeDecimalCount = parts[0].length; // Count digits before the decimal
const afterDecimalCount = parts[1] ? parts[1].length : 0; // Count digits after the decimal const afterDecimalCount = parts[1] ? parts[1].length : 0; // Count digits after the decimal
@ -68,14 +69,14 @@ const handleArrowKey = (
// Convert the input value to a number, increment or decrement it based on the // Convert the input value to a number, increment or decrement it based on the
// arrow key // arrow key
const numValue = parseFloat(value) + (key === "ArrowUp" ? increment : -increment); const numValue = parseFloat(value) + (key === 'ArrowUp' ? increment : -increment);
// Convert the resulting number to a string, maintaining the same number of digits // Convert the resulting number to a string, maintaining the same number of digits
// after the decimal // after the decimal
const newValue = numValue.toFixed(afterDecimalCount); const newValue = numValue.toFixed(afterDecimalCount);
// Check if the length of the integer part of the number string has in-/decreased // Check if the length of the integer part of the number string has in-/decreased
const newBeforeDecimalCount = newValue.split(".")[0].length; const newBeforeDecimalCount = newValue.split('.')[0].length;
if (newBeforeDecimalCount > beforeDecimalCount) { if (newBeforeDecimalCount > beforeDecimalCount) {
// Move the cursor one position to the right // Move the cursor one position to the right
selectionStart += 1; selectionStart += 1;
@ -89,18 +90,18 @@ const handleArrowKey = (
const handleBackspaceKey = ( const handleBackspaceKey = (
value: string, value: string,
selectionStart: number, selectionStart: number,
selectionEnd: number, selectionEnd: number
) => { ) => {
if (selectionEnd > selectionStart) { if (selectionEnd > selectionStart) {
// If there is a selection, delete all characters in the selection // If there is a selection, delete all characters in the selection
return { return {
value: value.slice(0, selectionStart) + value.slice(selectionEnd), value: value.slice(0, selectionStart) + value.slice(selectionEnd),
selectionStart, selectionStart
}; };
} else if (selectionStart > 0) { } else if (selectionStart > 0) {
return { return {
value: value.slice(0, selectionStart - 1) + value.slice(selectionStart), value: value.slice(0, selectionStart - 1) + value.slice(selectionStart),
selectionStart: selectionStart - 1, selectionStart: selectionStart - 1
}; };
} }
return { value, selectionStart }; return { value, selectionStart };
@ -109,18 +110,18 @@ const handleBackspaceKey = (
const handleDeleteKey = ( const handleDeleteKey = (
value: string, value: string,
selectionStart: number, selectionStart: number,
selectionEnd: number, selectionEnd: number
) => { ) => {
if (selectionEnd > selectionStart) { if (selectionEnd > selectionStart) {
// If there is a selection, delete all characters in the selection // If there is a selection, delete all characters in the selection
return { return {
value: value.slice(0, selectionStart) + value.slice(selectionEnd), value: value.slice(0, selectionStart) + value.slice(selectionEnd),
selectionStart, selectionStart
}; };
} else if (selectionStart < value.length) { } else if (selectionStart < value.length) {
return { return {
value: value.slice(0, selectionStart) + value.slice(selectionStart + 1), value: value.slice(0, selectionStart) + value.slice(selectionStart + 1),
selectionStart, selectionStart
}; };
} }
return { value, selectionStart }; return { value, selectionStart };
@ -130,78 +131,29 @@ const handleNumericKey = (
key: string, key: string,
value: string, value: string,
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.
console.warn("Invalid input! Ignoring..."); console.warn('Invalid input! Ignoring...');
return { value, selectionStart }; return { value, selectionStart };
} }
// Handle minus sign input let newValue = value;
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, insert the key at the cursor position // otherwise, append the key after the selection start
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart); newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
} }
return { value: newValue, selectionStart: selectionStart + 1 }; return { value: newValue, selectionStart: selectionStart + 1 };
}; };
/**
* Calculates the new cursor position after moving left by a specified step size.
*
* @param cursorPosition - The current position of the cursor.
* @param step - The number of positions to move left.
* @returns The new cursor position, clamped to a minimum of 0.
*/
const getCursorLeftPosition = (cursorPosition: number, step: number): number => {
return Math.max(0, cursorPosition - step);
};
/**
* Calculates the new cursor position after moving right by a specified step size.
*
* @param cursorPosition - The current position of the cursor.
* @param step - The number of positions to move right.
* @param maxPosition - The maximum allowed cursor position (e.g., value.length).
* @returns The new cursor position, clamped to a maximum of maxPosition.
*/
const getCursorRightPosition = (
cursorPosition: number,
step: number,
maxPosition: number,
): number => {
return Math.min(maxPosition, cursorPosition + step);
};
export const NumberComponent = React.memo((props: NumberComponentProps) => { export const NumberComponent = React.memo((props: NumberComponentProps) => {
const { const {
fullAccessPath, fullAccessPath,
@ -214,112 +166,98 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
addNotification, addNotification,
changeCallback = () => {}, changeCallback = () => {},
displayName, displayName,
id, id
} = props; } = props;
// Create a state for the cursor position // Create a state for the cursor position
const cursorPositionRef = useRef<number | null>(null); const [cursorPosition, setCursorPosition] = useState(null);
// Create a state for the input string // Create a state for the input string
const [inputString, setInputString] = useState(value.toString()); const [inputString, setInputString] = useState(value.toString());
const renderCount = useRenderCount(); const renderCount = useRef(0);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event) => {
const { key, target } = event; const { key, target } = event;
if (
const inputTarget = target as HTMLInputElement; key === 'F1' ||
key === 'F5' ||
// Get the current input value and cursor position key === 'F12' ||
const { value } = inputTarget; key === 'Tab' ||
const valueLength = value.length; key === 'ArrowRight' ||
const selectionEnd = inputTarget.selectionEnd ?? 0; key === 'ArrowLeft'
let selectionStart = inputTarget.selectionStart ?? 0; ) {
if (key === "F1" || key === "F5" || key === "F12" || key === "Tab") {
return;
} else if (key === "ArrowLeft" || key === "ArrowRight") {
const hasSelection = selectionEnd > selectionStart;
if (hasSelection && !event.shiftKey) {
// Collapse selection: ArrowLeft -> start, ArrowRight -> end
const collapseTo = key === "ArrowLeft" ? selectionStart : selectionEnd;
cursorPositionRef.current = collapseTo;
} else {
// No selection or shift key is pressed, just move cursor by one
const newSelectionStart =
key === "ArrowLeft"
? getCursorLeftPosition(selectionStart, 1)
: getCursorRightPosition(selectionEnd, 1, valueLength);
cursorPositionRef.current = newSelectionStart;
}
return; return;
} }
event.preventDefault(); event.preventDefault();
// Get the current input value and cursor position
const { value } = target;
let { selectionStart } = target;
const { selectionEnd } = target;
let newValue: string = value; let newValue: string = value;
if (event.ctrlKey && key === "a") { if (event.ctrlKey && key === 'a') {
// Select everything when pressing Ctrl + a // Select everything when pressing Ctrl + a
inputTarget.setSelectionRange(0, value.length); target.setSelectionRange(0, target.value.length);
return; return;
} else if ((key >= "0" && key <= "9") || key === "-") { } else if (key === '-') {
if (selectionStart === 0 && !value.startsWith('-')) {
newValue = '-' + value;
selectionStart++;
} else if (value.startsWith('-') && selectionStart === 1) {
newValue = value.substring(1); // remove minus sign
selectionStart--;
} else {
return; // Ignore "-" pressed in other positions
}
} else if (!isNaN(key) && key !== ' ') {
// Check if a number key or a decimal point key is pressed // Check if a number key or a decimal point key is pressed
({ value: newValue, selectionStart } = handleNumericKey( ({ value: newValue, selectionStart } = handleNumericKey(
key, key,
value, value,
selectionStart, selectionStart,
selectionEnd, selectionEnd
)); ));
} else if (key === "." && (type === "float" || type === "Quantity")) { } else if (key === '.' && (type === 'float' || type === 'Quantity')) {
({ value: newValue, selectionStart } = handleNumericKey( ({ value: newValue, selectionStart } = handleNumericKey(
key, key,
value, value,
selectionStart, selectionStart,
selectionEnd, selectionEnd
)); ));
} else if (key === "ArrowUp" || key === "ArrowDown") { } else if (key === 'ArrowUp' || key === 'ArrowDown') {
({ value: newValue, selectionStart } = handleArrowKey( ({ value: newValue, selectionStart } = handleArrowKey(
key, key,
value, value,
selectionStart, selectionStart
// selectionEnd // selectionEnd
)); ));
} else if (key === "Backspace") { } else if (key === 'Backspace') {
({ value: newValue, selectionStart } = handleBackspaceKey( ({ value: newValue, selectionStart } = handleBackspaceKey(
value, value,
selectionStart, selectionStart,
selectionEnd, selectionEnd
)); ));
} else if (key === "Delete") { } else if (key === 'Delete') {
({ value: newValue, selectionStart } = handleDeleteKey( ({ value: newValue, selectionStart } = handleDeleteKey(
value, value,
selectionStart, selectionStart,
selectionEnd, selectionEnd
)); ));
} else if (key === "Enter" && !isInstantUpdate) { } else if (key === 'Enter' && !isInstantUpdate) {
let serializedObject: SerializedObject; let updatedValue: number | Record<string, unknown> = Number(newValue);
if (type === "Quantity") { if (type === 'Quantity') {
serializedObject = { updatedValue = {
type: "Quantity", magnitude: Number(newValue),
value: { unit: unit
magnitude: Number(newValue),
unit: unit,
} as QuantityMap,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString,
};
} else {
serializedObject = {
type: type,
value: Number(newValue),
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString,
}; };
} }
changeCallback({
changeCallback(serializedObject); type: type,
value: updatedValue,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
return; return;
} else { } else {
console.debug(key); console.debug(key);
@ -328,69 +266,51 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Update the input value and maintain the cursor position // Update the input value and maintain the cursor position
if (isInstantUpdate) { if (isInstantUpdate) {
let serializedObject: SerializedObject; let updatedValue: number | Record<string, unknown> = Number(newValue);
if (type === "Quantity") { if (type === 'Quantity') {
serializedObject = { updatedValue = {
type: "Quantity", magnitude: Number(newValue),
value: { unit: unit
magnitude: Number(newValue),
unit: unit,
} as QuantityMap,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString,
};
} else {
serializedObject = {
type: type,
value: Number(newValue),
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString,
}; };
} }
changeCallback({
changeCallback(serializedObject); type: type,
value: updatedValue,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
} }
setInputString(newValue); setInputString(newValue);
// Save the current cursor position before the component re-renders // Save the current cursor position before the component re-renders
cursorPositionRef.current = selectionStart; setCursorPosition(selectionStart);
}; };
const handleBlur = () => { const handleBlur = () => {
if (!isInstantUpdate) { if (!isInstantUpdate) {
// If not in "instant update" mode, emit an update when the input field loses focus // If not in "instant update" mode, emit an update when the input field loses focus
let serializedObject: SerializedObject; let updatedValue: number | Record<string, unknown> = Number(inputString);
if (type === "Quantity") { if (type === 'Quantity') {
serializedObject = { updatedValue = {
type: "Quantity", magnitude: Number(inputString),
value: { unit: unit
magnitude: Number(inputString),
unit: unit,
} as QuantityMap,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString,
};
} else {
serializedObject = {
type: type,
value: Number(inputString),
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString,
}; };
} }
changeCallback({
changeCallback(serializedObject); type: type,
value: updatedValue,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
} }
}; };
useEffect(() => { useEffect(() => {
// Parse the input string to a number for comparison // Parse the input string to a number for comparison
const numericInputString = const numericInputString =
type === "int" ? parseInt(inputString) : parseFloat(inputString); type === 'int' ? parseInt(inputString) : parseFloat(inputString);
// Only update the inputString if it's different from the prop value // Only update the inputString if it's different from the prop value
if (value !== numericInputString) { if (value !== numericInputString) {
setInputString(value.toString()); setInputString(value.toString());
@ -399,7 +319,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// emitting notification // emitting notification
let notificationMsg = `${fullAccessPath} changed to ${props.value}`; let notificationMsg = `${fullAccessPath} changed to ${props.value}`;
if (unit === undefined) { if (unit === undefined) {
notificationMsg += "."; notificationMsg += '.';
} else { } else {
notificationMsg += ` ${unit}.`; notificationMsg += ` ${unit}.`;
} }
@ -409,17 +329,16 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
useEffect(() => { useEffect(() => {
// Set the cursor position after the component re-renders // Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(id)[0] as HTMLInputElement; const inputElement = document.getElementsByName(id)[0] as HTMLInputElement;
if (inputElement && cursorPositionRef.current !== null) { if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange( inputElement.setSelectionRange(cursorPosition, cursorPosition);
cursorPositionRef.current,
cursorPositionRef.current,
);
} }
}); });
return ( return (
<div className="component numberComponent" id={id}> <div className="component numberComponent" id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<InputGroup> <InputGroup>
{displayName && ( {displayName && (
<InputGroup.Text> <InputGroup.Text>
@ -435,12 +354,10 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
name={id} name={id}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleBlur}
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""} className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
/> />
{unit && <InputGroup.Text>{unit}</InputGroup.Text>} {unit && <InputGroup.Text>{unit}</InputGroup.Text>}
</InputGroup> </InputGroup>
</div> </div>
); );
}); });
NumberComponent.displayName = "NumberComponent";

View File

@ -1,48 +1,28 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from 'react';
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from "react-bootstrap"; import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import { Slider } from "@mui/material"; import { Slider } from '@mui/material';
import { NumberComponent, NumberObject } from "./NumberComponent"; import { NumberComponent, NumberObject } from './NumberComponent';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import { SerializedObject } from "../types/SerializedObject"; import { SerializedValue } from './GenericComponent';
import { QuantityMap } from "../types/QuantityMap";
import { propsAreEqual } from "../utils/propsAreEqual";
import useRenderCount from "../hooks/useRenderCount";
interface SliderComponentProps { type SliderComponentProps = {
fullAccessPath: string; fullAccessPath: string;
min: NumberObject; min: NumberObject;
max: NumberObject; max: NumberObject;
value: NumberObject; value: NumberObject;
readOnly: boolean; readOnly: boolean;
docString: string | null; docString: string;
stepSize: NumberObject; stepSize: NumberObject;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void; changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName: string; displayName: string;
id: string; id: string;
}
const deconstructNumberDict = (
numberDict: NumberObject,
): [number, boolean, string | undefined] => {
let numberMagnitude = 0;
let numberUnit: string | undefined = undefined;
const numberReadOnly = numberDict.readonly;
if (numberDict.type === "int" || numberDict.type === "float") {
numberMagnitude = numberDict.value;
} else if (numberDict.type === "Quantity") {
numberMagnitude = numberDict.value.magnitude;
numberUnit = numberDict.value.unit;
}
return [numberMagnitude, numberReadOnly, numberUnit];
}; };
export const SliderComponent = React.memo((props: SliderComponentProps) => { export const SliderComponent = React.memo((props: SliderComponentProps) => {
const renderCount = useRenderCount(); const renderCount = useRef(0);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { const {
fullAccessPath, fullAccessPath,
@ -55,83 +35,72 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
addNotification, addNotification,
changeCallback = () => {}, changeCallback = () => {},
displayName, displayName,
id, id
} = props; } = props;
useEffect(() => {
renderCount.current++;
});
useEffect(() => { useEffect(() => {
addNotification(`${fullAccessPath} changed to ${value.value}.`); addNotification(`${fullAccessPath} changed to ${value.value}.`);
}, [props.value.value]); }, [props.value]);
useEffect(() => { useEffect(() => {
addNotification(`${fullAccessPath}.min changed to ${min.value}.`); addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
}, [props.min.value, props.min.type]); }, [props.min]);
useEffect(() => { useEffect(() => {
addNotification(`${fullAccessPath}.max changed to ${max.value}.`); addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
}, [props.max.value, props.max.type]); }, [props.max]);
useEffect(() => { useEffect(() => {
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`); addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
}, [props.stepSize.value, props.stepSize.type]); }, [props.stepSize]);
const handleOnChange = (_: Event, newNumber: number | number[]) => { const handleOnChange = (event, newNumber: number | number[]) => {
// This will never be the case as we do not have a range slider. However, we should // This will never be the case as we do not have a range slider. However, we should
// make sure this is properly handled. // make sure this is properly handled.
if (Array.isArray(newNumber)) { if (Array.isArray(newNumber)) {
newNumber = newNumber[0]; newNumber = newNumber[0];
} }
changeCallback({
let serializedObject: SerializedObject; type: value.type,
if (value.type === "Quantity") { value: newNumber,
serializedObject = { full_access_path: `${fullAccessPath}.value`,
type: "Quantity", readonly: value.readonly,
value: { doc: docString
magnitude: newNumber, });
unit: value.value.unit,
} as QuantityMap,
full_access_path: `${fullAccessPath}.value`,
readonly: value.readonly,
doc: docString,
};
} else {
serializedObject = {
type: value.type,
value: newNumber,
full_access_path: `${fullAccessPath}.value`,
readonly: value.readonly,
doc: docString,
};
}
changeCallback(serializedObject);
}; };
const handleValueChange = ( const handleValueChange = (
newValue: number, newValue: number,
name: string, name: string,
valueObject: NumberObject, valueObject: NumberObject
) => { ) => {
let serializedObject: SerializedObject; changeCallback({
if (valueObject.type === "Quantity") { type: valueObject.type,
serializedObject = { value: newValue,
type: valueObject.type, full_access_path: `${fullAccessPath}.${name}`,
value: { readonly: valueObject.readonly
magnitude: newValue, });
unit: valueObject.value.unit, };
} as QuantityMap,
full_access_path: `${fullAccessPath}.${name}`, const deconstructNumberDict = (
readonly: valueObject.readonly, numberDict: NumberObject
doc: null, ): [number, boolean, string | null] => {
}; let numberMagnitude: number;
} else { let numberUnit: string | null = null;
serializedObject = { const numberReadOnly = numberDict.readonly;
type: valueObject.type,
value: newValue, if (numberDict.type === 'int' || numberDict.type === 'float') {
full_access_path: `${fullAccessPath}.${name}`, numberMagnitude = numberDict.value;
readonly: valueObject.readonly, } else if (numberDict.type === 'Quantity') {
doc: null, numberMagnitude = numberDict.value.magnitude;
}; numberUnit = numberDict.value.unit;
} }
changeCallback(serializedObject);
return [numberMagnitude, numberReadOnly, numberUnit];
}; };
const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value); const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value);
@ -141,7 +110,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
return ( return (
<div className="component sliderComponent" id={id}> <div className="component sliderComponent" id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<Row> <Row>
<Col xs="auto" xl="auto"> <Col xs="auto" xl="auto">
@ -152,7 +123,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
</Col> </Col>
<Col xs="5" xl> <Col xs="5" xl>
<Slider <Slider
style={{ margin: "0px 0px 10px 0px" }} style={{ margin: '0px 0px 10px 0px' }}
aria-label="Always visible" aria-label="Always visible"
// valueLabelDisplay="on" // valueLabelDisplay="on"
disabled={valueReadOnly} disabled={valueReadOnly}
@ -163,7 +134,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
step={stepSizeMagnitude} step={stepSizeMagnitude}
marks={[ marks={[
{ value: minMagnitude, label: `${minMagnitude}` }, { value: minMagnitude, label: `${minMagnitude}` },
{ value: maxMagnitude, label: `${maxMagnitude}` }, { value: maxMagnitude, label: `${maxMagnitude}` }
]} ]}
/> />
</Col> </Col>
@ -173,12 +144,12 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
fullAccessPath={`${fullAccessPath}.value`} fullAccessPath={`${fullAccessPath}.value`}
docString={docString} docString={docString}
readOnly={valueReadOnly} readOnly={valueReadOnly}
type={value.type} type="float"
value={valueMagnitude} value={valueMagnitude}
unit={valueUnit} unit={valueUnit}
addNotification={() => {}} addNotification={() => {}}
changeCallback={changeCallback} changeCallback={changeCallback}
id={id + "-value"} id={id + '-value'}
/> />
</Col> </Col>
<Col xs="auto"> <Col xs="auto">
@ -208,14 +179,14 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Group> <Form.Group>
<Row <Row
className="justify-content-center" className="justify-content-center"
style={{ paddingTop: "20px", margin: "10px" }}> style={{ paddingTop: '20px', margin: '10px' }}>
<Col xs="auto"> <Col xs="auto">
<Form.Label>Min Value</Form.Label> <Form.Label>Min Value</Form.Label>
<Form.Control <Form.Control
type="number" type="number"
value={minMagnitude} value={minMagnitude}
disabled={minReadOnly} disabled={minReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), "min", min)} onChange={(e) => handleValueChange(Number(e.target.value), 'min', min)}
/> />
</Col> </Col>
@ -225,7 +196,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
type="number" type="number"
value={maxMagnitude} value={maxMagnitude}
disabled={maxReadOnly} disabled={maxReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), "max", max)} onChange={(e) => handleValueChange(Number(e.target.value), 'max', max)}
/> />
</Col> </Col>
@ -236,7 +207,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
value={stepSizeMagnitude} value={stepSizeMagnitude}
disabled={stepSizeReadOnly} disabled={stepSizeReadOnly}
onChange={(e) => onChange={(e) =>
handleValueChange(Number(e.target.value), "step_size", stepSize) handleValueChange(Number(e.target.value), 'step_size', stepSize)
} }
/> />
</Col> </Col>
@ -245,6 +216,4 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
</Collapse> </Collapse>
</div> </div>
); );
}, propsAreEqual); });
SliderComponent.displayName = "SliderComponent";

View File

@ -1,24 +1,23 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from "react-bootstrap"; import { Form, InputGroup } from 'react-bootstrap';
import { DocStringComponent } from "./DocStringComponent"; import { DocStringComponent } from './DocStringComponent';
import "../App.css"; import '../App.css';
import { LevelName } from "./NotificationsComponent"; import { LevelName } from './NotificationsComponent';
import { SerializedObject } from "../types/SerializedObject"; import { SerializedValue } from './GenericComponent';
import useRenderCount from "../hooks/useRenderCount";
// TODO: add button functionality // TODO: add button functionality
interface StringComponentProps { type StringComponentProps = {
fullAccessPath: string; fullAccessPath: string;
value: string; value: string;
readOnly: boolean; readOnly: boolean;
docString: string | null; docString: string;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void; changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName: string; displayName: string;
id: string; id: string;
} };
export const StringComponent = React.memo((props: StringComponentProps) => { export const StringComponent = React.memo((props: StringComponentProps) => {
const { const {
@ -29,12 +28,16 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
addNotification, addNotification,
changeCallback = () => {}, changeCallback = () => {},
displayName, displayName,
id, id
} = props; } = props;
const renderCount = useRenderCount(); const renderCount = useRef(0);
const [inputString, setInputString] = useState(props.value); const [inputString, setInputString] = useState(props.value);
useEffect(() => {
renderCount.current++;
}, [isInstantUpdate, inputString, renderCount]);
useEffect(() => { useEffect(() => {
// Only update the inputString if it's different from the prop value // Only update the inputString if it's different from the prop value
if (props.value !== inputString) { if (props.value !== inputString) {
@ -43,27 +46,21 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
addNotification(`${fullAccessPath} changed to ${props.value}.`); addNotification(`${fullAccessPath} changed to ${props.value}.`);
}, [props.value]); }, [props.value]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (event) => {
setInputString(event.target.value); setInputString(event.target.value);
if (isInstantUpdate) { if (isInstantUpdate) {
changeCallback({ changeCallback(event.target.value);
type: "str",
value: event.target.value,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString,
});
} }
}; };
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event) => {
if (event.key === "Enter" && !isInstantUpdate) { if (event.key === 'Enter' && !isInstantUpdate) {
changeCallback({ changeCallback({
type: "str", type: 'str',
value: inputString, value: inputString,
full_access_path: fullAccessPath, full_access_path: fullAccessPath,
readonly: readOnly, readonly: readOnly,
doc: docString, doc: docString
}); });
event.preventDefault(); event.preventDefault();
} }
@ -72,18 +69,20 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
const handleBlur = () => { const handleBlur = () => {
if (!isInstantUpdate) { if (!isInstantUpdate) {
changeCallback({ changeCallback({
type: "str", type: 'str',
value: inputString, value: inputString,
full_access_path: fullAccessPath, full_access_path: fullAccessPath,
readonly: readOnly, readonly: readOnly,
doc: docString, doc: docString
}); });
} }
}; };
return ( return (
<div className="component stringComponent" id={id}> <div className="component stringComponent" id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>} {process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<InputGroup> <InputGroup>
<InputGroup.Text> <InputGroup.Text>
{displayName} {displayName}
@ -97,11 +96,9 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleBlur}
className={isInstantUpdate && !readOnly ? "instantUpdate" : ""} className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
/> />
</InputGroup> </InputGroup>
</div> </div>
); );
}); });
StringComponent.displayName = "StringComponent";

View File

@ -1,75 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { runMethod } from "../socket";
import { Form, Button, InputGroup, Spinner } from "react-bootstrap";
import { DocStringComponent } from "./DocStringComponent";
import { LevelName } from "./NotificationsComponent";
import useRenderCount from "../hooks/useRenderCount";
export type TaskStatus = "RUNNING" | "NOT_RUNNING";
interface TaskProps {
fullAccessPath: string;
docString: string | null;
status: TaskStatus;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
}
export const TaskComponent = React.memo((props: TaskProps) => {
const { fullAccessPath, docString, status, addNotification, displayName, id } = props;
const renderCount = useRenderCount();
const formRef = useRef(null);
const [spinning, setSpinning] = useState(false);
useEffect(() => {
let message: string;
if (status === "RUNNING") {
message = `${fullAccessPath} was started.`;
} else {
message = `${fullAccessPath} was stopped.`;
}
addNotification(message);
setSpinning(false);
}, [status]);
const execute = async (event: React.FormEvent) => {
event.preventDefault();
const method_name = status == "RUNNING" ? "stop" : "start";
const accessPath = [fullAccessPath, method_name]
.filter((element) => element)
.join(".");
setSpinning(true);
runMethod(accessPath);
};
return (
<div className="component taskComponent" id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
<Form onSubmit={execute} ref={formRef}>
<InputGroup>
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
<Button id={`button-${id}`} type="submit">
{spinning ? (
<Spinner size="sm" role="status" aria-hidden="true" />
) : status === "RUNNING" ? (
"Stop "
) : (
"Start "
)}
</Button>
</InputGroup>
</Form>
</div>
);
});
TaskComponent.displayName = "TaskComponent";

View File

@ -1,19 +0,0 @@
import { useState, useEffect } from "react";
import { authority } from "../socket";
export default function useLocalStorage(key: string, defaultValue: unknown) {
const [value, setValue] = useState(() => {
const storedValue = localStorage.getItem(`${authority}:${key}`);
if (storedValue) {
return JSON.parse(storedValue);
}
return defaultValue;
});
useEffect(() => {
if (value === undefined) return;
localStorage.setItem(`${authority}:${key}`, JSON.stringify(value));
}, [value, key]);
return [value, setValue];
}

View File

@ -1,11 +0,0 @@
import { useRef, useEffect } from "react";
export default function useRenderCount() {
const count = useRef(0);
useEffect(() => {
count.current += 1;
});
return count.current;
}

View File

@ -1,28 +0,0 @@
import { useContext } from "react";
import { WebSettingsContext } from "../WebSettings";
import { SerializedObject } from "../types/SerializedObject";
export default function useSortedEntries(
props: Record<string, SerializedObject> | SerializedObject[],
) {
const webSettings = useContext(WebSettingsContext);
// Get the order for sorting
const getOrder = (fullAccessPath: string) => {
return webSettings[fullAccessPath]?.displayOrder ?? Number.MAX_SAFE_INTEGER;
};
// Sort entries based on whether props is an array or an object
let sortedEntries;
if (Array.isArray(props)) {
// Need to make copy of array to leave the original array unmodified
sortedEntries = [...props].sort((objectA, objectB) => {
return getOrder(objectA.full_access_path) - getOrder(objectB.full_access_path);
});
} else {
sortedEntries = Object.values(props).sort((objectA, objectB) => {
return getOrder(objectA.full_access_path) - getOrder(objectB.full_access_path);
});
}
return sortedEntries;
}

View File

@ -1,13 +1,10 @@
import App from "./App"; import App from './App';
import React from "react"; import { createRoot } from 'react-dom/client';
import ReactDOM from "react-dom/client";
// Importing the Bootstrap CSS // Importing the Bootstrap CSS
import "bootstrap/dist/css/bootstrap.min.css"; import 'bootstrap/dist/css/bootstrap.min.css';
// Render the App component into the #root div // Render the App component into the #root div
ReactDOM.createRoot(document.getElementById("root")!).render( const container = document.getElementById('root');
<React.StrictMode> const root = createRoot(container);
<App /> root.render(<App />);
</React.StrictMode>,
);

View File

@ -1,45 +1,30 @@
import { io } from "socket.io-client"; import { io } from 'socket.io-client';
import { serializeDict, serializeList } from "./utils/serializationUtils"; import { SerializedValue } from './components/GenericComponent';
import { SerializedObject } from "./types/SerializedObject"; import { serializeDict, serializeList } from './utils/serializationUtils';
const hostname = export const hostname =
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname; process.env.NODE_ENV === 'development' ? `localhost` : window.location.hostname;
const port = process.env.NODE_ENV === "development" ? 8001 : window.location.port; export const port =
process.env.NODE_ENV === 'development' ? 8001 : window.location.port;
const URL = `ws://${hostname}:${port}/`;
console.debug('Websocket: ', URL);
// Get the forwarded prefix from the global variable export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
export const forwardedPrefix: string =
(window as any) /* eslint-disable-line @typescript-eslint/no-explicit-any */
.__FORWARDED_PREFIX__ || "";
// Get the forwarded protocol type from the global variable
export const forwardedProto: string =
(window as any) /* eslint-disable-line @typescript-eslint/no-explicit-any */
.__FORWARDED_PROTO__ || "http";
export const authority = `${hostname}:${port}${forwardedPrefix}`;
const wsProto = forwardedProto === "http" ? "ws" : "wss";
const URL = `${wsProto}://${hostname}:${port}/`;
console.debug("Websocket: ", URL);
export const socket = io(URL, {
path: `${forwardedPrefix}/ws/socket.io`,
transports: ["websocket"],
});
export const updateValue = ( export const updateValue = (
serializedObject: SerializedObject, serializedObject: SerializedValue,
callback?: (ack: unknown) => void, callback?: (ack: unknown) => void
) => { ) => {
if (callback) { if (callback) {
socket.emit( socket.emit(
"update_value", 'update_value',
{ access_path: serializedObject["full_access_path"], value: serializedObject }, { access_path: serializedObject['full_access_path'], value: serializedObject },
callback, callback
); );
} else { } else {
socket.emit("update_value", { socket.emit('update_value', {
access_path: serializedObject["full_access_path"], access_path: serializedObject['full_access_path'],
value: serializedObject, value: serializedObject
}); });
} }
}; };
@ -48,22 +33,22 @@ export const runMethod = (
accessPath: string, accessPath: string,
args: unknown[] = [], args: unknown[] = [],
kwargs: Record<string, unknown> = {}, kwargs: Record<string, unknown> = {},
callback?: (ack: unknown) => void, callback?: (ack: unknown) => void
) => { ) => {
const serializedArgs = serializeList(args); const serializedArgs = serializeList(args);
const serializedKwargs = serializeDict(kwargs); const serializedKwargs = serializeDict(kwargs);
if (callback) { if (callback) {
socket.emit( socket.emit(
"trigger_method", 'trigger_method',
{ access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs }, { access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs },
callback, callback
); );
} else { } else {
socket.emit("trigger_method", { socket.emit('trigger_method', {
access_path: accessPath, access_path: accessPath,
args: serializedArgs, args: serializedArgs,
kwargs: serializedKwargs, kwargs: serializedKwargs
}); });
} }
}; };

View File

@ -1,4 +0,0 @@
export interface QuantityMap {
magnitude: number;
unit: string;
}

View File

@ -1,106 +0,0 @@
import { QuantityMap } from "./QuantityMap";
interface SignatureDict {
parameters: Record<string, Record<string, unknown>>;
return_annotation: Record<string, unknown>;
}
interface SerializedObjectBase {
full_access_path: string;
doc: string | null;
readonly: boolean;
}
type SerializedInteger = SerializedObjectBase & {
value: number;
type: "int";
};
type SerializedFloat = SerializedObjectBase & {
value: number;
type: "float";
};
type SerializedQuantity = SerializedObjectBase & {
value: QuantityMap;
type: "Quantity";
};
type SerializedBool = SerializedObjectBase & {
value: boolean;
type: "bool";
};
type SerializedString = SerializedObjectBase & {
value: string;
type: "str";
};
export type SerializedEnum = SerializedObjectBase & {
name: string;
value: string;
type: "Enum" | "ColouredEnum";
enum: Record<string, string>;
};
type SerializedList = SerializedObjectBase & {
value: SerializedObject[];
type: "list";
};
type SerializedDict = SerializedObjectBase & {
value: Record<string, SerializedObject>;
type: "dict";
};
type SerializedNoneType = SerializedObjectBase & {
value: null;
type: "NoneType";
};
type SerializedNoValue = SerializedObjectBase & {
value: null;
type: "None";
};
type SerializedMethod = SerializedObjectBase & {
value: "RUNNING" | null;
type: "method";
async: boolean;
signature: SignatureDict;
frontend_render: boolean;
};
type SerializedException = SerializedObjectBase & {
name: string;
value: string;
type: "Exception";
};
type DataServiceTypes =
| "DataService"
| "Image"
| "NumberSlider"
| "DeviceConnection"
| "Task";
type SerializedDataService = SerializedObjectBase & {
name: string;
value: Record<string, SerializedObject>;
type: DataServiceTypes;
};
export type SerializedObject =
| SerializedBool
| SerializedFloat
| SerializedInteger
| SerializedString
| SerializedList
| SerializedDict
| SerializedNoneType
| SerializedMethod
| SerializedException
| SerializedDataService
| SerializedEnum
| SerializedQuantity
| SerializedNoValue;

View File

@ -1,17 +0,0 @@
import deepEqual from "deep-equal";
export const propsAreEqual = <T extends object>(
prevProps: T,
nextProps: T,
): boolean => {
for (const key in nextProps) {
if (typeof nextProps[key] === "object") {
if (!deepEqual(prevProps[key], nextProps[key])) {
return false;
}
} else if (!Object.is(prevProps[key], nextProps[key])) {
return false;
}
}
return true;
};

View File

@ -1,97 +1,101 @@
import { SerializedObject } from "../types/SerializedObject";
const serializePrimitive = ( const serializePrimitive = (
obj: number | boolean | string | null, obj: number | boolean | string | null,
accessPath: string, accessPath: string
): SerializedObject => { ) => {
if (typeof obj === "number") { let type: string;
if (typeof obj === 'number') {
type = Number.isInteger(obj) ? 'int' : 'float';
return { return {
full_access_path: accessPath, full_access_path: accessPath,
doc: null, doc: null,
readonly: false, readonly: false,
type: Number.isInteger(obj) ? "int" : "float", type,
value: obj, value: obj
}; };
} else if (typeof obj === "boolean") { } else if (typeof obj === 'boolean') {
type = 'bool';
return { return {
full_access_path: accessPath, full_access_path: accessPath,
doc: null, doc: null,
readonly: false, readonly: false,
type: "bool", type,
value: obj, value: obj
}; };
} else if (typeof obj === "string") { } else if (typeof obj === 'string') {
type = 'str';
return { return {
full_access_path: accessPath, full_access_path: accessPath,
doc: null, doc: null,
readonly: false, readonly: false,
type: "str", type,
value: obj, value: obj
}; };
} else if (obj === null) { } else if (obj === null) {
type = 'NoneType';
return { return {
full_access_path: accessPath, full_access_path: accessPath,
doc: null, doc: null,
readonly: false, readonly: false,
type: "None", type,
value: null, value: null
}; };
} else { } else {
throw new Error("Unsupported type for serialization"); throw new Error('Unsupported type for serialization');
} }
}; };
export const serializeList = (obj: unknown[], accessPath = "") => { export const serializeList = (obj: unknown[], accessPath: string = '') => {
const doc = null; const doc = null;
const value = obj.map((item, index) => { const value = obj.map((item, index) => {
if ( if (
typeof item === "number" || typeof item === 'number' ||
typeof item === "boolean" || typeof item === 'boolean' ||
typeof item === "string" || typeof item === 'string' ||
item === null item === null
) { ) {
serializePrimitive( serializePrimitive(
item as number | boolean | string | null, item as number | boolean | string | null,
`${accessPath}[${index}]`, `${accessPath}[${index}]`
); );
} }
}); });
return { return {
full_access_path: accessPath, full_access_path: accessPath,
type: "list", type: 'list',
value, value,
readonly: false, readonly: false,
doc, doc
}; };
}; };
export const serializeDict = (obj: Record<string, unknown>, accessPath = "") => { export const serializeDict = (
obj: Record<string, unknown>,
accessPath: string = ''
) => {
const doc = null; const doc = null;
const value = Object.entries(obj).reduce( const value = Object.entries(obj).reduce((acc, [key, val]) => {
(acc, [key, val]) => { // Construct the new access path for nested properties
// Construct the new access path for nested properties const newPath = `${accessPath}["${key}"]`;
const newPath = `${accessPath}["${key}"]`;
// Serialize each value in the dictionary and assign to the accumulator // Serialize each value in the dictionary and assign to the accumulator
if ( if (
typeof val === "number" || typeof val === 'number' ||
typeof val === "boolean" || typeof val === 'boolean' ||
typeof val === "string" || typeof val === 'string' ||
val === null val === null
) { ) {
acc[key] = serializePrimitive(val as number | boolean | string | null, newPath); acc[key] = serializePrimitive(val as number | boolean | string | null, newPath);
} }
return acc; return acc;
}, }, {});
{} as Record<string, SerializedObject>,
);
return { return {
full_access_path: accessPath, full_access_path: accessPath,
type: "dict", type: 'dict',
value, value,
readonly: false, readonly: false,
doc, doc
}; };
}; };

View File

@ -1,12 +1,11 @@
import { SerializedObject } from "../types/SerializedObject"; import { SerializedValue } from '../components/GenericComponent';
export interface State { export type State = {
type: string; type: string;
name: string; value: Record<string, SerializedValue> | null;
value: Record<string, SerializedObject> | null;
readonly: boolean; readonly: boolean;
doc: string | null; doc: string | null;
} };
/** /**
* Splits a full access path into its atomic parts, separating attribute names, numeric * Splits a full access path into its atomic parts, separating attribute names, numeric
@ -45,7 +44,7 @@ export function parseFullAccessPath(path: string): string[] {
*/ */
function parseSerializedKey(serializedKey: string): string | number { function parseSerializedKey(serializedKey: string): string | number {
// Strip outer brackets if present // Strip outer brackets if present
if (serializedKey.startsWith("[") && serializedKey.endsWith("]")) { if (serializedKey.startsWith('[') && serializedKey.endsWith(']')) {
serializedKey = serializedKey.slice(1, -1); serializedKey = serializedKey.slice(1, -1);
} }
@ -68,13 +67,12 @@ function parseSerializedKey(serializedKey: string): string | number {
} }
function getOrCreateItemInContainer( function getOrCreateItemInContainer(
container: Record<string | number, SerializedObject> | SerializedObject[], container: Record<string | number, SerializedValue> | SerializedValue[],
key: string | number, key: string | number,
allowAddKey: boolean, allowAddKey: boolean
): SerializedObject { ): SerializedValue {
// Check if the key exists and return the item if it does // Check if the key exists and return the item if it does
if (key in container) { if (key in container) {
/* @ts-expect-error Key is in the correct form but converted to type any for some reason */
return container[key]; return container[key];
} }
@ -108,10 +106,10 @@ function getOrCreateItemInContainer(
* @throws SerializationValueError If the expected structure is incorrect. * @throws SerializationValueError If the expected structure is incorrect.
*/ */
function getContainerItemByKey( function getContainerItemByKey(
container: Record<string, SerializedObject> | SerializedObject[], container: Record<string, SerializedValue> | SerializedValue[],
key: string, key: string,
allowAppend = false, allowAppend: boolean = false
): SerializedObject { ): SerializedValue {
const processedKey = parseSerializedKey(key); const processedKey = parseSerializedKey(key);
try { try {
@ -127,13 +125,13 @@ function getContainerItemByKey(
} }
export function setNestedValueByPath( export function setNestedValueByPath(
serializationDict: Record<string, SerializedObject>, serializationDict: Record<string, SerializedValue>,
path: string, path: string,
serializedValue: SerializedObject, serializedValue: SerializedValue
): Record<string, SerializedObject> { ): Record<string, SerializedValue> {
const pathParts = parseFullAccessPath(path); const pathParts = parseFullAccessPath(path);
const newSerializationDict: Record<string, SerializedObject> = JSON.parse( const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
JSON.stringify(serializationDict), JSON.stringify(serializationDict)
); );
let currentDict = newSerializationDict; let currentDict = newSerializationDict;
@ -144,11 +142,11 @@ export function setNestedValueByPath(
const nextLevelSerializedObject = getContainerItemByKey( const nextLevelSerializedObject = getContainerItemByKey(
currentDict, currentDict,
pathPart, pathPart,
false, false
); );
currentDict = nextLevelSerializedObject["value"] as Record< currentDict = nextLevelSerializedObject['value'] as Record<
string, string,
SerializedObject SerializedValue
>; >;
} }
@ -161,15 +159,14 @@ export function setNestedValueByPath(
} catch (error) { } catch (error) {
console.error(`Error occurred trying to change ${path}: ${error}`); console.error(`Error occurred trying to change ${path}: ${error}`);
} }
return {};
} }
function createEmptySerializedObject(): SerializedObject { function createEmptySerializedObject(): SerializedValue {
return { return {
full_access_path: "", full_access_path: '',
value: null, value: undefined,
type: "None", type: 'None',
doc: null, doc: null,
readonly: false, readonly: false
}; };
} }

View File

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

View File

@ -1,31 +0,0 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
]
}

View File

@ -1,11 +1,8 @@
{ {
"files": [], "compilerOptions": {
"references": [ "jsx": "react-jsx",
{ "allowImportingTsExtensions": true,
"path": "./tsconfig.app.json" "noEmit": true,
}, "esModuleInterop": true
{ }
"path": "./tsconfig.node.json" }
}
]
}

View File

@ -1,15 +0,0 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": [
"vite.config.ts"
]
}

View File

@ -1,13 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: "../src/pydase/frontend",
},
esbuild: {
drop: ["console", "debugger"],
},
});

View File

@ -4,22 +4,8 @@ edit_uri: blob/docs/docs/
nav: nav:
- Home: index.md - Home: index.md
- 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
- Interaction:
- Overview: user-guide/interaction/README.md
- Auto-generated Frontend: user-guide/interaction/Auto-generated-Frontend.md
- RESTful API: user-guide/interaction/RESTful-API.md
- Python Client: user-guide/interaction/Python-Client.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
- Configuring pydase: user-guide/Configuration.md
- Logging in pydase: user-guide/Logging.md
- Advanced:
- Deploying behind a Reverse Proxy: user-guide/advanced/Reverse-Proxy.md
- Connecting through a SOCKS Proxy: user-guide/advanced/SOCKS-Proxy.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
@ -30,11 +16,7 @@ nav:
- Contributing: about/contributing.md - Contributing: about/contributing.md
- License: about/license.md - License: about/license.md
theme: theme: readthedocs
logo: images/logo-colour.png
name: material
features:
- content.code.copy
extra_css: extra_css:
- css/extra.css - css/extra.css
@ -44,46 +26,17 @@ markdown_extensions:
- toc: - toc:
permalink: true permalink: true
- pymdownx.highlight: - pymdownx.highlight:
use_pygments: true
anchor_linenums: true anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.snippets - pymdownx.snippets
- pymdownx.superfences - pymdownx.superfences
- pymdownx.inlinehilite # - pymdownx.highlight:
# - pymdownx.inlinehilite
plugins: plugins:
- include-markdown - include-markdown
- search - search
- mkdocstrings: - mkdocstrings
handlers:
python:
paths: [src] # search packages in the src folder
inventories:
- https://docs.python.org/3/objects.inv
- https://docs.pydantic.dev/latest/objects.inv
- https://confz.readthedocs.io/en/latest/objects.inv
- https://python-socketio.readthedocs.io/en/stable/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

3248
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +1,49 @@
[project]
name = "pydase"
version = "0.10.16"
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 = [
{name = "Mose Müller",email = "mosemueller@gmail.com"}
]
readme = "README.md"
requires-python = ">=3.10,<4.0"
dependencies = [
"toml (>=0.10.2,<0.11.0)",
"python-socketio (>=5.13.0,<6.0.0)",
"confz (>=2.1.0,<3.0.0)",
"pint (>=0.24.4,<0.25.0)",
"websocket-client (>=1.8.0,<2.0.0)",
"aiohttp (>=3.11.18,<4.0.0)",
"click (>=8.2.0,<9.0.0)",
"aiohttp-middlewares (>=2.4.0,<3.0.0)",
"anyio (>=4.9.0,<5.0.0)"
]
[project.optional-dependencies]
socks = ["aiohttp-socks (>=0.10.1,<0.11.0)"]
[tool.poetry] [tool.poetry]
packages = [{include = "pydase", from = "src"}] name = "pydase"
version = "0.8.3"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md"
packages = [{ include = "pydase", from = "src" }]
[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.108.0"
uvicorn = "^0.27.0"
toml = "^0.10.2"
python-socketio = "^5.8.0"
confz = "^2.0.0"
pint = "^0.22"
pillow = "^10.0.0"
websocket-client = "^1.7.0"
aiohttp = "^3.9.3"
[tool.poetry.group.dev] [tool.poetry.group.dev]
optional = true optional = true
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
types-toml = "^0.10.8.20240310" types-toml = "^0.10.8.6"
pytest = "^8.3.5" pytest = "^7.4.0"
pytest-cov = "^6.1.1" pytest-cov = "^4.1.0"
mypy = "^1.15.0" mypy = "^1.4.1"
matplotlib = "^3.10.3" matplotlib = "^3.7.2"
pyright = "^1.1.400" pyright = "^1.1.323"
pytest-mock = "^3.14.0" pytest-mock = "^3.11.1"
ruff = "^0.11.10" ruff = "^0.2.0"
pytest-asyncio = "^0.26.0" pytest-asyncio = "^0.23.2"
[tool.poetry.group.docs] [tool.poetry.group.docs]
optional = true optional = true
[tool.poetry.group.docs.dependencies] [tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.6.14" mkdocs = "^1.5.2"
mkdocs-include-markdown-plugin = "^7.1.5" mkdocs-include-markdown-plugin = "^3.9.1"
mkdocstrings = {extras = ["python"], version = "^0.29.1"} mkdocstrings = "^0.22.0"
pymdown-extensions = "^10.15" pymdown-extensions = "^10.1"
mkdocs-swagger-ui-tag = "^0.7.1"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.ruff] [tool.ruff]
@ -92,7 +85,6 @@ select = [
ignore = [ ignore = [
"RUF006", # asyncio-dangling-task "RUF006", # asyncio-dangling-task
"PERF203", # try-except-in-loop "PERF203", # try-except-in-loop
"ASYNC110", # async-busy-wait
] ]
[tool.ruff.lint.mccabe] [tool.ruff.lint.mccabe]
@ -111,10 +103,3 @@ disallow_incomplete_defs = true
disallow_any_generics = true disallow_any_generics = true
check_untyped_defs = true check_untyped_defs = true
ignore_missing_imports = false ignore_missing_imports = false
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
# I don't controll the usage of the timeout
"ignore:parameter 'timeout' of type 'float' is deprecated, please use 'timeout=ClientWSTimeout"
]

View File

@ -6,7 +6,7 @@ from pydase.utils.logging import setup_logging
setup_logging() setup_logging()
__all__ = [ __all__ = [
"Client",
"DataService", "DataService",
"Server", "Server",
"Client",
] ]

View File

@ -1,27 +1,15 @@
import asyncio import asyncio
import logging import logging
import socket
import sys
import threading import threading
import urllib.parse from typing import TypedDict, cast
from builtins import ModuleNotFoundError
from types import TracebackType
from typing import TYPE_CHECKING, Any, TypedDict, cast
import aiohttp
import socketio # type: ignore import socketio # type: ignore
from pydase.client.proxy_class import ProxyClass import pydase.components
from pydase.client.proxy_loader import ProxyLoader from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader
from pydase.utils.serialization.deserializer import loads from pydase.utils.serialization.deserializer import loads
from pydase.utils.serialization.types import SerializedDataService, SerializedObject from pydase.utils.serialization.types import SerializedDataService, SerializedObject
if sys.version_info < (3, 11):
from typing_extensions import Self
else:
from typing import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,216 +24,124 @@ 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()
finally:
loop.close()
class Client: class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
"""A client for connecting to a remote pydase service using Socket.IO. This client """
handles asynchronous communication with a service, manages events such as A proxy class that serves as the interface for interacting with device connections
connection, disconnection, and updates, and ensures that the proxy object is via a socket.io client in an asyncio environment.
up-to-date with the server state.
Args: Args:
url: The URL of the pydase Socket.IO server. This should always contain the sio_client (socketio.AsyncClient):
protocol (e.g., `ws` or `wss`) and the hostname, and can optionally include The socket.io client instance used for asynchronous communication with the
a path prefix (e.g., `ws://localhost:8001/service`). pydase service server.
block_until_connected: If set to True, the constructor will block until the loop (asyncio.AbstractEventLoop):
connection to the service has been established. This is useful for ensuring The event loop in which the client operations are managed and executed.
the client is ready to use immediately after instantiation. Default is True.
sio_client_kwargs: Additional keyword arguments passed to the underlying
[`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the
client's behaviour (e.g., reconnection attempts or reconnection delay).
client_id: An optional client identifier. This ID is sent to the server as the
`X-Client-Id` HTTP header. It can be used for logging or authentication
purposes on the server side. If not provided, it defaults to the hostname
of the machine running the client.
proxy_url: An optional proxy URL to route the connection through. This is useful
if the service is only reachable via an SSH tunnel or behind a firewall
(e.g., `socks5://localhost:2222`).
Example: This class is used to create a proxy object that behaves like a local representation
Connect to a service directly: of a remote pydase service, facilitating direct interaction as if it were local
while actually communicating over network protocols.
It can also be used as an attribute of a pydase service itself, e.g.
```python ```python
client = pydase.Client(url="ws://localhost:8001") import pydase
```
Connect over a secure connection:
```python class MyService(pydase.DataService):
client = pydase.Client(url="wss://my-service.example.com") proxy = pydase.Client(
``` hostname="...", port=8001, block_until_connected=False
).proxy
Connect using a SOCKS5 proxy (e.g., through an SSH tunnel):
```bash if __name__ == "__main__":
ssh -D 2222 user@gateway.example.com service = MyService()
``` server = pydase.Server(service, web_port=8002).run()
```python
client = pydase.Client(
url="ws://remote-server:8001",
proxy_url="socks5://localhost:2222"
)
``` ```
""" """
def __init__( def __init__(
self, self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop
*,
url: str,
block_until_connected: bool = True,
sio_client_kwargs: dict[str, Any] = {},
client_id: str | None = None,
proxy_url: str | None = None,
):
# Parse the URL to separate base URL and path prefix
parsed_url = urllib.parse.urlparse(url)
# Construct the base URL without the path
self._base_url = urllib.parse.urlunparse(
(parsed_url.scheme, parsed_url.netloc, "", "", "", "")
)
# Store the path prefix (e.g., "/service" in "ws://localhost:8081/service")
self._path_prefix = parsed_url.path.rstrip("/") # Remove trailing slash if any
self._url = url
self._proxy_url = proxy_url
self._client_id = client_id or socket.gethostname()
self._sio_client_kwargs = sio_client_kwargs
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self.proxy: ProxyClass
"""A proxy object representing the remote service, facilitating interaction as
if it were local."""
self.connect(block_until_connected=block_until_connected)
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None: ) -> None:
self.disconnect() super().__init__()
self._initialise(sio_client=sio_client, loop=loop)
def connect(self, block_until_connected: bool = True) -> None:
if self._thread is None or self._loop is None:
self._loop = self._initialize_loop_and_thread()
self._initialize_socketio_client()
self.proxy = ProxyClass(
sio_client=self._sio,
loop=self._loop,
reconnect=self.connect,
)
class Client:
"""
A client for connecting to a remote pydase service using socket.io. This client
handles asynchronous communication with a service, manages events such as
connection, disconnection, and updates, and ensures that the proxy object is
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:
hostname (str):
Hostname of the exposed service this client attempts to connect to.
Default is "localhost".
port (int):
Port of the exposed service this client attempts to connect on.
Default is 8001.
block_until_connected (bool):
If set to True, the constructor will block until the connection to the
service has been established. This is useful for ensuring the client is
ready to use immediately after instantiation. Default is True.
"""
def __init__(
self,
hostname: str,
port: int,
block_until_connected: bool = True,
):
self._hostname = hostname
self._port = port
self._sio = socketio.AsyncClient()
self._loop = asyncio.new_event_loop()
self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop)
self._thread = threading.Thread(
target=asyncio_loop_thread, args=(self._loop,), daemon=True
)
self._thread.start()
connection_future = asyncio.run_coroutine_threadsafe( connection_future = asyncio.run_coroutine_threadsafe(
self._connect(), self._loop self._connect(), self._loop
) )
if block_until_connected: if block_until_connected:
connection_future.result() connection_future.result()
def _initialize_socketio_client(self) -> None:
if self._proxy_url is not None:
try:
import aiohttp_socks.connector
except ModuleNotFoundError:
raise ModuleNotFoundError(
"Missing dependency 'aiohttp_socks'. To use SOCKS5 proxy support, "
"install the optional 'socks' extra:\n\n"
' pip install "pydase[socks]"\n\n'
"This is required when specifying a `proxy_url` for "
"`pydase.Client`."
)
session = aiohttp.ClientSession(
connector=aiohttp_socks.connector.ProxyConnector.from_url(
url=self._proxy_url, loop=self._loop
),
loop=self._loop,
)
self._sio = socketio.AsyncClient(
http_session=session, **self._sio_client_kwargs
)
else:
self._sio = socketio.AsyncClient(**self._sio_client_kwargs)
def _initialize_loop_and_thread(self) -> asyncio.AbstractEventLoop:
"""Initialize a new asyncio event loop, start it in a background thread,
and create the ProxyClass instance bound to that loop.
"""
loop = asyncio.new_event_loop()
self._thread = threading.Thread(
target=asyncio_loop_thread,
args=(loop,),
daemon=True,
)
self._thread.start()
return loop
def disconnect(self) -> None:
if self._loop is not None and self._thread is not None:
connection_future = asyncio.run_coroutine_threadsafe(
self._disconnect(), self._loop
)
connection_future.result()
# Stop the event loop and thread
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join()
self._thread = None
async def _connect(self) -> None: async def _connect(self) -> None:
logger.debug("Connecting to server '%s' ...", self._url) logger.debug("Connecting to server '%s:%s' ...", self._hostname, self._port)
await self._setup_events() await self._setup_events()
headers = {}
if self._client_id is not None:
headers["X-Client-Id"] = self._client_id
await self._sio.connect( await self._sio.connect(
url=self._base_url, f"ws://{self._hostname}:{self._port}",
headers=headers, socketio_path="/ws/socket.io",
socketio_path=f"{self._path_prefix}/ws/socket.io",
transports=["websocket"], transports=["websocket"],
retry=True, retry=True,
) )
async def _disconnect(self) -> None:
await self._sio.disconnect()
async def _setup_events(self) -> None: async def _setup_events(self) -> None:
self._sio.on("connect", self._handle_connect) self._sio.on("connect", self._handle_connect)
self._sio.on("disconnect", self._handle_disconnect) self._sio.on("disconnect", self._handle_disconnect)
self._sio.on("notify", self._handle_update) self._sio.on("notify", self._handle_update)
async def _handle_connect(self) -> None: async def _handle_connect(self) -> None:
logger.debug("Connected to '%s' ...", self._url) logger.debug("Connected to '%s:%s' ...", self._hostname, self._port)
serialized_object = cast( serialized_object = cast(
"SerializedDataService", await self._sio.call("service_serialization") SerializedDataService, await self._sio.call("service_serialization")
) )
ProxyLoader.update_data_service_proxy( ProxyLoader.update_data_service_proxy(
self.proxy, serialized_object=serialized_object self.proxy, serialized_object=serialized_object
) )
serialized_object["type"] = "DeviceConnection" serialized_object["type"] = "DeviceConnection"
if self.proxy._service_representation is not None: self.proxy._notify_changed("", loads(serialized_object))
# need to use object.__setattr__ to not trigger an observer notification
object.__setattr__(self.proxy, "_service_representation", serialized_object)
if TYPE_CHECKING:
self.proxy._service_representation = serialized_object # type: ignore
self.proxy._notify_changed("", self.proxy)
self.proxy._connected = True self.proxy._connected = True
async def _handle_disconnect(self) -> None: async def _handle_disconnect(self) -> None:
logger.debug("Disconnected from '%s' ...", self._url) logger.debug("Disconnected from '%s:%s' ...", self._hostname, self._port)
self.proxy._connected = False self.proxy._connected = False
async def _handle_update(self, data: NotifyDict) -> None: async def _handle_update(self, data: NotifyDict) -> None:

View File

@ -1,112 +0,0 @@
import asyncio
import logging
from collections.abc import Callable
from copy import deepcopy
from typing import TYPE_CHECKING, cast
import socketio # type: ignore
import pydase.components
from pydase.client.proxy_loader import ProxyClassMixin
from pydase.utils.helpers import get_attribute_doc
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
logger = logging.getLogger(__name__)
class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
"""
A proxy class that serves as the interface for interacting with device connections
via a socket.io client in an asyncio environment.
Args:
sio_client:
The socket.io client instance used for asynchronous communication with the
pydase service server.
loop:
The event loop in which the client operations are managed and executed.
reconnect:
The method that is called periodically when the client is not connected.
This class is used to create a proxy object that behaves like a local representation
of a remote pydase service, facilitating direct interaction as if it were local
while actually communicating over network protocols.
It can also be used as an attribute of a pydase service itself, e.g.
```python
import pydase
class MyService(pydase.DataService):
proxy = pydase.Client(
hostname="...", port=8001, block_until_connected=False
).proxy
if __name__ == "__main__":
service = MyService()
server = pydase.Server(service, web_port=8002).run()
```
"""
def __init__(
self,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
reconnect: Callable[..., None],
) -> None:
if TYPE_CHECKING:
self._service_representation: None | SerializedObject = None
super().__init__()
pydase.components.DeviceConnection.__init__(self)
self._initialise(sio_client=sio_client, loop=loop)
object.__setattr__(self, "_service_representation", None)
self.reconnect = reconnect
def serialize(self) -> SerializedObject:
if self._service_representation is None:
serialization_future = cast(
"asyncio.Future[SerializedDataService]",
asyncio.run_coroutine_threadsafe(
self._sio.call("service_serialization"), self._loop
),
)
# need to use object.__setattr__ to not trigger an observer notification
object.__setattr__(
self, "_service_representation", serialization_future.result()
)
if TYPE_CHECKING:
self._service_representation = serialization_future.result()
device_connection_value = cast(
"dict[str, SerializedObject]",
pydase.components.DeviceConnection().serialize()["value"],
)
readonly = False
doc = get_attribute_doc(self)
obj_name = self.__class__.__name__
value = {
**cast(
"dict[str, SerializedObject]",
# need to deepcopy to not overwrite the _service_representation dict
# when adding a prefix with add_prefix_to_full_access_path
deepcopy(self._service_representation["value"]),
),
**device_connection_value,
}
return {
"full_access_path": "",
"name": obj_name,
"type": "DeviceConnection",
"value": value,
"readonly": readonly,
"doc": doc,
}
def connect(self) -> None:
if not self._sio.reconnection or self._sio.reconnection_attempts > 0:
self.reconnect(block_until_connected=False)

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
from collections.abc import Iterable from collections.abc import Iterable
from copy import copy
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
import socketio # type: ignore import socketio # type: ignore
@ -123,35 +124,35 @@ class ProxyList(list[Any]):
update_value(self._sio, self._loop, full_access_path, value) update_value(self._sio, self._loop, full_access_path, value)
def append(self, object_: Any, /) -> None: def append(self, __object: Any) -> None:
full_access_path = f"{self._parent_path}.append" full_access_path = f"{self._parent_path}.append"
trigger_method(self._sio, self._loop, full_access_path, [object_], {}) trigger_method(self._sio, self._loop, full_access_path, [__object], {})
def clear(self) -> None: def clear(self) -> None:
full_access_path = f"{self._parent_path}.clear" full_access_path = f"{self._parent_path}.clear"
trigger_method(self._sio, self._loop, full_access_path, [], {}) trigger_method(self._sio, self._loop, full_access_path, [], {})
def extend(self, iterable: Iterable[Any], /) -> None: def extend(self, __iterable: Iterable[Any]) -> None:
full_access_path = f"{self._parent_path}.extend" full_access_path = f"{self._parent_path}.extend"
trigger_method(self._sio, self._loop, full_access_path, [iterable], {}) trigger_method(self._sio, self._loop, full_access_path, [__iterable], {})
def insert(self, index: SupportsIndex, object_: Any, /) -> None: def insert(self, __index: SupportsIndex, __object: Any) -> None:
full_access_path = f"{self._parent_path}.insert" full_access_path = f"{self._parent_path}.insert"
trigger_method(self._sio, self._loop, full_access_path, [index, object_], {}) trigger_method(self._sio, self._loop, full_access_path, [__index, __object], {})
def pop(self, index: SupportsIndex = -1, /) -> Any: def pop(self, __index: SupportsIndex = -1) -> Any:
full_access_path = f"{self._parent_path}.pop" full_access_path = f"{self._parent_path}.pop"
return trigger_method(self._sio, self._loop, full_access_path, [index], {}) return trigger_method(self._sio, self._loop, full_access_path, [__index], {})
def remove(self, value: Any, /) -> None: def remove(self, __value: Any) -> None:
full_access_path = f"{self._parent_path}.remove" full_access_path = f"{self._parent_path}.remove"
trigger_method(self._sio, self._loop, full_access_path, [value], {}) trigger_method(self._sio, self._loop, full_access_path, [__value], {})
class ProxyClassMixin: class ProxyClassMixin:
@ -201,8 +202,25 @@ class ProxyClassMixin:
def _handle_serialized_method( def _handle_serialized_method(
self, attr_name: str, serialized_object: SerializedObject self, attr_name: str, serialized_object: SerializedObject
) -> None: ) -> None:
def add_prefix_to_last_path_element(s: str, prefix: str) -> str:
parts = s.split(".")
parts[-1] = f"{prefix}_{parts[-1]}"
return ".".join(parts)
if serialized_object["type"] == "method": if serialized_object["type"] == "method":
self._add_method_proxy(attr_name, serialized_object) if serialized_object["async"] is True:
start_method = copy(serialized_object)
start_method["full_access_path"] = add_prefix_to_last_path_element(
start_method["full_access_path"], "start"
)
stop_method = copy(serialized_object)
stop_method["full_access_path"] = add_prefix_to_last_path_element(
stop_method["full_access_path"], "stop"
)
self._add_method_proxy(f"start_{attr_name}", start_method)
self._add_method_proxy(f"stop_{attr_name}", stop_method)
else:
self._add_method_proxy(attr_name, serialized_object)
def _add_method_proxy( def _add_method_proxy(
self, attr_name: str, serialized_object: SerializedObject self, attr_name: str, serialized_object: SerializedObject
@ -266,7 +284,7 @@ class ProxyLoader:
return ProxyList( return ProxyList(
[ [
ProxyLoader.loads_proxy(item, sio_client, loop) ProxyLoader.loads_proxy(item, sio_client, loop)
for item in cast("list[SerializedObject]", serialized_object["value"]) for item in cast(list[SerializedObject], serialized_object["value"])
], ],
parent_path=serialized_object["full_access_path"], parent_path=serialized_object["full_access_path"],
sio_client=sio_client, sio_client=sio_client,
@ -283,7 +301,7 @@ class ProxyLoader:
{ {
key: ProxyLoader.loads_proxy(value, sio_client, loop) key: ProxyLoader.loads_proxy(value, sio_client, loop)
for key, value in cast( for key, value in cast(
"dict[str, SerializedObject]", serialized_object["value"] dict[str, SerializedObject], serialized_object["value"]
).items() ).items()
}, },
parent_path=serialized_object["full_access_path"], parent_path=serialized_object["full_access_path"],
@ -300,7 +318,7 @@ class ProxyLoader:
proxy_class._proxy_setters.clear() proxy_class._proxy_setters.clear()
proxy_class._proxy_methods.clear() proxy_class._proxy_methods.clear()
for key, value in cast( for key, value in cast(
"dict[str, SerializedObject]", serialized_object["value"] dict[str, SerializedObject], serialized_object["value"]
).items(): ).items():
type_handler: dict[str | None, None | Callable[..., Any]] = { type_handler: dict[str | None, None | Callable[..., Any]] = {
None: None, None: None,
@ -333,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_service_base_class(serialized_object["type"]) type, Deserializer.get_component_class(serialized_object["type"])
) )
class_bases = ( class_bases = (
ProxyClassMixin, ProxyClassMixin,

View File

@ -33,8 +33,8 @@ from pydase.components.image import Image
from pydase.components.number_slider import NumberSlider from pydase.components.number_slider import NumberSlider
__all__ = [ __all__ = [
"NumberSlider",
"Image",
"ColouredEnum", "ColouredEnum",
"DeviceConnection", "DeviceConnection",
"Image",
"NumberSlider",
] ]

View File

@ -7,59 +7,58 @@ 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 - Hexadecimal colours with transparency
- Hexadecimal colours with transparency - RGB colours
- RGB colours - RGBA colours
- RGBA colours - HSL colours
- HSL colours - HSLA colours
- HSLA colours - Predefined/Cross-browser colour names
- 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,
- As property with a setter or as attribute: Renders as a dropdown menu, allowing allowing users to select and change its value from the frontend.
users to select and change its value from the frontend. - As property without a setter: Displays as a coloured box with the key of the
- As property without a setter: Displays as a coloured box with the key of the `ColouredEnum` as text inside, serving as a visual indicator without user
`ColouredEnum` as text inside, serving as a visual indicator without user interaction.
interaction.
Example: Example:
```python --------
import pydase.components as pyc ```python
import pydase import pydase.components as pyc
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 ----
different colour formats when you want to use a colour multiple times. 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.
""" """

View File

@ -1,7 +1,6 @@
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):
@ -20,26 +19,22 @@ 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:
```python >>> class MyDeviceConnection(DeviceConnection):
class MyDeviceConnection(DeviceConnection): ... def connect(self) -> None:
def connect(self) -> None: ... # Implementation to connect to the device
# Implementation to connect to the device ... # Update self._connected to `True` if connection is successful,
# Update self._connected to `True` if connection is successful, ... # `False` otherwise
# `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:
```python >>> class MyDeviceConnection(DeviceConnection):
class MyDeviceConnection(DeviceConnection): ... @property
@property ... def connected(self) -> bool:
def connected(self) -> bool: ... # Custom logic to determine connection status
# Custom logic to determine connection status ... return some_custom_condition
return some_custom_condition ...
```
Frontend Representation Frontend Representation
----------------------- -----------------------
@ -53,6 +48,7 @@ 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:
@ -70,7 +66,6 @@ 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

@ -5,6 +5,8 @@ from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.request import urlopen from urllib.request import urlopen
import PIL.Image # type: ignore[import-untyped]
from pydase.data_service.data_service import DataService from pydase.data_service.data_service import DataService
if TYPE_CHECKING: if TYPE_CHECKING:
@ -14,7 +16,9 @@ logger = logging.getLogger(__name__)
class Image(DataService): class Image(DataService):
def __init__(self) -> None: def __init__(
self,
) -> None:
super().__init__() super().__init__()
self._value: str = "" self._value: str = ""
self._format: str = "" self._format: str = ""
@ -28,14 +32,8 @@ class Image(DataService):
return self._format return self._format
def load_from_path(self, path: Path | str) -> None: def load_from_path(self, path: Path | str) -> None:
with open(path, "rb") as image_file: with PIL.Image.open(path) as image:
image_data = image_file.read() self._load_from_pil(image)
format_ = self._get_image_format_from_bytes(image_data)
if format_ is None:
logger.error("Unsupported image format. Skipping...")
return
value_ = base64.b64encode(image_data)
self._load_from_base64(value_, format_)
def load_from_matplotlib_figure(self, fig: "Figure", format_: str = "png") -> None: def load_from_matplotlib_figure(self, fig: "Figure", format_: str = "png") -> None:
buffer = io.BytesIO() buffer = io.BytesIO()
@ -44,18 +42,12 @@ class Image(DataService):
self._load_from_base64(value_, format_) self._load_from_base64(value_, format_)
def load_from_url(self, url: str) -> None: def load_from_url(self, url: str) -> None:
with urlopen(url) as response: image = PIL.Image.open(urlopen(url))
image_data = response.read() self._load_from_pil(image)
format_ = self._get_image_format_from_bytes(image_data)
if format_ is None:
logger.error("Unsupported image format. Skipping...")
return
value_ = base64.b64encode(image_data)
self._load_from_base64(value_, format_)
def load_from_base64(self, value_: bytes, format_: str | None = None) -> None: def load_from_base64(self, value_: bytes, format_: str | None = None) -> None:
if format_ is None: if format_ is None:
format_ = self._get_image_format_from_bytes(base64.b64decode(value_)) format_ = self._get_image_format_from_bytes(value_)
if format_ is None: if format_ is None:
logger.warning( logger.warning(
"Format of passed byte string could not be determined. Skipping..." "Format of passed byte string could not be determined. Skipping..."
@ -68,14 +60,19 @@ class Image(DataService):
self._value = value self._value = value
self._format = format_ self._format = format_
def _load_from_pil(self, image: PIL.Image.Image) -> None:
if image.format is not None:
format_ = image.format
buffer = io.BytesIO()
image.save(buffer, format=format_)
value_ = base64.b64encode(buffer.getvalue())
self._load_from_base64(value_, format_)
else:
logger.error("Image format is 'None'. Skipping...")
def _get_image_format_from_bytes(self, value_: bytes) -> str | None: def _get_image_format_from_bytes(self, value_: bytes) -> str | None:
format_map = { image_data = base64.b64decode(value_)
b"\xff\xd8": "JPEG", # Create a writable memory buffer for the image
b"\x89PNG": "PNG", image_buffer = io.BytesIO(image_data)
b"GIF": "GIF", # Read the image from the buffer and return format
b"RIFF": "WEBP", return PIL.Image.open(image_buffer).format
}
for signature, format_name in format_map.items():
if value_.startswith(signature):
return format_name
return None

View File

@ -11,82 +11,84 @@ 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.
Args: Parameters:
value: -----------
The initial value of the slider. Defaults to 0.0. value (float, optional):
min_: The initial value of the slider. Defaults to 0.
The minimum value of the slider. Defaults to 0.0. min (float, optional):
max_: The minimum value of the slider. Defaults to 0.
The maximum value of the slider. Defaults to 100.0. max (float, optional):
step_size: The maximum value of the slider. Defaults to 100.
The increment/decrement step size of the slider. Defaults to 1.0. step_size (float, optional):
The increment/decrement step size of the slider. Defaults to 1.0.
Example: Example:
```python --------
class MySlider(pydase.components.NumberSlider): ```python
def __init__( class MySlider(pydase.components.NumberSlider):
self, def __init__(
value: float = 0.0, self,
min_: float = 0.0, value: float = 0.0,
max_: float = 100.0, min_: float = 0.0,
step_size: float = 1.0, max_: float = 100.0,
) -> None: step_size: float = 1.0,
super().__init__(value, min_, max_, step_size) ) -> None:
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__(
self, self,
value: Any = 0.0, value: Any = 0.0,
min_: Any = 0.0, min_: float = 0.0,
max_: Any = 100.0, max_: float = 100.0,
step_size: Any = 1.0, step_size: float = 1.0,
) -> None: ) -> None:
super().__init__() super().__init__()
self._step_size = step_size self._step_size = step_size
@ -95,17 +97,17 @@ class NumberSlider(DataService):
self._max = max_ self._max = max_
@property @property
def min(self) -> Any: def min(self) -> float:
"""The min property.""" """The min property."""
return self._min return self._min
@property @property
def max(self) -> Any: def max(self) -> float:
"""The min property.""" """The min property."""
return self._max return self._max
@property @property
def step_size(self) -> Any: def step_size(self) -> float:
"""The min property.""" """The min property."""
return self._step_size return self._step_size

View File

@ -5,31 +5,19 @@ from confz import BaseConfig, EnvSource
class OperationMode(BaseConfig): # type: ignore[misc] class OperationMode(BaseConfig): # type: ignore[misc]
environment: Literal["testing", "development", "production"] = "development" environment: Literal["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,7 +1,15 @@
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):
pass __root__: DataService
_task_manager: TaskManager
_autostart_tasks: dict[str, tuple[Any]]

View File

@ -1,23 +1,22 @@
import inspect import inspect
import logging import logging
from collections.abc import Callable
from enum import Enum from enum import Enum
from typing import Any 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 (
SerializedObject,
Serializer, Serializer,
) )
from pydase.utils.serialization.types import SerializedObject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,19 +24,24 @@ 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:
# Check and warn for unexpected type changes in attributes # Check and warn for unexpected type changes in attributes
self._warn_on_type_change(name, value) self._warn_on_type_change(__name, __value)
# every class defined by the user should inherit from DataService if it is # every class defined by the user should inherit from DataService if it is
# assigned to a public attribute # assigned to a public attribute
if not name.startswith("_") and not inspect.isfunction(value): if not __name.startswith("_") and not inspect.isfunction(__value):
self.__warn_if_not_observable(value) self.__warn_if_not_observable(__value)
# Set the attribute # Set the attribute
super().__setattr__(name, value) super().__setattr__(__name, __value)
def _warn_on_type_change(self, attr_name: str, new_value: Any) -> None: def _warn_on_type_change(self, attr_name: str, new_value: Any) -> None:
if is_property_attribute(self, attr_name): if is_property_attribute(self, attr_name):
@ -56,30 +60,21 @@ class DataService(AbstractDataService):
def _is_unexpected_type_change(self, current_value: Any, new_value: Any) -> bool: def _is_unexpected_type_change(self, current_value: Any, new_value: Any) -> bool:
return ( return (
isinstance(current_value, float) and not isinstance(new_value, float) isinstance(current_value, float)
) or ( and not isinstance(new_value, float)
isinstance(current_value, u.Quantity) or (
and not isinstance(new_value, u.Quantity) isinstance(current_value, u.Quantity)
and not isinstance(new_value, u.Quantity)
)
) )
def __warn_if_not_observable(self, value: Any, /) -> None: def __warn_if_not_observable(self, __value: Any) -> None:
value_class = value if inspect.isclass(value) else value.__class__ value_class = __value if inspect.isclass(__value) else __value.__class__
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
| Callable
),
) 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

@ -2,10 +2,12 @@ import logging
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from pydase.utils.serialization.serializer import ( from pydase.utils.serialization.serializer import (
SerializationPathError,
SerializationValueError,
SerializedObject,
get_nested_dict_by_path, get_nested_dict_by_path,
set_nested_value_by_path, set_nested_value_by_path,
) )
from pydase.utils.serialization.types import SerializedObject
if TYPE_CHECKING: if TYPE_CHECKING:
from pydase import DataService from pydase import DataService
@ -14,22 +16,6 @@ logger = logging.getLogger(__name__)
class DataServiceCache: class DataServiceCache:
"""Maintains a serialized cache of the current state of a DataService instance.
This class is responsible for storing and updating a representation of the service's
public attributes and properties. It is primarily used by the StateManager and the
web server to serve consistent state to clients without accessing the DataService
attributes directly.
The cache is initialized once upon construction by serializing the full state of
the service. After that, it can be incrementally updated using attribute paths and
values as notified by the
[`DataServiceObserver`][pydase.data_service.data_service_observer.DataServiceObserver].
Args:
service: The DataService instance whose state should be cached.
"""
def __init__(self, service: "DataService") -> None: def __init__(self, service: "DataService") -> None:
self._cache: SerializedObject self._cache: SerializedObject
self.service = service self.service = service
@ -46,13 +32,22 @@ class DataServiceCache:
def update_cache(self, full_access_path: str, value: Any) -> None: def update_cache(self, full_access_path: str, value: Any) -> None:
set_nested_value_by_path( set_nested_value_by_path(
cast("dict[str, SerializedObject]", self._cache["value"]), cast(dict[str, SerializedObject], self._cache["value"]),
full_access_path, full_access_path,
value, value,
) )
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject: def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
return get_nested_dict_by_path( try:
cast("dict[str, SerializedObject]", self._cache["value"]), return get_nested_dict_by_path(
full_access_path, cast(dict[str, SerializedObject], self._cache["value"]),
) full_access_path,
)
except (SerializationPathError, SerializationValueError, KeyError):
return {
"full_access_path": full_access_path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}

View File

@ -8,31 +8,12 @@ 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 ( from pydase.utils.helpers import get_object_attr_from_path
get_object_attr_from_path, from pydase.utils.serialization.serializer import SerializedObject, dump
)
from pydase.utils.serialization.serializer import (
SerializationPathError,
dump,
)
from pydase.utils.serialization.types import SerializedObject
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _is_nested_attribute(full_access_path: str, changing_attributes: list[str]) -> bool:
"""Return True if the full_access_path is a nested attribute of any
changing_attribute."""
return any(
(
full_access_path.startswith((f"{attr}.", f"{attr}["))
and full_access_path != attr
)
for attr in changing_attributes
)
class DataServiceObserver(PropertyObserver): class DataServiceObserver(PropertyObserver):
def __init__(self, state_manager: StateManager) -> None: def __init__(self, state_manager: StateManager) -> None:
self.state_manager = state_manager self.state_manager = state_manager
@ -42,36 +23,29 @@ class DataServiceObserver(PropertyObserver):
super().__init__(state_manager.service) super().__init__(state_manager.service)
def on_change(self, full_access_path: str, value: Any) -> None: def on_change(self, full_access_path: str, value: Any) -> None:
if _is_nested_attribute(full_access_path, self.changing_attributes): if any(
full_access_path.startswith(changing_attribute)
and full_access_path != changing_attribute
for changing_attribute in self.changing_attributes
):
return return
cached_value_dict: SerializedObject
try: cached_value_dict = deepcopy(
cached_value_dict = deepcopy( self.state_manager._data_service_cache.get_value_dict_from_cache(
self.state_manager.cache_manager.get_value_dict_from_cache( full_access_path
full_access_path
)
) )
except (SerializationPathError, KeyError): )
cached_value_dict = {
"full_access_path": full_access_path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
cached_value = cached_value_dict.get("value") cached_value = cached_value_dict.get("value")
if ( if cached_value != dump(value)["value"] and all(
all(part[0] != "_" for part in full_access_path.split(".")) part[0] != "_" for part in full_access_path.split(".")
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)
self._update_cache_value(full_access_path, value, cached_value_dict) self._update_cache_value(full_access_path, value, cached_value_dict)
cached_value_dict = deepcopy( cached_value_dict = deepcopy(
self.state_manager.cache_manager.get_value_dict_from_cache( self.state_manager._data_service_cache.get_value_dict_from_cache(
full_access_path full_access_path
) )
) )
@ -104,7 +78,7 @@ class DataServiceObserver(PropertyObserver):
value_dict["type"], value_dict["type"],
cached_value_dict["type"], cached_value_dict["type"],
) )
self.state_manager.cache_manager.update_cache( self.state_manager._data_service_cache.update_cache(
full_access_path, full_access_path,
value, value,
) )
@ -135,10 +109,8 @@ class DataServiceObserver(PropertyObserver):
object. object.
Args: Args:
callback: callback (Callable[[str, Any, dict[str, Any]]): The callback function to be
The callback function to be registered. The function should have the registered. The function should have the following signature:
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

@ -1,5 +1,3 @@
import asyncio
import contextlib
import json import json
import logging import logging
import os import os
@ -17,11 +15,11 @@ from pydase.utils.helpers import (
from pydase.utils.serialization.deserializer import loads from pydase.utils.serialization.deserializer import loads
from pydase.utils.serialization.serializer import ( from pydase.utils.serialization.serializer import (
SerializationPathError, SerializationPathError,
SerializedObject,
generate_serialized_data_paths, generate_serialized_data_paths,
get_nested_dict_by_path, get_nested_dict_by_path,
serialized_dict_is_nested_object, serialized_dict_is_nested_object,
) )
from pydase.utils.serialization.types import SerializedObject
if TYPE_CHECKING: if TYPE_CHECKING:
from pydase import DataService from pydase import DataService
@ -34,19 +32,17 @@ 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:
```python >>> class Service(pydase.DataService):
class Service(pydase.DataService): ... _name = "Service"
_name = "Service" ...
... @property
@property ... def name(self) -> str:
def name(self) -> str: ... return self._name
return self._name ...
... @name.setter
@name.setter ... @load_state
@load_state ... def name(self, value: str) -> None:
def name(self, value: str) -> None: ... self._name = value
self._name = value
```
""" """
func._load_state = True # type: ignore[attr-defined] func._load_state = True # type: ignore[attr-defined]
@ -67,41 +63,45 @@ def has_load_state_decorator(prop: property) -> bool:
class StateManager: class StateManager:
""" """
Manages the state of a DataService instance, serving as both a cache and a Manages the state of a DataService instance, serving as both a cache and a
persistence layer. It provides fast access to the most recently known state of the persistence layer. It is designed to provide quick access to the latest known state
service and ensures consistent state updates across connected clients and service for newly connecting web clients without the need for expensive property accesses
restarts. that may involve complex calculations or I/O operations.
The StateManager is used by the web server to apply updates to service attributes The StateManager listens for state change notifications from the DataService's
and to serve the current state to newly connected clients. Internally, it creates a callback manager and updates its cache accordingly. This cache does not always
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache] reflect the most current complex property states but rather retains the value from
instance to track the state of public attributes and properties. the last known state, optimizing for performance and reducing the load on the
system.
The StateManager also handles state persistence: it can load a previously saved While the StateManager ensures that the cached state is as up-to-date as possible,
state from disk at startup and periodically autosave the current state to a file it does not autonomously update complex properties of the DataService. Such
during runtime. properties must be updated programmatically, for instance, by invoking specific
tasks or methods that trigger the necessary operations to refresh their state.
Args: The cached state maintained by the StateManager is particularly useful for web
service: The DataService instance whose state is being managed. clients that connect to the system and need immediate access to the current state of
filename: The file name used for loading and storing the DataService's state. the DataService. By avoiding direct and potentially costly property accesses, the
If provided, the state is loaded from this file at startup and saved to it StateManager provides a snapshot of the DataService's state that is sufficiently
on shutdown or at regular intervals. accurate for initial rendering and interaction.
autosave_interval: Interval in seconds between automatic state save events.
If set to `None`, automatic saving is disabled. Attributes:
cache (dict[str, Any]):
A dictionary cache of the DataService's state.
filename (str):
The file name used for storing the DataService's state.
service (DataService):
The DataService instance whose state is being managed.
Note: Note:
The StateManager does not autonomously poll hardware state. It relies on the The StateManager's cache updates are triggered by notifications and do not
service to perform such updates. The cache maintained by include autonomous updates of complex DataService properties, which must be
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache] managed programmatically. The cache serves the purpose of providing immediate
reflects the last known state as notified by the `DataServiceObserver`, and is state information to web clients, reflecting the state after the last property
used by the web interface to provide fast and accurate state rendering for update.
connected clients.
""" """
def __init__( def __init__(
self, self, service: "DataService", filename: str | Path | None = None
service: "DataService",
filename: str | Path | None = None,
autosave_interval: float | None = None,
) -> None: ) -> None:
self.filename = getattr(service, "_filename", None) self.filename = getattr(service, "_filename", None)
@ -113,52 +113,38 @@ class StateManager:
self.filename = filename self.filename = filename
self.service = service self.service = service
self.cache_manager = DataServiceCache(self.service) self._data_service_cache = DataServiceCache(self.service)
self.autosave_interval = autosave_interval
async def autosave(self) -> None: @property
"""Periodically saves the current service state to the configured file. def cache(self) -> SerializedObject:
"""Returns the cached DataService state."""
This coroutine is automatically started by the [`pydase.Server`][pydase.Server] return self._data_service_cache.cache
when a filename is provided. It runs in the background and writes the latest
known state of the service to disk every `autosave_interval` seconds.
If `autosave_interval` is set to `None`, autosaving is disabled and this
coroutine exits immediately.
"""
if self.autosave_interval is None:
return
while True:
try:
if self.filename is not None:
self.save_state()
await asyncio.sleep(self.autosave_interval)
except Exception as e:
logger.exception(e)
@property @property
def cache_value(self) -> dict[str, SerializedObject]: def cache_value(self) -> dict[str, SerializedObject]:
"""Returns the "value" value of the DataService serialization.""" """Returns the "value" value of the DataService serialization."""
return cast("dict[str, SerializedObject]", self.cache_manager.cache["value"]) return cast(
dict[str, SerializedObject], self._data_service_cache.cache["value"]
)
def save_state(self) -> None: def save_state(self) -> None:
"""Saves the DataService's current state to a JSON file defined by """
`self.filename`. Saves the DataService's current state to a JSON file defined by `self.filename`.
Logs an error if `self.filename` is not set.
""" """
if self.filename is not None: if self.filename is not None:
with open(self.filename, "w") as f: with open(self.filename, "w") as f:
json.dump(self.cache_value, f, indent=4) json.dump(self.cache_value, f, indent=4)
else: else:
logger.debug( logger.info(
"State manager was not initialised with a filename. Skipping " "State manager was not initialised with a filename. Skipping "
"'save_state'..." "'save_state'..."
) )
def load_state(self) -> None: def load_state(self) -> None:
"""Loads the DataService's state from a JSON file defined by `self.filename`. """
Loads the DataService's state from a JSON file defined by `self.filename`.
Updates the service's attributes, respecting type and read-only constraints. Updates the service's attributes, respecting type and read-only constraints.
""" """
@ -171,18 +157,9 @@ class StateManager:
for path in generate_serialized_data_paths(json_dict): for path in generate_serialized_data_paths(json_dict):
if self.__is_loadable_state_attribute(path): if self.__is_loadable_state_attribute(path):
nested_json_dict = get_nested_dict_by_path(json_dict, path) nested_json_dict = get_nested_dict_by_path(json_dict, path)
try: nested_class_dict = self._data_service_cache.get_value_dict_from_cache(
nested_class_dict = self.cache_manager.get_value_dict_from_cache( path
path )
)
except (SerializationPathError, KeyError):
nested_class_dict = {
"full_access_path": path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
value_type = nested_json_dict["type"] value_type = nested_json_dict["type"]
class_attr_value_type = nested_class_dict.get("type", None) class_attr_value_type = nested_class_dict.get("type", None)
@ -203,7 +180,7 @@ class StateManager:
with open(self.filename) as f: with open(self.filename) as f:
# Load JSON data from file and update class attributes with these # Load JSON data from file and update class attributes with these
# values # values
return cast("dict[str, Any]", json.load(f)) return cast(dict[str, Any], json.load(f))
return {} return {}
def set_service_attribute_value_by_path( def set_service_attribute_value_by_path(
@ -211,7 +188,8 @@ class StateManager:
path: str, path: str,
serialized_value: SerializedObject, serialized_value: SerializedObject,
) -> None: ) -> None:
"""Sets the value of an attribute in the service managed by the `StateManager` """
Sets the value of an attribute in the service managed by the `StateManager`
given its path as a dot-separated string. given its path as a dot-separated string.
This method updates the attribute specified by 'path' with 'value' only if the This method updates the attribute specified by 'path' with 'value' only if the
@ -219,23 +197,17 @@ 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: path: A dot-separated string indicating the hierarchical path to the
A dot-separated string indicating the hierarchical path to the
attribute. attribute.
serialized_value: value: The new value to set for the attribute.
The serialized representation of the new value to set for the attribute.
""" """
try: current_value_dict = get_nested_dict_by_path(self.cache_value, path)
current_value_dict = self.cache_manager.get_value_dict_from_cache(path)
except (SerializationPathError, KeyError): # This will also filter out methods as they are 'read-only'
current_value_dict = { if current_value_dict["readonly"]:
"full_access_path": path, logger.debug("Attribute '%s' is read-only. Ignoring new value...", path)
"value": None, return
"type": "None",
"doc": None,
"readonly": False,
}
if "full_access_path" not in serialized_value: if "full_access_path" not in serialized_value:
# Backwards compatibility for JSON files not containing the # Backwards compatibility for JSON files not containing the
@ -265,21 +237,24 @@ class StateManager:
def __update_attribute_by_path( def __update_attribute_by_path(
self, path: str, serialized_value: SerializedObject self, path: str, serialized_value: SerializedObject
) -> None: ) -> None:
is_value_set = False
path_parts = parse_full_access_path(path) path_parts = parse_full_access_path(path)
target_obj = get_object_by_path_parts(self.service, path_parts[:-1]) target_obj = get_object_by_path_parts(self.service, path_parts[:-1])
if self.__cached_value_is_enum(path): attr_cache_type = get_nested_dict_by_path(self.cache_value, path)["type"]
# De-serialize the value
if attr_cache_type in ("ColouredEnum", "Enum"):
enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]]) enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]])
# take the value of the existing enum class # take the value of the existing enum class
if serialized_value["type"] in ("ColouredEnum", "Enum"): if serialized_value["type"] in ("ColouredEnum", "Enum"):
# This error will arise when setting an enum from another enum class. try:
# In this case, we resort to loading the enum and setting it directly.
with contextlib.suppress(KeyError):
value = enum_attr.__class__[serialized_value["value"]] value = enum_attr.__class__[serialized_value["value"]]
is_value_set = True except KeyError:
# This error will arise when setting an enum from another enum class
if not is_value_set: # In this case, we resort to loading the enum and setting it
# directly
value = loads(serialized_value)
else:
value = loads(serialized_value) value = loads(serialized_value)
# set the value # set the value
@ -287,15 +262,6 @@ class StateManager:
processed_key = parse_serialized_key(path_parts[-1]) processed_key = parse_serialized_key(path_parts[-1])
target_obj[processed_key] = value # type: ignore target_obj[processed_key] = value # type: ignore
else: else:
# Don't allow adding attributes to objects through state manager
if self.__attr_exists_on_target_obj(
target_obj=target_obj, name=path_parts[-1]
):
raise AttributeError(
f"{target_obj.__class__.__name__!r} object has no attribute "
f"{path_parts[-1]!r}"
)
setattr(target_obj, path_parts[-1], value) setattr(target_obj, path_parts[-1], value)
def __is_loadable_state_attribute(self, full_access_path: str) -> bool: def __is_loadable_state_attribute(self, full_access_path: str) -> bool:
@ -321,8 +287,8 @@ class StateManager:
return has_decorator return has_decorator
try: try:
cached_serialization_dict = self.cache_manager.get_value_dict_from_cache( cached_serialization_dict = get_nested_dict_by_path(
full_access_path self.cache_value, full_access_path
) )
if cached_serialization_dict["value"] == "method": if cached_serialization_dict["value"] == "method":
@ -337,16 +303,3 @@ class StateManager:
path_parts[-1], path_parts[-1],
) )
return False return False
def __cached_value_is_enum(self, path: str) -> bool:
try:
attr_cache_type = self.cache_manager.get_value_dict_from_cache(path)["type"]
return attr_cache_type in ("ColouredEnum", "Enum")
except Exception:
return False
def __attr_exists_on_target_obj(self, target_obj: Any, name: str) -> bool:
return not is_property_attribute(target_obj, name) and not hasattr(
target_obj, name
)

View File

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

@ -0,0 +1,13 @@
{
"files": {
"main.css": "/static/css/main.7ef670d5.css",
"main.js": "/static/js/main.57f8ec4c.js",
"index.html": "/index.html",
"main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
"main.57f8ec4c.js.map": "/static/js/main.57f8ec4c.js.map"
},
"entrypoints": [
"static/css/main.7ef670d5.css",
"static/js/main.57f8ec4c.js"
]
}

File diff suppressed because one or more lines are too long

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