111 Commits

Author SHA1 Message Date
Mose Müller
c894215ddc Merge pull request #147 from tiqi-group/feat/add_http_api_endpoints
Feat: add http API endpoints
2024-07-30 10:47:59 +02:00
Mose Müller
22d836587e udpates to version v0.8.5 2024-07-30 10:40:11 +02:00
Mose Müller
9e852c17ac docs: updates documentation 2024-07-30 10:38:56 +02:00
Mose Müller
bd6220cb9e chore: refactoring state_manager 2024-07-30 10:17:43 +02:00
Mose Müller
940f7039d3 reflecting changes in openapi.yaml 2024-07-30 10:15:40 +02:00
Mose Müller
d45d2dba7d updates api tests 2024-07-30 10:15:34 +02:00
Mose Müller
1fb296c3c1 removes read-only check from state manager's set_service_attribute_value_by_path 2024-07-30 10:15:34 +02:00
Mose Müller
bfe2d82c0b api: getting value from service instead of cache 2024-07-30 09:30:28 +02:00
Mose Müller
5d8471fd47 disallows clients to add class attributes (through the state manager)
Note that adding dictionary keys still works. You can also append to lists.
2024-07-30 09:18:22 +02:00
Mose Müller
75e355faf9 pytest: changes fixture scopes 2024-07-30 08:36:59 +02:00
Mose Müller
f91be30ad0 adds tests for http api endpoints 2024-07-30 08:28:37 +02:00
Mose Müller
b148d6919a StateManager: replaces _data_service_cache with cache_manager
- _data_service_cache -> cache_manager
2024-07-30 08:28:37 +02:00
Mose Müller
74ebbc6223 http api: replaces post endpoints with put endpoints 2024-07-30 08:28:37 +02:00
Mose Müller
554d6f7daa changes http API (reflected in openapi specification) 2024-07-30 08:28:37 +02:00
Mose Müller
80243487cb fixing image link 2024-07-30 08:28:37 +02:00
Mose Müller
aeaf57331e updates docs python requirements 2024-07-30 08:28:37 +02:00
Mose Müller
baad1268e8 updates documentation
- using material theme instead of readthedocs
- introducing "Interacting with pydase Services" guide
    - restful api docs
    - auto-generated frontend
    - pydase.Client
