Compare commits
326 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c36cebf17c | ||
|
|
a96387b4d7 | ||
|
|
d1feff1a6a | ||
|
|
95df2f1650 | ||
|
|
0565c82448 | ||
|
|
755265bf53 | ||
|
|
4c7b386ab4 | ||
|
|
92b2326dfc | ||
|
|
9e18783a05 | ||
|
|
9be4aac988 | ||
|
|
f3d659670f | ||
|
|
23f051d6f1 | ||
|
|
c8979ab2e6 | ||
|
|
bd33252775 | ||
|
|
1fbcbc72bf | ||
|
|
9a8628cfbd | ||
|
|
3d13b20fda | ||
|
|
f2183ec3e4 | ||
|
|
360aeb5574 | ||
|
|
e85e93a1d9 | ||
|
|
ea5fd42919 | ||
|
|
247113f1db | ||
|
|
c76b0b0b6e | ||
|
|
2d39c56e3d | ||
|
|
60287fef95 | ||
|
|
c5e1a08c54 | ||
|
|
9424d4c412 | ||
|
|
0a4c13c617 | ||
|
|
5d72604199 | ||
|
|
3479c511fe | ||
|
|
9bf3b28390 | ||
|
|
0195f9d6f6 | ||
|
|
197268255b | ||
|
|
3698cb7f92 | ||
|
|
0625832457 | ||
|
|
f35bcf3be6 | ||
|
|
3fe77bb4e5 | ||
|
|
9b2d181f4a | ||
|
|
045334e51e | ||
|
|
1d8d17d715 | ||
|
|
4d84c9778f | ||
|
|
e3c144fa6e | ||
|
|
192075057f | ||
|
|
053050a62c | ||
|
|
aacc69ae94 | ||
|
|
de1483bdc5 | ||
|
|
b24db00eda | ||
|
|
36ee760610 | ||
|
|
3a67c07bad | ||
|
|
b9a91e5ee2 | ||
|
|
f83bc0073b | ||
|
|
c66b90c4e5 | ||
|
|
d0b0803407 | ||
|
|
e25511768d | ||
|
|
303de82318 | ||
|
|
db559e8ada | ||
|
|
1b35dba64f | ||
|
|
8a8ac9d297 | ||
|
|
40a8863ecd | ||
|
|
1dca04f693 | ||
|
|
2b520834dc | ||
|
|
d6bad37233 | ||
|
|
53a2a3303f | ||
|
|
4f206bbae9 | ||
|
|
090b8acd44 | ||
|
|
17b2ad32e5 | ||
|
|
3c99f3fe04 | ||
|
|
2bcc6b9660 | ||
|
|
c1ace54c78 | ||
|
|
56af2a423b | ||
|
|
eba0eb83e6 | ||
|
|
b7818c0d8a | ||
|
|
a0c3882f35 | ||
|
|
1d773ba09b | ||
|
|
10f1b8691c | ||
|
|
a99db6f053 | ||
|
|
36ab8ab68b | ||
|
|
27a832bbd1 | ||
|
|
18df9e288a | ||
|
|
7b786be892 | ||
|
|
374a930745 | ||
|
|
6d12e5c939 | ||
|
|
bcf37067ad | ||
|
|
a1ac0c2f88 | ||
|
|
cfe190ca5b | ||
|
|
c002d04328 | ||
|
|
0d1df4f9e5 | ||
|
|
59cc834a81 | ||
|
|
dc54d9faef | ||
|
|
89bf5cb3f1 | ||
|
|
c72ea9eb20 | ||
|
|
897387e39e | ||
|
|
4454a10f78 | ||
|
|
c9814f7cdc | ||
|
|
187d8bcf28 | ||
|
|
204d426663 | ||
|
|
29e9afa47e | ||
|
|
a6943c027f | ||
|
|
70e4fa73e1 | ||
|
|
579fa4715b | ||
|
|
0100bab04f | ||
|
|
bdf97fa181 | ||
|
|
eb1587fa7d | ||
|
|
5827cda316 | ||
|
|
0e9ec7a66a | ||
|
|
155957f0c5 | ||
|
|
a8b46f191b | ||
|
|
3862ce3405 | ||
|
|
5403b51a5b | ||
|
|
1270400e95 | ||
|
|
3d2bb1c528 | ||
|
|
7c68f02cfd | ||
|
|
ccd6447869 | ||
|
|
056c02c5a5 | ||
|
|
52a798e4c8 | ||
|
|
fdfdef5837 | ||
|
|
ff301f225c | ||
|
|
87f720f567 | ||
|
|
fecb46c02c | ||
|
|
cce2399b07 | ||
|
|
df1db99ec0 | ||
|
|
5f2619500b | ||
|
|
843675fa1e | ||
|
|
2aa370c8ac | ||
|
|
c25ff4a3aa | ||
|
|
5e32a70c3e | ||
|
|
3f6692a1cd | ||
|
|
eb32b34b59 | ||
|
|
9eedf03c01 | ||
|
|
5ec7a8b530 | ||
|
|
f2f330dbd9 | ||
|
|
2e0e056489 | ||
|
|
d8685fe9a0 | ||
|
|
e52a019d5e | ||
|
|
0d5cef1537 | ||
|
|
e8f33eee4d | ||
|
|
a3b71b174c | ||
|
|
e2ce0e9acb | ||
|
|
f47a183c11 | ||
|
|
a9ea237cf3 | ||
|
|
6db1652dd3 | ||
|
|
e3b95a8076 | ||
|
|
0fe2a8516f | ||
|
|
51bbaba162 | ||
|
|
77802da417 | ||
|
|
3e21858cb7 | ||
|
|
2003f28fd1 | ||
|
|
172b50bf77 | ||
|
|
ec5694fedf | ||
|
|
968f774092 | ||
|
|
757dc9aa3c | ||
|
|
3d938562a6 | ||
|
|
964a62d4b4 | ||
|
|
99aa38fcfe | ||
|
|
5658514c8a | ||
|
|
109ee7d5e1 | ||
|
|
f4fa02fe11 | ||
|
|
487ef504a8 | ||
|
|
c98e407ed7 | ||
|
|
6b6ce1d43f | ||
|
|
e491ac7458 | ||
|
|
e9d8cbafc2 | ||
|
|
aa705592b2 | ||
|
|
008e1262bb | ||
|
|
91a71ad004 | ||
|
|
bbf479a440 | ||
|
|
983d392ba8 | ||
|
|
56dd9dd8aa | ||
|
|
20028c379d | ||
|
|
e48046795e | ||
|
|
1ac9e45c73 | ||
|
|
488415436c | ||
|
|
d7c5c2cd6e | ||
|
|
5388fd0d2b | ||
|
|
e74b5c773a | ||
|
|
bb6cd159f1 | ||
|
|
4a09f02882 | ||
|
|
9180bb1d9e | ||
|
|
ece68b4b99 | ||
|
|
0c95b5e3cb | ||
|
|
0450bb1570 | ||
|
|
2f5a640c4c | ||
|
|
78964be506 | ||
|
|
fbdf6de63c | ||
|
|
9b04dcd41e | ||
|
|
32e36d4962 | ||
|
|
62f28f79db | ||
|
|
e88965b69d | ||
|
|
e422d627af | ||
|
|
2e31ebb7d9 | ||
|
|
71adc8bea2 | ||
|
|
bfa0acedab | ||
|
|
416b9ee815 | ||
|
|
d1d2ac2614 | ||
|
|
fa35fa53e2 | ||
|
|
c0e5a77d6f | ||
|
|
96cc7b31b4 | ||
|
|
0d6d312f68 | ||
|
|
be3011c565 | ||
|
|
09fae01985 | ||
|
|
12c0c9763d | ||
|
|
15322b742d | ||
|
|
85d6229aa6 | ||
|
|
083fab0a29 | ||
|
|
2a1aff589d | ||
|
|
3cd7198747 | ||
|
|
1e02f12794 | ||
|
|
e4a3cf341f | ||
|
|
7ddcd97f68 | ||
|
|
80da96657c | ||
|
|
861e89f37a | ||
|
|
c00cf9a6ff | ||
|
|
ed7f3d8509 | ||
|
|
456090fee9 | ||
|
|
e69ef376ae | ||
|
|
5f78771f66 | ||
|
|
09ceae90ec | ||
|
|
c34351270c | ||
|
|
743c18bdd7 | ||
|
|
12d7ddab08 | ||
|
|
e40646c664 | ||
|
|
ab9b4257f2 | ||
|
|
a2effca2b0 | ||
|
|
f76703340c | ||
|
|
dbc1fa00f7 | ||
|
|
4ecc1a191f | ||
|
|
4f8e3f845c | ||
|
|
132856a8f0 | ||
|
|
b1f75bb786 | ||
|
|
0011a0f92e | ||
|
|
b7ab364aab | ||
|
|
52e4647433 | ||
|
|
b2b3d426ed | ||
|
|
7ae3ff504d | ||
|
|
50f3686c12 | ||
|
|
b0c3c4cad9 | ||
|
|
9b8279da85 | ||
|
|
97e21b2ea8 | ||
|
|
fb75de5b51 | ||
|
|
3eb9c6476b | ||
|
|
c7ec929d05 | ||
|
|
ca19fcc63f | ||
|
|
7904d0d7d9 | ||
|
|
8526e74aa7 | ||
|
|
6e16d84ba4 | ||
|
|
6765246231 | ||
|
|
f50976358b | ||
|
|
aa37fa8533 | ||
|
|
2ebdb77433 | ||
|
|
5ce30cfeaa | ||
|
|
82d6a7f895 | ||
|
|
9aad9dfbc6 | ||
|
|
86bac8f9e5 | ||
|
|
348ff092aa | ||
|
|
1ac08bf97d | ||
|
|
42357d7901 | ||
|
|
014a7b9492 | ||
|
|
e0d710644b | ||
|
|
4a9dba30d7 | ||
|
|
9663dea79d | ||
|
|
81e40860df | ||
|
|
9021e3a903 | ||
|
|
2136d1a157 | ||
|
|
c894215ddc | ||
|
|
22d836587e | ||
|
|
9e852c17ac | ||
|
|
bd6220cb9e | ||
|
|
940f7039d3 | ||
|
|
d45d2dba7d | ||
|
|
1fb296c3c1 | ||
|
|
bfe2d82c0b | ||
|
|
5d8471fd47 | ||
|
|
75e355faf9 | ||
|
|
f91be30ad0 | ||
|
|
b148d6919a | ||
|
|
74ebbc6223 | ||
|
|
554d6f7daa | ||
|
|
80243487cb | ||
|
|
aeaf57331e | ||
|
|
baad1268e8 | ||
|
|
9ce0c93954 | ||
|
|
95d29ee4e8 | ||
|
|
6f4fcf52dd | ||
|
|
0e73239d08 | ||
|
|
e659ca9d1c | ||
|
|
eaf76a7211 | ||
|
|
aa55ac772e | ||
|
|
755a303239 | ||
|
|
7e63f34c0a | ||
|
|
2364fc892c | ||
|
|
a1da332dba | ||
|
|
8bf0b771fa | ||
|
|
fd73653433 | ||
|
|
d09675de6a | ||
|
|
36d3a7becc | ||
|
|
d54eed8a58 | ||
|
|
817afc610a | ||
|
|
ad0f9420d9 | ||
|
|
6d786cd0f8 | ||
|
|
de4270daa4 | ||
|
|
7286017715 | ||
|
|
1a23206f42 | ||
|
|
fdb17e44e2 | ||
|
|
fc738e2743 | ||
|
|
9de4071120 | ||
|
|
369d0b1126 | ||
|
|
c396de75fb | ||
|
|
4ed8899708 | ||
|
|
2fa3505310 | ||
|
|
9d387944ef | ||
|
|
0d70b7492d | ||
|
|
7bc12b340f | ||
|
|
e996966388 | ||
|
|
c07efe056b | ||
|
|
9e0adba8dc | ||
|
|
1789a6ad7e | ||
|
|
0e5f1ede20 | ||
|
|
f8cae28128 | ||
|
|
e31af9ae31 | ||
|
|
60c671eb0d | ||
|
|
203059822c | ||
|
|
e7f9ad799c | ||
|
|
3e5a56446f | ||
|
|
cf0780b2ca | ||
|
|
8afee54c51 | ||
|
|
6e4e000c28 |
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -18,7 +18,10 @@ 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.
|
||||||
|
|
||||||
## Screenshot/Video
|
## Actual behaviour
|
||||||
|
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
|
||||||
|
|||||||
34
.github/workflows/publish-to-pypi.yaml
vendored
@@ -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@v3
|
uses: actions/upload-artifact@v4
|
||||||
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@v3
|
uses: actions/download-artifact@v4
|
||||||
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@v3
|
uses: actions/download-artifact@v4
|
||||||
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@v1.2.3
|
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
||||||
with:
|
with:
|
||||||
inputs: >-
|
inputs: |
|
||||||
./dist/*.tar.gz
|
./dist/*.tar.gz
|
||||||
./dist/*.whl
|
./dist/*.whl
|
||||||
- name: Upload artifact signatures to GitHub Release
|
- name: Upload artifact signatures to GitHub Release
|
||||||
@@ -85,27 +85,3 @@ jobs:
|
|||||||
gh release upload
|
gh release upload
|
||||||
'${{ github.ref_name }}' dist/**
|
'${{ github.ref_name }}' dist/**
|
||||||
--repo '${{ github.repository }}'
|
--repo '${{ github.repository }}'
|
||||||
|
|
||||||
# publish-to-testpypi:
|
|
||||||
# name: Publish Python 🐍 distribution 📦 to TestPyPI
|
|
||||||
# needs:
|
|
||||||
# - build
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
#
|
|
||||||
# environment:
|
|
||||||
# name: testpypi
|
|
||||||
# url: https://test.pypi.org/p/pydase
|
|
||||||
#
|
|
||||||
# permissions:
|
|
||||||
# id-token: write # IMPORTANT: mandatory for trusted publishing
|
|
||||||
#
|
|
||||||
# steps:
|
|
||||||
# - name: Download all the dists
|
|
||||||
# uses: actions/download-artifact@v3
|
|
||||||
# with:
|
|
||||||
# name: python-package-distributions
|
|
||||||
# path: dist/
|
|
||||||
# - name: Publish distribution 📦 to TestPyPI
|
|
||||||
# uses: pypa/gh-action-pypi-publish@release/v1
|
|
||||||
# with:
|
|
||||||
# repository-url: https://test.pypi.org/legacy/
|
|
||||||
|
|||||||
9
.github/workflows/python-package.yml
vendored
@@ -20,9 +20,6 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: chartboost/ruff-action@v1
|
|
||||||
with:
|
|
||||||
src: "./src"
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
@@ -32,6 +29,12 @@ jobs:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install poetry
|
python -m pip install poetry
|
||||||
poetry install --with dev
|
poetry install --with dev
|
||||||
|
- name: Check with ruff
|
||||||
|
run: |
|
||||||
|
poetry run ruff check src
|
||||||
|
- name: Check formatting with ruff
|
||||||
|
run: |
|
||||||
|
poetry run ruff format --check src
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: |
|
run: |
|
||||||
poetry run pytest
|
poetry run pytest
|
||||||
|
|||||||
2
.vscode/launch.json
vendored
@@ -25,7 +25,7 @@
|
|||||||
"type": "firefox",
|
"type": "firefox",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "react: firefox",
|
"name": "react: firefox",
|
||||||
"url": "http://localhost:3000",
|
"url": "http://localhost:5173",
|
||||||
"webRoot": "${workspaceFolder}/frontend"
|
"webRoot": "${workspaceFolder}/frontend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
4
LICENSE
@@ -1,6 +1,4 @@
|
|||||||
MIT License
|
Copyright (c) 2023-2024 Mose Müller <mosemueller@gmail.com>
|
||||||
|
|
||||||
Copyright (c) 2023 Mose Müller <mosemueller@gmail.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
```tsx
|
```ts
|
||||||
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,8 +203,7 @@ 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:
|
||||||
|
|
||||||
```tsx
|
```ts title="frontend/src/components/ButtonComponent.tsx"
|
||||||
// file: frontend/src/components/ButtonComponent.tsx
|
|
||||||
// ... (import statements)
|
// ... (import statements)
|
||||||
|
|
||||||
type ButtonComponentProps = {
|
type ButtonComponentProps = {
|
||||||
@@ -249,7 +248,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):
|
||||||
|
|
||||||
```tsx
|
```ts title="frontend/src/components/_YourComponent_.tsx"
|
||||||
import { runMethod } from '../socket';
|
import { runMethod } from '../socket';
|
||||||
// ... (other imports)
|
// ... (other imports)
|
||||||
|
|
||||||
@@ -287,9 +286,7 @@ 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`:
|
||||||
|
|
||||||
```tsx
|
```ts title="frontend/src/components/GenericComponent.tsx"
|
||||||
// file: frontend/src/components/GenericComponent.tsx
|
|
||||||
|
|
||||||
import { ImageComponent } from './ImageComponent';
|
import { ImageComponent } from './ImageComponent';
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -299,7 +296,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:
|
||||||
|
|
||||||
```tsx
|
```ts
|
||||||
type AttributeType =
|
type AttributeType =
|
||||||
| 'str'
|
| 'str'
|
||||||
| 'bool'
|
| 'bool'
|
||||||
@@ -318,7 +315,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'`:
|
||||||
|
|
||||||
```tsx
|
```ts
|
||||||
} else if (attribute.type === 'Image') {
|
} else if (attribute.type === 'Image') {
|
||||||
return (
|
return (
|
||||||
<ImageComponent
|
<ImageComponent
|
||||||
@@ -348,7 +345,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:
|
||||||
|
|
||||||
```tsx
|
```ts
|
||||||
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Observer Pattern is a fundamental design pattern in the `pydase` package, serving as the central communication mechanism for state updates to clients connected to a service.
|
The [Observer Pattern](https://en.wikipedia.org/wiki/Observer_pattern) is a fundamental design pattern in the `pydase` package, serving as the central communication mechanism for state updates to clients connected to a service.
|
||||||
|
|
||||||
## How it Works
|
## How it Works
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
::: 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
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
# Getting Started
|
# Getting Started
|
||||||
## Installation
|
|
||||||
{%
|
{%
|
||||||
include-markdown "../README.md"
|
include-markdown "../README.md"
|
||||||
start="<!--installation-start-->"
|
start="<!--getting-started-start-->"
|
||||||
end="<!--installation-end-->"
|
end="<!--getting-started-end-->"
|
||||||
%}
|
%}
|
||||||
|
|
||||||
## Usage
|
[RESTful API]: ./user-guide/interaction/README.md#restful-api
|
||||||
{%
|
[Python RPC Client]: ./user-guide/interaction/README.md#python-rpc-client
|
||||||
include-markdown "../README.md"
|
[Custom Components]: ./user-guide/Components.md#custom-components-pydasecomponents
|
||||||
start="<!--usage-start-->"
|
[Components]: ./user-guide/Components.md
|
||||||
end="<!--usage-end-->"
|
|
||||||
%}
|
|
||||||
|
|||||||
BIN
docs/images/Tailoring_frontend_component_layout.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/images/logo-bw.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
11
docs/images/logo-bw.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
BIN
docs/images/logo-colour.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
153
docs/images/logo-colour.svg
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/images/logo-with-text.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
145
docs/images/logo-with-text.svg
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
@@ -1 +1,17 @@
|
|||||||
{% 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/README.md#customization-options
|
||||||
|
[Task Management]: ./user-guide/Tasks.md
|
||||||
|
[Units]: ./user-guide/Understanding-Units.md
|
||||||
|
[Property Validation]: ./user-guide/Validating-Property-Setters.md
|
||||||
|
|||||||
@@ -1,20 +1,38 @@
|
|||||||
|
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" and platform_system == "Windows"
|
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
|
griffe==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0"
|
idna==3.7 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
|
jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
markdown==3.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
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==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
mkdocs-autorefs==1.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
mkdocs-get-deps==0.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
mkdocs-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==1.5.3 ; python_version >= "3.10" and python_version < "4.0"
|
mkdocs-material-extensions==1.3.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
mkdocstrings==0.22.0 ; python_version >= "3.10" and python_version < "4.0"
|
mkdocs-material==9.5.31 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
packaging==23.1 ; 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"
|
||||||
pathspec==0.11.2 ; python_version >= "3.10" and python_version < "4.0"
|
mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "4.0"
|
mkdocstrings-python==1.10.8 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
pymdown-extensions==10.3 ; python_version >= "3.10" and python_version < "4.0"
|
mkdocstrings==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"
|
mkdocstrings[python]==0.25.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
packaging==24.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
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"
|
||||||
watchdog==3.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
soupsieve==2.5 ; 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"
|
||||||
|
|||||||
@@ -1,6 +1,435 @@
|
|||||||
# Components Guide
|
# Components Guide
|
||||||
{%
|
|
||||||
include-markdown "../../README.md"
|
In `pydase`, components are fundamental building blocks that bridge the Python backend logic with frontend visual representation and interactions. This system can be understood based on the following categories:
|
||||||
start="<!-- Component User Guide Start -->"
|
|
||||||
end="<!-- Component User Guide End -->"
|
## Built-in Type and Enum Components
|
||||||
%}
|
|
||||||
|
`pydase` automatically maps standard Python data types to their corresponding frontend components:
|
||||||
|
|
||||||
|
- `str`: Translated into a `StringComponent` on the frontend.
|
||||||
|
- `int` and `float`: Manifested as the `NumberComponent`.
|
||||||
|
- `bool`: Rendered as a `ButtonComponent`.
|
||||||
|
- `list`: Each item displayed individually, named after the list attribute and its index.
|
||||||
|
- `dict`: Each key-value pair displayed individually, named after the dictionary attribute and its key. **Note** that the dictionary keys must be strings.
|
||||||
|
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
|
||||||
|
|
||||||
|
## Method Components
|
||||||
|
Within the `DataService` class of `pydase`, only methods devoid of arguments can be represented in the frontend, classified into two distinct categories
|
||||||
|
|
||||||
|
1. [**Tasks**](./Tasks.md): Argument-free asynchronous functions, identified within `pydase` as tasks, are inherently designed for frontend interaction. These tasks are automatically rendered with a start/stop button, allowing users to initiate or halt the task execution directly from the web interface.
|
||||||
|
2. **Synchronous Methods with `@frontend` Decorator**: Synchronous methods without arguments can also be presented in the frontend. For this, they have to be decorated with the `@frontend` decorator.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pydase
|
||||||
|
import pydase.components
|
||||||
|
import pydase.units as u
|
||||||
|
from pydase.utils.decorators import frontend
|
||||||
|
|
||||||
|
|
||||||
|
class MyService(pydase.DataService):
|
||||||
|
@frontend
|
||||||
|
def exposed_method(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def my_task(self) -> None:
|
||||||
|
while True:
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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()
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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()
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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()
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
|||||||
211
docs/user-guide/Configuration.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
|
||||||
|
# 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, we’ll 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](https://github.com/tiqi-group/pydase?tab=readme-ov-file#logging-in-pydase)).
|
||||||
|
|
||||||
|
- **`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 server’s 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. 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 service’s 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.
|
||||||
91
docs/user-guide/Logging.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 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).
|
||||||
66
docs/user-guide/Service_Persistence.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
Here’s 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.
|
||||||
82
docs/user-guide/Tasks.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 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()
|
||||||
|
```
|
||||||
64
docs/user-guide/Understanding-Units.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Understanding Units
|
||||||
|
|
||||||
|
`pydase` integrates with the [`pint`](https://pint.readthedocs.io/en/stable/) package to allow you to work with physical quantities within your service. This enables you to define attributes with units, making your service more expressive and ensuring consistency in the handling of physical quantities.
|
||||||
|
|
||||||
|
You can define quantities in your `pydase.DataService` subclass using the `pydase.units` module.
|
||||||
|
Here's an example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pydase
|
||||||
|
import pydase.units as u
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceClass(pydase.DataService):
|
||||||
|
voltage = 1.0 * u.units.V
|
||||||
|
_current: u.Quantity = 1.0 * u.units.mA
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current(self) -> u.Quantity:
|
||||||
|
return self._current
|
||||||
|
|
||||||
|
@current.setter
|
||||||
|
def current(self, value: u.Quantity) -> None:
|
||||||
|
self._current = value
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
service = ServiceClass()
|
||||||
|
|
||||||
|
service.voltage = 10.0 * u.units.V
|
||||||
|
service.current = 1.5 * u.units.mA
|
||||||
|
|
||||||
|
pydase.Server(service=service).run()
|
||||||
|
```
|
||||||
|
|
||||||
|
In the frontend, quantities are rendered as floats, with the unit displayed as additional text. This allows you to maintain a clear and consistent representation of physical quantities across both the backend and frontend of your service.
|
||||||
|

|
||||||
|
|
||||||
|
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/).
|
||||||
|
|
||||||
38
docs/user-guide/Validating-Property-Setters.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Using `validate_set` to Validate Property Setters
|
||||||
|
|
||||||
|
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
|
||||||
|
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
|
||||||
|
|
||||||
|
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
|
||||||
|
If the value is not within the precision boundary after this time, an exception is raised.
|
||||||
|
The `precision` argument defines the acceptable deviation from the desired value.
|
||||||
|
If `precision` is `None`, the value must be exact.
|
||||||
|
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
|
||||||
|
|
||||||
|
Here’s 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()
|
||||||
|
```
|
||||||
59
docs/user-guide/advanced/Reverse-Proxy.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 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 don’t 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 don’t need to make any changes to your application code or frontend resources.
|
||||||
196
docs/user-guide/interaction/Auto-generated Frontend.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
Here’s 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).
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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`.
|
||||||
89
docs/user-guide/interaction/Python Client.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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",
|
||||||
|
).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, which is particularly useful in distributed systems where services may initialize in any order.
|
||||||
|
- By setting `client_id`, the server will provide more accurate logs of the connecting client. If set, this ID is sent as `X-Client-Id` header in the HTTP(s) request.
|
||||||
|
|
||||||
|
## Custom `socketio.AsyncClient` Connection Parameters
|
||||||
|
|
||||||
|
You can also configure advanced connection options by passing additional arguments to the underlying [`AsyncClient`][socketio.AsyncClient] via `sio_client_kwargs`. This allows you to fine-tune reconnection behaviour, delays, and other settings:
|
||||||
|
|
||||||
|
```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.
|
||||||
81
docs/user-guide/interaction/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Interacting with `pydase` Services
|
||||||
|
|
||||||
|
`pydase` offers multiple ways for users to interact with the services they create, providing flexibility and convenience for different use cases. This section outlines the primary interaction methods available, including an auto-generated frontend, a RESTful API, and a Python client based on Socket.IO.
|
||||||
|
|
||||||
|
{%
|
||||||
|
include-markdown "./Auto-generated Frontend.md"
|
||||||
|
heading-offset=1
|
||||||
|
%}
|
||||||
|
|
||||||
|
{%
|
||||||
|
include-markdown "./RESTful API.md"
|
||||||
|
heading-offset=1
|
||||||
|
%}
|
||||||
|
|
||||||
|
{%
|
||||||
|
include-markdown "./Python Client.md"
|
||||||
|
heading-offset=1
|
||||||
|
%}
|
||||||
|
|
||||||
|
<!-- ## 2. **Socket.IO for Real-Time Updates** -->
|
||||||
|
<!-- For scenarios requiring real-time data updates, `pydase` includes a Socket.IO server. This feature is ideal for applications where live data tracking is crucial, such as monitoring systems or interactive dashboards. -->
|
||||||
|
<!---->
|
||||||
|
<!-- ### Key Features: -->
|
||||||
|
<!-- - **Live Data Streams**: Receive real-time updates for data changes. -->
|
||||||
|
<!-- - **Event-Driven Communication**: Utilize event-based messaging to push updates and handle client actions. -->
|
||||||
|
<!---->
|
||||||
|
<!-- ### Example Usage: -->
|
||||||
|
<!-- Clients can connect to the Socket.IO server to receive updates: -->
|
||||||
|
<!-- ```javascript -->
|
||||||
|
<!-- var socket = io.connect('http://<hostname>:<port>'); -->
|
||||||
|
<!-- socket.on('<event_name>', function(data) { -->
|
||||||
|
<!-- console.log(data); -->
|
||||||
|
<!-- }); -->
|
||||||
|
<!-- ``` -->
|
||||||
|
<!---->
|
||||||
|
<!-- **Use Cases:** -->
|
||||||
|
<!---->
|
||||||
|
<!-- - Real-time monitoring and alerts -->
|
||||||
|
<!-- - Live data visualization -->
|
||||||
|
<!-- - Collaborative applications -->
|
||||||
|
<!---->
|
||||||
|
<!-- ## 3. **Auto-Generated Frontend** -->
|
||||||
|
<!-- `pydase` automatically generates a web frontend based on the service definitions. This frontend is a convenient interface for interacting with the service, especially for users who prefer a graphical interface over command-line or code-based interactions. -->
|
||||||
|
<!---->
|
||||||
|
<!-- ### Key Features: -->
|
||||||
|
<!-- - **User-Friendly Interface**: Intuitive and easy to use, with real-time interaction capabilities. -->
|
||||||
|
<!-- - **Customizable**: Adjust the frontend's appearance and functionality to suit specific needs. -->
|
||||||
|
<!---->
|
||||||
|
<!-- ### Accessing the Frontend: -->
|
||||||
|
<!-- Once the service is running, access the frontend via a web browser: -->
|
||||||
|
<!-- ``` -->
|
||||||
|
<!-- http://<hostname>:<port> -->
|
||||||
|
<!-- ``` -->
|
||||||
|
<!---->
|
||||||
|
<!-- **Use Cases:** -->
|
||||||
|
<!---->
|
||||||
|
<!-- - End-user interfaces for data control and visualization -->
|
||||||
|
<!-- - Rapid prototyping and testing -->
|
||||||
|
<!-- - Demonstrations and training -->
|
||||||
|
<!---->
|
||||||
|
<!-- ## 4. **Python Client** -->
|
||||||
|
<!-- `pydase` also provides a Python client for programmatic interactions. This client is particularly useful for developers who want to integrate `pydase` services into other Python applications or automate interactions. -->
|
||||||
|
<!---->
|
||||||
|
<!-- ### Key Features: -->
|
||||||
|
<!-- - **Direct Interaction**: Call methods and access properties as if they were local. -->
|
||||||
|
<!-- - **Tab Completion**: Supports tab completion in interactive environments like Jupyter notebooks. -->
|
||||||
|
<!---->
|
||||||
|
<!-- ### Example Usage: -->
|
||||||
|
<!-- ```python -->
|
||||||
|
<!-- import pydase -->
|
||||||
|
<!---->
|
||||||
|
<!-- client = pydase.Client(hostname="<ip_addr>", port=8001) -->
|
||||||
|
<!-- service = client.proxy -->
|
||||||
|
<!-- service.some_method() -->
|
||||||
|
<!-- ``` -->
|
||||||
|
<!---->
|
||||||
|
<!-- **Use Cases:** -->
|
||||||
|
<!---->
|
||||||
|
<!-- - Integrating with other Python applications -->
|
||||||
|
<!-- - Automation and scripting -->
|
||||||
|
<!-- - Data analysis and manipulation -->
|
||||||
22
docs/user-guide/interaction/RESTful API.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 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"/>
|
||||||
326
docs/user-guide/interaction/openapi.yaml
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
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"
|
||||||
@@ -3,11 +3,18 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="description" content="Web site displaying a pydase UI." />
|
<meta name="description" content="Web site displaying a pydase UI." />
|
||||||
</head>
|
</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>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3070
frontend/package-lock.json
generated
@@ -10,31 +10,31 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/material": "^5.14.1",
|
"@mui/material": "^5.16.14",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"deep-equal": "^2.2.3",
|
"deep-equal": "^2.2.3",
|
||||||
"react": "^18.3.1",
|
"react": "^19.0.0",
|
||||||
"react-bootstrap": "^2.10.0",
|
"react-bootstrap": "^2.10.7",
|
||||||
"react-bootstrap-icons": "^1.11.4",
|
"react-bootstrap-icons": "^1.11.5",
|
||||||
"socket.io-client": "^4.7.1"
|
"socket.io-client": "^4.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.6.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@types/deep-equal": "^1.0.4",
|
"@types/deep-equal": "^1.0.4",
|
||||||
"@types/eslint__js": "^8.42.3",
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.17.14",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^19.0.7",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.2.3",
|
||||||
"eslint-plugin-react": "^7.34.3",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"prettier": "3.3.2",
|
"prettier": "3.3.2",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^7.15.0",
|
"typescript-eslint": "^7.18.0",
|
||||||
"vite": "^5.3.1"
|
"vite": "^5.4.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 77 KiB |
@@ -1,6 +1,6 @@
|
|||||||
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 { hostname, port, socket } from "./socket";
|
import { authority, socket, forwardedProto } from "./socket";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import {
|
import {
|
||||||
Notifications,
|
Notifications,
|
||||||
@@ -12,6 +12,7 @@ import { setNestedValueByPath, State } from "./utils/stateUtils";
|
|||||||
import { WebSettingsContext, WebSetting } from "./WebSettings";
|
import { WebSettingsContext, WebSetting } from "./WebSettings";
|
||||||
import { GenericComponent } from "./components/GenericComponent";
|
import { GenericComponent } from "./components/GenericComponent";
|
||||||
import { SerializedObject } from "./types/SerializedObject";
|
import { SerializedObject } from "./types/SerializedObject";
|
||||||
|
import useLocalStorage from "./hooks/useLocalStorage";
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "SET_DATA"; data: State }
|
| { type: "SET_DATA"; data: State }
|
||||||
@@ -53,26 +54,26 @@ const App = () => {
|
|||||||
const [state, dispatch] = useReducer(reducer, null);
|
const [state, dispatch] = useReducer(reducer, null);
|
||||||
const [serviceName, setServiceName] = useState<string | null>(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] = useState(() => {
|
const [isInstantUpdate, setIsInstantUpdate] = useLocalStorage(
|
||||||
const saved = localStorage.getItem("isInstantUpdate");
|
"isInstantUpdate",
|
||||||
return saved !== null ? JSON.parse(saved) : false;
|
false,
|
||||||
});
|
);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showNotification, setShowNotification] = useState(() => {
|
const [showNotification, setShowNotification] = useLocalStorage(
|
||||||
const saved = localStorage.getItem("showNotification");
|
"showNotification",
|
||||||
return saved !== null ? JSON.parse(saved) : false;
|
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(`http://${hostname}:${port}/custom.css`)
|
fetch(`${forwardedProto}://${authority}/custom.css`, { credentials: "include" })
|
||||||
.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 = `http://${hostname}:${port}/custom.css`;
|
link.href = `${forwardedProto}://${authority}/custom.css`;
|
||||||
link.type = "text/css";
|
link.type = "text/css";
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
@@ -82,7 +83,9 @@ const App = () => {
|
|||||||
|
|
||||||
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(`http://${hostname}:${port}/service-properties`)
|
fetch(`${forwardedProto}://${authority}/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 });
|
||||||
@@ -90,7 +93,7 @@ const App = () => {
|
|||||||
|
|
||||||
document.title = data.name; // Setting browser tab title
|
document.title = data.name; // Setting browser tab title
|
||||||
});
|
});
|
||||||
fetch(`http://${hostname}:${port}/web-settings`)
|
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");
|
||||||
@@ -115,14 +118,6 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Persist isInstantUpdate and showNotification state changes to localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem("isInstantUpdate", JSON.stringify(isInstantUpdate));
|
|
||||||
}, [isInstantUpdate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem("showNotification", JSON.stringify(showNotification));
|
|
||||||
}, [showNotification]);
|
|
||||||
// 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(
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export const WebSettingsContext = createContext<Record<string, WebSetting>>({});
|
|||||||
export interface WebSetting {
|
export interface WebSetting {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
display: boolean;
|
display: boolean;
|
||||||
index: number;
|
displayOrder: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ToggleButton } from "react-bootstrap";
|
|||||||
import { DocStringComponent } from "./DocStringComponent";
|
import { DocStringComponent } from "./DocStringComponent";
|
||||||
import { LevelName } from "./NotificationsComponent";
|
import { LevelName } from "./NotificationsComponent";
|
||||||
import { SerializedObject } from "../types/SerializedObject";
|
import { SerializedObject } from "../types/SerializedObject";
|
||||||
import { useRenderCount } from "../hooks/useRenderCount";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
|
||||||
interface ButtonComponentProps {
|
interface ButtonComponentProps {
|
||||||
fullAccessPath: string;
|
fullAccessPath: string;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, Collapse } from "react-bootstrap";
|
import { Card, Collapse } from "react-bootstrap";
|
||||||
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
|
||||||
import { GenericComponent } from "./GenericComponent";
|
import { GenericComponent } from "./GenericComponent";
|
||||||
import { LevelName } from "./NotificationsComponent";
|
import { LevelName } from "./NotificationsComponent";
|
||||||
import { SerializedObject } from "../types/SerializedObject";
|
import { SerializedObject } from "../types/SerializedObject";
|
||||||
|
import useLocalStorage from "../hooks/useLocalStorage";
|
||||||
|
import useSortedEntries from "../hooks/useSortedEntries";
|
||||||
|
|
||||||
interface DataServiceProps {
|
interface DataServiceProps {
|
||||||
props: DataServiceJSON;
|
props: DataServiceJSON;
|
||||||
@@ -19,15 +20,9 @@ export type DataServiceJSON = Record<string, SerializedObject>;
|
|||||||
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
|
// Retrieve the initial state from localStorage, default to true if not found
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useLocalStorage(`dataServiceComponent-${id}-open`, true);
|
||||||
const savedState = localStorage.getItem(`dataServiceComponent-${id}-open`);
|
|
||||||
return savedState !== null ? JSON.parse(savedState) : true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update localStorage whenever the state changes
|
const sortedEntries = useSortedEntries(props);
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(`dataServiceComponent-${id}-open`, JSON.stringify(open));
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
if (displayName !== "") {
|
if (displayName !== "") {
|
||||||
return (
|
return (
|
||||||
@@ -38,9 +33,9 @@ export const DataServiceComponent = React.memo(
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Collapse in={open}>
|
<Collapse in={open}>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
{Object.entries(props).map(([key, value]) => (
|
{sortedEntries.map((value) => (
|
||||||
<GenericComponent
|
<GenericComponent
|
||||||
key={key}
|
key={value.full_access_path}
|
||||||
attribute={value}
|
attribute={value}
|
||||||
isInstantUpdate={isInstantUpdate}
|
isInstantUpdate={isInstantUpdate}
|
||||||
addNotification={addNotification}
|
addNotification={addNotification}
|
||||||
@@ -54,9 +49,9 @@ export const DataServiceComponent = React.memo(
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="component dataServiceComponent" id={id}>
|
<div className="component dataServiceComponent" id={id}>
|
||||||
{Object.entries(props).map(([key, value]) => (
|
{sortedEntries.map((value) => (
|
||||||
<GenericComponent
|
<GenericComponent
|
||||||
key={key}
|
key={value.full_access_path}
|
||||||
attribute={value}
|
attribute={value}
|
||||||
isInstantUpdate={isInstantUpdate}
|
isInstantUpdate={isInstantUpdate}
|
||||||
addNotification={addNotification}
|
addNotification={addNotification}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { DocStringComponent } from "./DocStringComponent";
|
|||||||
import { GenericComponent } from "./GenericComponent";
|
import { GenericComponent } from "./GenericComponent";
|
||||||
import { LevelName } from "./NotificationsComponent";
|
import { LevelName } from "./NotificationsComponent";
|
||||||
import { SerializedObject } from "../types/SerializedObject";
|
import { SerializedObject } from "../types/SerializedObject";
|
||||||
import { useRenderCount } from "../hooks/useRenderCount";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
import useSortedEntries from "../hooks/useSortedEntries";
|
||||||
|
|
||||||
interface DictComponentProps {
|
interface DictComponentProps {
|
||||||
value: Record<string, SerializedObject>;
|
value: Record<string, SerializedObject>;
|
||||||
@@ -14,16 +15,16 @@ interface DictComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DictComponent = React.memo((props: DictComponentProps) => {
|
export const DictComponent = React.memo((props: DictComponentProps) => {
|
||||||
const { value, docString, isInstantUpdate, addNotification, id } = props;
|
const { docString, isInstantUpdate, addNotification, id } = props;
|
||||||
|
|
||||||
|
const sortedEntries = useSortedEntries(props.value);
|
||||||
const renderCount = useRenderCount();
|
const renderCount = useRenderCount();
|
||||||
const valueArray = Object.values(value);
|
|
||||||
|
|
||||||
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}</div>}
|
||||||
<DocStringComponent docString={docString} />
|
<DocStringComponent docString={docString} />
|
||||||
{valueArray.map((item) => {
|
{sortedEntries.map((item) => {
|
||||||
return (
|
return (
|
||||||
<GenericComponent
|
<GenericComponent
|
||||||
key={item.full_access_path}
|
key={item.full_access_path}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DocStringComponent } from "./DocStringComponent";
|
|||||||
import { LevelName } from "./NotificationsComponent";
|
import { LevelName } from "./NotificationsComponent";
|
||||||
import { SerializedObject, SerializedEnum } from "../types/SerializedObject";
|
import { SerializedObject, SerializedEnum } from "../types/SerializedObject";
|
||||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||||
import { useRenderCount } from "../hooks/useRenderCount";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
|
||||||
interface EnumComponentProps extends SerializedEnum {
|
interface EnumComponentProps extends SerializedEnum {
|
||||||
addNotification: (message: string, levelname?: LevelName) => void;
|
addNotification: (message: string, levelname?: LevelName) => void;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { NumberComponent, NumberObject } from "./NumberComponent";
|
|||||||
import { SliderComponent } from "./SliderComponent";
|
import { SliderComponent } from "./SliderComponent";
|
||||||
import { EnumComponent } from "./EnumComponent";
|
import { EnumComponent } from "./EnumComponent";
|
||||||
import { MethodComponent } from "./MethodComponent";
|
import { MethodComponent } from "./MethodComponent";
|
||||||
import { AsyncMethodComponent } from "./AsyncMethodComponent";
|
|
||||||
import { StringComponent } from "./StringComponent";
|
import { StringComponent } from "./StringComponent";
|
||||||
import { ListComponent } from "./ListComponent";
|
import { ListComponent } from "./ListComponent";
|
||||||
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
|
import { DataServiceComponent, DataServiceJSON } from "./DataServiceComponent";
|
||||||
@@ -17,6 +16,7 @@ import { updateValue } from "../socket";
|
|||||||
import { DictComponent } from "./DictComponent";
|
import { DictComponent } from "./DictComponent";
|
||||||
import { parseFullAccessPath } from "../utils/stateUtils";
|
import { parseFullAccessPath } from "../utils/stateUtils";
|
||||||
import { SerializedEnum, SerializedObject } from "../types/SerializedObject";
|
import { SerializedEnum, SerializedObject } from "../types/SerializedObject";
|
||||||
|
import { TaskComponent, TaskStatus } from "./TaskComponent";
|
||||||
|
|
||||||
interface GenericComponentProps {
|
interface GenericComponentProps {
|
||||||
attribute: SerializedObject;
|
attribute: SerializedObject;
|
||||||
@@ -144,30 +144,16 @@ export const GenericComponent = React.memo(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (attribute.type === "method") {
|
} else if (attribute.type === "method") {
|
||||||
if (!attribute.async) {
|
return (
|
||||||
return (
|
<MethodComponent
|
||||||
<MethodComponent
|
fullAccessPath={fullAccessPath}
|
||||||
fullAccessPath={fullAccessPath}
|
docString={attribute.doc}
|
||||||
docString={attribute.doc}
|
addNotification={addNotification}
|
||||||
addNotification={addNotification}
|
displayName={displayName}
|
||||||
displayName={displayName}
|
id={id}
|
||||||
id={id}
|
render={attribute.frontend_render}
|
||||||
render={attribute.frontend_render}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AsyncMethodComponent
|
|
||||||
fullAccessPath={fullAccessPath}
|
|
||||||
docString={attribute.doc}
|
|
||||||
value={attribute.value as "RUNNING" | null}
|
|
||||||
addNotification={addNotification}
|
|
||||||
displayName={displayName}
|
|
||||||
id={id}
|
|
||||||
render={attribute.frontend_render}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (attribute.type === "str") {
|
} else if (attribute.type === "str") {
|
||||||
return (
|
return (
|
||||||
<StringComponent
|
<StringComponent
|
||||||
@@ -182,6 +168,17 @@ export const GenericComponent = React.memo(
|
|||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (attribute.type == "Task") {
|
||||||
|
return (
|
||||||
|
<TaskComponent
|
||||||
|
fullAccessPath={fullAccessPath}
|
||||||
|
docString={attribute.doc}
|
||||||
|
status={attribute.value["status"].value as TaskStatus}
|
||||||
|
addNotification={addNotification}
|
||||||
|
displayName={displayName}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (attribute.type === "DataService") {
|
} else if (attribute.type === "DataService") {
|
||||||
return (
|
return (
|
||||||
<DataServiceComponent
|
<DataServiceComponent
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
|
||||||
interface ImageComponentProps {
|
interface ImageComponentProps {
|
||||||
fullAccessPath: string;
|
fullAccessPath: string;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { DocStringComponent } from "./DocStringComponent";
|
|||||||
import { GenericComponent } from "./GenericComponent";
|
import { GenericComponent } from "./GenericComponent";
|
||||||
import { LevelName } from "./NotificationsComponent";
|
import { LevelName } from "./NotificationsComponent";
|
||||||
import { SerializedObject } from "../types/SerializedObject";
|
import { SerializedObject } from "../types/SerializedObject";
|
||||||
import { useRenderCount } from "../hooks/useRenderCount";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
import useSortedEntries from "../hooks/useSortedEntries";
|
||||||
|
|
||||||
interface ListComponentProps {
|
interface ListComponentProps {
|
||||||
value: SerializedObject[];
|
value: SerializedObject[];
|
||||||
@@ -14,7 +15,9 @@ interface ListComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ListComponent = React.memo((props: ListComponentProps) => {
|
export const ListComponent = React.memo((props: ListComponentProps) => {
|
||||||
const { value, docString, isInstantUpdate, addNotification, id } = props;
|
const { docString, isInstantUpdate, addNotification, id } = props;
|
||||||
|
|
||||||
|
const sortedEntries = useSortedEntries(props.value);
|
||||||
|
|
||||||
const renderCount = useRenderCount();
|
const renderCount = useRenderCount();
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
|
|||||||
<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}</div>}
|
||||||
<DocStringComponent docString={docString} />
|
<DocStringComponent docString={docString} />
|
||||||
{value.map((item) => {
|
{sortedEntries.map((item) => {
|
||||||
return (
|
return (
|
||||||
<GenericComponent
|
<GenericComponent
|
||||||
key={item.full_access_path}
|
key={item.full_access_path}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 useRenderCount from "../hooks/useRenderCount";
|
||||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||||
|
|
||||||
interface MethodProps {
|
interface MethodProps {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import "../App.css";
|
|||||||
import { LevelName } from "./NotificationsComponent";
|
import { LevelName } from "./NotificationsComponent";
|
||||||
import { SerializedObject } from "../types/SerializedObject";
|
import { SerializedObject } from "../types/SerializedObject";
|
||||||
import { QuantityMap } from "../types/QuantityMap";
|
import { QuantityMap } from "../types/QuantityMap";
|
||||||
import { useRenderCount } from "../hooks/useRenderCount";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
|
||||||
// TODO: add button functionality
|
// TODO: add button functionality
|
||||||
|
|
||||||
@@ -132,6 +132,8 @@ const handleNumericKey = (
|
|||||||
selectionStart: number,
|
selectionStart: number,
|
||||||
selectionEnd: number,
|
selectionEnd: number,
|
||||||
) => {
|
) => {
|
||||||
|
let newValue = value;
|
||||||
|
|
||||||
// Check if a number key or a decimal point key is pressed
|
// Check if a number key or a decimal point key is pressed
|
||||||
if (key === "." && value.includes(".")) {
|
if (key === "." && value.includes(".")) {
|
||||||
// Check if value already contains a decimal. If so, ignore input.
|
// Check if value already contains a decimal. If so, ignore input.
|
||||||
@@ -139,14 +141,34 @@ const handleNumericKey = (
|
|||||||
return { value, selectionStart };
|
return { value, selectionStart };
|
||||||
}
|
}
|
||||||
|
|
||||||
let newValue = value;
|
// Handle minus sign input
|
||||||
|
if (key === "-") {
|
||||||
|
if (selectionStart === 0 && selectionEnd > selectionStart) {
|
||||||
|
// Replace selection with minus if selection starts at 0
|
||||||
|
newValue = "-" + value.slice(selectionEnd);
|
||||||
|
selectionStart = 1;
|
||||||
|
} else if (selectionStart === 0 && !value.startsWith("-")) {
|
||||||
|
// Add minus at the beginning if it doesn't exist
|
||||||
|
newValue = "-" + value;
|
||||||
|
selectionStart = 1;
|
||||||
|
} else if (
|
||||||
|
(selectionStart === 0 || selectionStart === 1) &&
|
||||||
|
value.startsWith("-")
|
||||||
|
) {
|
||||||
|
// Remove minus if it exists
|
||||||
|
newValue = value.slice(1);
|
||||||
|
selectionStart = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: newValue, selectionStart };
|
||||||
|
}
|
||||||
|
|
||||||
// Add the new key at the cursor's position
|
// Add the new key at the cursor's position
|
||||||
if (selectionEnd > selectionStart) {
|
if (selectionEnd > selectionStart) {
|
||||||
// If there is a selection, replace it with the key
|
// If there is a selection, replace it with the key
|
||||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
|
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
|
||||||
} else {
|
} else {
|
||||||
// otherwise, append the key after the selection start
|
// Otherwise, insert the key at the cursor position
|
||||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
|
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,18 +198,9 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
const { key, target } = event;
|
const { key, target } = event;
|
||||||
console.log(typeof key);
|
|
||||||
|
|
||||||
// Typecast
|
|
||||||
const inputTarget = target as HTMLInputElement;
|
const inputTarget = target as HTMLInputElement;
|
||||||
if (
|
if (key === "F1" || key === "F5" || key === "F12" || key === "Tab") {
|
||||||
key === "F1" ||
|
|
||||||
key === "F5" ||
|
|
||||||
key === "F12" ||
|
|
||||||
key === "Tab" ||
|
|
||||||
key === "ArrowRight" ||
|
|
||||||
key === "ArrowLeft"
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -202,17 +215,12 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
// Select everything when pressing Ctrl + a
|
// Select everything when pressing Ctrl + a
|
||||||
inputTarget.setSelectionRange(0, value.length);
|
inputTarget.setSelectionRange(0, value.length);
|
||||||
return;
|
return;
|
||||||
} else if (key === "-") {
|
} else if (key === "ArrowRight" || key === "ArrowLeft") {
|
||||||
if (selectionStart === 0 && !value.startsWith("-")) {
|
// Move the cursor with the arrow keys and store its position
|
||||||
newValue = "-" + value;
|
selectionStart = key === "ArrowRight" ? selectionStart + 1 : selectionStart - 1;
|
||||||
selectionStart++;
|
setCursorPosition(selectionStart);
|
||||||
} else if (value.startsWith("-") && selectionStart === 1) {
|
return;
|
||||||
newValue = value.substring(1); // remove minus sign
|
} else if ((key >= "0" && key <= "9") || key === "-") {
|
||||||
selectionStart--;
|
|
||||||
} else {
|
|
||||||
return; // Ignore "-" pressed in other positions
|
|
||||||
}
|
|
||||||
} else if (key >= "0" && key <= "9") {
|
|
||||||
// Check if a number key or a decimal point key is pressed
|
// Check if a number key or a decimal point key is pressed
|
||||||
({ value: newValue, selectionStart } = handleNumericKey(
|
({ value: newValue, selectionStart } = handleNumericKey(
|
||||||
key,
|
key,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { LevelName } from "./NotificationsComponent";
|
|||||||
import { SerializedObject } from "../types/SerializedObject";
|
import { SerializedObject } from "../types/SerializedObject";
|
||||||
import { QuantityMap } from "../types/QuantityMap";
|
import { QuantityMap } from "../types/QuantityMap";
|
||||||
import { propsAreEqual } from "../utils/propsAreEqual";
|
import { propsAreEqual } from "../utils/propsAreEqual";
|
||||||
import { useRenderCount } from "../hooks/useRenderCount";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
|
||||||
interface SliderComponentProps {
|
interface SliderComponentProps {
|
||||||
fullAccessPath: string;
|
fullAccessPath: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DocStringComponent } from "./DocStringComponent";
|
|||||||
import "../App.css";
|
import "../App.css";
|
||||||
import { LevelName } from "./NotificationsComponent";
|
import { LevelName } from "./NotificationsComponent";
|
||||||
import { SerializedObject } from "../types/SerializedObject";
|
import { SerializedObject } from "../types/SerializedObject";
|
||||||
import { useRenderCount } from "../hooks/useRenderCount";
|
import useRenderCount from "../hooks/useRenderCount";
|
||||||
|
|
||||||
// TODO: add button functionality
|
// TODO: add button functionality
|
||||||
|
|
||||||
|
|||||||
@@ -3,69 +3,53 @@ import { runMethod } from "../socket";
|
|||||||
import { Form, Button, InputGroup, Spinner } from "react-bootstrap";
|
import { Form, Button, InputGroup, Spinner } 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 useRenderCount from "../hooks/useRenderCount";
|
||||||
|
|
||||||
interface AsyncMethodProps {
|
export type TaskStatus = "RUNNING" | "NOT_RUNNING";
|
||||||
|
|
||||||
|
interface TaskProps {
|
||||||
fullAccessPath: string;
|
fullAccessPath: string;
|
||||||
value: "RUNNING" | null;
|
|
||||||
docString: string | null;
|
docString: string | null;
|
||||||
hideOutput?: boolean;
|
status: TaskStatus;
|
||||||
addNotification: (message: string, levelname?: LevelName) => void;
|
addNotification: (message: string, levelname?: LevelName) => void;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
id: string;
|
id: string;
|
||||||
render: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
export const TaskComponent = React.memo((props: TaskProps) => {
|
||||||
const {
|
const { fullAccessPath, docString, status, addNotification, displayName, id } = props;
|
||||||
fullAccessPath,
|
|
||||||
docString,
|
|
||||||
value: runningTask,
|
|
||||||
addNotification,
|
|
||||||
displayName,
|
|
||||||
id,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
// Conditional rendering based on the 'render' prop.
|
|
||||||
if (!props.render) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderCount = useRenderCount();
|
const renderCount = useRenderCount();
|
||||||
const formRef = useRef(null);
|
const formRef = useRef(null);
|
||||||
const [spinning, setSpinning] = useState(false);
|
const [spinning, setSpinning] = useState(false);
|
||||||
const name = fullAccessPath.split(".").at(-1)!;
|
|
||||||
const parentPath = fullAccessPath.slice(0, -(name.length + 1));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let message: string;
|
let message: string;
|
||||||
|
|
||||||
if (runningTask === null) {
|
if (status === "RUNNING") {
|
||||||
message = `${fullAccessPath} task was stopped.`;
|
|
||||||
} else {
|
|
||||||
message = `${fullAccessPath} was started.`;
|
message = `${fullAccessPath} was started.`;
|
||||||
|
} else {
|
||||||
|
message = `${fullAccessPath} was stopped.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
addNotification(message);
|
addNotification(message);
|
||||||
setSpinning(false);
|
setSpinning(false);
|
||||||
}, [props.value]);
|
}, [status]);
|
||||||
|
|
||||||
const execute = async (event: React.FormEvent) => {
|
const execute = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let method_name: string;
|
|
||||||
|
|
||||||
if (runningTask !== undefined && runningTask !== null) {
|
const method_name = status == "RUNNING" ? "stop" : "start";
|
||||||
method_name = `stop_${name}`;
|
|
||||||
} else {
|
|
||||||
method_name = `start_${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessPath = [parentPath, method_name].filter((element) => element).join(".");
|
const accessPath = [fullAccessPath, method_name]
|
||||||
|
.filter((element) => element)
|
||||||
|
.join(".");
|
||||||
setSpinning(true);
|
setSpinning(true);
|
||||||
runMethod(accessPath);
|
runMethod(accessPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="component asyncMethodComponent" id={id}>
|
<div className="component taskComponent" id={id}>
|
||||||
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
|
||||||
<Form onSubmit={execute} ref={formRef}>
|
<Form onSubmit={execute} ref={formRef}>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
@@ -76,7 +60,7 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
|||||||
<Button id={`button-${id}`} type="submit">
|
<Button id={`button-${id}`} type="submit">
|
||||||
{spinning ? (
|
{spinning ? (
|
||||||
<Spinner size="sm" role="status" aria-hidden="true" />
|
<Spinner size="sm" role="status" aria-hidden="true" />
|
||||||
) : runningTask === "RUNNING" ? (
|
) : status === "RUNNING" ? (
|
||||||
"Stop "
|
"Stop "
|
||||||
) : (
|
) : (
|
||||||
"Start "
|
"Start "
|
||||||
@@ -88,4 +72,4 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
AsyncMethodComponent.displayName = "AsyncMethodComponent";
|
TaskComponent.displayName = "TaskComponent";
|
||||||
18
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export default function useLocalStorage(key: string, defaultValue: unknown) {
|
||||||
|
const [value, setValue] = useState(() => {
|
||||||
|
const storedValue = localStorage.getItem(key);
|
||||||
|
if (storedValue) {
|
||||||
|
return JSON.parse(storedValue);
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === undefined) return;
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}, [value, key]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
export function useRenderCount() {
|
export default function useRenderCount() {
|
||||||
const count = useRef(0);
|
const count = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
28
frontend/src/hooks/useSortedEntries.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,14 +2,29 @@ import { io } from "socket.io-client";
|
|||||||
import { serializeDict, serializeList } from "./utils/serializationUtils";
|
import { serializeDict, serializeList } from "./utils/serializationUtils";
|
||||||
import { SerializedObject } from "./types/SerializedObject";
|
import { SerializedObject } from "./types/SerializedObject";
|
||||||
|
|
||||||
export const hostname =
|
const hostname =
|
||||||
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname;
|
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname;
|
||||||
export const port =
|
const port = process.env.NODE_ENV === "development" ? 8001 : window.location.port;
|
||||||
process.env.NODE_ENV === "development" ? 8001 : window.location.port;
|
|
||||||
const URL = `ws://${hostname}:${port}/`;
|
|
||||||
console.debug("Websocket: ", URL);
|
|
||||||
|
|
||||||
export const socket = io(URL, { path: "/ws/socket.io", transports: ["websocket"] });
|
// Get the forwarded prefix from the global variable
|
||||||
|
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: SerializedObject,
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ type SerializedException = SerializedObjectBase & {
|
|||||||
type: "Exception";
|
type: "Exception";
|
||||||
};
|
};
|
||||||
|
|
||||||
type DataServiceTypes = "DataService" | "Image" | "NumberSlider" | "DeviceConnection";
|
type DataServiceTypes =
|
||||||
|
| "DataService"
|
||||||
|
| "Image"
|
||||||
|
| "NumberSlider"
|
||||||
|
| "DeviceConnection"
|
||||||
|
| "Task";
|
||||||
|
|
||||||
type SerializedDataService = SerializedObjectBase & {
|
type SerializedDataService = SerializedObjectBase & {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
56
mkdocs.yml
@@ -4,8 +4,17 @@ 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
|
||||||
|
- Interacting with pydase Services: user-guide/interaction/README.md
|
||||||
|
- Achieving Service Persistence: user-guide/Service_Persistence.md
|
||||||
|
- Understanding Tasks: user-guide/Tasks.md
|
||||||
|
- Understanding Units: user-guide/Understanding-Units.md
|
||||||
|
- Validating Property Setters: user-guide/Validating-Property-Setters.md
|
||||||
|
- 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
|
||||||
- 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
|
||||||
@@ -16,7 +25,11 @@ nav:
|
|||||||
- Contributing: about/contributing.md
|
- Contributing: about/contributing.md
|
||||||
- License: about/license.md
|
- License: about/license.md
|
||||||
|
|
||||||
theme: readthedocs
|
theme:
|
||||||
|
logo: images/logo-colour.png
|
||||||
|
name: material
|
||||||
|
features:
|
||||||
|
- content.code.copy
|
||||||
|
|
||||||
extra_css:
|
extra_css:
|
||||||
- css/extra.css
|
- css/extra.css
|
||||||
@@ -26,17 +39,46 @@ 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.highlight:
|
- pymdownx.inlinehilite
|
||||||
# - pymdownx.inlinehilite
|
|
||||||
|
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
- include-markdown
|
- include-markdown
|
||||||
- search
|
- search
|
||||||
- mkdocstrings
|
- mkdocstrings:
|
||||||
|
handlers:
|
||||||
|
python:
|
||||||
|
paths: [src] # search packages in the src folder
|
||||||
|
import:
|
||||||
|
- https://docs.python.org/3/objects.inv
|
||||||
|
- https://docs.pydantic.dev/latest/objects.inv
|
||||||
|
- https://confz.readthedocs.io/en/latest/objects.inv
|
||||||
|
- 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
|
||||||
|
|||||||
3067
poetry.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "pydase"
|
name = "pydase"
|
||||||
version = "0.8.4"
|
version = "0.10.10"
|
||||||
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
|
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
|
||||||
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
|
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -9,14 +9,15 @@ packages = [{ include = "pydase", from = "src" }]
|
|||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.10"
|
python = "^3.10"
|
||||||
fastapi = "^0.108.0"
|
|
||||||
uvicorn = "^0.27.0"
|
|
||||||
toml = "^0.10.2"
|
toml = "^0.10.2"
|
||||||
python-socketio = "^5.8.0"
|
python-socketio = "^5.8.0"
|
||||||
confz = "^2.0.0"
|
confz = "^2.0.0"
|
||||||
pint = "^0.24"
|
pint = "^0.24"
|
||||||
websocket-client = "^1.7.0"
|
websocket-client = "^1.7.0"
|
||||||
aiohttp = "^3.9.3"
|
aiohttp = "^3.9.3"
|
||||||
|
click = "^8.1.7"
|
||||||
|
aiohttp-middlewares = "^2.3.0"
|
||||||
|
anyio = "^4.6.0"
|
||||||
|
|
||||||
[tool.poetry.group.dev]
|
[tool.poetry.group.dev]
|
||||||
optional = true
|
optional = true
|
||||||
@@ -29,17 +30,18 @@ mypy = "^1.4.1"
|
|||||||
matplotlib = "^3.7.2"
|
matplotlib = "^3.7.2"
|
||||||
pyright = "^1.1.323"
|
pyright = "^1.1.323"
|
||||||
pytest-mock = "^3.11.1"
|
pytest-mock = "^3.11.1"
|
||||||
ruff = "^0.2.0"
|
ruff = "^0.5.0"
|
||||||
pytest-asyncio = "^0.23.2"
|
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 = "^1.5.2"
|
mkdocs-material = "^9.5.30"
|
||||||
mkdocs-include-markdown-plugin = "^3.9.1"
|
mkdocs-include-markdown-plugin = "^3.9.1"
|
||||||
mkdocstrings = "^0.22.0"
|
mkdocstrings = {extras = ["python"], version = "^0.25.2"}
|
||||||
pymdown-extensions = "^10.1"
|
pymdown-extensions = "^10.1"
|
||||||
|
mkdocs-swagger-ui-tag = "^0.6.10"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from typing import TypedDict, cast
|
import urllib.parse
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||||
|
|
||||||
import socketio # type: ignore
|
import socketio # type: ignore
|
||||||
|
|
||||||
import pydase.components
|
from pydase.client.proxy_class import ProxyClass
|
||||||
from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader
|
from pydase.client.proxy_loader import 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__)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,46 +36,6 @@ def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
|||||||
loop.run_forever()
|
loop.run_forever()
|
||||||
|
|
||||||
|
|
||||||
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 (socketio.AsyncClient):
|
|
||||||
The socket.io client instance used for asynchronous communication with the
|
|
||||||
pydase service server.
|
|
||||||
loop (asyncio.AbstractEventLoop):
|
|
||||||
The event loop in which the client operations are managed and executed.
|
|
||||||
|
|
||||||
This class is used to create a proxy object that behaves like a local representation
|
|
||||||
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
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._initialise(sio_client=sio_client, loop=loop)
|
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
"""
|
"""
|
||||||
A client for connecting to a remote pydase service using socket.io. This client
|
A client for connecting to a remote pydase service using socket.io. This client
|
||||||
@@ -74,62 +43,127 @@ class Client:
|
|||||||
connection, disconnection, and updates, and ensures that the proxy object is
|
connection, disconnection, and updates, and ensures that the proxy object is
|
||||||
up-to-date with the server state.
|
up-to-date with the server state.
|
||||||
|
|
||||||
Attributes:
|
|
||||||
proxy (ProxyClass):
|
|
||||||
A proxy object representing the remote service, facilitating interaction as
|
|
||||||
if it were local.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostname (str):
|
url:
|
||||||
Hostname of the exposed service this client attempts to connect to.
|
The URL of the pydase Socket.IO server. This should always contain the
|
||||||
Default is "localhost".
|
protocol and the hostname.
|
||||||
port (int):
|
block_until_connected:
|
||||||
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
|
If set to True, the constructor will block until the connection to the
|
||||||
service has been established. This is useful for ensuring the client is
|
service has been established. This is useful for ensuring the client is
|
||||||
ready to use immediately after instantiation. Default is True.
|
ready to use immediately after instantiation. Default is True.
|
||||||
|
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).
|
||||||
|
Default is an empty dictionary.
|
||||||
|
client_id: Client identification that will be shown in the server logs this
|
||||||
|
client is connecting to. This ID is passed as a `X-Client-Id` header in the
|
||||||
|
HTTP(s) request. Defaults to None.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
The following example demonstrates a `Client` instance that connects to another
|
||||||
|
pydase service, while customising some of the connection settings for the
|
||||||
|
underlying [`AsyncClient`][socketio.AsyncClient].
|
||||||
|
|
||||||
|
```python
|
||||||
|
pydase.Client(url="ws://localhost:8001", sio_client_kwargs={
|
||||||
|
"reconnection_attempts": 2,
|
||||||
|
"reconnection_delay": 2,
|
||||||
|
"reconnection_delay_max": 8,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
When connecting to a server over a secure connection (i.e., the server is using
|
||||||
|
SSL/TLS encryption), make sure that the `wss` protocol is used instead of `ws`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
pydase.Client(url="wss://my-service.example.com")
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hostname: str,
|
*,
|
||||||
port: int,
|
url: str,
|
||||||
block_until_connected: bool = True,
|
block_until_connected: bool = True,
|
||||||
|
sio_client_kwargs: dict[str, Any] = {},
|
||||||
|
client_id: str | None = None,
|
||||||
):
|
):
|
||||||
self._hostname = hostname
|
# Parse the URL to separate base URL and path prefix
|
||||||
self._port = port
|
parsed_url = urllib.parse.urlparse(url)
|
||||||
self._sio = socketio.AsyncClient()
|
|
||||||
|
# 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._sio = socketio.AsyncClient(**sio_client_kwargs)
|
||||||
self._loop = asyncio.new_event_loop()
|
self._loop = asyncio.new_event_loop()
|
||||||
self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop)
|
self._client_id = client_id
|
||||||
|
self.proxy = ProxyClass(
|
||||||
|
sio_client=self._sio, loop=self._loop, reconnect=self.connect
|
||||||
|
)
|
||||||
|
"""A proxy object representing the remote service, facilitating interaction as
|
||||||
|
if it were local."""
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=asyncio_loop_thread, args=(self._loop,), daemon=True
|
target=asyncio_loop_thread, args=(self._loop,), daemon=True
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
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:
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
def connect(self, block_until_connected: bool = True) -> None:
|
||||||
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 disconnect(self) -> None:
|
||||||
|
connection_future = asyncio.run_coroutine_threadsafe(
|
||||||
|
self._disconnect(), self._loop
|
||||||
|
)
|
||||||
|
connection_future.result()
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
async def _connect(self) -> None:
|
||||||
logger.debug("Connecting to server '%s:%s' ...", self._hostname, self._port)
|
logger.debug("Connecting to server '%s' ...", self._url)
|
||||||
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(
|
||||||
f"ws://{self._hostname}:{self._port}",
|
url=self._base_url,
|
||||||
socketio_path="/ws/socket.io",
|
headers=headers,
|
||||||
|
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:%s' ...", self._hostname, self._port)
|
logger.debug("Connected to '%s' ...", self._url)
|
||||||
serialized_object = cast(
|
serialized_object = cast(
|
||||||
SerializedDataService, await self._sio.call("service_serialization")
|
SerializedDataService, await self._sio.call("service_serialization")
|
||||||
)
|
)
|
||||||
@@ -137,11 +171,17 @@ class Client:
|
|||||||
self.proxy, serialized_object=serialized_object
|
self.proxy, serialized_object=serialized_object
|
||||||
)
|
)
|
||||||
serialized_object["type"] = "DeviceConnection"
|
serialized_object["type"] = "DeviceConnection"
|
||||||
self.proxy._notify_changed("", loads(serialized_object))
|
if self.proxy._service_representation is not None:
|
||||||
|
# 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:%s' ...", self._hostname, self._port)
|
logger.debug("Disconnected from '%s' ...", self._url)
|
||||||
self.proxy._connected = False
|
self.proxy._connected = False
|
||||||
|
|
||||||
async def _handle_update(self, data: NotifyDict) -> None:
|
async def _handle_update(self, data: NotifyDict) -> None:
|
||||||
|
|||||||
112
src/pydase/client/proxy_class.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@@ -202,25 +201,8 @@ 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":
|
||||||
if serialized_object["async"] is True:
|
self._add_method_proxy(attr_name, serialized_object)
|
||||||
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
|
||||||
@@ -351,7 +333,7 @@ class ProxyLoader:
|
|||||||
) -> Any:
|
) -> Any:
|
||||||
# Custom types like Components or DataService classes
|
# Custom types like Components or DataService classes
|
||||||
component_class = cast(
|
component_class = cast(
|
||||||
type, Deserializer.get_component_class(serialized_object["type"])
|
type, Deserializer.get_service_base_class(serialized_object["type"])
|
||||||
)
|
)
|
||||||
class_bases = (
|
class_bases = (
|
||||||
ProxyClassMixin,
|
ProxyClassMixin,
|
||||||
|
|||||||
@@ -7,58 +7,59 @@ class ColouredEnum(Enum):
|
|||||||
|
|
||||||
This class extends the standard Enum but requires its values to be valid CSS
|
This class extends the standard Enum but requires its values to be valid CSS
|
||||||
colour codes. Supported colour formats include:
|
colour codes. Supported colour formats include:
|
||||||
- Hexadecimal colours
|
|
||||||
- Hexadecimal colours with transparency
|
- Hexadecimal colours
|
||||||
- RGB colours
|
- Hexadecimal colours with transparency
|
||||||
- RGBA colours
|
- RGB colours
|
||||||
- HSL colours
|
- RGBA colours
|
||||||
- HSLA colours
|
- HSL colours
|
||||||
- Predefined/Cross-browser colour names
|
- HSLA colours
|
||||||
|
- Predefined/Cross-browser colour names
|
||||||
|
|
||||||
Refer to the this website for more details on colour formats:
|
Refer to the this website for more details on colour formats:
|
||||||
(https://www.w3schools.com/cssref/css_colours_legal.php)
|
(https://www.w3schools.com/cssref/css_colours_legal.php)
|
||||||
|
|
||||||
The behavior of this component in the UI depends on how it's defined in the data
|
The behavior of this component in the UI depends on how it's defined in the data
|
||||||
service:
|
service:
|
||||||
- As property with a setter or as attribute: Renders as a dropdown menu,
|
|
||||||
allowing users to select and change its value from the frontend.
|
- As property with a setter or as attribute: Renders as a dropdown menu, allowing
|
||||||
- As property without a setter: Displays as a coloured box with the key of the
|
users to select and change its value from the frontend.
|
||||||
`ColouredEnum` as text inside, serving as a visual indicator without user
|
- As property without a setter: Displays as a coloured box with the key of the
|
||||||
interaction.
|
`ColouredEnum` as text inside, serving as a visual indicator without user
|
||||||
|
interaction.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
--------
|
```python
|
||||||
```python
|
import pydase.components as pyc
|
||||||
import pydase.components as pyc
|
import pydase
|
||||||
import pydase
|
|
||||||
|
|
||||||
class MyStatus(pyc.ColouredEnum):
|
class MyStatus(pyc.ColouredEnum):
|
||||||
PENDING = "#FFA500" # Orange
|
PENDING = "#FFA500" # Orange
|
||||||
RUNNING = "#0000FF80" # Transparent Blue
|
RUNNING = "#0000FF80" # Transparent Blue
|
||||||
PAUSED = "rgb(169, 169, 169)" # Dark Gray
|
PAUSED = "rgb(169, 169, 169)" # Dark Gray
|
||||||
RETRYING = "rgba(255, 255, 0, 0.3)" # Transparent Yellow
|
RETRYING = "rgba(255, 255, 0, 0.3)" # Transparent Yellow
|
||||||
COMPLETED = "hsl(120, 100%, 50%)" # Green
|
COMPLETED = "hsl(120, 100%, 50%)" # Green
|
||||||
FAILED = "hsla(0, 100%, 50%, 0.7)" # Transparent Red
|
FAILED = "hsla(0, 100%, 50%, 0.7)" # Transparent Red
|
||||||
CANCELLED = "SlateGray" # Slate Gray
|
CANCELLED = "SlateGray" # Slate Gray
|
||||||
|
|
||||||
class StatusExample(pydase.DataService):
|
class StatusExample(pydase.DataService):
|
||||||
_status = MyStatus.RUNNING
|
_status = MyStatus.RUNNING
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> MyStatus:
|
def status(self) -> MyStatus:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@status.setter
|
@status.setter
|
||||||
def status(self, value: MyStatus) -> None:
|
def status(self, value: MyStatus) -> None:
|
||||||
# Custom logic here...
|
# Custom logic here...
|
||||||
self._status = value
|
self._status = value
|
||||||
|
|
||||||
# Example usage:
|
# Example usage:
|
||||||
my_service = StatusExample()
|
my_service = StatusExample()
|
||||||
my_service.status = MyStatus.FAILED
|
my_service.status = MyStatus.FAILED
|
||||||
```
|
```
|
||||||
|
|
||||||
Note
|
Note:
|
||||||
----
|
Each enumeration name and value must be unique. This means that you should use
|
||||||
Each enumeration name and value must be unique. This means that you should use
|
different colour formats when you want to use a colour multiple times.
|
||||||
different colour formats when you want to use a colour multiple times.
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pydase.data_service
|
import pydase.data_service
|
||||||
|
import pydase.task.decorator
|
||||||
|
|
||||||
|
|
||||||
class DeviceConnection(pydase.data_service.DataService):
|
class DeviceConnection(pydase.data_service.DataService):
|
||||||
@@ -19,22 +20,26 @@ class DeviceConnection(pydase.data_service.DataService):
|
|||||||
to the device. This method should update the `self._connected` attribute to reflect
|
to the device. This method should update the `self._connected` attribute to reflect
|
||||||
the connection status:
|
the connection status:
|
||||||
|
|
||||||
>>> class MyDeviceConnection(DeviceConnection):
|
```python
|
||||||
... def connect(self) -> None:
|
class MyDeviceConnection(DeviceConnection):
|
||||||
... # Implementation to connect to the device
|
def connect(self) -> None:
|
||||||
... # Update self._connected to `True` if connection is successful,
|
# Implementation to connect to the device
|
||||||
... # `False` otherwise
|
# Update self._connected to `True` if connection is successful,
|
||||||
... ...
|
# `False` otherwise
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
Optionally, if additional logic is needed to determine the connection status,
|
Optionally, if additional logic is needed to determine the connection status,
|
||||||
the `connected` property can also be overridden:
|
the `connected` property can also be overridden:
|
||||||
|
|
||||||
>>> class MyDeviceConnection(DeviceConnection):
|
```python
|
||||||
... @property
|
class MyDeviceConnection(DeviceConnection):
|
||||||
... def connected(self) -> bool:
|
@property
|
||||||
... # Custom logic to determine connection status
|
def connected(self) -> bool:
|
||||||
... return some_custom_condition
|
# Custom logic to determine connection status
|
||||||
...
|
return some_custom_condition
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
Frontend Representation
|
Frontend Representation
|
||||||
-----------------------
|
-----------------------
|
||||||
@@ -48,7 +53,6 @@ class DeviceConnection(pydase.data_service.DataService):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._autostart_tasks["_handle_connection"] = () # type: ignore
|
|
||||||
self._reconnection_wait_time = 10.0
|
self._reconnection_wait_time = 10.0
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
@@ -66,6 +70,7 @@ class DeviceConnection(pydase.data_service.DataService):
|
|||||||
"""
|
"""
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
@pydase.task.decorator.task(autostart=True)
|
||||||
async def _handle_connection(self) -> None:
|
async def _handle_connection(self) -> None:
|
||||||
"""Automatically tries reconnecting to the device if it is not connected.
|
"""Automatically tries reconnecting to the device if it is not connected.
|
||||||
This method leverages the `connect` method and the `connected` property to
|
This method leverages the `connect` method and the `connected` property to
|
||||||
|
|||||||
@@ -11,84 +11,82 @@ class NumberSlider(DataService):
|
|||||||
This class models a UI slider for a data service, allowing for adjustments of a
|
This class models a UI slider for a data service, allowing for adjustments of a
|
||||||
parameter within a specified range and increments.
|
parameter within a specified range and increments.
|
||||||
|
|
||||||
Parameters:
|
Args:
|
||||||
-----------
|
value:
|
||||||
value (float, optional):
|
The initial value of the slider. Defaults to 0.0.
|
||||||
The initial value of the slider. Defaults to 0.
|
min_:
|
||||||
min (float, optional):
|
The minimum value of the slider. Defaults to 0.0.
|
||||||
The minimum value of the slider. Defaults to 0.
|
max_:
|
||||||
max (float, optional):
|
The maximum value of the slider. Defaults to 100.0.
|
||||||
The maximum value of the slider. Defaults to 100.
|
step_size:
|
||||||
step_size (float, optional):
|
The increment/decrement step size of the slider. Defaults to 1.0.
|
||||||
The increment/decrement step size of the slider. Defaults to 1.0.
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
--------
|
```python
|
||||||
```python
|
class MySlider(pydase.components.NumberSlider):
|
||||||
class MySlider(pydase.components.NumberSlider):
|
def __init__(
|
||||||
def __init__(
|
self,
|
||||||
self,
|
value: float = 0.0,
|
||||||
value: float = 0.0,
|
min_: float = 0.0,
|
||||||
min_: float = 0.0,
|
max_: float = 100.0,
|
||||||
max_: float = 100.0,
|
step_size: float = 1.0,
|
||||||
step_size: float = 1.0,
|
) -> None:
|
||||||
) -> None:
|
super().__init__(value, min_, max_, step_size)
|
||||||
super().__init__(value, min_, max_, step_size)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min(self) -> float:
|
def min(self) -> float:
|
||||||
return self._min
|
return self._min
|
||||||
|
|
||||||
@min.setter
|
@min.setter
|
||||||
def min(self, value: float) -> None:
|
def min(self, value: float) -> None:
|
||||||
self._min = value
|
self._min = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max(self) -> float:
|
def max(self) -> float:
|
||||||
return self._max
|
return self._max
|
||||||
|
|
||||||
@max.setter
|
@max.setter
|
||||||
def max(self, value: float) -> None:
|
def max(self, value: float) -> None:
|
||||||
self._max = value
|
self._max = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def step_size(self) -> float:
|
def step_size(self) -> float:
|
||||||
return self._step_size
|
return self._step_size
|
||||||
|
|
||||||
@step_size.setter
|
@step_size.setter
|
||||||
def step_size(self, value: float) -> None:
|
def step_size(self, value: float) -> None:
|
||||||
self._step_size = value
|
self._step_size = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def value(self) -> float:
|
def value(self) -> float:
|
||||||
return self._value
|
return self._value
|
||||||
|
|
||||||
@value.setter
|
@value.setter
|
||||||
def value(self, value: float) -> None:
|
def value(self, value: float) -> None:
|
||||||
if value < self._min or value > self._max:
|
if value < self._min or value > self._max:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Value is either below allowed min or above max value."
|
"Value is either below allowed min or above max value."
|
||||||
)
|
)
|
||||||
|
|
||||||
self._value = value
|
self._value = value
|
||||||
|
|
||||||
class MyService(pydase.DataService):
|
class MyService(pydase.DataService):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.voltage = MyService()
|
self.voltage = MyService()
|
||||||
|
|
||||||
# Modifying or accessing the voltage value:
|
# Modifying or accessing the voltage value:
|
||||||
my_service = MyService()
|
my_service = MyService()
|
||||||
my_service.voltage.value = 5
|
my_service.voltage.value = 5
|
||||||
print(my_service.voltage.value) # Output: 5
|
print(my_service.voltage.value) # Output: 5
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
value: Any = 0.0,
|
value: Any = 0.0,
|
||||||
min_: float = 0.0,
|
min_: Any = 0.0,
|
||||||
max_: float = 100.0,
|
max_: Any = 100.0,
|
||||||
step_size: float = 1.0,
|
step_size: Any = 1.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._step_size = step_size
|
self._step_size = step_size
|
||||||
@@ -97,17 +95,17 @@ class NumberSlider(DataService):
|
|||||||
self._max = max_
|
self._max = max_
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min(self) -> float:
|
def min(self) -> Any:
|
||||||
"""The min property."""
|
"""The min property."""
|
||||||
return self._min
|
return self._min
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max(self) -> float:
|
def max(self) -> Any:
|
||||||
"""The min property."""
|
"""The min property."""
|
||||||
return self._max
|
return self._max
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def step_size(self) -> float:
|
def step_size(self) -> Any:
|
||||||
"""The min property."""
|
"""The min property."""
|
||||||
return self._step_size
|
return self._step_size
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,31 @@ from confz import BaseConfig, EnvSource
|
|||||||
|
|
||||||
|
|
||||||
class OperationMode(BaseConfig): # type: ignore[misc]
|
class OperationMode(BaseConfig): # type: ignore[misc]
|
||||||
environment: Literal["development", "production"] = "development"
|
environment: Literal["testing", "development", "production"] = "development"
|
||||||
|
"""The service's operation mode."""
|
||||||
|
|
||||||
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
|
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
|
||||||
|
|
||||||
|
|
||||||
class ServiceConfig(BaseConfig): # type: ignore[misc]
|
class ServiceConfig(BaseConfig): # type: ignore[misc]
|
||||||
|
"""Service configuration.
|
||||||
|
|
||||||
|
Variables can be set through environment variables prefixed with `SERVICE_` or an
|
||||||
|
`.env` file containing those variables.
|
||||||
|
"""
|
||||||
|
|
||||||
config_dir: Path = Path("config")
|
config_dir: Path = Path("config")
|
||||||
|
"""Configuration directory"""
|
||||||
web_port: int = 8001
|
web_port: int = 8001
|
||||||
|
"""Web server port"""
|
||||||
|
|
||||||
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
|
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
|
||||||
|
|
||||||
|
|
||||||
class WebServerConfig(BaseConfig): # type: ignore[misc]
|
class WebServerConfig(BaseConfig): # type: ignore[misc]
|
||||||
|
"""The service's web server configuration."""
|
||||||
|
|
||||||
generate_web_settings: bool = False
|
generate_web_settings: bool = False
|
||||||
|
"""Should generate web_settings.json file"""
|
||||||
|
|
||||||
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])
|
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from pydase.observer_pattern.observable.observable import Observable
|
from pydase.observer_pattern.observable.observable import Observable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pydase.data_service.data_service import DataService
|
|
||||||
from pydase.data_service.task_manager import TaskManager
|
|
||||||
|
|
||||||
|
|
||||||
class AbstractDataService(Observable):
|
class AbstractDataService(Observable):
|
||||||
__root__: DataService
|
pass
|
||||||
_task_manager: TaskManager
|
|
||||||
_autostart_tasks: dict[str, tuple[Any]]
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
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 (
|
||||||
@@ -24,11 +25,6 @@ logger = logging.getLogger(__name__)
|
|||||||
class DataService(AbstractDataService):
|
class DataService(AbstractDataService):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._task_manager = TaskManager(self)
|
|
||||||
|
|
||||||
if not hasattr(self, "_autostart_tasks"):
|
|
||||||
self._autostart_tasks = {}
|
|
||||||
|
|
||||||
self.__check_instance_classes()
|
self.__check_instance_classes()
|
||||||
|
|
||||||
def __setattr__(self, __name: str, __value: Any) -> None:
|
def __setattr__(self, __name: str, __value: Any) -> None:
|
||||||
@@ -73,8 +69,19 @@ class DataService(AbstractDataService):
|
|||||||
|
|
||||||
if not issubclass(
|
if not issubclass(
|
||||||
value_class,
|
value_class,
|
||||||
(int | float | bool | str | list | dict | Enum | u.Quantity | Observable),
|
(
|
||||||
):
|
int
|
||||||
|
| float
|
||||||
|
| bool
|
||||||
|
| str
|
||||||
|
| list
|
||||||
|
| dict
|
||||||
|
| Enum
|
||||||
|
| u.Quantity
|
||||||
|
| Observable
|
||||||
|
| 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!",
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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,
|
SerializedObject,
|
||||||
get_nested_dict_by_path,
|
get_nested_dict_by_path,
|
||||||
set_nested_value_by_path,
|
set_nested_value_by_path,
|
||||||
@@ -16,6 +14,22 @@ 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
|
||||||
@@ -38,16 +52,7 @@ class DataServiceCache:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
|
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
|
||||||
try:
|
return get_nested_dict_by_path(
|
||||||
return get_nested_dict_by_path(
|
cast(dict[str, SerializedObject], self._cache["value"]),
|
||||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
full_access_path,
|
||||||
full_access_path,
|
)
|
||||||
)
|
|
||||||
except (SerializationPathError, SerializationValueError, KeyError):
|
|
||||||
return {
|
|
||||||
"full_access_path": full_access_path,
|
|
||||||
"value": None,
|
|
||||||
"type": "None",
|
|
||||||
"doc": None,
|
|
||||||
"readonly": False,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ from pydase.observer_pattern.observable.observable_object import ObservableObjec
|
|||||||
from pydase.observer_pattern.observer.property_observer import (
|
from pydase.observer_pattern.observer.property_observer import (
|
||||||
PropertyObserver,
|
PropertyObserver,
|
||||||
)
|
)
|
||||||
from pydase.utils.helpers import get_object_attr_from_path
|
from pydase.utils.helpers import (
|
||||||
from pydase.utils.serialization.serializer import SerializedObject, dump
|
get_object_attr_from_path,
|
||||||
|
)
|
||||||
|
from pydase.utils.serialization.serializer import (
|
||||||
|
SerializationPathError,
|
||||||
|
SerializedObject,
|
||||||
|
dump,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,12 +35,22 @@ class DataServiceObserver(PropertyObserver):
|
|||||||
for changing_attribute in self.changing_attributes
|
for changing_attribute in self.changing_attributes
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
cached_value_dict: SerializedObject
|
||||||
|
|
||||||
cached_value_dict = deepcopy(
|
try:
|
||||||
self.state_manager._data_service_cache.get_value_dict_from_cache(
|
cached_value_dict = deepcopy(
|
||||||
full_access_path
|
self.state_manager.cache_manager.get_value_dict_from_cache(
|
||||||
|
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 (
|
||||||
@@ -46,7 +62,7 @@ class DataServiceObserver(PropertyObserver):
|
|||||||
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._data_service_cache.get_value_dict_from_cache(
|
self.state_manager.cache_manager.get_value_dict_from_cache(
|
||||||
full_access_path
|
full_access_path
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -79,7 +95,7 @@ class DataServiceObserver(PropertyObserver):
|
|||||||
value_dict["type"],
|
value_dict["type"],
|
||||||
cached_value_dict["type"],
|
cached_value_dict["type"],
|
||||||
)
|
)
|
||||||
self.state_manager._data_service_cache.update_cache(
|
self.state_manager.cache_manager.update_cache(
|
||||||
full_access_path,
|
full_access_path,
|
||||||
value,
|
value,
|
||||||
)
|
)
|
||||||
@@ -110,8 +126,10 @@ class DataServiceObserver(PropertyObserver):
|
|||||||
object.
|
object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
callback (Callable[[str, Any, dict[str, Any]]): The callback function to be
|
callback:
|
||||||
registered. The function should have the following signature:
|
The callback function to be registered. The function should have the
|
||||||
|
following signature:
|
||||||
|
|
||||||
- full_access_path (str): The full dot-notation access path of the
|
- full_access_path (str): The full dot-notation access path of the
|
||||||
changed attribute. This path indicates the location of the changed
|
changed attribute. This path indicates the location of the changed
|
||||||
attribute within the observable object's structure.
|
attribute within the observable object's structure.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -32,17 +34,19 @@ def load_state(func: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
the value should be loaded from the JSON file.
|
the value should be loaded from the JSON file.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> class Service(pydase.DataService):
|
```python
|
||||||
... _name = "Service"
|
class Service(pydase.DataService):
|
||||||
...
|
_name = "Service"
|
||||||
... @property
|
|
||||||
... def name(self) -> str:
|
@property
|
||||||
... return self._name
|
def name(self) -> str:
|
||||||
...
|
return self._name
|
||||||
... @name.setter
|
|
||||||
... @load_state
|
@name.setter
|
||||||
... def name(self, value: str) -> None:
|
@load_state
|
||||||
... self._name = value
|
def name(self, value: str) -> None:
|
||||||
|
self._name = value
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
func._load_state = True # type: ignore[attr-defined]
|
func._load_state = True # type: ignore[attr-defined]
|
||||||
@@ -63,45 +67,41 @@ 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 is designed to provide quick access to the latest known state
|
persistence layer. It provides fast access to the most recently known state of the
|
||||||
for newly connecting web clients without the need for expensive property accesses
|
service and ensures consistent state updates across connected clients and service
|
||||||
that may involve complex calculations or I/O operations.
|
restarts.
|
||||||
|
|
||||||
The StateManager listens for state change notifications from the DataService's
|
The StateManager is used by the web server to apply updates to service attributes
|
||||||
callback manager and updates its cache accordingly. This cache does not always
|
and to serve the current state to newly connected clients. Internally, it creates a
|
||||||
reflect the most current complex property states but rather retains the value from
|
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache]
|
||||||
the last known state, optimizing for performance and reducing the load on the
|
instance to track the state of public attributes and properties.
|
||||||
system.
|
|
||||||
|
|
||||||
While the StateManager ensures that the cached state is as up-to-date as possible,
|
The StateManager also handles state persistence: it can load a previously saved
|
||||||
it does not autonomously update complex properties of the DataService. Such
|
state from disk at startup and periodically autosave the current state to a file
|
||||||
properties must be updated programmatically, for instance, by invoking specific
|
during runtime.
|
||||||
tasks or methods that trigger the necessary operations to refresh their state.
|
|
||||||
|
|
||||||
The cached state maintained by the StateManager is particularly useful for web
|
Args:
|
||||||
clients that connect to the system and need immediate access to the current state of
|
service: The DataService instance whose state is being managed.
|
||||||
the DataService. By avoiding direct and potentially costly property accesses, the
|
filename: The file name used for loading and storing the DataService's state.
|
||||||
StateManager provides a snapshot of the DataService's state that is sufficiently
|
If provided, the state is loaded from this file at startup and saved to it
|
||||||
accurate for initial rendering and interaction.
|
on shutdown or at regular intervals.
|
||||||
|
autosave_interval: Interval in seconds between automatic state save events.
|
||||||
Attributes:
|
If set to `None`, automatic saving is disabled.
|
||||||
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's cache updates are triggered by notifications and do not
|
The StateManager does not autonomously poll hardware state. It relies on the
|
||||||
include autonomous updates of complex DataService properties, which must be
|
service to perform such updates. The cache maintained by
|
||||||
managed programmatically. The cache serves the purpose of providing immediate
|
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache]
|
||||||
state information to web clients, reflecting the state after the last property
|
reflects the last known state as notified by the `DataServiceObserver`, and is
|
||||||
update.
|
used by the web interface to provide fast and accurate state rendering for
|
||||||
|
connected clients.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, service: "DataService", filename: str | Path | None = None
|
self,
|
||||||
|
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,38 +113,52 @@ class StateManager:
|
|||||||
self.filename = filename
|
self.filename = filename
|
||||||
|
|
||||||
self.service = service
|
self.service = service
|
||||||
self._data_service_cache = DataServiceCache(self.service)
|
self.cache_manager = DataServiceCache(self.service)
|
||||||
|
self.autosave_interval = autosave_interval
|
||||||
|
|
||||||
@property
|
async def autosave(self) -> None:
|
||||||
def cache(self) -> SerializedObject:
|
"""Periodically saves the current service state to the configured file.
|
||||||
"""Returns the cached DataService state."""
|
|
||||||
return self._data_service_cache.cache
|
This coroutine is automatically started by the [`pydase.Server`][pydase.Server]
|
||||||
|
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(
|
return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
|
||||||
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
|
||||||
Saves the DataService's current state to a JSON file defined by `self.filename`.
|
`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.info(
|
logger.debug(
|
||||||
"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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -157,9 +171,18 @@ 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)
|
||||||
nested_class_dict = self._data_service_cache.get_value_dict_from_cache(
|
try:
|
||||||
path
|
nested_class_dict = self.cache_manager.get_value_dict_from_cache(
|
||||||
)
|
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)
|
||||||
@@ -188,8 +211,7 @@ 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
|
||||||
@@ -197,17 +219,23 @@ class StateManager:
|
|||||||
It also handles type-specific conversions for the new value before setting it.
|
It also handles type-specific conversions for the new value before setting it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: A dot-separated string indicating the hierarchical path to the
|
path:
|
||||||
|
A dot-separated string indicating the hierarchical path to the
|
||||||
attribute.
|
attribute.
|
||||||
value: The new value to set for the attribute.
|
serialized_value:
|
||||||
|
The serialized representation of the new value to set for the attribute.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
current_value_dict = get_nested_dict_by_path(self.cache_value, path)
|
try:
|
||||||
|
current_value_dict = self.cache_manager.get_value_dict_from_cache(path)
|
||||||
# This will also filter out methods as they are 'read-only'
|
except (SerializationPathError, KeyError):
|
||||||
if current_value_dict["readonly"]:
|
current_value_dict = {
|
||||||
logger.debug("Attribute '%s' is read-only. Ignoring new value...", path)
|
"full_access_path": path,
|
||||||
return
|
"value": None,
|
||||||
|
"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
|
||||||
@@ -237,24 +265,21 @@ 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])
|
||||||
|
|
||||||
attr_cache_type = get_nested_dict_by_path(self.cache_value, path)["type"]
|
if self.__cached_value_is_enum(path):
|
||||||
|
|
||||||
# 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"):
|
||||||
try:
|
# This error will arise when setting an enum from another enum class.
|
||||||
|
# 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"]]
|
||||||
except KeyError:
|
is_value_set = True
|
||||||
# This error will arise when setting an enum from another enum class
|
|
||||||
# In this case, we resort to loading the enum and setting it
|
if not is_value_set:
|
||||||
# directly
|
|
||||||
value = loads(serialized_value)
|
|
||||||
else:
|
|
||||||
value = loads(serialized_value)
|
value = loads(serialized_value)
|
||||||
|
|
||||||
# set the value
|
# set the value
|
||||||
@@ -262,6 +287,15 @@ 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:
|
||||||
@@ -287,8 +321,8 @@ class StateManager:
|
|||||||
return has_decorator
|
return has_decorator
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cached_serialization_dict = get_nested_dict_by_path(
|
cached_serialization_dict = self.cache_manager.get_value_dict_from_cache(
|
||||||
self.cache_value, full_access_path
|
full_access_path
|
||||||
)
|
)
|
||||||
|
|
||||||
if cached_serialization_dict["value"] == "method":
|
if cached_serialization_dict["value"] == "method":
|
||||||
@@ -303,3 +337,16 @@ 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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
|
||||||
import logging
|
|
||||||
from enum import Enum
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
|
||||||
from pydase.utils.helpers import (
|
|
||||||
function_has_arguments,
|
|
||||||
get_class_and_instance_attributes,
|
|
||||||
is_property_attribute,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from .data_service import DataService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(Enum):
|
|
||||||
RUNNING = "running"
|
|
||||||
|
|
||||||
|
|
||||||
class TaskManager:
|
|
||||||
"""
|
|
||||||
The TaskManager class is a utility designed to manage asynchronous tasks. It
|
|
||||||
provides functionality for starting, stopping, and tracking these tasks. The class
|
|
||||||
is primarily used by the DataService class to manage its tasks.
|
|
||||||
|
|
||||||
A task in TaskManager is any asynchronous function. To add a task, you simply need
|
|
||||||
to define an async function within your class that extends TaskManager. For example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyService(DataService):
|
|
||||||
async def my_task(self):
|
|
||||||
# Your task implementation here
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
With the above definition, TaskManager automatically creates `start_my_task` and
|
|
||||||
`stop_my_task` methods that can be used to control the task.
|
|
||||||
|
|
||||||
TaskManager also supports auto-starting tasks. If there are tasks that should start
|
|
||||||
running as soon as an instance of your class is created, you can define them in
|
|
||||||
`self._autostart_tasks` in your class constructor (__init__ method). Here's how:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyService(DataService):
|
|
||||||
def __init__(self):
|
|
||||||
self._autostart_tasks = {
|
|
||||||
"my_task": (*args) # Replace with actual arguments
|
|
||||||
}
|
|
||||||
self.wait_time = 1
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
async def my_task(self, *args):
|
|
||||||
while True:
|
|
||||||
# Your task implementation here
|
|
||||||
await asyncio.sleep(self.wait_time)
|
|
||||||
```
|
|
||||||
|
|
||||||
In the above example, `my_task` will start running as soon as
|
|
||||||
`_start_autostart_tasks` is called which is done when the DataService instance is
|
|
||||||
passed to the `pydase.Server` class.
|
|
||||||
|
|
||||||
The responsibilities of the TaskManager class are:
|
|
||||||
|
|
||||||
- Track all running tasks: Keeps track of all the tasks that are currently running.
|
|
||||||
This allows for monitoring of task statuses and for making sure tasks do not
|
|
||||||
overlap.
|
|
||||||
- Provide the ability to start and stop tasks: Automatically creates methods to
|
|
||||||
start and stop each task.
|
|
||||||
- Emit notifications when the status of a task changes: Has a built-in mechanism for
|
|
||||||
emitting notifications when a task starts or stops. This is used to update the user
|
|
||||||
interfaces, but can also be used to write logs, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, service: DataService) -> None:
|
|
||||||
self.service = service
|
|
||||||
|
|
||||||
self.tasks: dict[str, asyncio.Task[None]] = {}
|
|
||||||
"""A dictionary to keep track of running tasks. The keys are the names of the
|
|
||||||
tasks and the values are TaskDict instances which include the task itself and
|
|
||||||
its kwargs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._set_start_and_stop_for_async_methods()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _loop(self) -> asyncio.AbstractEventLoop:
|
|
||||||
return asyncio.get_running_loop()
|
|
||||||
|
|
||||||
def _set_start_and_stop_for_async_methods(self) -> None:
|
|
||||||
for name in dir(self.service):
|
|
||||||
# circumvents calling properties
|
|
||||||
if is_property_attribute(self.service, name):
|
|
||||||
continue
|
|
||||||
|
|
||||||
method = getattr(self.service, name)
|
|
||||||
if inspect.iscoroutinefunction(method):
|
|
||||||
if function_has_arguments(method):
|
|
||||||
logger.info(
|
|
||||||
"Async function %a is defined with at least one argument. If "
|
|
||||||
"you want to use it as a task, remove the argument(s) from the "
|
|
||||||
"function definition.",
|
|
||||||
method.__name__,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# create start and stop methods for each coroutine
|
|
||||||
setattr(
|
|
||||||
self.service, f"start_{name}", self._make_start_task(name, method)
|
|
||||||
)
|
|
||||||
setattr(self.service, f"stop_{name}", self._make_stop_task(name))
|
|
||||||
|
|
||||||
def _initiate_task_startup(self) -> None:
|
|
||||||
if self.service._autostart_tasks is not None:
|
|
||||||
for service_name, args in self.service._autostart_tasks.items():
|
|
||||||
start_method = getattr(self.service, f"start_{service_name}", None)
|
|
||||||
if start_method is not None and callable(start_method):
|
|
||||||
start_method(*args)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"No start method found for service '%s'", service_name
|
|
||||||
)
|
|
||||||
|
|
||||||
def start_autostart_tasks(self) -> None:
|
|
||||||
self._initiate_task_startup()
|
|
||||||
attrs = get_class_and_instance_attributes(self.service)
|
|
||||||
|
|
||||||
for attr_value in attrs.values():
|
|
||||||
if isinstance(attr_value, AbstractDataService):
|
|
||||||
attr_value._task_manager.start_autostart_tasks()
|
|
||||||
elif isinstance(attr_value, list):
|
|
||||||
for item in attr_value:
|
|
||||||
if isinstance(item, AbstractDataService):
|
|
||||||
item._task_manager.start_autostart_tasks()
|
|
||||||
|
|
||||||
def _make_stop_task(self, name: str) -> Callable[..., Any]:
|
|
||||||
"""
|
|
||||||
Factory function to create a 'stop_task' function for a running task.
|
|
||||||
|
|
||||||
The generated function cancels the associated asyncio task using 'name' for
|
|
||||||
identification, ensuring proper cleanup. Avoids closure and late binding issues.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): The name of the coroutine task, used for its identification.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def stop_task() -> None:
|
|
||||||
# cancel the task
|
|
||||||
task = self.tasks.get(name, None)
|
|
||||||
if task is not None:
|
|
||||||
self._loop.call_soon_threadsafe(task.cancel)
|
|
||||||
|
|
||||||
return stop_task
|
|
||||||
|
|
||||||
def _make_start_task(
|
|
||||||
self, name: str, method: Callable[..., Any]
|
|
||||||
) -> Callable[..., Any]:
|
|
||||||
"""
|
|
||||||
Factory function to create a 'start_task' function for a coroutine.
|
|
||||||
|
|
||||||
The generated function starts the coroutine as an asyncio task, handling
|
|
||||||
registration and monitoring.
|
|
||||||
It uses 'name' and 'method' to avoid the closure and late binding issue.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): The name of the coroutine, used for task management.
|
|
||||||
method (callable): The coroutine to be turned into an asyncio task.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def start_task() -> None:
|
|
||||||
def task_done_callback(task: asyncio.Task[None], name: str) -> None:
|
|
||||||
"""Handles tasks that have finished.
|
|
||||||
|
|
||||||
Removes a task from the tasks dictionary, calls the defined
|
|
||||||
callbacks, and logs and re-raises exceptions."""
|
|
||||||
|
|
||||||
# removing the finished task from the tasks i
|
|
||||||
self.tasks.pop(name, None)
|
|
||||||
|
|
||||||
# emit the notification that the task was stopped
|
|
||||||
self.service._notify_changed(name, None)
|
|
||||||
|
|
||||||
exception = task.exception()
|
|
||||||
if exception is not None:
|
|
||||||
# Handle the exception, or you can re-raise it.
|
|
||||||
logger.error(
|
|
||||||
"Task '%s' encountered an exception: %s: %s",
|
|
||||||
name,
|
|
||||||
type(exception).__name__,
|
|
||||||
exception,
|
|
||||||
)
|
|
||||||
raise exception
|
|
||||||
|
|
||||||
async def task() -> None:
|
|
||||||
try:
|
|
||||||
await method()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.info("Task '%s' was cancelled", name)
|
|
||||||
|
|
||||||
if not self.tasks.get(name):
|
|
||||||
# creating the task and adding the task_done_callback which checks
|
|
||||||
# if an exception has occured during the task execution
|
|
||||||
task_object = self._loop.create_task(task())
|
|
||||||
task_object.add_done_callback(
|
|
||||||
lambda task: task_done_callback(task, name)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store the task and its arguments in the '__tasks' dictionary. The
|
|
||||||
# key is the name of the method, and the value is a dictionary
|
|
||||||
# containing the task object and the updated keyword arguments.
|
|
||||||
self.tasks[name] = task_object
|
|
||||||
|
|
||||||
# emit the notification that the task was started
|
|
||||||
self.service._notify_changed(name, TaskStatus.RUNNING)
|
|
||||||
else:
|
|
||||||
logger.error("Task '%s' is already running!", name)
|
|
||||||
|
|
||||||
return start_task
|
|
||||||
71
src/pydase/frontend/assets/index-DpoEqi_N.js
Normal file
BIN
src/pydase/frontend/favicon.ico
Normal file
|
After Width: | Height: | Size: 77 KiB |
@@ -3,13 +3,20 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="description" content="Web site displaying a pydase UI." />
|
<meta name="description" content="Web site displaying a pydase UI." />
|
||||||
<script type="module" crossorigin src="/assets/index-C12UM6g5.js"></script>
|
<script type="module" crossorigin src="/assets/index-DpoEqi_N.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DJzFvk4W.css">
|
||||||
</head>
|
</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>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ def validate_set(
|
|||||||
getter and check against the desired value.
|
getter and check against the desired value.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
timeout (float):
|
timeout:
|
||||||
The maximum time (in seconds) to wait for the value to be within the
|
The maximum time (in seconds) to wait for the value to be within the
|
||||||
precision boundary.
|
precision boundary.
|
||||||
precision (float | None):
|
precision:
|
||||||
The acceptable deviation from the desired value. If None, the value must be
|
The acceptable deviation from the desired value. If None, the value must be
|
||||||
exact.
|
exact.
|
||||||
"""
|
"""
|
||||||
@@ -44,13 +44,11 @@ def has_validate_set_decorator(prop: property) -> bool:
|
|||||||
Checks if a property setter has been decorated with the `validate_set` decorator.
|
Checks if a property setter has been decorated with the `validate_set` decorator.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prop (property):
|
prop:
|
||||||
The property to check.
|
The property to check.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool:
|
True if the property setter has the `validate_set` decorator, False otherwise.
|
||||||
True if the property setter has the `validate_set` decorator, False
|
|
||||||
otherwise.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
property_setter = prop.fset
|
property_setter = prop.fset
|
||||||
@@ -68,11 +66,11 @@ def _validate_value_was_correctly_set(
|
|||||||
specified `precision` and time `timeout`.
|
specified `precision` and time `timeout`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
obj (Observable):
|
obj:
|
||||||
The instance of the class containing the property.
|
The instance of the class containing the property.
|
||||||
name (str):
|
name:
|
||||||
The name of the property to validate.
|
The name of the property to validate.
|
||||||
value (Any):
|
value:
|
||||||
The desired value to check against.
|
The desired value to check against.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydase.observer_pattern.observable.decorators import (
|
|||||||
has_validate_set_decorator,
|
has_validate_set_decorator,
|
||||||
)
|
)
|
||||||
from pydase.observer_pattern.observable.observable_object import ObservableObject
|
from pydase.observer_pattern.observable.observable_object import ObservableObject
|
||||||
from pydase.utils.helpers import is_property_attribute
|
from pydase.utils.helpers import is_descriptor, is_property_attribute
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,7 +22,9 @@ class Observable(ObservableObject):
|
|||||||
- {"__annotations__"}
|
- {"__annotations__"}
|
||||||
}
|
}
|
||||||
for name, value in class_attrs.items():
|
for name, value in class_attrs.items():
|
||||||
if isinstance(value, property) or callable(value):
|
if isinstance(value, property) or callable(value) or is_descriptor(value):
|
||||||
|
# Properties, methods and descriptors have to be stored as class
|
||||||
|
# attributes to work properly. So don't make it an instance attribute.
|
||||||
continue
|
continue
|
||||||
self.__dict__[name] = self._initialise_new_objects(name, value)
|
self.__dict__[name] = self._initialise_new_objects(name, value)
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ class Observer(ABC):
|
|||||||
self.on_change_start(changing_attribute)
|
self.on_change_start(changing_attribute)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def on_change(self, full_access_path: str, value: Any) -> None:
|
def on_change(self, full_access_path: str, value: Any) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
def on_change_start(self, full_access_path: str) -> None:
|
def on_change_start(self, full_access_path: str) -> None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from pydase.observer_pattern.observable.observable import Observable
|
from pydase.observer_pattern.observable.observable import Observable
|
||||||
from pydase.observer_pattern.observer.observer import Observer
|
from pydase.observer_pattern.observer.observer import Observer
|
||||||
|
from pydase.utils.helpers import is_descriptor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -60,18 +61,28 @@ class PropertyObserver(Observer):
|
|||||||
def _process_nested_observables_properties(
|
def _process_nested_observables_properties(
|
||||||
self, obj: Observable, deps: dict[str, Any], prefix: str
|
self, obj: Observable, deps: dict[str, Any], prefix: str
|
||||||
) -> None:
|
) -> None:
|
||||||
for k, value in vars(obj).items():
|
for k, value in {**vars(type(obj)), **vars(obj)}.items():
|
||||||
|
actual_value = value
|
||||||
prefix = (
|
prefix = (
|
||||||
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
|
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
|
||||||
)
|
)
|
||||||
parent_path = f"{prefix}{k}"
|
parent_path = f"{prefix}{k}"
|
||||||
if isinstance(value, Observable):
|
|
||||||
|
# Get value from descriptor
|
||||||
|
if not isinstance(value, property) and is_descriptor(value):
|
||||||
|
actual_value = getattr(obj, k)
|
||||||
|
|
||||||
|
if isinstance(actual_value, Observable):
|
||||||
new_prefix = f"{parent_path}."
|
new_prefix = f"{parent_path}."
|
||||||
deps.update(
|
deps.update(
|
||||||
self._get_properties_and_their_dependencies(value, new_prefix)
|
self._get_properties_and_their_dependencies(
|
||||||
|
actual_value, new_prefix
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif isinstance(value, list | dict):
|
elif isinstance(value, list | dict):
|
||||||
self._process_collection_item_properties(value, deps, parent_path)
|
self._process_collection_item_properties(
|
||||||
|
actual_value, deps, parent_path
|
||||||
|
)
|
||||||
|
|
||||||
def _process_collection_item_properties(
|
def _process_collection_item_properties(
|
||||||
self,
|
self,
|
||||||
@@ -89,7 +100,7 @@ class PropertyObserver(Observer):
|
|||||||
elif isinstance(collection, dict):
|
elif isinstance(collection, dict):
|
||||||
for key, val in collection.items():
|
for key, val in collection.items():
|
||||||
if isinstance(val, Observable):
|
if isinstance(val, Observable):
|
||||||
new_prefix = f"{parent_path}['{key}']"
|
new_prefix = f'{parent_path}["{key}"]'
|
||||||
deps.update(
|
deps.update(
|
||||||
self._get_properties_and_their_dependencies(val, new_prefix)
|
self._get_properties_and_their_dependencies(val, new_prefix)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,18 +2,26 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
from typing import Any, Protocol, TypedDict
|
from typing import Any, Protocol, TypedDict
|
||||||
|
|
||||||
from uvicorn.server import HANDLED_SIGNALS
|
|
||||||
|
|
||||||
from pydase import DataService
|
from pydase import DataService
|
||||||
from pydase.config import ServiceConfig
|
from pydase.config import ServiceConfig
|
||||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
from pydase.server.web_server import WebServer
|
from pydase.server.web_server import WebServer
|
||||||
|
from pydase.task.autostart import autostart_service_tasks
|
||||||
|
from pydase.utils.helpers import current_event_loop_exists
|
||||||
|
|
||||||
|
HANDLED_SIGNALS = (
|
||||||
|
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
|
||||||
|
signal.SIGTERM, # Unix signal 15. Sent by `kill <pid>`.
|
||||||
|
)
|
||||||
|
if sys.platform == "win32": # pragma: py-not-win32
|
||||||
|
HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break.
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,18 +37,18 @@ class AdditionalServerProtocol(Protocol):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
data_service_observer:
|
data_service_observer:
|
||||||
Observer for the DataService, handling state updates and communication to
|
Observer for the DataService, handling state updates and communication to
|
||||||
connected clients through injected callbacks. Can be utilized to access the
|
connected clients through injected callbacks. Can be utilized to access the
|
||||||
service and state manager, and to add custom state-update callbacks.
|
service and state manager, and to add custom state-update callbacks.
|
||||||
host:
|
host:
|
||||||
Hostname or IP address where the server is accessible. Commonly '0.0.0.0' to
|
Hostname or IP address where the server is accessible. Commonly '0.0.0.0' to
|
||||||
bind to all network interfaces.
|
bind to all network interfaces.
|
||||||
port:
|
port:
|
||||||
Port number on which the server listens. Typically in the range 1024-65535
|
Port number on which the server listens. Typically in the range 1024-65535
|
||||||
(non-standard ports).
|
(non-standard ports).
|
||||||
**kwargs:
|
**kwargs:
|
||||||
Any additional parameters required for initializing the server. These
|
Any additional parameters required for initializing the server. These
|
||||||
parameters are specific to the server's implementation.
|
parameters are specific to the server's implementation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -58,18 +66,17 @@ class AdditionalServerProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class AdditionalServer(TypedDict):
|
class AdditionalServer(TypedDict):
|
||||||
"""
|
"""A TypedDict that represents the configuration for an additional server to be run
|
||||||
A TypedDict that represents the configuration for an additional server to be run
|
|
||||||
alongside the main server.
|
alongside the main server.
|
||||||
|
|
||||||
This class is used to specify the server type, the port on which the server should
|
|
||||||
run, and any additional keyword arguments that should be passed to the server when
|
|
||||||
it's instantiated.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
server: type[AdditionalServerProtocol]
|
server: type[AdditionalServerProtocol]
|
||||||
|
"""Server adhering to the
|
||||||
|
[`AdditionalServerProtocol`][pydase.server.server.AdditionalServerProtocol]."""
|
||||||
port: int
|
port: int
|
||||||
|
"""Port on which the server should run."""
|
||||||
kwargs: dict[str, Any]
|
kwargs: dict[str, Any]
|
||||||
|
"""Additional keyword arguments that will be passed to the server's constructor """
|
||||||
|
|
||||||
|
|
||||||
class Server:
|
class Server:
|
||||||
@@ -77,30 +84,15 @@ class Server:
|
|||||||
The `Server` class provides a flexible server implementation for the `DataService`.
|
The `Server` class provides a flexible server implementation for the `DataService`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service: DataService
|
service: The DataService instance that this server will manage.
|
||||||
The DataService instance that this server will manage.
|
host: The host address for the server. Defaults to `'0.0.0.0'`, which means all
|
||||||
host: str
|
|
||||||
The host address for the server. Default is '0.0.0.0', which means all
|
|
||||||
available network interfaces.
|
available network interfaces.
|
||||||
web_port: int
|
web_port: The port number for the web server. Defaults to
|
||||||
The port number for the web server. Default is
|
[`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port].
|
||||||
`pydase.config.ServiceConfig().web_port`.
|
enable_web: Whether to enable the web server.
|
||||||
enable_web: bool
|
filename: Filename of the file managing the service state persistence.
|
||||||
Whether to enable the web server. Default is True.
|
additional_servers: A list of additional servers to run alongside the main
|
||||||
filename: str | Path | None
|
server.
|
||||||
Filename of the file managing the service state persistence.
|
|
||||||
Defaults to None.
|
|
||||||
additional_servers : list[AdditionalServer]
|
|
||||||
A list of additional servers to run alongside the main server. Each entry in
|
|
||||||
the list should be a dictionary with the following structure:
|
|
||||||
- server: A class that adheres to the AdditionalServerProtocol. This
|
|
||||||
class should have an `__init__` method that accepts the DataService
|
|
||||||
instance, port, host, and optional keyword arguments, and a `serve`
|
|
||||||
method that is a coroutine responsible for starting the server.
|
|
||||||
- port: The port on which the additional server will be running.
|
|
||||||
- kwargs: A dictionary containing additional keyword arguments that will
|
|
||||||
be passed to the server's `__init__` method.
|
|
||||||
|
|
||||||
Here's an example of how you might define an additional server:
|
Here's an example of how you might define an additional server:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -139,8 +131,9 @@ class Server:
|
|||||||
)
|
)
|
||||||
server.run()
|
server.run()
|
||||||
```
|
```
|
||||||
**kwargs: Any
|
autosave_interval: Interval in seconds between automatic state save events.
|
||||||
Additional keyword arguments.
|
If set to `None`, automatic saving is disabled. Defaults to 30 seconds.
|
||||||
|
**kwargs: Additional keyword arguments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__( # noqa: PLR0913
|
def __init__( # noqa: PLR0913
|
||||||
@@ -151,6 +144,7 @@ class Server:
|
|||||||
enable_web: bool = True,
|
enable_web: bool = True,
|
||||||
filename: str | Path | None = None,
|
filename: str | Path | None = None,
|
||||||
additional_servers: list[AdditionalServer] | None = None,
|
additional_servers: list[AdditionalServer] | None = None,
|
||||||
|
autosave_interval: float = 30.0,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
if additional_servers is None:
|
if additional_servers is None:
|
||||||
@@ -160,13 +154,22 @@ class Server:
|
|||||||
self._web_port = web_port
|
self._web_port = web_port
|
||||||
self._enable_web = enable_web
|
self._enable_web = enable_web
|
||||||
self._kwargs = kwargs
|
self._kwargs = kwargs
|
||||||
self._loop: asyncio.AbstractEventLoop
|
|
||||||
self._additional_servers = additional_servers
|
self._additional_servers = additional_servers
|
||||||
self.should_exit = False
|
self.should_exit = False
|
||||||
self.servers: dict[str, asyncio.Future[Any]] = {}
|
self.servers: dict[str, asyncio.Future[Any]] = {}
|
||||||
self._state_manager = StateManager(self._service, filename)
|
self._state_manager = StateManager(
|
||||||
|
service=self._service,
|
||||||
|
filename=filename,
|
||||||
|
autosave_interval=autosave_interval,
|
||||||
|
)
|
||||||
self._observer = DataServiceObserver(self._state_manager)
|
self._observer = DataServiceObserver(self._state_manager)
|
||||||
self._state_manager.load_state()
|
self._state_manager.load_state()
|
||||||
|
autostart_service_tasks(self._service)
|
||||||
|
if not current_event_loop_exists():
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
else:
|
||||||
|
self._loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -174,7 +177,7 @@ class Server:
|
|||||||
|
|
||||||
This method should be called to start the server after it's been instantiated.
|
This method should be called to start the server after it's been instantiated.
|
||||||
"""
|
"""
|
||||||
asyncio.run(self.serve())
|
self._loop.run_until_complete(self.serve())
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
process_id = os.getpid()
|
process_id = os.getpid()
|
||||||
@@ -190,10 +193,8 @@ class Server:
|
|||||||
logger.info("Finished server process [%s]", process_id)
|
logger.info("Finished server process [%s]", process_id)
|
||||||
|
|
||||||
async def startup(self) -> None:
|
async def startup(self) -> None:
|
||||||
self._loop = asyncio.get_running_loop()
|
|
||||||
self._loop.set_exception_handler(self.custom_exception_handler)
|
self._loop.set_exception_handler(self.custom_exception_handler)
|
||||||
self.install_signal_handlers()
|
self.install_signal_handlers()
|
||||||
self._service._task_manager.start_autostart_tasks()
|
|
||||||
|
|
||||||
for server in self._additional_servers:
|
for server in self._additional_servers:
|
||||||
addin_server = server["server"](
|
addin_server = server["server"](
|
||||||
@@ -207,8 +208,9 @@ class Server:
|
|||||||
addin_server.__module__ + "." + addin_server.__class__.__name__
|
addin_server.__module__ + "." + addin_server.__class__.__name__
|
||||||
)
|
)
|
||||||
|
|
||||||
future_or_task = self._loop.create_task(addin_server.serve())
|
server_task = self._loop.create_task(addin_server.serve())
|
||||||
self.servers[server_name] = future_or_task
|
server_task.add_done_callback(self._handle_server_shutdown)
|
||||||
|
self.servers[server_name] = server_task
|
||||||
if self._enable_web:
|
if self._enable_web:
|
||||||
self._web_server = WebServer(
|
self._web_server = WebServer(
|
||||||
data_service_observer=self._observer,
|
data_service_observer=self._observer,
|
||||||
@@ -216,8 +218,24 @@ class Server:
|
|||||||
port=self._web_port,
|
port=self._web_port,
|
||||||
**self._kwargs,
|
**self._kwargs,
|
||||||
)
|
)
|
||||||
future_or_task = self._loop.create_task(self._web_server.serve())
|
server_task = self._loop.create_task(self._web_server.serve())
|
||||||
self.servers["web"] = future_or_task
|
|
||||||
|
server_task.add_done_callback(self._handle_server_shutdown)
|
||||||
|
self.servers["web"] = server_task
|
||||||
|
|
||||||
|
self._loop.create_task(self._state_manager.autosave())
|
||||||
|
|
||||||
|
def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None:
|
||||||
|
"""Handle server shutdown. If the service should exit, do nothing. Else, make
|
||||||
|
the service exit."""
|
||||||
|
|
||||||
|
if self.should_exit:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
task.result()
|
||||||
|
except Exception:
|
||||||
|
self.should_exit = True
|
||||||
|
|
||||||
async def main_loop(self) -> None:
|
async def main_loop(self) -> None:
|
||||||
while not self.should_exit:
|
while not self.should_exit:
|
||||||
@@ -229,7 +247,9 @@ class Server:
|
|||||||
logger.info("Saving data to %s.", self._state_manager.filename)
|
logger.info("Saving data to %s.", self._state_manager.filename)
|
||||||
self._state_manager.save_state()
|
self._state_manager.save_state()
|
||||||
|
|
||||||
|
logger.debug("Cancelling servers")
|
||||||
await self.__cancel_servers()
|
await self.__cancel_servers()
|
||||||
|
logger.debug("Cancelling tasks")
|
||||||
await self.__cancel_tasks()
|
await self.__cancel_tasks()
|
||||||
|
|
||||||
async def __cancel_servers(self) -> None:
|
async def __cancel_servers(self) -> None:
|
||||||
@@ -240,7 +260,7 @@ class Server:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug("Cancelled '%s' server.", server_name)
|
logger.debug("Cancelled '%s' server.", server_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Unexpected exception: %s", e)
|
logger.exception("Unexpected exception: %s", e)
|
||||||
|
|
||||||
async def __cancel_tasks(self) -> None:
|
async def __cancel_tasks(self) -> None:
|
||||||
for task in asyncio.all_tasks(self._loop):
|
for task in asyncio.all_tasks(self._loop):
|
||||||
|
|||||||
24
src/pydase/server/web_server/api/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import aiohttp.web
|
||||||
|
import aiohttp_middlewares.error
|
||||||
|
|
||||||
|
import pydase.server.web_server.api.v1.application
|
||||||
|
from pydase.data_service.state_manager import StateManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_api_application(state_manager: StateManager) -> aiohttp.web.Application:
|
||||||
|
api_application = aiohttp.web.Application(
|
||||||
|
middlewares=(aiohttp_middlewares.error.error_middleware(),)
|
||||||
|
)
|
||||||
|
|
||||||
|
api_application.add_subapp(
|
||||||
|
"/v1/",
|
||||||
|
pydase.server.web_server.api.v1.application.create_api_application(
|
||||||
|
state_manager
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_application
|
||||||
0
src/pydase/server/web_server/api/v1/__init__.py
Normal file
125
src/pydase/server/web_server/api/v1/application.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import aiohttp.web
|
||||||
|
import aiohttp_middlewares.error
|
||||||
|
import click
|
||||||
|
|
||||||
|
from pydase.data_service.state_manager import StateManager
|
||||||
|
from pydase.server.web_server.api.v1.endpoints import (
|
||||||
|
get_value,
|
||||||
|
trigger_async_method,
|
||||||
|
trigger_method,
|
||||||
|
update_value,
|
||||||
|
)
|
||||||
|
from pydase.utils.helpers import get_object_attr_from_path
|
||||||
|
from pydase.utils.serialization.serializer import dump
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pydase.server.web_server.sio_setup import TriggerMethodDict, UpdateDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATUS_OK = 200
|
||||||
|
STATUS_FAILED = 400
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_value(
|
||||||
|
request: aiohttp.web.Request, state_manager: StateManager
|
||||||
|
) -> aiohttp.web.Response:
|
||||||
|
log_id = get_log_id(request)
|
||||||
|
|
||||||
|
access_path = request.rel_url.query["access_path"]
|
||||||
|
|
||||||
|
logger.info("Client [%s] is getting the value of '%s'", log_id, access_path)
|
||||||
|
|
||||||
|
status = STATUS_OK
|
||||||
|
try:
|
||||||
|
result = get_value(state_manager, access_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
result = dump(e)
|
||||||
|
status = STATUS_FAILED
|
||||||
|
return aiohttp.web.json_response(result, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_value(
|
||||||
|
request: aiohttp.web.Request, state_manager: StateManager
|
||||||
|
) -> aiohttp.web.Response:
|
||||||
|
log_id = get_log_id(request)
|
||||||
|
|
||||||
|
data: UpdateDict = await request.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Client [%s] is updating the value of '%s'", log_id, data["access_path"]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_value(state_manager, data)
|
||||||
|
|
||||||
|
return aiohttp.web.json_response()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
return aiohttp.web.json_response(dump(e), status=STATUS_FAILED)
|
||||||
|
|
||||||
|
|
||||||
|
async def _trigger_method(
|
||||||
|
request: aiohttp.web.Request, state_manager: StateManager
|
||||||
|
) -> aiohttp.web.Response:
|
||||||
|
log_id = get_log_id(request)
|
||||||
|
|
||||||
|
data: TriggerMethodDict = await request.json()
|
||||||
|
|
||||||
|
access_path = data["access_path"]
|
||||||
|
|
||||||
|
logger.info("Client [%s] is triggering the method '%s'", log_id, access_path)
|
||||||
|
|
||||||
|
method = get_object_attr_from_path(state_manager.service, access_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if inspect.iscoroutinefunction(method):
|
||||||
|
method_return = await trigger_async_method(
|
||||||
|
state_manager=state_manager, data=data
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
method_return = trigger_method(state_manager=state_manager, data=data)
|
||||||
|
|
||||||
|
return aiohttp.web.json_response(method_return)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
return aiohttp.web.json_response(dump(e), status=STATUS_FAILED)
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_id(request: aiohttp.web.Request) -> str:
|
||||||
|
client_id_header = request.headers.get("x-client-id", None)
|
||||||
|
remote_username_header = request.headers.get("remote-user", None)
|
||||||
|
|
||||||
|
if remote_username_header is not None:
|
||||||
|
log_id = f"user={click.style(remote_username_header, fg='cyan')}"
|
||||||
|
elif client_id_header is not None:
|
||||||
|
log_id = f"id={click.style(client_id_header, fg='cyan')}"
|
||||||
|
else:
|
||||||
|
log_id = f"id={click.style(None, fg='cyan')}"
|
||||||
|
|
||||||
|
return log_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_api_application(state_manager: StateManager) -> aiohttp.web.Application:
|
||||||
|
api_application = aiohttp.web.Application(
|
||||||
|
middlewares=(aiohttp_middlewares.error.error_middleware(),)
|
||||||
|
)
|
||||||
|
|
||||||
|
api_application.router.add_get(
|
||||||
|
"/get_value", partial(_get_value, state_manager=state_manager)
|
||||||
|
)
|
||||||
|
api_application.router.add_put(
|
||||||
|
"/update_value", partial(_update_value, state_manager=state_manager)
|
||||||
|
)
|
||||||
|
api_application.router.add_put(
|
||||||
|
"/trigger_method", partial(_trigger_method, state_manager=state_manager)
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_application
|
||||||
57
src/pydase/server/web_server/api/v1/endpoints.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pydase.utils.serialization.deserializer
|
||||||
|
import pydase.utils.serialization.serializer
|
||||||
|
from pydase.data_service.state_manager import StateManager
|
||||||
|
from pydase.server.web_server.sio_setup import TriggerMethodDict, UpdateDict
|
||||||
|
from pydase.utils.helpers import get_object_attr_from_path
|
||||||
|
from pydase.utils.serialization.types import SerializedObject
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
loads = pydase.utils.serialization.deserializer.loads
|
||||||
|
Serializer = pydase.utils.serialization.serializer.Serializer
|
||||||
|
|
||||||
|
|
||||||
|
def update_value(state_manager: StateManager, data: UpdateDict) -> None:
|
||||||
|
path = data["access_path"]
|
||||||
|
|
||||||
|
state_manager.set_service_attribute_value_by_path(
|
||||||
|
path=path, serialized_value=data["value"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_value(state_manager: StateManager, access_path: str) -> SerializedObject:
|
||||||
|
return Serializer.serialize_object(
|
||||||
|
get_object_attr_from_path(state_manager.service, access_path),
|
||||||
|
access_path=access_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_method(state_manager: StateManager, data: TriggerMethodDict) -> Any:
|
||||||
|
method = get_object_attr_from_path(state_manager.service, data["access_path"])
|
||||||
|
|
||||||
|
serialized_args = data.get("args", None)
|
||||||
|
args = loads(serialized_args) if serialized_args else []
|
||||||
|
|
||||||
|
serialized_kwargs = data.get("kwargs", None)
|
||||||
|
kwargs: dict[str, Any] = loads(serialized_kwargs) if serialized_kwargs else {}
|
||||||
|
|
||||||
|
return Serializer.serialize_object(method(*args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_async_method(
|
||||||
|
state_manager: StateManager, data: TriggerMethodDict
|
||||||
|
) -> Any:
|
||||||
|
method: Callable[..., Awaitable[Any]] = get_object_attr_from_path(
|
||||||
|
state_manager.service, data["access_path"]
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized_args = data.get("args", None)
|
||||||
|
args = loads(serialized_args) if serialized_args else []
|
||||||
|
|
||||||
|
serialized_kwargs = data.get("kwargs", None)
|
||||||
|
kwargs: dict[str, Any] = loads(serialized_kwargs) if serialized_kwargs else {}
|
||||||
|
|
||||||
|
return Serializer.serialize_object(await method(*args, **kwargs))
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
from typing import Any, TypedDict
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
from pydase.utils.helpers import get_object_attr_from_path
|
||||||
|
|
||||||
|
if sys.version_info < (3, 11):
|
||||||
|
from typing_extensions import NotRequired
|
||||||
|
else:
|
||||||
|
from typing import NotRequired
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import socketio # type: ignore[import-untyped]
|
import socketio # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
import pydase.utils.serialization.deserializer
|
||||||
|
import pydase.utils.serialization.serializer
|
||||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
from pydase.utils.helpers import get_object_attr_from_path
|
from pydase.server.web_server.api.v1 import endpoints
|
||||||
from pydase.utils.logging import SocketIOHandler
|
from pydase.utils.logging import SocketIOHandler
|
||||||
from pydase.utils.serialization.deserializer import Deserializer
|
from pydase.utils.serialization.serializer import SerializedObject
|
||||||
from pydase.utils.serialization.serializer import SerializedObject, dump
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# These functions can be monkey-patched by other libraries at runtime
|
||||||
|
dump = pydase.utils.serialization.serializer.dump
|
||||||
|
sio_client_manager = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateDict(TypedDict):
|
class UpdateDict(TypedDict):
|
||||||
"""
|
"""
|
||||||
@@ -34,8 +48,8 @@ class UpdateDict(TypedDict):
|
|||||||
|
|
||||||
class TriggerMethodDict(TypedDict):
|
class TriggerMethodDict(TypedDict):
|
||||||
access_path: str
|
access_path: str
|
||||||
args: SerializedObject
|
args: NotRequired[SerializedObject]
|
||||||
kwargs: SerializedObject
|
kwargs: NotRequired[SerializedObject]
|
||||||
|
|
||||||
|
|
||||||
class RunMethodDict(TypedDict):
|
class RunMethodDict(TypedDict):
|
||||||
@@ -44,12 +58,15 @@ class RunMethodDict(TypedDict):
|
|||||||
exposed DataService.
|
exposed DataService.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
name (str): The name of the method to be run.
|
name:
|
||||||
parent_path (str): The access path for the parent object of the method to be
|
The name of the method to be run.
|
||||||
run. This is used to construct the full access path for the method. For
|
parent_path:
|
||||||
example, for an method with access path 'attr1.list_attr[0].method_name',
|
The access path for the parent object of the method to be run. This is used
|
||||||
'attr1.list_attr[0]' would be the parent_path.
|
to construct the full access path for the method. For example, for an method
|
||||||
kwargs (dict[str, Any]): The arguments passed to the method.
|
with access path 'attr1.list_attr[0].method_name', 'attr1.list_attr[0]'
|
||||||
|
would be the parent_path.
|
||||||
|
kwargs:
|
||||||
|
The arguments passed to the method.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
@@ -66,23 +83,30 @@ def setup_sio_server(
|
|||||||
Sets up and configures a Socket.IO asynchronous server.
|
Sets up and configures a Socket.IO asynchronous server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
observer (DataServiceObserver):
|
observer:
|
||||||
The observer managing state updates and communication.
|
The observer managing state updates and communication.
|
||||||
enable_cors (bool):
|
enable_cors:
|
||||||
Flag indicating whether CORS should be enabled for the server.
|
Flag indicating whether CORS should be enabled for the server.
|
||||||
loop (asyncio.AbstractEventLoop):
|
loop:
|
||||||
The event loop in which the server will run.
|
The event loop in which the server will run.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
socketio.AsyncServer: The configured Socket.IO asynchronous server.
|
The configured Socket.IO asynchronous server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
state_manager = observer.state_manager
|
state_manager = observer.state_manager
|
||||||
|
|
||||||
if enable_cors:
|
if enable_cors:
|
||||||
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
|
sio = socketio.AsyncServer(
|
||||||
|
async_mode="aiohttp",
|
||||||
|
cors_allowed_origins="*",
|
||||||
|
client_manager=sio_client_manager,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
sio = socketio.AsyncServer(async_mode="asgi")
|
sio = socketio.AsyncServer(
|
||||||
|
async_mode="aiohttp",
|
||||||
|
client_manager=sio_client_manager,
|
||||||
|
)
|
||||||
|
|
||||||
setup_sio_events(sio, state_manager)
|
setup_sio_events(sio, state_manager)
|
||||||
setup_logging_handler(sio)
|
setup_logging_handler(sio)
|
||||||
@@ -117,37 +141,59 @@ def setup_sio_server(
|
|||||||
def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) -> None: # noqa: C901
|
def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) -> None: # noqa: C901
|
||||||
@sio.event # type: ignore
|
@sio.event # type: ignore
|
||||||
async def connect(sid: str, environ: Any) -> None:
|
async def connect(sid: str, environ: Any) -> None:
|
||||||
logging.debug("Client [%s] connected", click.style(str(sid), fg="cyan"))
|
client_id_header = environ.get("HTTP_X_CLIENT_ID", None)
|
||||||
|
remote_username_header = environ.get("HTTP_REMOTE_USER", None)
|
||||||
|
|
||||||
|
if remote_username_header is not None:
|
||||||
|
log_id = f"user={click.style(remote_username_header, fg='cyan')}"
|
||||||
|
elif client_id_header is not None:
|
||||||
|
log_id = f"id={click.style(client_id_header, fg='cyan')}"
|
||||||
|
else:
|
||||||
|
log_id = f"sid={click.style(sid, fg='cyan')}"
|
||||||
|
|
||||||
|
async with sio.session(sid) as session:
|
||||||
|
session["client_id"] = log_id
|
||||||
|
logger.info("Client [%s] connected", session["client_id"])
|
||||||
|
|
||||||
@sio.event # type: ignore
|
@sio.event # type: ignore
|
||||||
async def disconnect(sid: str) -> None:
|
async def disconnect(sid: str) -> None:
|
||||||
logging.debug("Client [%s] disconnected", click.style(str(sid), fg="cyan"))
|
async with sio.session(sid) as session:
|
||||||
|
logger.info("Client [%s] disconnected", session["client_id"])
|
||||||
|
|
||||||
@sio.event # type: ignore
|
@sio.event # type: ignore
|
||||||
async def service_serialization(sid: str) -> SerializedObject:
|
async def service_serialization(sid: str) -> SerializedObject:
|
||||||
logging.debug(
|
async with sio.session(sid) as session:
|
||||||
"Client [%s] requested service serialization",
|
logger.info(
|
||||||
click.style(str(sid), fg="cyan"),
|
"Client [%s] requested service serialization", session["client_id"]
|
||||||
)
|
)
|
||||||
return state_manager.cache
|
return state_manager.cache_manager.cache
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None: # type: ignore
|
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None:
|
||||||
path = data["access_path"]
|
async with sio.session(sid) as session:
|
||||||
|
logger.info(
|
||||||
try:
|
"Client [%s] is updating the value of '%s'",
|
||||||
state_manager.set_service_attribute_value_by_path(
|
session["client_id"],
|
||||||
path=path, serialized_value=data["value"]
|
data["access_path"],
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
endpoints.update_value(state_manager=state_manager, data=data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
return dump(e)
|
return dump(e)
|
||||||
|
return None
|
||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
async def get_value(sid: str, access_path: str) -> SerializedObject:
|
async def get_value(sid: str, access_path: str) -> SerializedObject:
|
||||||
|
async with sio.session(sid) as session:
|
||||||
|
logger.info(
|
||||||
|
"Client [%s] is getting the value of '%s'",
|
||||||
|
session["client_id"],
|
||||||
|
access_path,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return state_manager._data_service_cache.get_value_dict_from_cache(
|
return endpoints.get_value(
|
||||||
access_path
|
state_manager=state_manager, access_path=access_path
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
@@ -155,18 +201,26 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
|
|||||||
|
|
||||||
@sio.event
|
@sio.event
|
||||||
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
|
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
|
||||||
|
async with sio.session(sid) as session:
|
||||||
|
logger.info(
|
||||||
|
"Client [%s] is triggering the method '%s'",
|
||||||
|
session["client_id"],
|
||||||
|
data["access_path"],
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
method = get_object_attr_from_path(
|
method = get_object_attr_from_path(
|
||||||
state_manager.service, data["access_path"]
|
state_manager.service, data["access_path"]
|
||||||
)
|
)
|
||||||
args = Deserializer.deserialize(data["args"])
|
if inspect.iscoroutinefunction(method):
|
||||||
kwargs: dict[str, Any] = Deserializer.deserialize(data["kwargs"])
|
return await endpoints.trigger_async_method(
|
||||||
return dump(method(*args, **kwargs))
|
state_manager=state_manager, data=data
|
||||||
|
)
|
||||||
|
return endpoints.trigger_method(state_manager=state_manager, data=data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.exception(e)
|
||||||
return dump(e)
|
return dump(e)
|
||||||
|
|
||||||
|
|
||||||
def setup_logging_handler(sio: socketio.AsyncServer) -> None:
|
def setup_logging_handler(sio: socketio.AsyncServer) -> None:
|
||||||
logger = logging.getLogger()
|
logging.getLogger().addHandler(SocketIOHandler(sio))
|
||||||
logger.addHandler(SocketIOHandler(sio))
|
logging.getLogger("pydase").addHandler(SocketIOHandler(sio))
|
||||||
|
|||||||
@@ -1,65 +1,78 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import socketio # type: ignore[import-untyped]
|
import aiohttp.web
|
||||||
import uvicorn
|
import aiohttp_middlewares.cors
|
||||||
from fastapi import FastAPI, Response
|
import anyio
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from pydase.config import ServiceConfig, WebServerConfig
|
from pydase.config import ServiceConfig, WebServerConfig
|
||||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||||
|
from pydase.server.web_server.api import create_api_application
|
||||||
from pydase.server.web_server.sio_setup import (
|
from pydase.server.web_server.sio_setup import (
|
||||||
setup_sio_server,
|
setup_sio_server,
|
||||||
)
|
)
|
||||||
from pydase.utils.helpers import get_path_from_path_parts, parse_full_access_path
|
from pydase.utils.helpers import (
|
||||||
|
get_path_from_path_parts,
|
||||||
|
parse_full_access_path,
|
||||||
|
)
|
||||||
from pydase.utils.serialization.serializer import generate_serialized_data_paths
|
from pydase.utils.serialization.serializer import generate_serialized_data_paths
|
||||||
from pydase.version import __version__
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WebServer:
|
class WebServer:
|
||||||
"""
|
"""
|
||||||
Represents a web server that adheres to the AdditionalServerProtocol, designed to
|
Represents a web server that adheres to the
|
||||||
work with a DataService instance. This server facilitates client-server
|
[`AdditionalServerProtocol`][pydase.server.server.AdditionalServerProtocol],
|
||||||
communication and state management through web protocols and socket connections.
|
designed to work with a [`DataService`][pydase.DataService] instance. This server
|
||||||
|
facilitates client-server communication and state management through web protocols
|
||||||
|
and socket connections.
|
||||||
|
|
||||||
The WebServer class initializes and manages a web server environment using FastAPI
|
The WebServer class initializes and manages a web server environment aiohttp and
|
||||||
and Socket.IO, allowing for HTTP and WebSocket communications. It incorporates CORS
|
Socket.IO, allowing for HTTP and Socket.IO communications. It incorporates CORS
|
||||||
(Cross-Origin Resource Sharing) support, custom CSS, and serves a frontend static
|
(Cross-Origin Resource Sharing) support, custom CSS, and serves a static files
|
||||||
files directory. It also initializes web server settings based on configuration
|
directory. It also initializes web server settings based on configuration files or
|
||||||
files or generates default settings if necessary.
|
generates default settings if necessary.
|
||||||
|
|
||||||
Configuration for the web server (like service configuration directory and whether
|
Configuration for the web server (like service configuration directory and whether
|
||||||
to generate new web settings) is determined in the following order of precedence:
|
to generate new web settings) is determined in the following order of precedence:
|
||||||
|
|
||||||
1. Values provided directly to the constructor.
|
1. Values provided directly to the constructor.
|
||||||
2. Environment variable settings (via configuration classes like
|
2. Environment variable settings (via configuration classes like
|
||||||
`pydase.config.ServiceConfig` and `pydase.config.WebServerConfig`).
|
[`ServiceConfig`][pydase.config.ServiceConfig] and
|
||||||
|
[`WebServerConfig`][pydase.config.WebServerConfig]).
|
||||||
3. Default values defined in the configuration classes.
|
3. Default values defined in the configuration classes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data_service_observer (DataServiceObserver): Observer for the DataService,
|
data_service_observer:
|
||||||
handling state updates and communication to connected clients.
|
Observer for the [`DataService`][pydase.DataService], handling state updates
|
||||||
host (str): Hostname or IP address where the server is accessible. Commonly
|
and communication to connected clients.
|
||||||
'0.0.0.0' to bind to all network interfaces.
|
host:
|
||||||
port (int): Port number on which the server listens. Typically in the range
|
Hostname or IP address where the server is accessible. Commonly '0.0.0.0'
|
||||||
1024-65535 (non-standard ports).
|
to bind to all network interfaces.
|
||||||
css (str | Path | None, optional): Path to a custom CSS file for styling the
|
port:
|
||||||
frontend. If None, no custom styles are applied. Defaults to None.
|
Port number on which the server listens. Typically in the range 1024-65535
|
||||||
enable_cors (bool, optional): Flag to enable or disable CORS policy. When True,
|
(non-standard ports).
|
||||||
CORS is enabled, allowing cross-origin requests. Defaults to True.
|
css:
|
||||||
config_dir (Path | None, optional): Path to the configuration
|
Path to a custom CSS file for styling the frontend. If None, no custom
|
||||||
directory where the web settings will be stored. Defaults to
|
styles are applied. Defaults to None.
|
||||||
`pydase.config.ServiceConfig().config_dir`.
|
favicon_path:
|
||||||
generate_new_web_settings (bool | None, optional): Flag to enable or disable
|
Path to a custom favicon.ico file. Defaults to None.
|
||||||
generation of new web settings if the configuration file is missing. Defaults
|
enable_cors:
|
||||||
to `pydase.config.WebServerConfig().generate_new_web_settings`.
|
Flag to enable or disable CORS policy. When True, CORS is enabled, allowing
|
||||||
**kwargs (Any): Additional unused keyword arguments.
|
cross-origin requests. Defaults to True.
|
||||||
|
config_dir:
|
||||||
|
Path to the configuration directory where the web settings will be stored.
|
||||||
|
Defaults to
|
||||||
|
[`ServiceConfig().config_dir`][pydase.config.ServiceConfig.config_dir].
|
||||||
|
generate_web_settings:
|
||||||
|
Flag to enable or disable generation of new web settings if the
|
||||||
|
configuration file is missing. Defaults to
|
||||||
|
[`WebServerConfig().generate_web_settings`][pydase.config.WebServerConfig.generate_web_settings].
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__( # noqa: PLR0913
|
def __init__( # noqa: PLR0913
|
||||||
@@ -67,7 +80,9 @@ class WebServer:
|
|||||||
data_service_observer: DataServiceObserver,
|
data_service_observer: DataServiceObserver,
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
|
*,
|
||||||
css: str | Path | None = None,
|
css: str | Path | None = None,
|
||||||
|
favicon_path: str | Path | None = None,
|
||||||
enable_cors: bool = True,
|
enable_cors: bool = True,
|
||||||
config_dir: Path = ServiceConfig().config_dir,
|
config_dir: Path = ServiceConfig().config_dir,
|
||||||
generate_web_settings: bool = WebServerConfig().generate_web_settings,
|
generate_web_settings: bool = WebServerConfig().generate_web_settings,
|
||||||
@@ -81,6 +96,11 @@ class WebServer:
|
|||||||
self.css = css
|
self.css = css
|
||||||
self.enable_cors = enable_cors
|
self.enable_cors = enable_cors
|
||||||
self.frontend_src = frontend_src
|
self.frontend_src = frontend_src
|
||||||
|
self.favicon_path: Path | str = favicon_path # type: ignore
|
||||||
|
|
||||||
|
if self.favicon_path is None:
|
||||||
|
self.favicon_path = self.frontend_src / "favicon.ico"
|
||||||
|
|
||||||
self._service_config_dir = config_dir
|
self._service_config_dir = config_dir
|
||||||
self._generate_web_settings = generate_web_settings
|
self._generate_web_settings = generate_web_settings
|
||||||
self._loop: asyncio.AbstractEventLoop
|
self._loop: asyncio.AbstractEventLoop
|
||||||
@@ -88,24 +108,114 @@ class WebServer:
|
|||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
self._setup_socketio()
|
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
|
||||||
self._setup_fastapi_app()
|
|
||||||
self.web_server = uvicorn.Server(
|
async def index(
|
||||||
uvicorn.Config(self.__fastapi_app, host=self.host, port=self.port)
|
request: aiohttp.web.Request,
|
||||||
|
) -> aiohttp.web.Response | aiohttp.web.FileResponse:
|
||||||
|
forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
|
||||||
|
escaped_proto = html.escape(forwarded_proto)
|
||||||
|
|
||||||
|
# Read the index.html file
|
||||||
|
index_file_path = self.frontend_src / "index.html"
|
||||||
|
|
||||||
|
async with await anyio.open_file(index_file_path) as f:
|
||||||
|
html_content = await f.read()
|
||||||
|
|
||||||
|
# Inject the escaped forwarded protocol into the HTML
|
||||||
|
modified_html = html_content.replace(
|
||||||
|
'window.__FORWARDED_PROTO__ = "";',
|
||||||
|
f'window.__FORWARDED_PROTO__ = "{escaped_proto}";',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read the X-Forwarded-Prefix header from the request
|
||||||
|
forwarded_prefix = request.headers.get("X-Forwarded-Prefix", "")
|
||||||
|
|
||||||
|
if forwarded_prefix != "":
|
||||||
|
# Escape the forwarded prefix to prevent XSS
|
||||||
|
escaped_prefix = html.escape(forwarded_prefix)
|
||||||
|
|
||||||
|
# Inject the escaped forwarded prefix into the HTML
|
||||||
|
modified_html = modified_html.replace(
|
||||||
|
'window.__FORWARDED_PREFIX__ = "";',
|
||||||
|
f'window.__FORWARDED_PREFIX__ = "{escaped_prefix}";',
|
||||||
|
)
|
||||||
|
modified_html = modified_html.replace(
|
||||||
|
"/assets/",
|
||||||
|
f"{escaped_prefix}/assets/",
|
||||||
|
)
|
||||||
|
|
||||||
|
modified_html = modified_html.replace(
|
||||||
|
"/favicon.ico",
|
||||||
|
f"{escaped_prefix}/favicon.ico",
|
||||||
|
)
|
||||||
|
|
||||||
|
return aiohttp.web.Response(text=modified_html, content_type="text/html")
|
||||||
|
|
||||||
|
app = aiohttp.web.Application()
|
||||||
|
|
||||||
|
# Add CORS middleware if enabled
|
||||||
|
if self.enable_cors:
|
||||||
|
app.middlewares.append(
|
||||||
|
aiohttp_middlewares.cors.cors_middleware(allow_all=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define routes
|
||||||
|
self._sio.attach(app, socketio_path="/ws/socket.io")
|
||||||
|
app.router.add_static("/assets", self.frontend_src / "assets")
|
||||||
|
app.router.add_get("/favicon.ico", self._favicon_route)
|
||||||
|
app.router.add_get("/service-properties", self._service_properties_route)
|
||||||
|
app.router.add_get("/web-settings", self._web_settings_route)
|
||||||
|
app.router.add_get("/custom.css", self._styles_route)
|
||||||
|
app.add_subapp("/api/", create_api_application(self.state_manager))
|
||||||
|
|
||||||
|
app.router.add_get(r"/", index)
|
||||||
|
app.router.add_get(r"/{tail:.*}", index)
|
||||||
|
|
||||||
|
await aiohttp.web._run_app(
|
||||||
|
app,
|
||||||
|
host=self.host,
|
||||||
|
port=self.port,
|
||||||
|
handle_signals=False,
|
||||||
|
print=logger.info,
|
||||||
|
shutdown_timeout=0.1,
|
||||||
)
|
)
|
||||||
# overwrite uvicorn's signal handlers, otherwise it will bogart SIGINT and
|
|
||||||
# SIGTERM, which makes it impossible to escape out of
|
async def _favicon_route(
|
||||||
self.web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
|
self,
|
||||||
await self.web_server.serve()
|
request: aiohttp.web.Request,
|
||||||
|
) -> aiohttp.web.FileResponse:
|
||||||
|
return aiohttp.web.FileResponse(self.favicon_path)
|
||||||
|
|
||||||
|
async def _service_properties_route(
|
||||||
|
self,
|
||||||
|
request: aiohttp.web.Request,
|
||||||
|
) -> aiohttp.web.Response:
|
||||||
|
return aiohttp.web.json_response(self.state_manager.cache_manager.cache)
|
||||||
|
|
||||||
|
async def _web_settings_route(
|
||||||
|
self,
|
||||||
|
request: aiohttp.web.Request,
|
||||||
|
) -> aiohttp.web.Response:
|
||||||
|
return aiohttp.web.json_response(self.web_settings)
|
||||||
|
|
||||||
|
async def _styles_route(
|
||||||
|
self,
|
||||||
|
request: aiohttp.web.Request,
|
||||||
|
) -> aiohttp.web.FileResponse | aiohttp.web.Response:
|
||||||
|
if self.css is not None:
|
||||||
|
return aiohttp.web.FileResponse(self.css)
|
||||||
|
|
||||||
|
return aiohttp.web.Response(content_type="text/css")
|
||||||
|
|
||||||
def _initialise_configuration(self) -> None:
|
def _initialise_configuration(self) -> None:
|
||||||
logger.debug("Initialising web server configuration...")
|
logger.debug("Initialising web server configuration...")
|
||||||
|
|
||||||
file_path = self._service_config_dir / "web_settings.json"
|
|
||||||
|
|
||||||
if self._generate_web_settings:
|
if self._generate_web_settings:
|
||||||
# File does not exist, create it with default content
|
|
||||||
logger.debug("Generating web settings file...")
|
logger.debug("Generating web settings file...")
|
||||||
|
file_path = self._service_config_dir / "web_settings.json"
|
||||||
|
|
||||||
|
# File does not exist, create it with default content
|
||||||
file_path.parent.mkdir(
|
file_path.parent.mkdir(
|
||||||
parents=True, exist_ok=True
|
parents=True, exist_ok=True
|
||||||
) # Ensure directory exists
|
) # Ensure directory exists
|
||||||
@@ -148,54 +258,3 @@ class WebServer:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return current_web_settings
|
return current_web_settings
|
||||||
|
|
||||||
def _setup_socketio(self) -> None:
|
|
||||||
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
|
|
||||||
self.__sio_app = socketio.ASGIApp(self._sio)
|
|
||||||
|
|
||||||
def _setup_fastapi_app(self) -> None: # noqa: C901
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
if self.enable_cors:
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
app.mount("/ws", self.__sio_app)
|
|
||||||
|
|
||||||
@app.get("/version")
|
|
||||||
def version() -> str:
|
|
||||||
return __version__
|
|
||||||
|
|
||||||
@app.get("/name")
|
|
||||||
def name() -> str:
|
|
||||||
return type(self.service).__name__
|
|
||||||
|
|
||||||
@app.get("/service-properties")
|
|
||||||
def service_properties() -> dict[str, Any]:
|
|
||||||
return self.state_manager.cache # type: ignore
|
|
||||||
|
|
||||||
@app.get("/web-settings")
|
|
||||||
def web_settings() -> dict[str, Any]:
|
|
||||||
return self.web_settings
|
|
||||||
|
|
||||||
# exposing custom.css file provided by user
|
|
||||||
@app.get("/custom.css")
|
|
||||||
async def styles() -> Response:
|
|
||||||
if self.css is not None:
|
|
||||||
return FileResponse(str(self.css))
|
|
||||||
|
|
||||||
return Response(content="", media_type="text/css")
|
|
||||||
|
|
||||||
app.mount(
|
|
||||||
"/",
|
|
||||||
StaticFiles(
|
|
||||||
directory=self.frontend_src,
|
|
||||||
html=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.__fastapi_app = app
|
|
||||||
|
|||||||
0
src/pydase/task/__init__.py
Normal file
46
src/pydase/task/autostart.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pydase.data_service.data_service
|
||||||
|
import pydase.task.task
|
||||||
|
from pydase.task.task_status import TaskStatus
|
||||||
|
from pydase.utils.helpers import is_property_attribute
|
||||||
|
|
||||||
|
|
||||||
|
def autostart_service_tasks(
|
||||||
|
service: pydase.data_service.data_service.DataService,
|
||||||
|
) -> None:
|
||||||
|
"""Starts the service tasks defined with the `autostart` keyword argument.
|
||||||
|
|
||||||
|
This method goes through the attributes of the passed service and its nested
|
||||||
|
[`DataService`][pydase.DataService] instances and calls the start method on
|
||||||
|
autostart-tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for attr in dir(service):
|
||||||
|
if is_property_attribute(service, attr) or attr in {
|
||||||
|
"_observers",
|
||||||
|
"__dict__",
|
||||||
|
}: # prevent eval of property attrs and recursion
|
||||||
|
continue
|
||||||
|
|
||||||
|
val = getattr(service, attr)
|
||||||
|
if isinstance(val, pydase.task.task.Task):
|
||||||
|
if val.autostart and val.status == TaskStatus.NOT_RUNNING:
|
||||||
|
val.start()
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
autostart_nested_service_tasks(val)
|
||||||
|
|
||||||
|
|
||||||
|
def autostart_nested_service_tasks(
|
||||||
|
service: pydase.data_service.data_service.DataService | list[Any] | dict[Any, Any],
|
||||||
|
) -> None:
|
||||||
|
if isinstance(service, pydase.DataService):
|
||||||
|
autostart_service_tasks(service)
|
||||||
|
elif isinstance(service, list):
|
||||||
|
for entry in service:
|
||||||
|
autostart_nested_service_tasks(entry)
|
||||||
|
elif isinstance(service, dict):
|
||||||
|
for entry in service.values():
|
||||||
|
autostart_nested_service_tasks(entry)
|
||||||
194
src/pydase/task/decorator.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import logging
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Any, Generic, TypeVar, overload
|
||||||
|
|
||||||
|
from pydase.data_service.data_service import DataService
|
||||||
|
from pydase.task.task import Task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
class PerInstanceTaskDescriptor(Generic[R]):
|
||||||
|
"""
|
||||||
|
A descriptor class that provides a unique [`Task`][pydase.task.task.Task] object
|
||||||
|
for each instance of a [`DataService`][pydase.data_service.data_service.DataService]
|
||||||
|
class.
|
||||||
|
|
||||||
|
The `PerInstanceTaskDescriptor` is used to transform an asynchronous function into a
|
||||||
|
task that is managed independently for each instance of a `DataService` subclass.
|
||||||
|
This allows tasks to be initialized, started, and stopped on a per-instance basis,
|
||||||
|
providing better control over task execution within the service.
|
||||||
|
|
||||||
|
The `PerInstanceTaskDescriptor` is not intended to be used directly. Instead, it is
|
||||||
|
used internally by the `@task` decorator to manage task objects for each instance of
|
||||||
|
the service class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
func: Callable[[Any], Coroutine[None, None, R]]
|
||||||
|
| Callable[[], Coroutine[None, None, R]],
|
||||||
|
autostart: bool,
|
||||||
|
restart_on_exception: bool,
|
||||||
|
restart_sec: float,
|
||||||
|
start_limit_interval_sec: float | None,
|
||||||
|
start_limit_burst: int,
|
||||||
|
exit_on_failure: bool,
|
||||||
|
) -> None:
|
||||||
|
self.__func = func
|
||||||
|
self.__autostart = autostart
|
||||||
|
self.__task_instances: dict[object, Task[R]] = {}
|
||||||
|
self.__restart_on_exception = restart_on_exception
|
||||||
|
self.__restart_sec = restart_sec
|
||||||
|
self.__start_limit_interval_sec = start_limit_interval_sec
|
||||||
|
self.__start_limit_burst = start_limit_burst
|
||||||
|
self.__exit_on_failure = exit_on_failure
|
||||||
|
|
||||||
|
def __set_name__(self, owner: type[DataService], name: str) -> None:
|
||||||
|
"""Stores the name of the task within the owning class. This method is called
|
||||||
|
automatically when the descriptor is assigned to a class attribute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.__task_name = name
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def __get__(
|
||||||
|
self, instance: None, owner: type[DataService]
|
||||||
|
) -> "PerInstanceTaskDescriptor[R]":
|
||||||
|
"""Returns the descriptor itself when accessed through the class."""
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def __get__(self, instance: DataService, owner: type[DataService]) -> Task[R]:
|
||||||
|
"""Returns the `Task` object associated with the specific `DataService`
|
||||||
|
instance.
|
||||||
|
If no task exists for the instance, a new `Task` object is created and stored
|
||||||
|
in the `__task_instances` dictionary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __get__(
|
||||||
|
self, instance: DataService | None, owner: type[DataService]
|
||||||
|
) -> "Task[R] | PerInstanceTaskDescriptor[R]":
|
||||||
|
if instance is None:
|
||||||
|
return self
|
||||||
|
|
||||||
|
# Create a new Task object for this instance, using the function's name.
|
||||||
|
if instance not in self.__task_instances:
|
||||||
|
self.__task_instances[instance] = instance._initialise_new_objects(
|
||||||
|
self.__task_name,
|
||||||
|
Task(
|
||||||
|
self.__func.__get__(instance, owner),
|
||||||
|
autostart=self.__autostart,
|
||||||
|
restart_on_exception=self.__restart_on_exception,
|
||||||
|
restart_sec=self.__restart_sec,
|
||||||
|
start_limit_interval_sec=self.__start_limit_interval_sec,
|
||||||
|
start_limit_burst=self.__start_limit_burst,
|
||||||
|
exit_on_failure=self.__exit_on_failure,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.__task_instances[instance]
|
||||||
|
|
||||||
|
|
||||||
|
def task( # noqa: PLR0913
|
||||||
|
*,
|
||||||
|
autostart: bool = False,
|
||||||
|
restart_on_exception: bool = True,
|
||||||
|
restart_sec: float = 1.0,
|
||||||
|
start_limit_interval_sec: float | None = None,
|
||||||
|
start_limit_burst: int = 3,
|
||||||
|
exit_on_failure: bool = False,
|
||||||
|
) -> Callable[
|
||||||
|
[
|
||||||
|
Callable[[Any], Coroutine[None, None, R]]
|
||||||
|
| Callable[[], Coroutine[None, None, R]]
|
||||||
|
],
|
||||||
|
PerInstanceTaskDescriptor[R],
|
||||||
|
]:
|
||||||
|
"""
|
||||||
|
A decorator to define an asynchronous function as a per-instance task within a
|
||||||
|
[`DataService`][pydase.DataService] class.
|
||||||
|
|
||||||
|
This decorator transforms an asynchronous function into a
|
||||||
|
[`Task`][pydase.task.task.Task] object that is unique to each instance of the
|
||||||
|
`DataService` class. The resulting `Task` object provides methods like `start()`
|
||||||
|
and `stop()` to control the execution of the task, and manages the task's lifecycle
|
||||||
|
independently for each instance of the service.
|
||||||
|
|
||||||
|
The decorator is particularly useful for defining tasks that need to run
|
||||||
|
periodically or perform asynchronous operations, such as polling data sources,
|
||||||
|
updating databases, or any recurring job that should be managed within the context
|
||||||
|
of a `DataService`.
|
||||||
|
|
||||||
|
The keyword arguments that can be passed to this decorator are inspired by systemd
|
||||||
|
unit services.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
autostart:
|
||||||
|
If set to True, the task will automatically start when the service is
|
||||||
|
initialized. Defaults to False.
|
||||||
|
restart_on_exception:
|
||||||
|
Configures whether the task shall be restarted when it exits with an
|
||||||
|
exception other than [`asyncio.CancelledError`][asyncio.CancelledError].
|
||||||
|
restart_sec:
|
||||||
|
Configures the time to sleep before restarting a task. Defaults to 1.0.
|
||||||
|
start_limit_interval_sec:
|
||||||
|
Configures start rate limiting. Tasks which are started more than
|
||||||
|
`start_limit_burst` times within an `start_limit_interval_sec` time span are
|
||||||
|
not permitted to start any more. Defaults to None (disabled rate limiting).
|
||||||
|
start_limit_burst:
|
||||||
|
Configures unit start rate limiting. Tasks which are started more than
|
||||||
|
`start_limit_burst` times within an `start_limit_interval_sec` time span are
|
||||||
|
not permitted to start any more. Defaults to 3.
|
||||||
|
exit_on_failure:
|
||||||
|
If True, exit the service if the task fails and restart_on_exception is
|
||||||
|
False or burst limits are exceeded.
|
||||||
|
Returns:
|
||||||
|
A decorator that wraps an asynchronous function in a
|
||||||
|
[`PerInstanceTaskDescriptor`][pydase.task.decorator.PerInstanceTaskDescriptor]
|
||||||
|
object, which, when accessed, provides an instance-specific
|
||||||
|
[`Task`][pydase.task.task.Task] object.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pydase
|
||||||
|
from pydase.task.decorator import task
|
||||||
|
|
||||||
|
|
||||||
|
class MyService(pydase.DataService):
|
||||||
|
@task(autostart=True)
|
||||||
|
async def my_task(self) -> None:
|
||||||
|
while True:
|
||||||
|
# Perform some periodic work
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
service = MyService()
|
||||||
|
pydase.Server(service=service).run()
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, `my_task` is defined as a task using the `@task` decorator, and
|
||||||
|
it will start automatically when the service is initialized because
|
||||||
|
`autostart=True` is set. You can manually start or stop the task using
|
||||||
|
`service.my_task.start()` and `service.my_task.stop()`, respectively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(
|
||||||
|
func: Callable[[Any], Coroutine[None, None, R]]
|
||||||
|
| Callable[[], Coroutine[None, None, R]],
|
||||||
|
) -> PerInstanceTaskDescriptor[R]:
|
||||||
|
return PerInstanceTaskDescriptor(
|
||||||
|
func,
|
||||||
|
autostart=autostart,
|
||||||
|
restart_on_exception=restart_on_exception,
|
||||||
|
restart_sec=restart_sec,
|
||||||
|
start_limit_interval_sec=start_limit_interval_sec,
|
||||||
|
start_limit_burst=start_limit_burst,
|
||||||
|
exit_on_failure=exit_on_failure,
|
||||||
|
)
|
||||||
|
|
||||||
|
return decorator
|
||||||
237
src/pydase/task/task.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from datetime import datetime
|
||||||
|
from time import time
|
||||||
|
from typing import (
|
||||||
|
Generic,
|
||||||
|
TypeVar,
|
||||||
|
)
|
||||||
|
|
||||||
|
import pydase.data_service.data_service
|
||||||
|
from pydase.task.task_status import TaskStatus
|
||||||
|
from pydase.utils.helpers import current_event_loop_exists
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
class Task(pydase.data_service.data_service.DataService, Generic[R]):
|
||||||
|
"""A class representing a task within the `pydase` framework.
|
||||||
|
|
||||||
|
The `Task` class wraps an asynchronous function and provides methods to manage its
|
||||||
|
lifecycle, such as `start()` and `stop()`. It is typically used to perform periodic
|
||||||
|
or recurring jobs in a [`DataService`][pydase.DataService], like reading
|
||||||
|
sensor data, updating databases, or executing other background tasks.
|
||||||
|
|
||||||
|
When a function is decorated with the [`@task`][pydase.task.decorator.task]
|
||||||
|
decorator, it is replaced by a `Task` instance that controls the execution of the
|
||||||
|
original function.
|
||||||
|
|
||||||
|
The keyword arguments that can be passed to this class are inspired by systemd unit
|
||||||
|
services.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func:
|
||||||
|
The asynchronous function that this task wraps. It must be a coroutine
|
||||||
|
without arguments.
|
||||||
|
autostart:
|
||||||
|
If set to True, the task will automatically start when the service is
|
||||||
|
initialized. Defaults to False.
|
||||||
|
restart_on_exception:
|
||||||
|
Configures whether the task shall be restarted when it exits with an
|
||||||
|
exception other than [`asyncio.CancelledError`][asyncio.CancelledError].
|
||||||
|
restart_sec:
|
||||||
|
Configures the time to sleep before restarting a task. Defaults to 1.0.
|
||||||
|
start_limit_interval_sec:
|
||||||
|
Configures start rate limiting. Tasks which are started more than
|
||||||
|
`start_limit_burst` times within an `start_limit_interval_sec` time span are
|
||||||
|
not permitted to start any more. Defaults to None (disabled rate limiting).
|
||||||
|
start_limit_burst:
|
||||||
|
Configures unit start rate limiting. Tasks which are started more than
|
||||||
|
`start_limit_burst` times within an `start_limit_interval_sec` time span are
|
||||||
|
not permitted to start any more. Defaults to 3.
|
||||||
|
exit_on_failure:
|
||||||
|
If True, exit the service if the task fails and restart_on_exception is
|
||||||
|
False or burst limits are exceeded.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pydase
|
||||||
|
from pydase.task.decorator import task
|
||||||
|
|
||||||
|
|
||||||
|
class MyService(pydase.DataService):
|
||||||
|
@task(autostart=True)
|
||||||
|
async def my_task(self) -> None:
|
||||||
|
while True:
|
||||||
|
# Perform some periodic work
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
service = MyService()
|
||||||
|
pydase.Server(service=service).run()
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, `my_task` is defined as a task using the `@task` decorator, and
|
||||||
|
it will start automatically when the service is initialized because
|
||||||
|
`autostart=True` is set. You can manually start or stop the task using
|
||||||
|
`service.my_task.start()` and `service.my_task.stop()`, respectively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__( # noqa: PLR0913
|
||||||
|
self,
|
||||||
|
func: Callable[[], Coroutine[None, None, R | None]],
|
||||||
|
*,
|
||||||
|
autostart: bool,
|
||||||
|
restart_on_exception: bool,
|
||||||
|
restart_sec: float,
|
||||||
|
start_limit_interval_sec: float | None,
|
||||||
|
start_limit_burst: int,
|
||||||
|
exit_on_failure: bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._autostart = autostart
|
||||||
|
self._restart_on_exception = restart_on_exception
|
||||||
|
self._restart_sec = restart_sec
|
||||||
|
self._start_limit_interval_sec = start_limit_interval_sec
|
||||||
|
self._start_limit_burst = start_limit_burst
|
||||||
|
self._exit_on_failure = exit_on_failure
|
||||||
|
self._func_name = func.__name__
|
||||||
|
self._func = func
|
||||||
|
self._task: asyncio.Task[R | None] | None = None
|
||||||
|
self._status = TaskStatus.NOT_RUNNING
|
||||||
|
self._result: R | None = None
|
||||||
|
|
||||||
|
if not current_event_loop_exists():
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
else:
|
||||||
|
self._loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def autostart(self) -> bool:
|
||||||
|
"""Defines if the task should be started automatically when the
|
||||||
|
[`Server`][pydase.Server] starts."""
|
||||||
|
return self._autostart
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> TaskStatus:
|
||||||
|
"""Returns the current status of the task."""
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Starts the asynchronous task if it is not already running."""
|
||||||
|
if self._task:
|
||||||
|
return
|
||||||
|
|
||||||
|
def task_done_callback(task: asyncio.Task[R | None]) -> None:
|
||||||
|
"""Handles tasks that have finished.
|
||||||
|
|
||||||
|
Updates the task status, calls the defined callbacks, and logs and re-raises
|
||||||
|
exceptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._task = None
|
||||||
|
self._status = TaskStatus.NOT_RUNNING
|
||||||
|
|
||||||
|
exception = None
|
||||||
|
try:
|
||||||
|
exception = task.exception()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if exception is not None:
|
||||||
|
logger.error(
|
||||||
|
"Task '%s' encountered an exception: %r",
|
||||||
|
self._func_name,
|
||||||
|
exception,
|
||||||
|
)
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
else:
|
||||||
|
self._result = task.result()
|
||||||
|
|
||||||
|
logger.info("Creating task %r", self._func_name)
|
||||||
|
self._task = self._loop.create_task(self.__running_task_loop())
|
||||||
|
self._task.add_done_callback(task_done_callback)
|
||||||
|
|
||||||
|
async def __running_task_loop(self) -> R | None:
|
||||||
|
logger.info("Starting task %r", self._func_name)
|
||||||
|
self._status = TaskStatus.RUNNING
|
||||||
|
attempts = 0
|
||||||
|
start_time_of_start_limit_interval = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return await self._func()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Task '%s' was cancelled", self._func_name)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
attempts, start_time_of_start_limit_interval = (
|
||||||
|
self._handle_task_exception(
|
||||||
|
e, attempts, start_time_of_start_limit_interval
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not self._should_restart_task(
|
||||||
|
attempts, start_time_of_start_limit_interval
|
||||||
|
):
|
||||||
|
if self._exit_on_failure:
|
||||||
|
raise e
|
||||||
|
break
|
||||||
|
await asyncio.sleep(self._restart_sec)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_task_exception(
|
||||||
|
self,
|
||||||
|
exception: Exception,
|
||||||
|
attempts: int,
|
||||||
|
start_time_of_start_limit_interval: float | None,
|
||||||
|
) -> tuple[int, float]:
|
||||||
|
"""Handle an exception raised during task execution."""
|
||||||
|
if start_time_of_start_limit_interval is None:
|
||||||
|
start_time_of_start_limit_interval = time()
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
logger.exception(
|
||||||
|
"Task %r encountered an exception: %r [attempt %s since %s].",
|
||||||
|
self._func.__name__,
|
||||||
|
exception,
|
||||||
|
attempts,
|
||||||
|
datetime.fromtimestamp(start_time_of_start_limit_interval),
|
||||||
|
)
|
||||||
|
return attempts, start_time_of_start_limit_interval
|
||||||
|
|
||||||
|
def _should_restart_task(
|
||||||
|
self, attempts: int, start_time_of_start_limit_interval: float
|
||||||
|
) -> bool:
|
||||||
|
"""Determine if the task should be restarted."""
|
||||||
|
if not self._restart_on_exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._start_limit_interval_sec is not None:
|
||||||
|
if (
|
||||||
|
time() - start_time_of_start_limit_interval
|
||||||
|
) > self._start_limit_interval_sec:
|
||||||
|
# Reset attempts if interval is exceeded
|
||||||
|
start_time_of_start_limit_interval = time()
|
||||||
|
attempts = 1
|
||||||
|
elif attempts > self._start_limit_burst:
|
||||||
|
logger.error(
|
||||||
|
"Task %r exceeded restart burst limit. Stopping.",
|
||||||
|
self._func.__name__,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stops the running asynchronous task by cancelling it."""
|
||||||
|
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
8
src/pydase/task/task_status.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(enum.Enum):
|
||||||
|
"""Possible statuses of a [`Task`][pydase.task.task.Task]."""
|
||||||
|
|
||||||
|
RUNNING = "running"
|
||||||
|
NOT_RUNNING = "not_running"
|
||||||
@@ -3,7 +3,7 @@ from typing import TypedDict
|
|||||||
import pint
|
import pint
|
||||||
|
|
||||||
units: pint.UnitRegistry = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
|
units: pint.UnitRegistry = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
|
||||||
units.default_format = "~P" # pretty and short format
|
units.formatter.default_format = "~P" # pretty and short format
|
||||||
|
|
||||||
Quantity = pint.Quantity
|
Quantity = pint.Quantity
|
||||||
Unit = units.Unit
|
Unit = units.Unit
|
||||||
@@ -21,18 +21,20 @@ def convert_to_quantity(
|
|||||||
Convert a given value into a pint.Quantity object with the specified unit.
|
Convert a given value into a pint.Quantity object with the specified unit.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value (QuantityDict | float | int | Quantity):
|
value:
|
||||||
The value to be converted into a Quantity object.
|
The value to be converted into a Quantity object.
|
||||||
|
|
||||||
- If value is a float or int, it will be directly converted to the specified
|
- If value is a float or int, it will be directly converted to the specified
|
||||||
unit.
|
unit.
|
||||||
- If value is a dict, it must have keys 'magnitude' and 'unit' to represent
|
- If value is a dict, it must have keys 'magnitude' and 'unit' to represent
|
||||||
the value and unit.
|
the value and unit.
|
||||||
- If value is a Quantity object, it will remain unchanged.\n
|
- If value is a Quantity object, it will remain unchanged.\n
|
||||||
unit (str, optional): The target unit for conversion. If empty and value is not
|
unit:
|
||||||
a Quantity object, it will assume a unitless quantity.
|
The target unit for conversion. If empty and value is not a Quantity object,
|
||||||
|
it will assume a unitless quantity.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Quantity: The converted value as a pint.Quantity object with the specified unit.
|
The converted value as a pint.Quantity object with the specified unit.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> convert_to_quantity(5, 'm')
|
>>> convert_to_quantity(5, 'm')
|
||||||
@@ -42,9 +44,9 @@ def convert_to_quantity(
|
|||||||
>>> convert_to_quantity(10.0 * u.units.V)
|
>>> convert_to_quantity(10.0 * u.units.V)
|
||||||
<Quantity(10.0, 'volt')>
|
<Quantity(10.0, 'volt')>
|
||||||
|
|
||||||
Notes:
|
Note:
|
||||||
- If unit is not provided and value is a float or int, the resulting Quantity
|
If unit is not provided and value is a float or int, the resulting Quantity will
|
||||||
will be unitless.
|
be unitless.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if isinstance(value, int | float):
|
if isinstance(value, int | float):
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ class FunctionDefinitionError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
|
def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
"""
|
"""Decorator to mark a [`DataService`][pydase.DataService] method for frontend
|
||||||
Decorator to mark a DataService method for frontend rendering. Ensures that the
|
rendering. Ensures that the method does not contain arguments, as they are not
|
||||||
method does not contain arguments, as they are not supported for frontend rendering.
|
supported for frontend rendering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if function_has_arguments(func):
|
if function_has_arguments(func):
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ def parse_full_access_path(path: str) -> list[str]:
|
|||||||
list[str]
|
list[str]
|
||||||
A list of components that make up the path, including attribute names,
|
A list of components that make up the path, including attribute names,
|
||||||
numeric indices, and string keys as separate elements.
|
numeric indices, and string keys as separate elements.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> parse_full_access_path('dict_attr["some_key"].attr_name["other_key"]')
|
||||||
|
["dict_attr", '["some_key"]', "attr_name", '["other_key"]']
|
||||||
"""
|
"""
|
||||||
# Matches:
|
# Matches:
|
||||||
# \w+ - Words
|
# \w+ - Words
|
||||||
@@ -110,24 +114,26 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
|
|||||||
|
|
||||||
If an attribute exists at both the instance and class level,the value from the
|
If an attribute exists at both the instance and class level,the value from the
|
||||||
instance attribute takes precedence.
|
instance attribute takes precedence.
|
||||||
The __root__ object is removed as this will lead to endless recursion in the for
|
|
||||||
loops.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
|
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
|
||||||
|
|
||||||
|
|
||||||
def get_object_by_path_parts(target_obj: Any, path_parts: list[str]) -> Any:
|
def get_object_by_path_parts(target_obj: Any, path_parts: list[str]) -> Any:
|
||||||
|
"""Gets nested attribute of `target_object` specified by `path_parts`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AttributeError: Attribute does not exist.
|
||||||
|
KeyError: Key in dict does not exist.
|
||||||
|
IndexError: Index out of list range.
|
||||||
|
TypeError: List index in the path is not a valid integer.
|
||||||
|
"""
|
||||||
for part in path_parts:
|
for part in path_parts:
|
||||||
if part.startswith("["):
|
if part.startswith("["):
|
||||||
deserialized_part = parse_serialized_key(part)
|
deserialized_part = parse_serialized_key(part)
|
||||||
target_obj = target_obj[deserialized_part]
|
target_obj = target_obj[deserialized_part]
|
||||||
else:
|
else:
|
||||||
try:
|
target_obj = getattr(target_obj, part)
|
||||||
target_obj = getattr(target_obj, part)
|
|
||||||
except AttributeError:
|
|
||||||
logger.debug("Attribute %a does not exist in the object.", part)
|
|
||||||
return None
|
|
||||||
return target_obj
|
return target_obj
|
||||||
|
|
||||||
|
|
||||||
@@ -145,12 +151,21 @@ def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
|
|||||||
the path does not exist, the function logs a debug message and returns None.
|
the path does not exist, the function logs a debug message and returns None.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If a list index in the path is not a valid integer.
|
AttributeError: Attribute does not exist.
|
||||||
|
KeyError: Key in dict does not exist.
|
||||||
|
IndexError: Index out of list range.
|
||||||
|
TypeError: List index in the path is not a valid integer.
|
||||||
"""
|
"""
|
||||||
path_parts = parse_full_access_path(path)
|
path_parts = parse_full_access_path(path)
|
||||||
return get_object_by_path_parts(target_obj, path_parts)
|
return get_object_by_path_parts(target_obj, path_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_class() -> type:
|
||||||
|
from pydase.task.task import Task
|
||||||
|
|
||||||
|
return Task
|
||||||
|
|
||||||
|
|
||||||
def get_component_classes() -> list[type]:
|
def get_component_classes() -> list[type]:
|
||||||
"""
|
"""
|
||||||
Returns references to the component classes in a list.
|
Returns references to the component classes in a list.
|
||||||
@@ -185,3 +200,26 @@ def function_has_arguments(func: Callable[..., Any]) -> bool:
|
|||||||
|
|
||||||
# Check if there are any parameters left which would indicate additional arguments.
|
# Check if there are any parameters left which would indicate additional arguments.
|
||||||
return len(parameters) > 0
|
return len(parameters) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_descriptor(obj: object) -> bool:
|
||||||
|
"""Check if an object is a descriptor."""
|
||||||
|
|
||||||
|
# Exclude functions, methods, builtins and properties
|
||||||
|
if (
|
||||||
|
inspect.isfunction(obj)
|
||||||
|
or inspect.ismethod(obj)
|
||||||
|
or inspect.isbuiltin(obj)
|
||||||
|
or isinstance(obj, property)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if it has any descriptor methods
|
||||||
|
return any(hasattr(obj, method) for method in ("__get__", "__set__", "__delete__"))
|
||||||
|
|
||||||
|
|
||||||
|
def current_event_loop_exists() -> bool:
|
||||||
|
"""Check if an event loop has been set."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
return asyncio.get_event_loop_policy()._local._loop is not None # type: ignore
|
||||||
|
|||||||
@@ -1,16 +1,97 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import logging.config
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
from copy import copy
|
from copy import copy
|
||||||
|
from typing import ClassVar, Literal, TextIO
|
||||||
|
|
||||||
|
import click
|
||||||
import socketio # type: ignore[import-untyped]
|
import socketio # type: ignore[import-untyped]
|
||||||
import uvicorn.logging
|
|
||||||
from uvicorn.config import LOGGING_CONFIG
|
|
||||||
|
|
||||||
import pydase.config
|
import pydase.config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
|
if pydase.config.OperationMode().environment == "development":
|
||||||
|
LOG_LEVEL = logging.DEBUG
|
||||||
|
else:
|
||||||
|
LOG_LEVEL = logging.INFO
|
||||||
|
|
||||||
|
LOGGING_CONFIG = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"()": "pydase.utils.logging.DefaultFormatter",
|
||||||
|
"fmt": "%(asctime)s.%(msecs)03d | %(levelprefix)s | "
|
||||||
|
"%(name)s:%(funcName)s:%(lineno)d - %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"only_pydase_server": {
|
||||||
|
"()": "pydase.utils.logging.NameFilter",
|
||||||
|
"match": "pydase.server",
|
||||||
|
},
|
||||||
|
"exclude_pydase_server": {
|
||||||
|
"()": "pydase.utils.logging.NameFilter",
|
||||||
|
"match": "pydase.server",
|
||||||
|
"invert": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"stdout_handler": {
|
||||||
|
"formatter": "default",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
"filters": ["only_pydase_server"],
|
||||||
|
},
|
||||||
|
"stderr_handler": {
|
||||||
|
"formatter": "default",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"stream": "ext://sys.stderr",
|
||||||
|
"filters": ["exclude_pydase_server"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"pydase": {
|
||||||
|
"handlers": ["stdout_handler", "stderr_handler"],
|
||||||
|
"level": LOG_LEVEL,
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"aiohttp_middlewares": {
|
||||||
|
"handlers": ["stderr_handler"],
|
||||||
|
"level": logging.WARNING,
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"aiohttp": {
|
||||||
|
"handlers": ["stderr_handler"],
|
||||||
|
"level": logging.INFO,
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NameFilter(logging.Filter):
|
||||||
|
"""
|
||||||
|
Logging filter that allows filtering logs based on the logger name.
|
||||||
|
Can either include or exclude a specific logger.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, match: str, invert: bool = False):
|
||||||
|
super().__init__()
|
||||||
|
self.match = match
|
||||||
|
self.invert = invert
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
if self.invert:
|
||||||
|
return not record.name.startswith(self.match)
|
||||||
|
return record.name.startswith(self.match)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultFormatter(logging.Formatter):
|
||||||
"""
|
"""
|
||||||
A custom log formatter class that:
|
A custom log formatter class that:
|
||||||
|
|
||||||
@@ -19,6 +100,36 @@ class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
|
|||||||
for formatting the output, instead of the plain text message.
|
for formatting the output, instead of the plain text message.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
level_name_colors: ClassVar[dict[int, Callable[..., str]]] = {
|
||||||
|
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
|
||||||
|
logging.INFO: lambda level_name: click.style(str(level_name), fg="green"),
|
||||||
|
logging.WARNING: lambda level_name: click.style(str(level_name), fg="yellow"),
|
||||||
|
logging.ERROR: lambda level_name: click.style(str(level_name), fg="red"),
|
||||||
|
logging.CRITICAL: lambda level_name: click.style(
|
||||||
|
str(level_name), fg="bright_red"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
fmt: str | None = None,
|
||||||
|
datefmt: str | None = None,
|
||||||
|
style: Literal["%", "{", "$"] = "%",
|
||||||
|
use_colors: bool | None = None,
|
||||||
|
):
|
||||||
|
if use_colors in (True, False):
|
||||||
|
self.use_colors = use_colors
|
||||||
|
else:
|
||||||
|
self.use_colors = sys.stdout.isatty()
|
||||||
|
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
|
||||||
|
|
||||||
|
def color_level_name(self, level_name: str, level_no: int) -> str:
|
||||||
|
def default(level_name: str) -> str:
|
||||||
|
return str(level_name)
|
||||||
|
|
||||||
|
func = self.level_name_colors.get(level_no, default)
|
||||||
|
return func(level_name)
|
||||||
|
|
||||||
def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802
|
def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802
|
||||||
recordcopy = copy(record)
|
recordcopy = copy(record)
|
||||||
levelname = recordcopy.levelname
|
levelname = recordcopy.levelname
|
||||||
@@ -47,7 +158,8 @@ class SocketIOHandler(logging.Handler):
|
|||||||
self._sio = sio
|
self._sio = sio
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
return f"{record.name}:{record.funcName}:{record.lineno} - {record.message}"
|
msg = record.getMessage()
|
||||||
|
return f"{record.name}:{record.funcName}:{record.lineno} - {msg}"
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
log_entry = self.format(record)
|
log_entry = self.format(record)
|
||||||
@@ -64,86 +176,64 @@ class SocketIOHandler(logging.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(level: str | int | None = None) -> None:
|
def setup_logging() -> None:
|
||||||
"""
|
"""
|
||||||
Configures the logging settings for the application.
|
Configures the logging settings for the application.
|
||||||
|
|
||||||
This function sets up logging with specific formatting and colorization of log
|
This function sets up logging with specific formatting and colorization of log
|
||||||
messages. The log level is determined based on the application's operation mode,
|
messages. The log level is determined based on the application's operation mode. By
|
||||||
with an option to override the level. By default, in a development environment, the
|
default, in a development environment, the log level is set to DEBUG, whereas in
|
||||||
log level is set to DEBUG, whereas in other environments, it is set to INFO.
|
other environments, it is set to INFO.
|
||||||
|
|
||||||
Args:
|
|
||||||
level (Optional[str | int]):
|
|
||||||
A specific log level to set for the application. If None, the log level is
|
|
||||||
determined based on the application's operation mode. Accepts standard log
|
|
||||||
level names ('DEBUG', 'INFO', etc.) and corresponding numerical values.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
>>> import logging
|
|
||||||
>>> setup_logging(logging.DEBUG)
|
|
||||||
>>> setup_logging("INFO")
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger.debug("Configuring pydase logging.")
|
||||||
|
|
||||||
if pydase.config.OperationMode().environment == "development":
|
logging.config.dictConfig(LOGGING_CONFIG)
|
||||||
log_level = logging.DEBUG
|
|
||||||
else:
|
|
||||||
log_level = logging.INFO
|
|
||||||
|
|
||||||
# If a level is specified, check whether it's a string or an integer.
|
|
||||||
if level is not None:
|
|
||||||
if isinstance(level, str):
|
|
||||||
# Convert known log level strings directly to their corresponding logging
|
|
||||||
# module constants.
|
|
||||||
level_name = level.upper() # Ensure level names are uppercase
|
|
||||||
if hasattr(logging, level_name):
|
|
||||||
log_level = getattr(logging, level_name)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"Invalid log level: {level}. Must be one of 'DEBUG', 'INFO', "
|
|
||||||
"'WARNING', 'ERROR', etc."
|
|
||||||
)
|
|
||||||
elif isinstance(level, int):
|
|
||||||
log_level = level # Directly use integer levels
|
|
||||||
else:
|
|
||||||
raise ValueError("Log level must be a string or an integer.")
|
|
||||||
|
|
||||||
# Set the logger's level.
|
def configure_logging_with_pydase_formatter(
|
||||||
logger.setLevel(log_level)
|
name: str | None = None, level: int = logging.INFO, stream: TextIO | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Configure a logger with the pydase `DefaultFormatter`.
|
||||||
|
|
||||||
# create console handler and set level to debug
|
This sets up a `StreamHandler` with the custom `DefaultFormatter`, which includes
|
||||||
ch = logging.StreamHandler()
|
timestamp, log level with color (if supported), logger name, function, and line
|
||||||
|
number. It can be used to configure the root logger or any named logger.
|
||||||
|
|
||||||
# add formatter to ch
|
Args:
|
||||||
ch.setFormatter(
|
name: The name of the logger to configure. If None, the root logger is used.
|
||||||
DefaultFormatter(
|
level: The logging level to set on the logger (e.g., logging.DEBUG,
|
||||||
fmt=(
|
logging.INFO). Defaults to logging.INFO.
|
||||||
"%(asctime)s.%(msecs)03d | %(levelprefix)s | "
|
stream: The output stream for the log messages (e.g., sys.stdout or sys.stderr).
|
||||||
"%(name)s:%(funcName)s:%(lineno)d - %(message)s"
|
If None, defaults to sys.stderr.
|
||||||
),
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
Example:
|
||||||
|
Configure logging in your service:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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.stdout # Set the output stream (stderr by default)
|
||||||
)
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This function adds a new handler each time it's called.
|
||||||
|
Use carefully to avoid duplicate logs.
|
||||||
|
- Colors are enabled if the stream supports TTY (e.g., in terminal).
|
||||||
|
""" # noqa: E501
|
||||||
|
|
||||||
|
logger = logging.getLogger(name=name)
|
||||||
|
handler = logging.StreamHandler(stream=stream)
|
||||||
|
formatter = DefaultFormatter(
|
||||||
|
fmt="%(asctime)s.%(msecs)03d | %(levelprefix)s | "
|
||||||
|
"%(name)s:%(funcName)s:%(lineno)d - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
# add ch to logger
|
logger.addHandler(handler)
|
||||||
logger.addHandler(ch)
|
logger.setLevel(level)
|
||||||
|
|
||||||
logger.debug("Configuring service logging.")
|
|
||||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
|
||||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
|
||||||
|
|
||||||
# configuring uvicorn logger
|
|
||||||
LOGGING_CONFIG["formatters"]["default"][
|
|
||||||
"fmt"
|
|
||||||
] = "%(asctime)s.%(msecs)03d | %(levelprefix)s %(message)s"
|
|
||||||
LOGGING_CONFIG["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
|
|
||||||
LOGGING_CONFIG["formatters"]["access"]["fmt"] = (
|
|
||||||
"%(asctime)s.%(msecs)03d | %(levelprefix)s %(client_addr)s "
|
|
||||||
'- "%(request_line)s" %(status_code)s'
|
|
||||||
)
|
|
||||||
LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
|
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any, NoReturn, cast
|
from typing import TYPE_CHECKING, Any, NoReturn, cast
|
||||||
|
|
||||||
import pydase
|
import pydase
|
||||||
import pydase.components
|
import pydase.components
|
||||||
import pydase.units as u
|
import pydase.units as u
|
||||||
from pydase.utils.helpers import get_component_classes
|
from pydase.utils.helpers import (
|
||||||
from pydase.utils.serialization.types import SerializedObject
|
get_component_classes,
|
||||||
|
)
|
||||||
|
from pydase.utils.serialization.types import (
|
||||||
|
SerializedDatetime,
|
||||||
|
SerializedException,
|
||||||
|
SerializedObject,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Deserializer:
|
class Deserializer:
|
||||||
@classmethod
|
@classmethod
|
||||||
def deserialize(cls, serialized_object: SerializedObject) -> Any:
|
def deserialize(cls, serialized_object: SerializedObject) -> Any:
|
||||||
|
"""Deserialize `serialized_object` (a `dict`) to a Python object."""
|
||||||
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
||||||
None: None,
|
None: None,
|
||||||
"int": cls.deserialize_primitive,
|
"int": cls.deserialize_primitive,
|
||||||
@@ -33,6 +42,7 @@ class Deserializer:
|
|||||||
"dict": cls.deserialize_dict,
|
"dict": cls.deserialize_dict,
|
||||||
"method": cls.deserialize_method,
|
"method": cls.deserialize_method,
|
||||||
"Exception": cls.deserialize_exception,
|
"Exception": cls.deserialize_exception,
|
||||||
|
"datetime": cls.deserialize_datetime,
|
||||||
}
|
}
|
||||||
|
|
||||||
# First go through handled types (as ColouredEnum is also within the components)
|
# First go through handled types (as ColouredEnum is also within the components)
|
||||||
@@ -41,9 +51,9 @@ class Deserializer:
|
|||||||
return handler(serialized_object)
|
return handler(serialized_object)
|
||||||
|
|
||||||
# Custom types like Components or DataService classes
|
# Custom types like Components or DataService classes
|
||||||
component_class = cls.get_component_class(serialized_object["type"])
|
service_base_class = cls.get_service_base_class(serialized_object["type"])
|
||||||
if component_class:
|
if service_base_class:
|
||||||
return cls.deserialize_component_type(serialized_object, component_class)
|
return cls.deserialize_data_service(serialized_object, service_base_class)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -57,6 +67,10 @@ class Deserializer:
|
|||||||
def deserialize_quantity(cls, serialized_object: SerializedObject) -> Any:
|
def deserialize_quantity(cls, serialized_object: SerializedObject) -> Any:
|
||||||
return u.convert_to_quantity(serialized_object["value"]) # type: ignore
|
return u.convert_to_quantity(serialized_object["value"]) # type: ignore
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deserialize_datetime(cls, serialized_object: SerializedDatetime) -> datetime:
|
||||||
|
return datetime.fromisoformat(serialized_object["value"])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def deserialize_enum(
|
def deserialize_enum(
|
||||||
cls,
|
cls,
|
||||||
@@ -88,27 +102,27 @@ class Deserializer:
|
|||||||
return
|
return
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def deserialize_exception(cls, serialized_object: SerializedObject) -> NoReturn:
|
def deserialize_exception(cls, serialized_object: SerializedException) -> NoReturn:
|
||||||
import builtins
|
import builtins
|
||||||
|
|
||||||
try:
|
try:
|
||||||
exception = getattr(builtins, serialized_object["name"]) # type: ignore
|
exception = getattr(builtins, serialized_object["name"])
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
exception = type(serialized_object["name"], (Exception,), {}) # type: ignore
|
exception = type(serialized_object["name"], (Exception,), {}) # type: ignore
|
||||||
raise exception(serialized_object["value"])
|
raise exception(serialized_object["value"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_component_class(type_name: str | None) -> type | None:
|
def get_service_base_class(type_name: str | None) -> type | None:
|
||||||
for component_class in get_component_classes():
|
for component_class in get_component_classes():
|
||||||
if type_name == component_class.__name__:
|
if type_name == component_class.__name__:
|
||||||
return component_class
|
return component_class
|
||||||
if type_name == "DataService":
|
if type_name in ("DataService", "Task"):
|
||||||
import pydase
|
import pydase
|
||||||
|
|
||||||
return pydase.DataService
|
return pydase.DataService
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod # TODO: this shouldn't be a class method
|
||||||
def create_attr_property(cls, serialized_attr: SerializedObject) -> property:
|
def create_attr_property(cls, serialized_attr: SerializedObject) -> property:
|
||||||
attr_name = serialized_attr["full_access_path"].split(".")[-1]
|
attr_name = serialized_attr["full_access_path"].split(".")[-1]
|
||||||
|
|
||||||
@@ -125,7 +139,7 @@ class Deserializer:
|
|||||||
return property(get, set)
|
return property(get, set)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def deserialize_component_type(
|
def deserialize_data_service(
|
||||||
cls, serialized_object: SerializedObject, base_class: type
|
cls, serialized_object: SerializedObject, base_class: type
|
||||||
) -> Any:
|
) -> Any:
|
||||||
def create_proxy_class(serialized_object: SerializedObject) -> type:
|
def create_proxy_class(serialized_object: SerializedObject) -> type:
|
||||||
@@ -148,4 +162,5 @@ class Deserializer:
|
|||||||
|
|
||||||
|
|
||||||
def loads(serialized_object: SerializedObject) -> Any:
|
def loads(serialized_object: SerializedObject) -> Any:
|
||||||
|
"""Deserialize `serialized_object` (a `dict`) to a Python object."""
|
||||||
return Deserializer.deserialize(serialized_object)
|
return Deserializer.deserialize(serialized_object)
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ from __future__ import annotations
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
|
|
||||||
import pydase.units as u
|
import pydase.units as u
|
||||||
from pydase.data_service.abstract_data_service import AbstractDataService
|
from pydase.data_service.abstract_data_service import AbstractDataService
|
||||||
from pydase.data_service.task_manager import TaskStatus
|
from pydase.task.task_status import TaskStatus
|
||||||
from pydase.utils.decorators import render_in_frontend
|
from pydase.utils.decorators import render_in_frontend
|
||||||
from pydase.utils.helpers import (
|
from pydase.utils.helpers import (
|
||||||
get_attribute_doc,
|
get_attribute_doc,
|
||||||
get_component_classes,
|
get_component_classes,
|
||||||
get_data_service_class_reference,
|
get_data_service_class_reference,
|
||||||
|
get_task_class,
|
||||||
|
is_property_attribute,
|
||||||
parse_full_access_path,
|
parse_full_access_path,
|
||||||
parse_serialized_key,
|
parse_serialized_key,
|
||||||
)
|
)
|
||||||
@@ -21,6 +24,7 @@ from pydase.utils.serialization.types import (
|
|||||||
DataServiceTypes,
|
DataServiceTypes,
|
||||||
SerializedBool,
|
SerializedBool,
|
||||||
SerializedDataService,
|
SerializedDataService,
|
||||||
|
SerializedDatetime,
|
||||||
SerializedDict,
|
SerializedDict,
|
||||||
SerializedEnum,
|
SerializedEnum,
|
||||||
SerializedException,
|
SerializedException,
|
||||||
@@ -38,6 +42,8 @@ from pydase.utils.serialization.types import (
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from pydase.client.proxy_class import ProxyClass
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -49,41 +55,63 @@ class SerializationPathError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SerializationValueError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Serializer:
|
class Serializer:
|
||||||
@staticmethod
|
"""Serializes objects into
|
||||||
def serialize_object(obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
|
[`SerializedObject`][pydase.utils.serialization.types.SerializedObject]
|
||||||
|
representations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize_object(cls, obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
|
||||||
|
"""Serialize `obj` to a
|
||||||
|
[`SerializedObject`][pydase.utils.serialization.types.SerializedObject].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj:
|
||||||
|
Object to be serialized.
|
||||||
|
access_path:
|
||||||
|
String corresponding to the full access path of the object. This will be
|
||||||
|
prepended to the full_access_path in the SerializedObject entries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary representation of `obj`.
|
||||||
|
"""
|
||||||
|
from pydase.client.client import ProxyClass
|
||||||
|
|
||||||
result: SerializedObject
|
result: SerializedObject
|
||||||
|
|
||||||
if isinstance(obj, Exception):
|
if isinstance(obj, Exception):
|
||||||
result = Serializer._serialize_exception(obj)
|
result = cls._serialize_exception(obj)
|
||||||
|
|
||||||
|
elif isinstance(obj, datetime):
|
||||||
|
result = cls._serialize_datetime(obj, access_path=access_path)
|
||||||
|
|
||||||
|
elif isinstance(obj, ProxyClass):
|
||||||
|
result = cls._serialize_proxy_class(obj, access_path=access_path)
|
||||||
|
|
||||||
elif isinstance(obj, AbstractDataService):
|
elif isinstance(obj, AbstractDataService):
|
||||||
result = Serializer._serialize_data_service(obj, access_path=access_path)
|
result = cls._serialize_data_service(obj, access_path=access_path)
|
||||||
|
|
||||||
elif isinstance(obj, list):
|
elif isinstance(obj, list):
|
||||||
result = Serializer._serialize_list(obj, access_path=access_path)
|
result = cls._serialize_list(obj, access_path=access_path)
|
||||||
|
|
||||||
elif isinstance(obj, dict):
|
elif isinstance(obj, dict):
|
||||||
result = Serializer._serialize_dict(obj, access_path=access_path)
|
result = cls._serialize_dict(obj, access_path=access_path)
|
||||||
|
|
||||||
# Special handling for u.Quantity
|
# Special handling for u.Quantity
|
||||||
elif isinstance(obj, u.Quantity):
|
elif isinstance(obj, u.Quantity):
|
||||||
result = Serializer._serialize_quantity(obj, access_path=access_path)
|
result = cls._serialize_quantity(obj, access_path=access_path)
|
||||||
|
|
||||||
# Handling for Enums
|
# Handling for Enums
|
||||||
elif isinstance(obj, Enum):
|
elif isinstance(obj, Enum):
|
||||||
result = Serializer._serialize_enum(obj, access_path=access_path)
|
result = cls._serialize_enum(obj, access_path=access_path)
|
||||||
|
|
||||||
# Methods and coroutines
|
# Methods and coroutines
|
||||||
elif inspect.isfunction(obj) or inspect.ismethod(obj):
|
elif inspect.isfunction(obj) or inspect.ismethod(obj):
|
||||||
result = Serializer._serialize_method(obj, access_path=access_path)
|
result = cls._serialize_method(obj, access_path=access_path)
|
||||||
|
|
||||||
elif isinstance(obj, int | float | bool | str | None):
|
elif isinstance(obj, int | float | bool | str | None):
|
||||||
result = Serializer._serialize_primitive(obj, access_path=access_path)
|
result = cls._serialize_primitive(obj, access_path=access_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return result
|
return result
|
||||||
@@ -92,8 +120,9 @@ class Serializer:
|
|||||||
f"Could not serialized object of type {type(obj)}."
|
f"Could not serialized object of type {type(obj)}."
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_primitive(
|
def _serialize_primitive(
|
||||||
|
cls,
|
||||||
obj: float | bool | str | None,
|
obj: float | bool | str | None,
|
||||||
access_path: str,
|
access_path: str,
|
||||||
) -> (
|
) -> (
|
||||||
@@ -112,8 +141,18 @@ class Serializer:
|
|||||||
"value": obj,
|
"value": obj,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_exception(obj: Exception) -> SerializedException:
|
def _serialize_datetime(cls, obj: datetime, access_path: str) -> SerializedDatetime:
|
||||||
|
return {
|
||||||
|
"type": "datetime",
|
||||||
|
"value": str(obj),
|
||||||
|
"doc": None,
|
||||||
|
"full_access_path": access_path,
|
||||||
|
"readonly": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_exception(cls, obj: Exception) -> SerializedException:
|
||||||
return {
|
return {
|
||||||
"full_access_path": "",
|
"full_access_path": "",
|
||||||
"doc": None,
|
"doc": None,
|
||||||
@@ -123,8 +162,8 @@ class Serializer:
|
|||||||
"name": obj.__class__.__name__,
|
"name": obj.__class__.__name__,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_enum(obj: Enum, access_path: str = "") -> SerializedEnum:
|
def _serialize_enum(cls, obj: Enum, access_path: str = "") -> SerializedEnum:
|
||||||
import pydase.components.coloured_enum
|
import pydase.components.coloured_enum
|
||||||
|
|
||||||
value = obj.name
|
value = obj.name
|
||||||
@@ -149,9 +188,9 @@ class Serializer:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_quantity(
|
def _serialize_quantity(
|
||||||
obj: u.Quantity, access_path: str = ""
|
cls, obj: u.Quantity, access_path: str = ""
|
||||||
) -> SerializedQuantity:
|
) -> SerializedQuantity:
|
||||||
doc = get_attribute_doc(obj)
|
doc = get_attribute_doc(obj)
|
||||||
value: u.QuantityDict = {"magnitude": obj.m, "unit": str(obj.u)}
|
value: u.QuantityDict = {"magnitude": obj.m, "unit": str(obj.u)}
|
||||||
@@ -163,13 +202,15 @@ class Serializer:
|
|||||||
"doc": doc,
|
"doc": doc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_dict(obj: dict[str, Any], access_path: str = "") -> SerializedDict:
|
def _serialize_dict(
|
||||||
|
cls, obj: dict[str, Any], access_path: str = ""
|
||||||
|
) -> SerializedDict:
|
||||||
readonly = False
|
readonly = False
|
||||||
doc = get_attribute_doc(obj)
|
doc = get_attribute_doc(obj)
|
||||||
value = {}
|
value = {}
|
||||||
for key, val in obj.items():
|
for key, val in obj.items():
|
||||||
value[key] = Serializer.serialize_object(
|
value[key] = cls.serialize_object(
|
||||||
val, access_path=f'{access_path}["{key}"]'
|
val, access_path=f'{access_path}["{key}"]'
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -180,12 +221,12 @@ class Serializer:
|
|||||||
"doc": doc,
|
"doc": doc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_list(obj: list[Any], access_path: str = "") -> SerializedList:
|
def _serialize_list(cls, obj: list[Any], access_path: str = "") -> SerializedList:
|
||||||
readonly = False
|
readonly = False
|
||||||
doc = get_attribute_doc(obj)
|
doc = get_attribute_doc(obj)
|
||||||
value = [
|
value = [
|
||||||
Serializer.serialize_object(o, access_path=f"{access_path}[{i}]")
|
cls.serialize_object(o, access_path=f"{access_path}[{i}]")
|
||||||
for i, o in enumerate(obj)
|
for i, o in enumerate(obj)
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
@@ -196,9 +237,9 @@ class Serializer:
|
|||||||
"doc": doc,
|
"doc": doc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_method(
|
def _serialize_method(
|
||||||
obj: Callable[..., Any], access_path: str = ""
|
cls, obj: Callable[..., Any], access_path: str = ""
|
||||||
) -> SerializedMethod:
|
) -> SerializedMethod:
|
||||||
readonly = True
|
readonly = True
|
||||||
doc = get_attribute_doc(obj)
|
doc = get_attribute_doc(obj)
|
||||||
@@ -231,9 +272,9 @@ class Serializer:
|
|||||||
"frontend_render": frontend_render,
|
"frontend_render": frontend_render,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def _serialize_data_service(
|
def _serialize_data_service(
|
||||||
obj: AbstractDataService, access_path: str = ""
|
cls, obj: AbstractDataService, access_path: str = ""
|
||||||
) -> SerializedDataService:
|
) -> SerializedDataService:
|
||||||
readonly = False
|
readonly = False
|
||||||
doc = get_attribute_doc(obj)
|
doc = get_attribute_doc(obj)
|
||||||
@@ -247,6 +288,10 @@ class Serializer:
|
|||||||
if component_base_cls:
|
if component_base_cls:
|
||||||
obj_type = component_base_cls.__name__ # type: ignore
|
obj_type = component_base_cls.__name__ # type: ignore
|
||||||
|
|
||||||
|
elif isinstance(obj, get_task_class()):
|
||||||
|
# Check if obj is a pydase task
|
||||||
|
obj_type = "Task"
|
||||||
|
|
||||||
# Get the set of DataService class attributes
|
# Get the set of DataService class attributes
|
||||||
data_service_attr_set = set(dir(get_data_service_class_reference()))
|
data_service_attr_set = set(dir(get_data_service_class_reference()))
|
||||||
# Get the set of the object attributes
|
# Get the set of the object attributes
|
||||||
@@ -261,29 +306,15 @@ class Serializer:
|
|||||||
if key.startswith("_"):
|
if key.startswith("_"):
|
||||||
continue # Skip attributes that start with underscore
|
continue # Skip attributes that start with underscore
|
||||||
|
|
||||||
# Skip keys that start with "start_" or "stop_" and end with an async
|
|
||||||
# method name
|
|
||||||
if key.startswith(("start_", "stop_")) and key.split("_", 1)[1] in {
|
|
||||||
name
|
|
||||||
for name, _ in inspect.getmembers(
|
|
||||||
obj, predicate=inspect.iscoroutinefunction
|
|
||||||
)
|
|
||||||
}:
|
|
||||||
continue
|
|
||||||
|
|
||||||
val = getattr(obj, key)
|
val = getattr(obj, key)
|
||||||
|
|
||||||
path = f"{access_path}.{key}" if access_path else key
|
path = f"{access_path}.{key}" if access_path else key
|
||||||
serialized_object = Serializer.serialize_object(val, access_path=path)
|
serialized_object = cls.serialize_object(val, access_path=path)
|
||||||
|
|
||||||
# If there's a running task for this method
|
|
||||||
if serialized_object["type"] == "method" and key in obj._task_manager.tasks:
|
|
||||||
serialized_object["value"] = TaskStatus.RUNNING.name
|
|
||||||
|
|
||||||
value[key] = serialized_object
|
value[key] = serialized_object
|
||||||
|
|
||||||
# If the DataService attribute is a property
|
# If the DataService attribute is a property
|
||||||
if isinstance(getattr(obj.__class__, key, None), property):
|
if is_property_attribute(obj, key):
|
||||||
prop: property = getattr(obj.__class__, key)
|
prop: property = getattr(obj.__class__, key)
|
||||||
value[key]["readonly"] = prop.fset is None
|
value[key]["readonly"] = prop.fset is None
|
||||||
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
|
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
|
||||||
@@ -297,8 +328,28 @@ class Serializer:
|
|||||||
"doc": doc,
|
"doc": doc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _serialize_proxy_class(
|
||||||
|
cls, obj: ProxyClass, access_path: str = ""
|
||||||
|
) -> SerializedDataService:
|
||||||
|
# Get serialization value from the remote service and adapt the full_access_path
|
||||||
|
return add_prefix_to_full_access_path(obj.serialize(), access_path)
|
||||||
|
|
||||||
|
|
||||||
def dump(obj: Any) -> SerializedObject:
|
def dump(obj: Any) -> SerializedObject:
|
||||||
|
"""Serialize `obj` to a
|
||||||
|
[`SerializedObject`][pydase.utils.serialization.types.SerializedObject].
|
||||||
|
|
||||||
|
The [`Serializer`][pydase.utils.serialization.serializer.Serializer] is used for
|
||||||
|
encoding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj:
|
||||||
|
Object to be serialized.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary representation of `obj`.
|
||||||
|
"""
|
||||||
return Serializer.serialize_object(obj)
|
return Serializer.serialize_object(obj)
|
||||||
|
|
||||||
|
|
||||||
@@ -307,12 +358,13 @@ def set_nested_value_by_path(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Set a value in a nested dictionary structure, which conforms to the serialization
|
Set a value in a nested dictionary structure, which conforms to the serialization
|
||||||
format used by `pydase.utils.serializer.Serializer`, using a dot-notation path.
|
format used by [`Serializer`][pydase.utils.serialization.serializer.Serializer],
|
||||||
|
using a dot-notation path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serialization_dict:
|
serialization_dict:
|
||||||
The base dictionary representing data serialized with
|
The base dictionary representing data serialized with
|
||||||
`pydase.utils.serializer.Serializer`.
|
[`Serializer`][pydase.utils.serialization.serializer.Serializer].
|
||||||
path:
|
path:
|
||||||
The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to
|
The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to
|
||||||
set the value.
|
set the value.
|
||||||
@@ -320,8 +372,8 @@ def set_nested_value_by_path(
|
|||||||
The new value to set at the specified path.
|
The new value to set at the specified path.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- If the index equals the length of the list, the function will append the
|
If the index equals the length of the list, the function will append the
|
||||||
serialized representation of the 'value' to the list.
|
serialized representation of the 'value' to the list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
path_parts = parse_full_access_path(path)
|
path_parts = parse_full_access_path(path)
|
||||||
@@ -340,8 +392,8 @@ def set_nested_value_by_path(
|
|||||||
next_level_serialized_object = get_container_item_by_key(
|
next_level_serialized_object = get_container_item_by_key(
|
||||||
current_dict, path_parts[-1], allow_append=True
|
current_dict, path_parts[-1], allow_append=True
|
||||||
)
|
)
|
||||||
except (SerializationPathError, SerializationValueError, KeyError) as e:
|
except (SerializationPathError, KeyError) as e:
|
||||||
logger.error("Error occured trying to change %a: %s", path, e)
|
logger.exception("Error occured trying to change %a: %s", path, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
if next_level_serialized_object["type"] == "method": # state change of task
|
if next_level_serialized_object["type"] == "method": # state change of task
|
||||||
@@ -424,36 +476,30 @@ def get_container_item_by_key(
|
|||||||
) -> SerializedObject:
|
) -> SerializedObject:
|
||||||
"""
|
"""
|
||||||
Retrieve an item from a container specified by the passed key. Add an item to the
|
Retrieve an item from a container specified by the passed key. Add an item to the
|
||||||
container if allow_append is set to True.
|
container if `allow_append` is set to `True`.
|
||||||
|
|
||||||
If specified keys or indexes do not exist, the function can append new elements to
|
If specified keys or indexes do not exist, the function can append new elements to
|
||||||
dictionaries and to lists if `allow_append` is True and the missing element is
|
dictionaries and to lists if `allow_append` is True and the missing element is
|
||||||
exactly the next sequential index (for lists).
|
exactly the next sequential index (for lists).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container: dict[str, SerializedObject] | list[SerializedObject]
|
container:
|
||||||
The container representing serialized data.
|
The container representing serialized data.
|
||||||
key: str
|
key:
|
||||||
The key name representing the attribute in the dictionary, which may include
|
The key name representing the attribute in the dictionary, which may include
|
||||||
direct keys or indexes (e.g., 'attr_name', '["key"]' or '[0]').
|
direct keys or indexes (e.g., 'attr_name', '["key"]' or '[0]').
|
||||||
allow_append: bool
|
allow_append:
|
||||||
Flag to allow appending a new entry if the specified index is out of range
|
Flag to allow appending a new entry if the specified index is out of range
|
||||||
by exactly one position.
|
by exactly one position.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SerializedObject
|
The dictionary or list item corresponding to the specified attribute and index.
|
||||||
The dictionary or list item corresponding to the specified attribute and
|
|
||||||
index.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
SerializationPathError:
|
SerializationPathError:
|
||||||
If the path composed of `attr_name` and any specified index is invalid, or
|
If the path composed of `attr_name` and any specified index is invalid, or
|
||||||
leads to an IndexError or KeyError. This error is also raised if an attempt
|
leads to an IndexError or KeyError. This error is also raised if an attempt
|
||||||
to access a nonexistent key or index occurs without permission to append.
|
to access a nonexistent key or index occurs without permission to append.
|
||||||
SerializationValueError:
|
|
||||||
If the retrieval results in an object that is expected to be a dictionary
|
|
||||||
but is not, indicating a mismatch between expected and actual serialized
|
|
||||||
data structure.
|
|
||||||
"""
|
"""
|
||||||
processed_key = parse_serialized_key(key)
|
processed_key = parse_serialized_key(key)
|
||||||
|
|
||||||
@@ -475,13 +521,12 @@ def get_data_paths_from_serialized_object( # noqa: C901
|
|||||||
Recursively extracts full access paths from a serialized object.
|
Recursively extracts full access paths from a serialized object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serialized_obj (SerializedObject):
|
serialized_obj:
|
||||||
The dictionary representing the serialization of an object. Produced by
|
The dictionary representing the serialization of an object. Produced by
|
||||||
`pydase.utils.serializer.Serializer`.
|
`pydase.utils.serializer.Serializer`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[str]:
|
A list of strings, each representing a full access path in the serialized
|
||||||
A list of strings, each representing a full access path in the serialized
|
|
||||||
object.
|
object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -522,12 +567,11 @@ def generate_serialized_data_paths(
|
|||||||
Recursively extracts full access paths from a serialized DataService class instance.
|
Recursively extracts full access paths from a serialized DataService class instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data (dict[str, SerializedObject]):
|
data:
|
||||||
The value of the "value" key of a serialized DataService class instance.
|
The value of the "value" key of a serialized DataService class instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[str]:
|
A list of strings, each representing a full access path in the serialized
|
||||||
A list of strings, each representing a full access path in the serialized
|
|
||||||
object.
|
object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -541,8 +585,67 @@ def generate_serialized_data_paths(
|
|||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def add_prefix_to_full_access_path(
|
||||||
|
serialized_obj: SerializedObject, prefix: str
|
||||||
|
) -> Any:
|
||||||
|
"""Recursively adds a specified prefix to all full access paths of the serialized
|
||||||
|
object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serialized_obj:
|
||||||
|
The serialized object to process.
|
||||||
|
prefix:
|
||||||
|
The prefix string to prepend to each full access path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The modified serialized object with the prefix added to all full access paths.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
>>> serialized_obj = {
|
||||||
|
... "full_access_path": "",
|
||||||
|
... "value": {
|
||||||
|
... "item": {
|
||||||
|
... "full_access_path": "some_item_path",
|
||||||
|
... "value": 1.0
|
||||||
|
... }
|
||||||
|
... }
|
||||||
|
... }
|
||||||
|
...
|
||||||
|
... modified_data = add_prefix_to_full_access_path(serialized_obj, 'prefix')
|
||||||
|
{"full_access_path": "prefix", "value": {"item": {"full_access_path":
|
||||||
|
"prefix.some_item_path", "value": 1.0}}}
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if serialized_obj.get("full_access_path", None) is not None:
|
||||||
|
serialized_obj["full_access_path"] = (
|
||||||
|
prefix + "." + serialized_obj["full_access_path"]
|
||||||
|
if serialized_obj["full_access_path"] != ""
|
||||||
|
else prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(serialized_obj["value"], list):
|
||||||
|
for value in serialized_obj["value"]:
|
||||||
|
add_prefix_to_full_access_path(cast(SerializedObject, value), prefix)
|
||||||
|
|
||||||
|
elif isinstance(serialized_obj["value"], dict):
|
||||||
|
for value in cast(
|
||||||
|
dict[str, SerializedObject], serialized_obj["value"]
|
||||||
|
).values():
|
||||||
|
add_prefix_to_full_access_path(cast(SerializedObject, value), prefix)
|
||||||
|
except (TypeError, KeyError, AttributeError):
|
||||||
|
# passed dictionary is not a serialized object
|
||||||
|
pass
|
||||||
|
return serialized_obj
|
||||||
|
|
||||||
|
|
||||||
def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool:
|
def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool:
|
||||||
value = serialized_dict["value"]
|
value = serialized_dict["value"]
|
||||||
# We are excluding Quantity here as the value corresponding to the "value" key is
|
# We are excluding Quantity here as the value corresponding to the "value" key is
|
||||||
# a dictionary of the form {"magnitude": ..., "unit": ...}
|
# a dictionary of the form {"magnitude": ..., "unit": ...}
|
||||||
return serialized_dict["type"] != "Quantity" and (isinstance(value, dict | list))
|
return serialized_dict["type"] != "Quantity" and (isinstance(value, dict | list))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Serializer", "dump"]
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ class SerializedString(SerializedObjectBase):
|
|||||||
type: Literal["str"]
|
type: Literal["str"]
|
||||||
|
|
||||||
|
|
||||||
|
class SerializedDatetime(SerializedObjectBase):
|
||||||
|
type: Literal["datetime"]
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
class SerializedEnum(SerializedObjectBase):
|
class SerializedEnum(SerializedObjectBase):
|
||||||
name: str
|
name: str
|
||||||
value: str
|
value: str
|
||||||
@@ -93,7 +98,9 @@ class SerializedException(SerializedObjectBase):
|
|||||||
type: Literal["Exception"]
|
type: Literal["Exception"]
|
||||||
|
|
||||||
|
|
||||||
DataServiceTypes = Literal["DataService", "Image", "NumberSlider", "DeviceConnection"]
|
DataServiceTypes = Literal[
|
||||||
|
"DataService", "Image", "NumberSlider", "DeviceConnection", "Task"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SerializedDataService(SerializedObjectBase):
|
class SerializedDataService(SerializedObjectBase):
|
||||||
@@ -107,6 +114,7 @@ SerializedObject = (
|
|||||||
| SerializedFloat
|
| SerializedFloat
|
||||||
| SerializedInteger
|
| SerializedInteger
|
||||||
| SerializedString
|
| SerializedString
|
||||||
|
| SerializedDatetime
|
||||||
| SerializedList
|
| SerializedList
|
||||||
| SerializedDict
|
| SerializedDict
|
||||||
| SerializedNoneType
|
| SerializedNoneType
|
||||||
@@ -117,3 +125,21 @@ SerializedObject = (
|
|||||||
| SerializedQuantity
|
| SerializedQuantity
|
||||||
| SerializedNoValue
|
| SerializedNoValue
|
||||||
)
|
)
|
||||||
|
"""
|
||||||
|
This type can be any of the following:
|
||||||
|
|
||||||
|
- SerializedBool
|
||||||
|
- SerializedFloat
|
||||||
|
- SerializedInteger
|
||||||
|
- SerializedString
|
||||||
|
- SerializedDatetime
|
||||||
|
- SerializedList
|
||||||
|
- SerializedDict
|
||||||
|
- SerializedNoneType
|
||||||
|
- SerializedMethod
|
||||||
|
- SerializedException
|
||||||
|
- SerializedDataService
|
||||||
|
- SerializedEnum
|
||||||
|
- SerializedQuantity
|
||||||
|
- SerializedNoValue
|
||||||
|
"""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from pydase.client.proxy_loader import ProxyAttributeError
|
from pydase.client.proxy_loader import ProxyAttributeError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="module")
|
||||||
def pydase_client() -> Generator[pydase.Client, None, Any]:
|
def pydase_client() -> Generator[pydase.Client, None, Any]:
|
||||||
class SubService(pydase.DataService):
|
class SubService(pydase.DataService):
|
||||||
name = "SubService"
|
name = "SubService"
|
||||||
@@ -41,11 +41,14 @@ def pydase_client() -> Generator[pydase.Client, None, Any]:
|
|||||||
def my_method(self, input_str: str) -> str:
|
def my_method(self, input_str: str) -> str:
|
||||||
return input_str
|
return input_str
|
||||||
|
|
||||||
|
async def my_async_method(self, input_str: str) -> str:
|
||||||
|
return input_str
|
||||||
|
|
||||||
server = pydase.Server(MyService(), web_port=9999)
|
server = pydase.Server(MyService(), web_port=9999)
|
||||||
thread = threading.Thread(target=server.run, daemon=True)
|
thread = threading.Thread(target=server.run, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
client = pydase.Client(hostname="localhost", port=9999)
|
client = pydase.Client(url="ws://localhost:9999")
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
@@ -79,6 +82,14 @@ def test_method_execution(pydase_client: pydase.Client) -> None:
|
|||||||
pydase_client.proxy.my_method(kwarg="hello")
|
pydase_client.proxy.my_method(kwarg="hello")
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_method_execution(pydase_client: pydase.Client) -> None:
|
||||||
|
assert pydase_client.proxy.my_async_method("My return string") == "My return string"
|
||||||
|
assert (
|
||||||
|
pydase_client.proxy.my_async_method(input_str="My return string")
|
||||||
|
== "My return string"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_nested_service(pydase_client: pydase.Client) -> None:
|
def test_nested_service(pydase_client: pydase.Client) -> None:
|
||||||
assert pydase_client.proxy.sub_service.name == "SubService"
|
assert pydase_client.proxy.sub_service.name == "SubService"
|
||||||
pydase_client.proxy.sub_service.name = "New name"
|
pydase_client.proxy.sub_service.name = "New name"
|
||||||
@@ -121,12 +132,16 @@ def test_dict(pydase_client: pydase.Client) -> None:
|
|||||||
# pop will remove the dictionary entry on the server
|
# pop will remove the dictionary entry on the server
|
||||||
assert list(pydase_client.proxy.dict_attr.keys()) == ["foo"]
|
assert list(pydase_client.proxy.dict_attr.keys()) == ["foo"]
|
||||||
|
|
||||||
|
pydase_client.proxy.dict_attr["non_existent_key"] = "Hello"
|
||||||
|
assert pydase_client.proxy.dict_attr["non_existent_key"] == "Hello"
|
||||||
|
|
||||||
|
|
||||||
def test_tab_completion(pydase_client: pydase.Client) -> None:
|
def test_tab_completion(pydase_client: pydase.Client) -> None:
|
||||||
# Tab completion gets its suggestions from the __dir__ class method
|
# Tab completion gets its suggestions from the __dir__ class method
|
||||||
assert all(
|
assert all(
|
||||||
x in pydase_client.proxy.__dir__()
|
x in pydase_client.proxy.__dir__()
|
||||||
for x in [
|
for x in [
|
||||||
|
"dict_attr",
|
||||||
"list_attr",
|
"list_attr",
|
||||||
"my_method",
|
"my_method",
|
||||||
"my_property",
|
"my_property",
|
||||||
@@ -134,3 +149,27 @@ def test_tab_completion(pydase_client: pydase.Client) -> None:
|
|||||||
"sub_service",
|
"sub_service",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_manager(pydase_client: pydase.Client) -> None:
|
||||||
|
client = pydase.Client(url="ws://localhost:9999")
|
||||||
|
|
||||||
|
assert client.proxy.connected
|
||||||
|
|
||||||
|
with client:
|
||||||
|
client.proxy.my_property = 1337.01
|
||||||
|
assert client.proxy.my_property == 1337.01
|
||||||
|
|
||||||
|
assert not client.proxy.connected
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_id(
|
||||||
|
pydase_client: pydase.Client, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
pydase.Client(url="ws://localhost:9999")
|
||||||
|
|
||||||
|
assert "Client [sid=" in caplog.text
|
||||||
|
caplog.clear()
|
||||||
|
|
||||||
|
pydase.Client(url="ws://localhost:9999", client_id="my_service")
|
||||||
|
assert "Client [id=my_service] connected" in caplog.text
|
||||||
|
|||||||