2024-07-30 08:28:37 +02:00
Mose Müller
9ce0c93954 adds swagger-ui-tag python dep to render swagger ui, updates mkdocs to include new page 2024-07-30 08:28:37 +02:00
Mose Müller
95d29ee4e8 return method results over http 2024-07-30 08:28:37 +02:00
Mose Müller
6f4fcf52dd adds user guide for restful api 2024-07-30 08:28:37 +02:00
Mose Müller
0e73239d08 adds API versioning 2024-07-30 08:28:37 +02:00
Mose Müller
e659ca9d1c adds requests to dev group 2024-07-30 08:28:37 +02:00
Mose Müller
eaf76a7211 fixing logging for aiohttp and SocketIOHandler 2024-07-30 08:28:37 +02:00
Mose Müller
aa55ac772e using api application as web server api endpoint 2024-07-30 08:28:37 +02:00
Mose Müller
755a303239 creates api definition, using that in sio_setup 2024-07-30 08:28:37 +02:00
Mose Müller
7e63f34c0a pytest: using asyncio session scope to get rid of more warnings 2024-07-30 08:28:18 +02:00
Mose Müller
2364fc892c pytest: removes warning (could not join thread) 2024-07-30 08:28:18 +02:00
Mose Müller
a1da332dba pytest: configures logging through caplog fixture 2024-07-30 08:28:07 +02:00
Mose Müller
8bf0b771fa replace deprecated object from pint package 2024-07-30 06:56:59 +02:00
Mose Müller
fd73653433 Merge pull request #148 from tiqi-group/124-adding-keys-to-dictionary-through-pydaseclient
feat: clients can add keys to dictionaries
2024-07-29 15:17:01 +02:00
Mose Müller
d09675de6a updates client test 2024-07-29 15:06:54 +02:00
Mose Müller
36d3a7becc restructure StateManager to allow extending dictionaries through clients 2024-07-29 15:02:47 +02:00
Mose Müller
d54eed8a58 get_object_by_path_parts and get_object_attr_from_path do not catch exceptions any more 2024-07-29 15:02:47 +02:00
Mose Müller
817afc610a StateManager: replaces _data_service_cache with cache_manager
- _data_service_cache -> cache_manager
- removes cache property
- replaces get_nested_dict_by_path with cache_manager.get_value_dict_from_cache where possible
2024-07-29 15:02:47 +02:00
Mose Müller
ad0f9420d9 get_value_dict_from_cache does not catch exceptions any more 2024-07-29 14:59:02 +02:00
Mose Müller
6d786cd0f8 removes unused SerializationValueError exception 2024-07-29 14:59:02 +02:00
Mose Müller
de4270daa4 Merge pull request #146 from tiqi-group/feat/replace_fastapi_with_aiohttp
Replace fastapi with aiohttp
2024-07-25 08:39:01 +02:00
Mose Müller
7286017715 removes unused imports 2024-07-25 08:28:33 +02:00
Mose Müller
1a23206f42 adds example to helper function 2024-07-25 08:25:28 +02:00
Mose Müller
fdb17e44e2 replaces fastapi with aiohttp 2024-07-25 08:25:10 +02:00
Mose Müller
fc738e2743 using aiohttp as socketio async_mode 2024-07-25 08:22:12 +02:00
Mose Müller
9de4071120 updates logging to remove uvicorn dependency 2024-07-25 07:48:22 +02:00
Mose Müller
369d0b1126 adds handle for server shutdown, removes uvicorn dependency from server.py 2024-07-25 07:47:59 +02:00
Mose Müller
c396de75fb frontend: default export useRenderCount hook 2024-07-24 15:06:36 +02:00
Mose Müller
4ed8899708 Merge pull request #145 from tiqi-group/11-frontend-user-should-be-able-to-change-the-order-of-the-elements-in-the-frontend
adds support for altering component display order
2024-07-24 15:02:22 +02:00
Mose Müller
2fa3505310 updates Readme with displayOrder customization 2024-07-24 14:58:56 +02:00
Mose Müller
9d387944ef npm run build 2024-07-24 14:58:47 +02:00
Mose Müller
0d70b7492d frontend: adds support for displayOrder in web settings 2024-07-24 14:53:40 +02:00
Mose Müller
7bc12b340f updates vscode debugging config 2024-07-24 13:41:48 +02:00
Mose Müller
e996966388 Merge pull request #144 from tiqi-group/feat/useLocalStorage_hook
Feat: useLocalStorage hook
2024-07-22 07:09:04 +02:00
Mose Müller
c07efe056b npm run build 2024-07-22 07:05:16 +02:00
Mose Müller
9e0adba8dc introduces useLocalStorage hook 2024-07-22 07:05:09 +02:00
Mose Müller
1789a6ad7e Merge pull request #143 from tiqi-group/fix/logging
configures pydase's logger only (not root logger anymore)
2024-07-12 07:46:32 +02:00
Mose Müller
0e5f1ede20 fixes logging test 2024-07-12 07:45:11 +02:00
Mose Müller
f8cae28128 fix tests: need to propagate logger when testing (due to pytest) 2024-07-11 19:49:55 +02:00
Mose Müller
e31af9ae31 moves log_config out of setup_logging method to make it configurable, removes argument from function 2024-07-11 17:04:11 +02:00
Mose Müller
60c671eb0d configures pydase's logger (not root logger anymore) 2024-07-11 16:53:12 +02:00
Mose Müller
203059822c Merge pull request #142 from tiqi-group/feat/allow_monkey_patching_sio_setup
Allow monkey-patching of serialization methods in socketio setup
2024-07-09 16:27:35 +02:00
Mose Müller
e7f9ad799c uses loads instead of Deserializer, allows for monkey-patching loads and dump 2024-07-09 16:25:35 +02:00
Mose Müller
3e5a56446f Merge pull request #141 from tiqi-group/feat/serialize_datetime
Adds support for datetime serialization
2024-07-09 15:55:00 +02:00
Mose Müller
cf0780b2ca adds support for datetime serialization 2024-07-09 15:41:30 +02:00
Mose Müller
8afee54c51 Merge pull request #140 from tiqi-group/feat/make_serializer_methods_properly_overridable
serializer: converting staticmethods to classmethods
2024-07-09 15:19:53 +02:00
Mose Müller
6e4e000c28 serializer: converting staticmethods to classmethods
This helps developers that want to add functionality to the serializer.
2024-07-09 15:15:30 +02:00
Mose Müller
b719684702 Merge pull request #139 from tiqi-group/chore/remove_pillow_dependency
removes Pillow dependency, updates Image component
2024-07-09 08:42:02 +02:00
Mose Müller
7254482b35 updates pint to fix numpy dependency issue 2024-07-09 08:39:35 +02:00
Mose Müller
44d5a98449 removes Pillow dependency, updates Image component 2024-07-09 07:52:18 +02:00
Mose Müller
29558758af Merge pull request #138 from tiqi-group/fix/unnecessary_component_rendering
Fixes unnecessary component rendering
2024-07-08 15:24:54 +02:00
Mose Müller
f9be97a910 npm run build 2024-07-08 15:16:32 +02:00
Mose Müller
fa45ee566b fixes eslint error 2024-07-08 15:16:13 +02:00
Mose Müller
6e8ad98282 frontend: updates when slider notifications are shown 2024-07-08 15:15:42 +02:00
Mose Müller
c42872aad4 moves functions from component to the outside (to not cause re-rendering) 2024-07-08 15:15:12 +02:00
Mose Müller
34eb4a0e7c frontend: introduces propsAreEqual function passed to React.memo to reduce re-rendering
This function accepts the component’s previous props, and its new props.
It should return true if the old and new props are equal: that is, if the component will
render the same output and behave in the same way with the new props as with the old.
I need to use this function as state objects that are passed as props will always have different references.
2024-07-08 15:15:12 +02:00
Mose Müller
7d50bd5759 frontend: cast type instead of ignoring typescript error 2024-07-08 15:11:05 +02:00
Mose Müller
c98f191d20 frontend: updates EnumComponent
- replaces type with SerializedEnum from types.ts
- passing props instead of attribute directly
2024-07-08 15:10:37 +02:00
Mose Müller
b1e6663c66 frontend: introduces useRenderCount hook
The useRenderCount hook contains all the necessary logic to count the re-render events.
This reduces duplication and code complexity.
2024-07-08 15:10:37 +02:00
Mose Müller
a5a957d290 using tseslint.config in eslint config (for types) 2024-07-08 08:58:04 +02:00
Mose Müller
b856ed3a12 using tsParser in eslint config 2024-07-08 08:51:01 +02:00
Mose Müller
b83e241b32 Merge pull request #137 from tiqi-group/chore/update_eslint
chore: update eslint config
2024-07-08 08:32:53 +02:00
Mose Müller
fb251649a0 updates eslint config, fixes linting errors 2024-07-08 08:30:55 +02:00
Mose Müller
a2ee8d02d6 Merge pull request #136 from tiqi-group/fix/allow_unserializable_objects_on_priv_attrs
Fix: allow unserializable objects on priv attrs
2024-07-04 17:43:32 +02:00
Mose Müller
44d73c3b77 adds function testing if private attributes can take values that are not serializable 2024-07-04 17:37:44 +02:00
Mose Müller
cddb83451a observer: first check if full access path contains private or protected attributes
As protected and private attributes are not stored in the cache, it does not make sense to
compare the cached value against the new value.
2024-07-04 17:26:32 +02:00
Mose Müller
218dab1ade Merge pull request #135 from tiqi-group/chore/move_to_vite
Replace unmaintained create-react-app with vite
2024-07-04 17:01:18 +02:00
Mose Müller
81af62dc6e frontend: updates set of packages 2024-07-04 16:53:44 +02:00
Mose Müller
6ffb068f47 npm run build 2024-07-04 16:45:05 +02:00
Mose Müller
73a3283a7d feat: moving from react-create-app to vite
- loads of type fixes
- configuration changes
2024-07-04 16:45:00 +02:00
Mose Müller
c0734d58ce updates package-lock.json 2024-07-04 12:53:19 +02:00
Mose Müller
b5a7d90d81 Merge pull request #134 from tiqi-group/fix/frontend_instant_string_update
Fix: frontend instant string update
2024-07-04 12:50:42 +02:00
Mose Müller
b91eaaaf90 npm run build 2024-07-04 12:49:28 +02:00
Mose Müller
4039d29f42 fix: instant string update through frontend 2024-07-04 12:48:56 +02:00
Mose Müller
e8428e4a31 Merge pull request #133 from tiqi-group/81-use-web-storage-api-to-store-client-settings
Use web storage api to store client settings
2024-07-04 12:48:25 +02:00
Mose Müller
25459949a0 npm run build 2024-07-04 12:44:46 +02:00
Mose Müller
9649f914ac feat: persist isInstantUpdate and showNotification state changes to localStorage 2024-07-04 12:44:46 +02:00
Mose Müller
4ecc44fdd8 feat: persist state of Collapse components on the client using localStorage 2024-07-04 12:44:46 +02:00
Mose Müller
4cea7eeb59 Merge pull request #132 from tiqi-group/feat/proper_frontend_title
Feat/proper frontend title
2024-07-04 12:27:18 +02:00
Mose Müller
3c48a23277 fix: ruff warning 2024-07-04 12:23:58 +02:00
Mose Müller
bfcf72fec7 npm run build 2024-07-04 12:20:07 +02:00
Mose Müller
639161d373 feat: showing service class name in browser tab and on top of the frontend page 2024-07-04 12:19:19 +02:00
Mose Müller
6f3910efd0 docs: fixing typos 2024-05-28 13:17:58 +02:00
Mose Müller
fe5d0eed2d Merge pull request #131 from tiqi-group/85-optionally-call-getter-after-setter
Adding validate_set decorator to ensure values are set correctly
2024-05-28 13:12:39 +02:00
Mose Müller
a11ab1520f updates version to v0.8.4 2024-05-28 13:12:15 +02:00
Mose Müller
ae79150252 adds tests for validate_set timeout 2024-05-28 13:08:01 +02:00
Mose Müller
7fdd08021a ignore mypy error 2024-05-28 12:56:43 +02:00
Mose Müller
00c6d4c068 adds validate_set decorator precision test 2024-05-28 12:08:23 +02:00
Mose Müller
f49cdd87e4 updates Readme 2024-05-28 11:45:43 +02:00
Mose Müller
052bf79487 adds setattr validation to observable if validate_set decorator is used 2024-05-28 11:22:18 +02:00
Mose Müller
203cc0f0f5 adds validate_set decorator 2024-05-28 11:22:18 +02:00
Mose Müller
0c54c9d4b7 Merge pull request #130 from tiqi-group/chore/update_workflow_action_versions
Chore/update workflow action versions
2024-05-27 15:35:48 +02:00
Mose Müller
381e73d624 using latest versions of github actions 2024-05-27 15:33:15 +02:00
Mose Müller
9f27f07ccb adds python 3.12 to python package checks 2024-05-27 15:33:01 +02:00
Mose Müller
94cef50e03 combines two lines in _ObservableList.append 2024-05-27 15:22:30 +02:00
106 changed files with 9791 additions and 21042 deletions

View File

@@ -16,15 +16,15 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11"]
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: chartboost/ruff-action@v1
with:
src: "./src"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies

2
.vscode/launch.json vendored
View File

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

258
README.md
View File

@@ -1,9 +1,9 @@
# pydase (Python Data Service) <!-- omit from toc -->
# pydase <!-- omit from toc -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Documentation Status](https://readthedocs.org/projects/pydase/badge/?version=latest)](https://pydase.readthedocs.io/en/latest/?badge=latest)
`pydase` is a Python library for creating data service servers with integrated web and RPC servers. It's designed to handle the management of data structures, automated tasks, and callbacks, and provides built-in functionality for serving data over different protocols.
`pydase` is a Python library designed to streamline the creation of services that interface with devices and data. It offers a unified API, simplifying the process of data querying and device interaction. Whether you're managing lab sensors, network devices, or any abstract data entity, `pydase` facilitates rapid service development and deployment.
- [Features](#features)
- [Installation](#installation)
@@ -14,6 +14,7 @@
- [Connecting to the Service via Python Client](#connecting-to-the-service-via-python-client)
- [Tab Completion Support](#tab-completion-support)
- [Integration within Another Service](#integration-within-another-service)
- [RESTful API](#restful-api)
- [Understanding the Component System](#understanding-the-component-system)
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
- [Method Components](#method-components)
@@ -30,6 +31,7 @@
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
- [Understanding Units in pydase](#understanding-units-in-pydase)
- [Using `validate_set` to Validate Property Setters](#using-validate_set-to-validate-property-setters)
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
- [Customizing the Web Interface](#customizing-the-web-interface)
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
@@ -44,14 +46,15 @@
## Features
<!-- no toc -->
- [Simple data service definition through class-based interface](#defining-a-dataService)
- [Integrated web interface for interactive access and control of your data service](#accessing-the-web-interface)
- [Simple service definition through class-based interface](#defining-a-dataService)
- [Integrated web interface for interactive access and control of your service](#accessing-the-web-interface)
- [Support for programmatic control and interaction with your service](#connecting-to-the-service-via-python-client)
- [Component system bridging Python backend with frontend visual representation](#understanding-the-component-system)
- [Customizable styling for the web interface through user-defined CSS](#customizing-web-interface-style)
- [Customizable styling for the web interface](#customizing-web-interface-style)
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
- [Support for units](#understanding-units-in-pydase)
- [Validating Property Setters](#using-validate_set-to-validate-property-setters)
<!-- Support for additional servers for specific use-cases -->
## Installation
@@ -207,6 +210,24 @@ In this setup, the `MyService` class has a `proxy` attribute that connects to a
The `block_until_connected=False` argument allows the service to start up even if the initial connection attempt fails.
This configuration is particularly useful in distributed systems where services may start in any order.
### RESTful API
The `pydase` RESTful API allows for standard HTTP-based interactions and provides access to various functionalities through specific routes.
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)
```
For more information, see [here](https://pydase.readthedocs.io/en/stable/user-guide/interaction/main/#restful-api).
<!--usage-end-->
## Understanding the Component System
@@ -508,96 +529,96 @@ In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value`
- 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.
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:
Here's an illustrative example:
```python
from collections.abc import Callable
```python
from collections.abc import Callable
import pydase
import pydase.components
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
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 ...
# ... other properties ...
@property
def value(self) -> float:
return self._value
@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)
@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,
)
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
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()
```
if __name__ == "__main__":
service_instance = MyService()
my_service.voltage.value = 7 # Output: "Voltage changed to: 7"
pydase.Server(service_instance).run()
```
- Incorporating units in `NumberSlider`
The `NumberSlider` is capable of [displaying units](#understanding-units-in-pydase) alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.
The `NumberSlider` is capable of [displaying units](#understanding-units-in-pydase) alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.
Here's how to implement a `NumberSlider` with unit display:
Here's how to implement a `NumberSlider` with unit display:
```python
import pydase
import pydase.components
import pydase.units as u
```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)
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
@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
@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()
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()
```
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`
@@ -800,6 +821,45 @@ if __name__ == "__main__":
For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
## Using `validate_set` to Validate Property Setters
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
If the value is not within the precision boundary after this time, an exception is raised.
The `precision` argument defines the acceptable deviation from the desired value.
If `precision` is `None`, the value must be exact.
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
Heres how to use the `validate_set` decorator in a `DataService` class:
```python
import pydase
from pydase.observer_pattern.observable.decorators import validate_set
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._device = RemoteDevice() # dummy class
@property
def value(self) -> float:
# Implement how to get the value from the remote device...
return self._device.value
@value.setter
@validate_set(timeout=1.0, precision=1e-5)
def value(self, value: float) -> None:
# Implement how to set the value on the remote device...
self._device.value = value
if __name__ == "__main__":
pydase.Server(Service()).run()
```
## Configuring pydase via Environment Variables
Configuring `pydase` through environment variables enhances flexibility, security, and reusability. This approach allows for easy adaptation of services across different environments without code changes, promoting scalability and maintainability. With that, it simplifies deployment processes and facilitates centralized configuration management. Moreover, environment variables enable separation of configuration from code, aiding in secure and collaborative development.
@@ -869,10 +929,60 @@ Please ensure that the CSS file path is accessible from the server's running loc
- **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. -->
- **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 `SERVICE_CONFIG_DIR`. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](#configuring-pydase-via-environment-variables).
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"
service_instance = Service()
pydase.Server(service_instance).run()
```
with the following `web_settings.json`
```json
{
"device": {
"displayName": "My Device",
"displayOrder": 1
},
"device.name": {
"display": false
},
"device.power": {
"displayName": "Power",
"displayOrder": 1
},
"device.temperature": {
"displayName": "Temperature",
"displayOrder": 0
},
"state": {
"displayOrder": 0
}
}
```
looks like this:
![Tailoring frontend component layout](./docs/images/Tailoring_frontend_component_layout.png)
### Specifying a Custom Frontend Source
To further personalize your web interface, you can provide `pydase` with a custom frontend GUI. To do so, you can use the `frontend_src` keyword in the `pydase.Server`:

View File

@@ -111,7 +111,7 @@ Write the React component code, following the structure and patterns used in exi
For example, for the `Image` component, a template could look like this:
```tsx
```ts
import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from 'react-bootstrap';
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:
```tsx
// file: frontend/src/components/ButtonComponent.tsx
```ts title="frontend/src/components/ButtonComponent.tsx"
// ... (import statements)
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):
```tsx
```ts title="frontend/src/components/_YourComponent_.tsx"
import { runMethod } from '../socket';
// ... (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`:
```tsx
// file: frontend/src/components/GenericComponent.tsx
```ts title="frontend/src/components/GenericComponent.tsx"
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:
```tsx
```ts
type AttributeType =
| 'str'
| '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'`:
```tsx
```ts
} else if (attribute.type === 'Image') {
return (
<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:
```tsx
```ts
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,20 +1,35 @@
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"
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"
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
idna==3.7 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.4 ; python_version >= "3.10" and python_version < "4.0"
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"
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==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"
mkdocs-material==9.5.30 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-swagger-ui-tag==0.6.10 ; python_version >= "3.10" and python_version < "4.0"
mkdocs==1.6.0 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings==0.22.0 ; python_version >= "3.10" and python_version < "4.0"
packaging==23.1 ; python_version >= "3.10" and python_version < "4.0"
pathspec==0.11.2 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "4.0"
pymdown-extensions==10.3 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
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==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"
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"

View File

@@ -0,0 +1,167 @@
# 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. 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.
### 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 `SERVICE_CONFIG_DIR`. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](#configuring-pydase-via-environment-variables).
For example, styling the following service
```python
import pydase
class Device(pydase.DataService):
name = "My Device"
temperature = 1.0
power = 1
class Service(pydase.DataService):
device = Device()
state = "RUNNING"
if __name__ == "__main__":
pydase.Server(Service()).run()
```
with the following `web_settings.json`
```json
{
"device": {
"displayName": "My Device",
"displayOrder": 1
},
"device.name": {
"display": false
},
"device.power": {
"displayName": "Power",
"displayOrder": 1
},
"device.temperature": {
"displayName": "Temperature",
"displayOrder": 0
},
"state": {
"displayOrder": 0
}
}
```
looks like this:
![Tailoring frontend component layout](../../images/Tailoring_frontend_component_layout.png)
### Specifying a Custom Frontend Source
To further customize your web interface, you can provide a custom frontend source.
By specifying the `frontend_src` parameter when initializing the server, you can host a tailored frontend application:
```python
from pathlib import Path
import pydase
class MyService(pydase.DataService):
# Service definition
if __name__ == "__main__":
service = MyService()
pydase.Server(
service,
frontend_src=Path("path/to/your/frontend/directory"),
).run()
```
`pydase` expects a directory structured as follows:
```bash title="Frontend directory structure"
<your_frontend_directory>
├── assets
│   └── ...
└── index.html
```
Any CSS, js, image or other files need to be put into the assets folder for the web server to be able to provide access to it.
#### Example: Custom React Frontend
You can use vite to generate a react app template:
```bash
npm create vite@latest my-react-app -- --template react
```
*TODO: Add some useful information here...*
To deploy the custom react frontend, build it with
```bash
npm run build
```
and pass the relative path of the output directory to the `frontend_src` parameter of the `pydase.Server`.
**Note** that you have to make sure that all the generated files (except the `index.html`) are in the `assets` folder. In the react app, you can achieve this by not using the `public` folder, but instead using e.g. `src/assets`.

View File

@@ -0,0 +1,45 @@
# Python Client
You can connect to the service using the `pydase.Client`. Below is an example of how to establish a connection to a service and interact with it:
```python
import pydase
# Replace the hostname and port with the IP address and the port of the machine
# where the service is running, respectively
client_proxy = pydase.Client(hostname="<ip_addr>", port=8001).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 demonstrates setting and retrieving the `voltage` attribute through the client proxy.
The proxy acts as a local representative of the remote service, enabling straightforward interaction.
The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object.
## Tab Completion Support
In interactive environments such as Python interpreters and Jupyter notebooks, the proxy class supports tab completion, which allows users to explore available methods and attributes.
## Integration within Other Services
You can also integrate a client proxy within another service. Here's how you can set it up:
```python
import pydase
class MyService(pydase.DataService):
# Initialize the client without blocking the constructor
proxy = pydase.Client(hostname="<ip_addr>", port=8001, block_until_connected=False).proxy
if __name__ == "__main__":
service = MyService()
# Create a server that exposes this service; adjust the web_port as needed
server = pydase.Server(service, web_port=8002). run()
```
In this setup, the `MyService` class has a `proxy` attribute that connects to a `pydase` service located at `<ip_addr>:8001`.
The `block_until_connected=False` argument allows the service to start up even if the initial connection attempt fails.
This configuration is particularly useful in distributed systems where services may start in any order.

View 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"/>

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

View 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"

View File

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

38
frontend/.gitignore vendored
View File

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

View File

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

View File

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

24
frontend/eslint.config.js Normal file
View File

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

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site displaying a pydase UI." />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

24624
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,20 @@
import React, { useEffect, useRef, useState } from 'react';
import { runMethod } from '../socket';
import { Form, Button, InputGroup, Spinner } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
import React, { useEffect, useRef, useState } from "react";
import { runMethod } from "../socket";
import { Form, Button, InputGroup, Spinner } from "react-bootstrap";
import { DocStringComponent } from "./DocStringComponent";
import { LevelName } from "./NotificationsComponent";
import useRenderCount from "../hooks/useRenderCount";
type AsyncMethodProps = {
interface AsyncMethodProps {
fullAccessPath: string;
value: 'RUNNING' | null;
docString?: string;
value: "RUNNING" | null;
docString: string | null;
hideOutput?: boolean;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
render: boolean;
};
}
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const {
@@ -22,7 +23,7 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
value: runningTask,
addNotification,
displayName,
id
id,
} = props;
// Conditional rendering based on the 'render' prop.
@@ -30,14 +31,13 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
return null;
}
const renderCount = useRef(0);
const renderCount = useRenderCount();
const formRef = useRef(null);
const [spinning, setSpinning] = useState(false);
const name = fullAccessPath.split('.').at(-1);
const name = fullAccessPath.split(".").at(-1)!;
const parentPath = fullAccessPath.slice(0, -(name.length + 1));
useEffect(() => {
renderCount.current++;
let message: string;
if (runningTask === null) {
@@ -59,16 +59,14 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
method_name = `start_${name}`;
}
const accessPath = [parentPath, method_name].filter((element) => element).join('.');
const accessPath = [parentPath, method_name].filter((element) => element).join(".");
setSpinning(true);
runMethod(accessPath);
};
return (
<div className="component asyncMethodComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
<Form onSubmit={execute} ref={formRef}>
<InputGroup>
<InputGroup.Text>
@@ -78,10 +76,10 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
<Button id={`button-${id}`} type="submit">
{spinning ? (
<Spinner size="sm" role="status" aria-hidden="true" />
) : runningTask === 'RUNNING' ? (
'Stop '
) : runningTask === "RUNNING" ? (
"Stop "
) : (
'Start '
"Start "
)}
</Button>
</InputGroup>
@@ -89,3 +87,5 @@ export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
</div>
);
});
AsyncMethodComponent.displayName = "AsyncMethodComponent";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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];
}

View File

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

View 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;
}

View File

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

View File

@@ -1,30 +1,30 @@
import { io } from 'socket.io-client';
import { SerializedValue } from './components/GenericComponent';
import { serializeDict, serializeList } from './utils/serializationUtils';
import { io } from "socket.io-client";
import { serializeDict, serializeList } from "./utils/serializationUtils";
import { SerializedObject } from "./types/SerializedObject";
export const hostname =
process.env.NODE_ENV === 'development' ? `localhost` : window.location.hostname;
process.env.NODE_ENV === "development" ? `localhost` : window.location.hostname;
export 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);
console.debug("Websocket: ", URL);
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
export const socket = io(URL, { path: "/ws/socket.io", transports: ["websocket"] });
export const updateValue = (
serializedObject: SerializedValue,
callback?: (ack: unknown) => void
serializedObject: SerializedObject,
callback?: (ack: unknown) => void,
) => {
if (callback) {
socket.emit(
'update_value',
{ access_path: serializedObject['full_access_path'], value: serializedObject },
callback
"update_value",
{ access_path: serializedObject["full_access_path"], value: serializedObject },
callback,
);
} else {
socket.emit('update_value', {
access_path: serializedObject['full_access_path'],
value: serializedObject
socket.emit("update_value", {
access_path: serializedObject["full_access_path"],
value: serializedObject,
});
}
};
@@ -33,22 +33,22 @@ export const runMethod = (
accessPath: string,
args: unknown[] = [],
kwargs: Record<string, unknown> = {},
callback?: (ack: unknown) => void
callback?: (ack: unknown) => void,
) => {
const serializedArgs = serializeList(args);
const serializedKwargs = serializeDict(kwargs);
if (callback) {
socket.emit(
'trigger_method',
"trigger_method",
{ access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs },
callback
callback,
);
} else {
socket.emit('trigger_method', {
socket.emit("trigger_method", {
access_path: accessPath,
args: serializedArgs,
kwargs: serializedKwargs
kwargs: serializedKwargs,
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
frontend/vite.config.ts Normal file
View File

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

View File

@@ -4,8 +4,9 @@ edit_uri: blob/docs/docs/
nav:
- Home: index.md
- Getting Started: getting-started.md
- User Guide:
- User Guide:
- Components Guide: user-guide/Components.md
- Interacting with pydase Services: user-guide/interaction/main.md
- Developer Guide:
- Developer Guide: dev-guide/README.md
- API Reference: dev-guide/api.md
@@ -16,7 +17,10 @@ nav:
- Contributing: about/contributing.md
- License: about/license.md
theme: readthedocs
theme:
name: material
features:
- content.code.copy
extra_css:
- css/extra.css
@@ -26,17 +30,20 @@ markdown_extensions:
- toc:
permalink: true
- pymdownx.highlight:
use_pygments: true
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.snippets
- pymdownx.superfences
# - pymdownx.highlight:
# - pymdownx.inlinehilite
- pymdownx.inlinehilite
plugins:
- include-markdown
- search
- mkdocstrings
- swagger-ui-tag
watch:
- src/pydase

1564
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydase"
version = "0.8.3"
version = "0.8.5"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md"
@@ -9,15 +9,14 @@ packages = [{ include = "pydase", from = "src" }]
[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.108.0"
uvicorn = "^0.27.0"
toml = "^0.10.2"
python-socketio = "^5.8.0"
confz = "^2.0.0"
pint = "^0.22"
pillow = "^10.0.0"
pint = "^0.24"
websocket-client = "^1.7.0"
aiohttp = "^3.9.3"
click = "^8.1.7"
aiohttp-middlewares = "^2.3.0"
[tool.poetry.group.dev]
optional = true
@@ -32,15 +31,17 @@ pyright = "^1.1.323"
pytest-mock = "^3.11.1"
ruff = "^0.2.0"
pytest-asyncio = "^0.23.2"
requests = "^2.32.3"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.2"
mkdocs-material = "^9.5.30"
mkdocs-include-markdown-plugin = "^3.9.1"
mkdocstrings = "^0.22.0"
pymdown-extensions = "^10.1"
mkdocs-swagger-ui-tag = "^0.6.10"
[build-system]
requires = ["poetry-core"]

View File

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

View File

@@ -2,8 +2,6 @@ import logging
from typing import TYPE_CHECKING, Any, cast
from pydase.utils.serialization.serializer import (
SerializationPathError,
SerializationValueError,
SerializedObject,
get_nested_dict_by_path,
set_nested_value_by_path,
@@ -38,16 +36,7 @@ class DataServiceCache:
)
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
try:
return get_nested_dict_by_path(
cast(dict[str, SerializedObject], self._cache["value"]),
full_access_path,
)
except (SerializationPathError, SerializationValueError, KeyError):
return {
"full_access_path": full_access_path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
return get_nested_dict_by_path(
cast(dict[str, SerializedObject], self._cache["value"]),
full_access_path,
)

View File

@@ -9,7 +9,11 @@ from pydase.observer_pattern.observer.property_observer import (
PropertyObserver,
)
from pydase.utils.helpers import get_object_attr_from_path
from pydase.utils.serialization.serializer import SerializedObject, dump
from pydase.utils.serialization.serializer import (
SerializationPathError,
SerializedObject,
dump,
)
logger = logging.getLogger(__name__)
@@ -29,23 +33,34 @@ class DataServiceObserver(PropertyObserver):
for changing_attribute in self.changing_attributes
):
return
cached_value_dict: SerializedObject
cached_value_dict = deepcopy(
self.state_manager._data_service_cache.get_value_dict_from_cache(
full_access_path
try:
cached_value_dict = deepcopy(
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")
if cached_value != dump(value)["value"] and all(
part[0] != "_" for part in full_access_path.split(".")
if (
all(part[0] != "_" for part in full_access_path.split("."))
and cached_value != value
):
logger.debug("'%s' changed to '%s'", full_access_path, value)
self._update_cache_value(full_access_path, value, cached_value_dict)
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
)
)
@@ -78,7 +93,7 @@ class DataServiceObserver(PropertyObserver):
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,
value,
)

View File

@@ -1,3 +1,4 @@
import contextlib
import json
import logging
import os
@@ -113,19 +114,12 @@ class StateManager:
self.filename = filename
self.service = service
self._data_service_cache = DataServiceCache(self.service)
@property
def cache(self) -> SerializedObject:
"""Returns the cached DataService state."""
return self._data_service_cache.cache
self.cache_manager = DataServiceCache(self.service)
@property
def cache_value(self) -> dict[str, SerializedObject]:
"""Returns the "value" value of the DataService serialization."""
return cast(
dict[str, SerializedObject], self._data_service_cache.cache["value"]
)
return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
def save_state(self) -> None:
"""
@@ -157,9 +151,18 @@ class StateManager:
for path in generate_serialized_data_paths(json_dict):
if self.__is_loadable_state_attribute(path):
nested_json_dict = get_nested_dict_by_path(json_dict, path)
nested_class_dict = self._data_service_cache.get_value_dict_from_cache(
path
)
try:
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"]
class_attr_value_type = nested_class_dict.get("type", None)
@@ -202,12 +205,16 @@ class StateManager:
value: The new value to set for the attribute.
"""
current_value_dict = get_nested_dict_by_path(self.cache_value, path)
# This will also filter out methods as they are 'read-only'
if current_value_dict["readonly"]:
logger.debug("Attribute '%s' is read-only. Ignoring new value...", path)
return
try:
current_value_dict = self.cache_manager.get_value_dict_from_cache(path)
except (SerializationPathError, KeyError):
current_value_dict = {
"full_access_path": path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
if "full_access_path" not in serialized_value:
# Backwards compatibility for JSON files not containing the
@@ -237,24 +244,21 @@ class StateManager:
def __update_attribute_by_path(
self, path: str, serialized_value: SerializedObject
) -> None:
is_value_set = False
path_parts = parse_full_access_path(path)
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"]
# De-serialize the value
if attr_cache_type in ("ColouredEnum", "Enum"):
if self.__cached_value_is_enum(path):
enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]])
# take the value of the existing enum class
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"]]
except KeyError:
# 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
value = loads(serialized_value)
else:
is_value_set = True
if not is_value_set:
value = loads(serialized_value)
# set the value
@@ -262,6 +266,15 @@ class StateManager:
processed_key = parse_serialized_key(path_parts[-1])
target_obj[processed_key] = value # type: ignore
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)
def __is_loadable_state_attribute(self, full_access_path: str) -> bool:
@@ -287,8 +300,8 @@ class StateManager:
return has_decorator
try:
cached_serialization_dict = get_nested_dict_by_path(
self.cache_value, full_access_path
cached_serialization_dict = self.cache_manager.get_value_dict_from_cache(
full_access_path
)
if cached_serialization_dict["value"] == "method":
@@ -303,3 +316,16 @@ class StateManager:
path_parts[-1],
)
return False
def __cached_value_is_enum(self, path: str) -> bool:
try:
attr_cache_type = self.cache_manager.get_value_dict_from_cache(path)["type"]
return attr_cache_type in ("ColouredEnum", "Enum")
except Exception:
return False
def __attr_exists_on_target_obj(self, target_obj: Any, name: str) -> bool:
return not is_property_attribute(target_obj, name) and not hasattr(
target_obj, name
)

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1,18 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.57f8ec4c.js"></script><link href="/static/css/main.7ef670d5.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site displaying a pydase UI." />
<script type="module" crossorigin src="/assets/index-D7tStNHJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,45 +0,0 @@
/*!
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,101 @@
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
if TYPE_CHECKING:
from pydase.observer_pattern.observable.observable import Observable
P = ParamSpec("P")
R = TypeVar("R")
def validate_set(
*, timeout: float = 0.1, precision: float | None = None
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""
Decorator marking a property setter to read back the set value using the property
getter and check against the desired value.
Args:
timeout (float):
The maximum time (in seconds) to wait for the value to be within the
precision boundary.
precision (float | None):
The acceptable deviation from the desired value. If None, the value must be
exact.
"""
def validate_set_decorator(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
wrapper._validate_kwargs = { # type: ignore
"timeout": timeout,
"precision": precision,
}
return wrapper
return validate_set_decorator
def has_validate_set_decorator(prop: property) -> bool:
"""
Checks if a property setter has been decorated with the `validate_set` decorator.
Args:
prop (property):
The property to check.
Returns:
bool:
True if the property setter has the `validate_set` decorator, False
otherwise.
"""
property_setter = prop.fset
return hasattr(property_setter, "_validate_kwargs")
def _validate_value_was_correctly_set(
*,
obj: "Observable",
name: str,
value: Any,
) -> None:
"""
Validates if the property `name` of `obj` attains the desired `value` within the
specified `precision` and time `timeout`.
Args:
obj (Observable):
The instance of the class containing the property.
name (str):
The name of the property to validate.
value (Any):
The desired value to check against.
Raises:
ValueError:
If the property value does not match the desired value within the specified
precision and timeout.
"""
prop: property = getattr(type(obj), name)
timeout = prop.fset._validate_kwargs["timeout"] # type: ignore
precision = prop.fset._validate_kwargs["precision"] # type: ignore
if precision is None:
precision = 0.0
start_time = time.time()
while time.time() - start_time < timeout:
current_value = obj.__getattribute__(name)
# This check is faster than rounding and comparing to 0
if abs(current_value - value) <= precision:
return
time.sleep(0.01)
raise ValueError(
f"Failed to set value to {value} within {timeout} seconds. Current value: "
f"{current_value}."
)

View File

@@ -1,6 +1,10 @@
import logging
from typing import Any
from pydase.observer_pattern.observable.decorators import (
_validate_value_was_correctly_set,
has_validate_set_decorator,
)
from pydase.observer_pattern.observable.observable_object import ObservableObject
from pydase.utils.helpers import is_property_attribute
@@ -35,7 +39,12 @@ class Observable(ObservableObject):
super().__setattr__(name, value)
self._notify_changed(name, value)
if is_property_attribute(self, name) and has_validate_set_decorator(
getattr(type(self), name)
):
_validate_value_was_correctly_set(obj=self, name=name, value=value)
else:
self._notify_changed(name, value)
def __getattribute__(self, name: str) -> Any:
if is_property_attribute(self, name):

View File

@@ -166,8 +166,7 @@ class _ObservableList(ObservableObject, list[Any]):
def append(self, __object: Any) -> None:
self._notify_change_start("")
self._initialise_new_objects(f"[{len(self)}]", __object)
super().append(__object)
super().append(self._initialise_new_objects(f"[{len(self)}]", __object))
self._notify_changed("", self)
def clear(self) -> None:

View File

@@ -2,19 +2,25 @@ import asyncio
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from types import FrameType
from typing import Any, Protocol, TypedDict
from uvicorn.server import HANDLED_SIGNALS
from pydase import DataService
from pydase.config import ServiceConfig
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.server.web_server import WebServer
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__)
@@ -207,8 +213,9 @@ class Server:
addin_server.__module__ + "." + addin_server.__class__.__name__
)
future_or_task = self._loop.create_task(addin_server.serve())
self.servers[server_name] = future_or_task
server_task = self._loop.create_task(addin_server.serve())
server_task.add_done_callback(self.handle_server_shutdown)
self.servers[server_name] = server_task
if self._enable_web:
self._web_server = WebServer(
data_service_observer=self._observer,
@@ -216,8 +223,22 @@ class Server:
port=self._web_port,
**self._kwargs,
)
future_or_task = self._loop.create_task(self._web_server.serve())
self.servers["web"] = future_or_task
server_task = self._loop.create_task(self._web_server.serve())
server_task.add_done_callback(self.handle_server_shutdown)
self.servers["web"] = server_task
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:
while not self.should_exit:
@@ -229,7 +250,9 @@ class Server:
logger.info("Saving data to %s.", self._state_manager.filename)
self._state_manager.save_state()
logger.debug("Cancelling servers")
await self.__cancel_servers()
logger.debug("Cancelling tasks")
await self.__cancel_tasks()
async def __cancel_servers(self) -> None:
@@ -240,7 +263,7 @@ class Server:
except asyncio.CancelledError:
logger.debug("Cancelled '%s' server.", server_name)
except Exception as e:
logger.warning("Unexpected exception: %s", e)
logger.error("Unexpected exception: %s", e)
async def __cancel_tasks(self) -> None:
for task in asyncio.all_tasks(self._loop):

View 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

View File

@@ -0,0 +1,70 @@
import logging
from typing import TYPE_CHECKING
import aiohttp.web
import aiohttp_middlewares.error
from pydase.data_service.state_manager import StateManager
from pydase.server.web_server.api.v1.endpoints import (
get_value,
trigger_method,
update_value,
)
from pydase.utils.serialization.serializer import dump
if TYPE_CHECKING:
from pydase.server.web_server.sio_setup import TriggerMethodDict, UpdateDict
logger = logging.getLogger(__name__)
API_VERSION = "v1"
STATUS_OK = 200
STATUS_FAILED = 400
def create_api_application(state_manager: StateManager) -> aiohttp.web.Application:
api_application = aiohttp.web.Application(
middlewares=(aiohttp_middlewares.error.error_middleware(),)
)
async def _get_value(request: aiohttp.web.Request) -> aiohttp.web.Response:
logger.info("Handle api request: %s", request)
access_path = request.rel_url.query["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) -> aiohttp.web.Response:
data: UpdateDict = await request.json()
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) -> aiohttp.web.Response:
data: TriggerMethodDict = await request.json()
try:
return aiohttp.web.json_response(trigger_method(state_manager, data))
except Exception as e:
logger.exception(e)
return aiohttp.web.json_response(dump(e), status=STATUS_FAILED)
api_application.router.add_get("/get_value", _get_value)
api_application.router.add_put("/update_value", _update_value)
api_application.router.add_put("/trigger_method", _trigger_method)
return api_application

View File

@@ -0,0 +1,35 @@
from typing import Any
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.deserializer import loads
from pydase.utils.serialization.serializer import Serializer, dump
from pydase.utils.serialization.types import SerializedObject
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 dump(method(*args, **kwargs))

View File

@@ -1,19 +1,30 @@
import asyncio
import logging
import sys
from typing import Any, TypedDict
if sys.version_info < (3, 11):
from typing_extensions import NotRequired
else:
from typing import NotRequired
import click
import socketio # type: ignore[import-untyped]
import pydase.server.web_server.api.v1.endpoints
import pydase.utils.serialization.deserializer
import pydase.utils.serialization.serializer
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.utils.helpers import get_object_attr_from_path
from pydase.utils.logging import SocketIOHandler
from pydase.utils.serialization.deserializer import Deserializer
from pydase.utils.serialization.serializer import SerializedObject, dump
from pydase.utils.serialization.serializer import SerializedObject
logger = logging.getLogger(__name__)
# These functions can be monkey-patched by other libraries at runtime
loads = pydase.utils.serialization.deserializer.loads
dump = pydase.utils.serialization.serializer.dump
class UpdateDict(TypedDict):
"""
@@ -34,8 +45,8 @@ class UpdateDict(TypedDict):
class TriggerMethodDict(TypedDict):
access_path: str
args: SerializedObject
kwargs: SerializedObject
args: NotRequired[SerializedObject]
kwargs: NotRequired[SerializedObject]
class RunMethodDict(TypedDict):
@@ -80,9 +91,9 @@ def setup_sio_server(
state_manager = observer.state_manager
if enable_cors:
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
sio = socketio.AsyncServer(async_mode="aiohttp", cors_allowed_origins="*")
else:
sio = socketio.AsyncServer(async_mode="asgi")
sio = socketio.AsyncServer(async_mode="aiohttp")
setup_sio_events(sio, state_manager)
setup_logging_handler(sio)
@@ -129,25 +140,24 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
"Client [%s] requested service serialization",
click.style(str(sid), fg="cyan"),
)
return state_manager.cache
return state_manager.cache_manager.cache
@sio.event
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None: # type: ignore
path = data["access_path"]
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None:
try:
state_manager.set_service_attribute_value_by_path(
path=path, serialized_value=data["value"]
pydase.server.web_server.api.v1.endpoints.update_value(
state_manager=state_manager, data=data
)
except Exception as e:
logger.exception(e)
return dump(e)
return None
@sio.event
async def get_value(sid: str, access_path: str) -> SerializedObject:
try:
return state_manager._data_service_cache.get_value_dict_from_cache(
access_path
return pydase.server.web_server.api.v1.endpoints.get_value(
state_manager=state_manager, access_path=access_path
)
except Exception as e:
logger.exception(e)
@@ -156,17 +166,14 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
@sio.event
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
try:
method = get_object_attr_from_path(
state_manager.service, data["access_path"]
return pydase.server.web_server.api.v1.endpoints.trigger_method(
state_manager=state_manager, data=data
)
args = Deserializer.deserialize(data["args"])
kwargs: dict[str, Any] = Deserializer.deserialize(data["kwargs"])
return dump(method(*args, **kwargs))
except Exception as e:
logger.error(e)
return dump(e)
def setup_logging_handler(sio: socketio.AsyncServer) -> None:
logger = logging.getLogger()
logger.addHandler(SocketIOHandler(sio))
logging.getLogger().addHandler(SocketIOHandler(sio))
logging.getLogger("pydase").addHandler(SocketIOHandler(sio))

View File

@@ -4,23 +4,23 @@ import logging
from pathlib import Path
from typing import Any
import socketio # type: ignore[import-untyped]
import uvicorn
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
import aiohttp.web
import aiohttp_middlewares.cors
from pydase.config import ServiceConfig, WebServerConfig
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 (
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.version import __version__
logger = logging.getLogger(__name__)
API_VERSION = "v1"
class WebServer:
@@ -88,24 +88,68 @@ class WebServer:
async def serve(self) -> None:
self._loop = asyncio.get_running_loop()
self._setup_socketio()
self._setup_fastapi_app()
self.web_server = uvicorn.Server(
uvicorn.Config(self.__fastapi_app, host=self.host, port=self.port)
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
async def index(request: aiohttp.web.Request) -> aiohttp.web.FileResponse:
return aiohttp.web.FileResponse(self.frontend_src / "index.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("/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
self.web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
await self.web_server.serve()
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:
logger.debug("Initialising web server configuration...")
file_path = self._service_config_dir / "web_settings.json"
if self._generate_web_settings:
# File does not exist, create it with default content
logger.debug("Generating web settings file...")
file_path = self._service_config_dir / "web_settings.json"
# File does not exist, create it with default content
file_path.parent.mkdir(
parents=True, exist_ok=True
) # Ensure directory exists
@@ -148,54 +192,3 @@ class WebServer:
}
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

View File

@@ -3,7 +3,7 @@ from typing import TypedDict
import pint
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
Unit = units.Unit

View File

@@ -60,6 +60,10 @@ def parse_full_access_path(path: str) -> list[str]:
list[str]
A list of components that make up the path, including attribute names,
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:
# \w+ - Words
@@ -118,16 +122,20 @@ def get_class_and_instance_attributes(obj: object) -> dict[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:
if part.startswith("["):
deserialized_part = parse_serialized_key(part)
target_obj = target_obj[deserialized_part]
else:
try:
target_obj = getattr(target_obj, part)
except AttributeError:
logger.debug("Attribute %a does not exist in the object.", part)
return None
target_obj = getattr(target_obj, part)
return target_obj
@@ -145,7 +153,10 @@ 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.
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)
return get_object_by_path_parts(target_obj, path_parts)
@@ -184,6 +195,4 @@ def function_has_arguments(func: Callable[..., Any]) -> bool:
parameters.pop("self", None)
# Check if there are any parameters left which would indicate additional arguments.
if len(parameters) > 0:
return True
return False
return len(parameters) > 0

View File

@@ -1,16 +1,58 @@
import asyncio
import logging
import logging.config
import sys
from collections.abc import Callable
from copy import copy
from typing import ClassVar, Literal
import click
import socketio # type: ignore[import-untyped]
import uvicorn.logging
from uvicorn.config import LOGGING_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",
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
},
"loggers": {
"pydase": {"handlers": ["default"], "level": LOG_LEVEL, "propagate": False},
"aiohttp_middlewares": {
"handlers": ["default"],
"level": logging.WARNING,
"propagate": False,
},
"aiohttp": {
"handlers": ["default"],
"level": logging.INFO,
"propagate": False,
},
},
}
class DefaultFormatter(logging.Formatter):
"""
A custom log formatter class that:
@@ -19,6 +61,36 @@ class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
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
recordcopy = copy(record)
levelname = recordcopy.levelname
@@ -47,7 +119,8 @@ class SocketIOHandler(logging.Handler):
self._sio = sio
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:
log_entry = self.format(record)
@@ -64,86 +137,16 @@ class SocketIOHandler(logging.Handler):
)
def setup_logging(level: str | int | None = None) -> None:
def setup_logging() -> None:
"""
Configures the logging settings for the application.
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,
with an option to override the level. By default, in a development environment, the
log level is set to DEBUG, whereas in 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")
```
messages. The log level is determined based on the application's operation mode. By
default, in a development environment, the log level is set to DEBUG, whereas in
other environments, it is set to INFO.
"""
logger = logging.getLogger()
logger.debug("Configuring pydase logging.")
if pydase.config.OperationMode().environment == "development":
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.
logger.setLevel(log_level)
# create console handler and set level to debug
ch = logging.StreamHandler()
# add formatter to ch
ch.setFormatter(
DefaultFormatter(
fmt=(
"%(asctime)s.%(msecs)03d | %(levelprefix)s | "
"%(name)s:%(funcName)s:%(lineno)d - %(message)s"
),
datefmt="%Y-%m-%d %H:%M:%S",
)
)
# add ch to logger
logger.addHandler(ch)
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"
logging.config.dictConfig(LOGGING_CONFIG)

View File

@@ -1,16 +1,22 @@
import enum
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Any, NoReturn, cast
import pydase
import pydase.components
import pydase.units as u
from pydase.utils.helpers import get_component_classes
from pydase.utils.serialization.types import SerializedObject
from pydase.utils.serialization.types import (
SerializedDatetime,
SerializedException,
SerializedObject,
)
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
@@ -33,6 +39,7 @@ class Deserializer:
"dict": cls.deserialize_dict,
"method": cls.deserialize_method,
"Exception": cls.deserialize_exception,
"datetime": cls.deserialize_datetime,
}
# First go through handled types (as ColouredEnum is also within the components)
@@ -57,6 +64,10 @@ class Deserializer:
def deserialize_quantity(cls, serialized_object: SerializedObject) -> Any:
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
def deserialize_enum(
cls,
@@ -88,11 +99,11 @@ class Deserializer:
return
@classmethod
def deserialize_exception(cls, serialized_object: SerializedObject) -> NoReturn:
def deserialize_exception(cls, serialized_object: SerializedException) -> NoReturn:
import builtins
try:
exception = getattr(builtins, serialized_object["name"]) # type: ignore
exception = getattr(builtins, serialized_object["name"])
except AttributeError:
exception = type(serialized_object["name"], (Exception,), {}) # type: ignore
raise exception(serialized_object["value"])
@@ -108,7 +119,7 @@ class Deserializer:
return pydase.DataService
return None
@classmethod
@classmethod # TODO: this shouldn't be a class method
def create_attr_property(cls, serialized_attr: SerializedObject) -> property:
attr_name = serialized_attr["full_access_path"].split(".")[-1]

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import inspect
import logging
import sys
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -21,6 +22,7 @@ from pydase.utils.serialization.types import (
DataServiceTypes,
SerializedBool,
SerializedDataService,
SerializedDatetime,
SerializedDict,
SerializedEnum,
SerializedException,
@@ -49,41 +51,40 @@ class SerializationPathError(Exception):
pass
class SerializationValueError(Exception):
pass
class Serializer:
@staticmethod
def serialize_object(obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
@classmethod
def serialize_object(cls, obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
result: SerializedObject
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, 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):
result = Serializer._serialize_list(obj, access_path=access_path)
result = cls._serialize_list(obj, access_path=access_path)
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
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
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
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):
result = Serializer._serialize_primitive(obj, access_path=access_path)
result = cls._serialize_primitive(obj, access_path=access_path)
try:
return result
@@ -92,8 +93,9 @@ class Serializer:
f"Could not serialized object of type {type(obj)}."
)
@staticmethod
@classmethod
def _serialize_primitive(
cls,
obj: float | bool | str | None,
access_path: str,
) -> (
@@ -112,8 +114,18 @@ class Serializer:
"value": obj,
}
@staticmethod
def _serialize_exception(obj: Exception) -> SerializedException:
@classmethod
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 {
"full_access_path": "",
"doc": None,
@@ -123,8 +135,8 @@ class Serializer:
"name": obj.__class__.__name__,
}
@staticmethod
def _serialize_enum(obj: Enum, access_path: str = "") -> SerializedEnum:
@classmethod
def _serialize_enum(cls, obj: Enum, access_path: str = "") -> SerializedEnum:
import pydase.components.coloured_enum
value = obj.name
@@ -149,9 +161,9 @@ class Serializer:
},
}
@staticmethod
@classmethod
def _serialize_quantity(
obj: u.Quantity, access_path: str = ""
cls, obj: u.Quantity, access_path: str = ""
) -> SerializedQuantity:
doc = get_attribute_doc(obj)
value: u.QuantityDict = {"magnitude": obj.m, "unit": str(obj.u)}
@@ -163,13 +175,15 @@ class Serializer:
"doc": doc,
}
@staticmethod
def _serialize_dict(obj: dict[str, Any], access_path: str = "") -> SerializedDict:
@classmethod
def _serialize_dict(
cls, obj: dict[str, Any], access_path: str = ""
) -> SerializedDict:
readonly = False
doc = get_attribute_doc(obj)
value = {}
for key, val in obj.items():
value[key] = Serializer.serialize_object(
value[key] = cls.serialize_object(
val, access_path=f'{access_path}["{key}"]'
)
return {
@@ -180,12 +194,12 @@ class Serializer:
"doc": doc,
}
@staticmethod
def _serialize_list(obj: list[Any], access_path: str = "") -> SerializedList:
@classmethod
def _serialize_list(cls, obj: list[Any], access_path: str = "") -> SerializedList:
readonly = False
doc = get_attribute_doc(obj)
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)
]
return {
@@ -196,9 +210,9 @@ class Serializer:
"doc": doc,
}
@staticmethod
@classmethod
def _serialize_method(
obj: Callable[..., Any], access_path: str = ""
cls, obj: Callable[..., Any], access_path: str = ""
) -> SerializedMethod:
readonly = True
doc = get_attribute_doc(obj)
@@ -231,9 +245,9 @@ class Serializer:
"frontend_render": frontend_render,
}
@staticmethod
@classmethod
def _serialize_data_service(
obj: AbstractDataService, access_path: str = ""
cls, obj: AbstractDataService, access_path: str = ""
) -> SerializedDataService:
readonly = False
doc = get_attribute_doc(obj)
@@ -274,7 +288,7 @@ class Serializer:
val = getattr(obj, 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:
@@ -340,7 +354,7 @@ def set_nested_value_by_path(
next_level_serialized_object = get_container_item_by_key(
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)
return
@@ -450,10 +464,6 @@ def get_container_item_by_key(
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
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)

View File

@@ -45,6 +45,11 @@ class SerializedString(SerializedObjectBase):
type: Literal["str"]
class SerializedDatetime(SerializedObjectBase):
type: Literal["datetime"]
value: str
class SerializedEnum(SerializedObjectBase):
name: str
value: str
@@ -107,6 +112,7 @@ SerializedObject = (
| SerializedFloat
| SerializedInteger
| SerializedString
| SerializedDatetime
| SerializedList
| SerializedDict
| SerializedNoneType

View File

@@ -7,7 +7,7 @@ import pytest
from pydase.client.proxy_loader import ProxyAttributeError
@pytest.fixture(scope="session")
@pytest.fixture(scope="module")
def pydase_client() -> Generator[pydase.Client, None, Any]:
class SubService(pydase.DataService):
name = "SubService"
@@ -121,12 +121,16 @@ def test_dict(pydase_client: pydase.Client) -> None:
# pop will remove the dictionary entry on the server
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:
# Tab completion gets its suggestions from the __dir__ class method
assert all(
x in pydase_client.proxy.__dir__()
for x in [
"dict_attr",
"list_attr",
"my_method",
"my_property",

View File

@@ -1,15 +1,12 @@
import asyncio
import logging
import pydase
import pydase.components.device_connection
import pytest
from pytest import LogCaptureFixture
logger = logging.getLogger(__name__)
@pytest.mark.asyncio
@pytest.mark.asyncio(scope="function")
async def test_reconnection(caplog: LogCaptureFixture) -> None:
class MyService(pydase.components.device_connection.DeviceConnection):
def __init__(

View File

@@ -1,4 +1,3 @@
import logging
import pydase
import pydase.components
@@ -7,8 +6,6 @@ from pydase.data_service.state_manager import StateManager
from pydase.utils.serialization.serializer import dump
from pytest import LogCaptureFixture
logger = logging.getLogger(__name__)
def test_image_functions(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):

View File

@@ -8,7 +8,7 @@ from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
logger = logging.getLogger(__name__)
logger = logging.getLogger("pydase")
def test_number_slider(caplog: LogCaptureFixture) -> None:

11
tests/conftest.py Normal file
View File

@@ -0,0 +1,11 @@
import logging
import pytest
@pytest.fixture
def caplog(caplog: pytest.LogCaptureFixture):
logger = logging.getLogger("pydase")
logger.propagate = True
yield caplog

View File

@@ -22,20 +22,20 @@ def test_nested_attributes_cache_callback() -> None:
service_instance.name = "Peepz"
assert (
state_manager._data_service_cache.get_value_dict_from_cache("name")["value"]
state_manager.cache_manager.get_value_dict_from_cache("name")["value"]
== "Peepz"
)
service_instance.class_attr.name = "Ciao"
assert (
state_manager._data_service_cache.get_value_dict_from_cache("class_attr.name")[
state_manager.cache_manager.get_value_dict_from_cache("class_attr.name")[
"value"
]
== "Ciao"
)
@pytest.mark.asyncio
@pytest.mark.asyncio(scope="function")
async def test_task_status_update() -> None:
class ServiceClass(pydase.DataService):
name = "World"
@@ -48,24 +48,20 @@ async def test_task_status_update() -> None:
DataServiceObserver(state_manager)
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")["type"]
state_manager.cache_manager.get_value_dict_from_cache("my_method")["type"]
== "method"
)
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[
"value"
]
state_manager.cache_manager.get_value_dict_from_cache("my_method")["value"]
is None
)
service_instance.start_my_method() # type: ignore
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")["type"]
state_manager.cache_manager.get_value_dict_from_cache("my_method")["type"]
== "method"
)
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[
"value"
]
state_manager.cache_manager.get_value_dict_from_cache("my_method")["value"]
== "RUNNING"
)

View File

@@ -1,9 +1,11 @@
import logging
from typing import Any
import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.utils.serialization.serializer import SerializationError
logger = logging.getLogger()
@@ -122,3 +124,25 @@ def test_dynamic_list_entry_with_property(caplog: pytest.LogCaptureFixture) -> N
assert "'list_attr[0].name' changed to 'Hello'" not in caplog.text
assert "'list_attr[0].name' changed to 'Hoooo'" in caplog.text
def test_private_attribute_does_not_have_to_be_serializable() -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.publ_attr: Any = 1
self.__priv_attr = (1,)
def change_publ_attr(self) -> None:
self.publ_attr = (2,) # cannot be serialized
def change_priv_attr(self) -> None:
self.__priv_attr = (2,)
service_instance = MyService()
pydase.Server(service_instance)
with pytest.raises(SerializationError):
service_instance.change_publ_attr()
service_instance.change_priv_attr()

View File

@@ -7,10 +7,10 @@ from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
logger = logging.getLogger()
logger = logging.getLogger("pydase")
@pytest.mark.asyncio
@pytest.mark.asyncio(scope="function")
async def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:
@@ -36,7 +36,7 @@ async def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
assert "'my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
@pytest.mark.asyncio
@pytest.mark.asyncio(scope="function")
async def test_DataService_subclass_autostart_task_callback(
caplog: LogCaptureFixture,
) -> None:
@@ -66,7 +66,7 @@ async def test_DataService_subclass_autostart_task_callback(
assert "'sub_service.my_other_task' changed to 'TaskStatus.RUNNING'" in caplog.text
@pytest.mark.asyncio
@pytest.mark.asyncio(scope="function")
async def test_DataService_subclass_list_autostart_task_callback(
caplog: LogCaptureFixture,
) -> None:
@@ -108,7 +108,7 @@ async def test_DataService_subclass_list_autostart_task_callback(
)
@pytest.mark.asyncio
@pytest.mark.asyncio(scope="function")
async def test_start_and_stop_task_methods(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:

View File

@@ -0,0 +1,129 @@
import asyncio
import threading
import pydase
import pytest
from pydase.observer_pattern.observable.decorators import validate_set
def linspace(start: float, stop: float, n: int):
if n == 1:
yield stop
return
h = (stop - start) / (n - 1)
for i in range(n):
yield start + h * i
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
asyncio.set_event_loop(loop)
loop.run_forever()
def test_validate_set_precision(caplog: pytest.LogCaptureFixture) -> None:
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._value_1 = 0.0
self._value_2 = 0.0
@property
def value_1(self) -> float:
return self._value_1
@value_1.setter
@validate_set(precision=None)
def value_1(self, value: float) -> None:
self._value_1 = round(value, 1)
@property
def value_2(self) -> float:
return self._value_2
@value_2.setter
@validate_set(precision=1e-1)
def value_2(self, value: float) -> None:
self._value_2 = round(value, 1)
service_instance = Service()
pydase.Server(service_instance) # needed to initialise observer
with pytest.raises(ValueError) as exc_info:
service_instance.value_1 = 1.12
assert "Failed to set value to 1.12 within 1 second. Current value: 1.1" in str(
exc_info
)
caplog.clear()
service_instance.value_2 = 1.12 # no assertion raised
assert service_instance.value_2 == 1.1 # noqa
assert "'value_2' changed to '1.1'" in caplog.text
def test_validate_set_timeout(caplog: pytest.LogCaptureFixture) -> None:
class RemoteDevice:
def __init__(self) -> None:
self._value = 0.0
self.loop = asyncio.new_event_loop()
self._lock = asyncio.Lock()
self.thread = threading.Thread(
target=asyncio_loop_thread, args=(self.loop,), daemon=True
)
self.thread.start()
def close_connection(self) -> None:
self.loop.call_soon_threadsafe(self.loop.stop)
self.thread.join()
@property
def value(self) -> float:
future = asyncio.run_coroutine_threadsafe(self._get_value(), self.loop)
return future.result()
async def _get_value(self) -> float:
return self._value
@value.setter
def value(self, value: float) -> None:
self.loop.create_task(self.set_value(value))
async def set_value(self, value) -> None:
for i in linspace(self._value, value, 10):
self._value = i
await asyncio.sleep(0.1)
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._driver = RemoteDevice()
@property
def value_1(self) -> float:
return self._driver.value
@value_1.setter
@validate_set(timeout=0.5)
def value_1(self, value: float) -> None:
self._driver.value = value
@property
def value_2(self) -> float:
return self._driver.value
@value_2.setter
@validate_set(timeout=1)
def value_2(self, value: float) -> None:
self._driver.value = value
service_instance = Service()
with pytest.raises(ValueError) as exc_info:
service_instance.value_1 = 2.0
assert "Failed to set value to 2.0 within 0.5 seconds. Current value:" in str(
exc_info
)
service_instance.value_2 = 3.0 # no assertion raised
service_instance._driver.close_connection()

View File

@@ -5,7 +5,7 @@ import pytest
from pydase.observer_pattern.observable import Observable
from pydase.observer_pattern.observer import Observer
logger = logging.getLogger(__name__)
logger = logging.getLogger("pydase")
class MyObserver(Observer):

View File

@@ -5,7 +5,7 @@ import pytest
from pydase.observer_pattern.observable import Observable
from pydase.observer_pattern.observer import Observer
logger = logging.getLogger(__name__)
logger = logging.getLogger("pydase")
class MyObserver(Observer):

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