126 Commits

Author SHA1 Message Date
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
Mose Müller
9fa8f06280 Merge pull request #127 from tiqi-group/feature/ignore_coroutine
Skip coroutines with arguments instead of raising an exception
2024-05-27 15:10:46 +02:00
Mose Müller
84abd63d56 Merge branch 'main' into feature/ignore_coroutine 2024-05-27 15:08:14 +02:00
Mose Müller
999a6016ff using __future__.annotations instead of quoted types 2024-05-27 14:51:49 +02:00
Mose Müller
19f91b7cf3 removes TaskDefinitionError 2024-05-27 14:42:54 +02:00
Mose Müller
a0b7b92898 fixes test 2024-05-27 14:42:30 +02:00
Mose Müller
d7e604992d updates wording and formatting 2024-05-27 14:42:26 +02:00
Mose Müller
2d1d228c78 Merge pull request #128 from tiqi-group/refactor/remove_unused_attribute_key_from_observers_dict
Refactor: remove unused attribute key from observers dict
2024-05-21 14:08:48 +02:00
Mose Müller
9c3c92361b updates tests 2024-05-21 14:03:25 +02:00
Mose Müller
ba9dbc03f1 removes attribute key from observers dict if list of observers is empty 2024-05-21 14:03:21 +02:00
Mose Müller
f783d0b25c Merge pull request #126 from tiqi-group/fix/memory_leak
Fix memory leak in ObservableObject
2024-05-21 13:51:03 +02:00
Mose Müller
8285a37a4c updates version to v0.8.3 2024-05-21 13:43:13 +02:00
Mose Müller
6a894b6154 adds test for dict/list garbage collection 2024-05-21 13:42:25 +02:00
Mose Müller
f9a5352efe moves lines adding weakref to mapping dict into _initialise_new_objects
This groups together all the lines that add elements to or get elements from the mapping dicts.
2024-05-21 13:42:25 +02:00
Mose Müller
9c5d133d65 fixes types 2024-05-21 10:51:13 +02:00
Martin Stadler
eacd5bc6b1 Skip coroutines with arguments instead of raising an exception 2024-05-20 17:41:57 +02:00
Martin Stadler
314e89ba38 Use weak references in dict/list mappping to avoid memory leak 2024-05-20 17:25:35 +02:00
Mose Müller
46868743c7 Merge pull request #123 from tiqi-group/36-feat-add-support-for-dictionaries
feat: adds support for dictionaries
2024-04-30 15:48:16 +02:00
Mose Müller
8203e3a498 updates version to v0.8.2 2024-04-30 15:47:50 +02:00
Mose Müller
82b9c14af3 ignores mypy overrides error 2024-04-30 15:46:50 +02:00
Mose Müller
b209ad75bb fixes serializer types and test
pydase dicts can only have stringed keys. This is now reflected in the serializer, as well.
2024-04-30 15:46:39 +02:00
Mose Müller
88a630518b updates ProxyDict types, ignores mypy error 2024-04-30 15:42:48 +02:00
Mose Müller
ed80c92b1f adds dict test for pydase.Client
The pop
2024-04-30 15:33:56 +02:00
Mose Müller
36e30970c5 adds dict.pop to pydase.Client
The pop method removes the element in the dictionary on the server, but it will not
return anything. This is because pop will delete the element on the server, and returned
proxy classes will not be meaningful.
2024-04-30 15:24:15 +02:00
Mose Müller
3384d1bebf adds dict.pop method to ObservableDict 2024-04-30 13:15:42 +02:00
Mose Müller
e2f94c8a28 updates Readme (mentions dict support under standard data types) 2024-04-30 13:02:58 +02:00
Mose Müller
4d442cfadc adds ProxyDict to pydase client 2024-04-30 11:50:15 +02:00
Mose Müller
2701a995e1 updates test_helpers (replacing float key with dotted string key) 2024-04-30 11:48:05 +02:00
Mose Müller
47a73ad55f moves _ObservableDict tests into separate file 2024-04-30 11:47:22 +02:00
Mose Müller
ad4f926472 replaces ObservableDict key type warning with exception 2024-04-30 11:44:07 +02:00
Mose Müller
208dee2b92 dictionaries can only take strings now
The object serializations are passed through json.dumps before they are emitted to the
clients. JSON, apparently, can only handle keys of type string, which is why I have to
limit the dictionary key types to strings, as well.
2024-04-30 11:12:45 +02:00
Mose Müller
02b2d4fb10 observer: ignoring __annotations__ class attribute 2024-04-30 11:09:54 +02:00
Mose Müller
b2f59dd447 updates serializer type (dictionary object) 2024-04-30 10:51:52 +02:00
Mose Müller
33aa8708fd frontend: fixes displayName for dotted dictionary keys 2024-04-30 10:02:46 +02:00
Mose Müller
37d698a1b2 fixes web settings (displayName for dotted dictionary keys) 2024-04-30 10:02:13 +02:00
Mose Müller
8fa91e8121 adds tests for generate_serialized_data_paths and get_data_paths_from_serialized_object 2024-04-30 10:02:13 +02:00
Mose Müller
b9131c9df2 updates generate_serialized_data_paths to handle dictionaries well 2024-04-30 10:02:13 +02:00
Mose Müller
1c1584c2cf fixes dictionary serialization for keys that are not strings 2024-04-30 10:02:13 +02:00
Mose Müller
bb3d6fcce1 updates _ObservableDict
- allows for strings and numbers now
- key will have double quotes (") instead of single quote (') when key is a string
- fixed some few things
- added/updated tests
2024-04-30 10:02:13 +02:00
Mose Müller
e9a7e785dd npm run build 2024-04-30 10:02:13 +02:00
Mose Müller
a214d6d85a using id as form name for number and string component
This removes errors saying that quotes within element name are not allowed.
2024-04-30 10:02:13 +02:00
Mose Müller
6eaf1a03d1 adds onChange prop to number component form field to remove console errors 2024-04-29 15:20:11 +02:00
Mose Müller
31f1c9a8ce adds "None" type to AttributeType type 2024-04-29 15:08:12 +02:00
Mose Müller
02f1dba0f3 frontend: updates stateUtils 2024-04-29 15:05:48 +02:00
Mose Müller
dc40fc299f adds test for failing get_object_by_path_parts 2024-04-26 09:49:27 +02:00
Mose Müller
348f8aac9b removes tests for get_object_attr_from_path (uses get_object_by_path_parts internally) 2024-04-26 09:49:01 +02:00
Mose Müller
b314ae7dec updates helper tests 2024-04-26 09:42:57 +02:00
Mose Müller
25e578fbba parse_full_access_path can match floats inside brackets now 2024-04-26 09:42:26 +02:00
Mose Müller
1ee6a299b2 updates tests for is_property_attribute 2024-04-26 09:25:11 +02:00
Mose Müller
f315cd62d6 moves render_in_frontend function to decorators module, removes unused update_value_if_change function 2024-04-26 09:10:05 +02:00
Mose Müller
87d172b94b simplifies, documents and tests parse_serialized_key helper function 2024-04-26 08:15:21 +02:00
Mose Müller
a2c60a9c40 chore: replacing old method, adding TODO 2024-04-25 17:44:16 +02:00
Mose Müller
66376e2e6c removes parse_keyed_attribute 2024-04-25 17:40:33 +02:00
Mose Müller
d1c00a2612 using parse_full_access_path instead of path.split(".") in state manager 2024-04-25 17:34:48 +02:00
Mose Müller
6dd878a062 uses helper method in serializer 2024-04-25 17:33:42 +02:00
Mose Müller
2898b62b9c adds parse_serialized_key and get_object_by_path_parts helper methods 2024-04-25 17:33:32 +02:00
Mose Müller
b29c86ac2c updates Serializer functions
- using parse_full_access_path instead of parse_keyed_attribute
- renames get_next_level_dict_by_key to get_container_item_by_key
- replaces ensure_exists and get_nested_value by get_or_create_item_in_container

This allows us to handle access paths like "dict_attr['key'][0].some_attr".
2024-04-25 16:52:40 +02:00
Mose Müller
c75b203c3d creates functions to split full access paths and combine the atomic parts back together 2024-04-25 16:30:20 +02:00
Mose Müller
036e80b920 removes warning when using dict as attribute 2024-04-25 14:23:59 +02:00
Mose Müller
de7badd007 fixes exception message 2024-04-25 14:22:49 +02:00
Mose Müller
7e06944018 updates tests for get_next_level_dict_by_key 2024-04-25 10:52:53 +02:00
Mose Müller
4e9e1384df updates get_next_level_dict_by_key to handle lists and dictionaries 2024-04-25 10:52:36 +02:00
Mose Müller
5f7cc7f671 fixes type 2024-04-25 10:51:53 +02:00
Mose Müller
768be76cc8 replaces parseListAttrAndIndex with parseKeyedAttribute inn stateUtils 2024-04-23 14:35:22 +02:00
Mose Müller
8fd83fbd7d updates get_object_attr_from_path to support dictionaries 2024-04-23 14:21:39 +02:00
Mose Müller
564eeeb433 adds dictionary support to state_manager (__update_attribute_by_path) 2024-04-22 19:32:09 +02:00
Mose Müller
216368571a fixes parse_keyed_attributes 2024-04-22 19:31:29 +02:00
Mose Müller
2df1a673ac adds DictComponent to GenericComponent 2024-04-22 19:11:23 +02:00
Mose Müller
d40d9c5e47 adds first version of DictComponent 2024-04-22 19:11:13 +02:00
Mose Müller
6cae76bde1 adds tests for parse_keyed_attribute 2024-04-22 19:11:02 +02:00
Mose Müller
32e2a8a4d1 replaces parse_list_attr_and_index with parse_keyed_attribute to support dictionaries 2024-04-22 19:11:02 +02:00
Mose Müller
0ac4049282 Merge pull request #122 from tiqi-group/fix/ListComponent_item_key
Fix: list component item key
2024-04-22 18:39:51 +02:00
Mose Müller
d24c66e522 npm run build 2024-04-22 18:38:27 +02:00
Mose Müller
9ae6895858 replaces undefined name by full_access_path in ListComponent item 2024-04-22 18:38:03 +02:00
Mose Müller
2b8e25f5f1 Merge pull request #121 from tiqi-group/feat/add_async_method_status_spinner
Feat: adds async method status spinner
2024-04-22 17:49:58 +02:00
Mose Müller
9cfcb1ba0c npm run build 2024-04-22 17:47:14 +02:00
Mose Müller
a73e721b73 adds spinner to async task when waiting for backend status update 2024-04-22 17:46:58 +02:00
Mose Müller
503240aeae Merge pull request #120 from tiqi-group/documentation/coloured_enum
adds note that coloured enum values must be unique
2024-04-17 11:49:45 +02:00
Mose Müller
ba24deecb7 adds note that coloured enum values must be unique 2024-04-17 11:47:43 +02:00
80 changed files with 9075 additions and 21099 deletions

View File

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

View File

@@ -30,6 +30,7 @@
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state) - [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase) - [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
- [Understanding Units in pydase](#understanding-units-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) - [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
- [Customizing the Web Interface](#customizing-the-web-interface) - [Customizing the Web Interface](#customizing-the-web-interface)
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css) - [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
@@ -52,6 +53,7 @@
- [Saving and restoring the service state for service persistence](#understanding-service-persistence) - [Saving and restoring the service state for service persistence](#understanding-service-persistence)
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase) - [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
- [Support for units](#understanding-units-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 --> <!-- Support for additional servers for specific use-cases -->
## Installation ## Installation
@@ -223,6 +225,7 @@ In `pydase`, components are fundamental building blocks that bridge the Python b
- `int` and `float`: Manifested as the `NumberComponent`. - `int` and `float`: Manifested as the `NumberComponent`.
- `bool`: Rendered as a `ButtonComponent`. - `bool`: Rendered as a `ButtonComponent`.
- `list`: Each item displayed individually, named after the list attribute and its index. - `list`: Each item displayed individually, named after the list attribute and its index.
- `dict`: Each key-value pair displayed individually, named after the dictionary attribute and its key. **Note** that the dictionary keys must be strings.
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection. - `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
### Method Components ### Method Components
@@ -638,6 +641,9 @@ my_service.status = MyStatus.FAILED
![ColouredEnum Component](docs/images/ColouredEnum_component.png) ![ColouredEnum Component](docs/images/ColouredEnum_component.png)
**Note** that each enumeration name and value must be unique.
This means that you should use different colour formats when you want to use a colour multiple times.
#### Extending with New Components #### Extending with New Components
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this. Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
@@ -796,6 +802,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/). For more information about what you can do with the units, please consult the documentation of [`pint`](https://pint.readthedocs.io/en/stable/).
## Using `validate_set` to Validate Property Setters
The `validate_set` decorator ensures that a property setter reads back the set value using the property getter and checks it against the desired value.
This decorator can be used to validate that a parameter has been correctly set on a device within a specified precision and timeout.
The decorator takes two keyword arguments: `timeout` and `precision`. The `timeout` argument specifies the maximum time (in seconds) to wait for the value to be within the precision boundary.
If the value is not within the precision boundary after this time, an exception is raised.
The `precision` argument defines the acceptable deviation from the desired value.
If `precision` is `None`, the value must be exact.
For example, if `precision` is set to `1e-5`, the value read from the device must be within ±0.00001 of the desired value.
Heres how to use the `validate_set` decorator in a `DataService` class:
```python
import pydase
from pydase.observer_pattern.observable.decorators import validate_set
class Service(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._device = RemoteDevice() # dummy class
@property
def value(self) -> float:
# Implement how to get the value from the remote device...
return self._device.value
@value.setter
@validate_set(timeout=1.0, precision=1e-5)
def value(self, value: float) -> None:
# Implement how to set the value on the remote device...
self._device.value = value
if __name__ == "__main__":
pydase.Server(Service()).run()
```
## Configuring pydase via Environment Variables ## Configuring pydase via Environment Variables
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. 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.

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. # Logs
logs
# dependencies *.log
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -3,9 +3,7 @@
"bracketSameLine": true, "bracketSameLine": true,
"endOfLine": "auto", "endOfLine": "auto",
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": false,
"tabWidth": 2, "tabWidth": 2,
"vueIndentScriptAndStyle": true, "printWidth": 88
"printWidth": 88,
"trailingComma": "none"
} }

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.\ If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\ - Configure the top-level `parserOptions` property like this:
You may also see any lint errors in the console.
### `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.\ - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - 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
### `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)

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>

17840
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +1,40 @@
{ {
"name": "pydase", "name": "pydase",
"version": "0.1.0",
"private": true, "private": true,
"dependencies": { "version": "0.1.0",
"@emotion/react": "^11.11.1", "type": "module",
"@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": { "scripts": {
"start": "NODE_ENV=development react-scripts start", "dev": "vite",
"build": "BUILD_PATH='../src/pydase/frontend' react-scripts build", "build": "tsc -b && vite build --emptyOutDir",
"test": "react-scripts test", "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"eject": "react-scripts eject" "preview": "vite preview"
}, },
"eslintConfig": { "dependencies": {
"extends": [ "@emotion/styled": "^11.11.0",
"react-app", "@mui/material": "^5.14.1",
"react-app/jest" "bootstrap": "^5.3.3",
] "deep-equal": "^2.2.3",
}, "react": "^18.3.1",
"browserslist": { "react-bootstrap": "^2.10.0",
"production": [ "react-bootstrap-icons": "^1.11.4",
">0.2%", "socket.io-client": "^4.7.1"
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@eslint/js": "^9.6.0",
"@types/react": "^18.0.0", "@types/deep-equal": "^1.0.4",
"@types/react-dom": "^18.0.0", "@types/eslint__js": "^8.42.3",
"@typescript-eslint/eslint-plugin": "^6.11.0", "@types/node": "^20.14.10",
"@typescript-eslint/parser": "^6.9.0", "@types/react": "^18.3.3",
"eslint": "^8.52.0", "@types/react-dom": "^18.3.0",
"eslint-config-prettier": "^9.0.0", "@typescript-eslint/eslint-plugin": "^7.15.0",
"eslint-plugin-prettier": "^5.0.1", "@vitejs/plugin-react-swc": "^3.5.0",
"prettier": "^3.0.3", "eslint": "^8.57.0",
"typescript": "^4.9.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,48 @@
import { useCallback, useEffect, useReducer, useState } from 'react'; import { useCallback, useEffect, useReducer, useState } from "react";
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap'; import { Navbar, Form, Offcanvas, Container } from "react-bootstrap";
import { hostname, port, socket } from './socket'; import { hostname, port, socket } from "./socket";
import './App.css'; import "./App.css";
import { import {
Notifications, Notifications,
Notification, Notification,
LevelName LevelName,
} from './components/NotificationsComponent'; } from "./components/NotificationsComponent";
import { ConnectionToast } from './components/ConnectionToast'; import { ConnectionToast } from "./components/ConnectionToast";
import { setNestedValueByPath, State } from './utils/stateUtils'; import { setNestedValueByPath, State } from "./utils/stateUtils";
import { WebSettingsContext, WebSetting } from './WebSettings'; import { WebSettingsContext, WebSetting } from "./WebSettings";
import { SerializedValue, GenericComponent } from './components/GenericComponent'; import { GenericComponent } from "./components/GenericComponent";
import { SerializedObject } from "./types/SerializedObject";
type Action = type Action =
| { type: 'SET_DATA'; data: State } | { type: "SET_DATA"; data: State }
| { | {
type: 'UPDATE_ATTRIBUTE'; type: "UPDATE_ATTRIBUTE";
fullAccessPath: string; fullAccessPath: string;
newValue: SerializedValue; newValue: SerializedObject;
}; };
type UpdateMessage = { interface UpdateMessage {
data: { full_access_path: string; value: SerializedValue }; data: { full_access_path: string; value: SerializedObject };
}; }
type LogMessage = { interface LogMessage {
levelname: LevelName; levelname: LevelName;
message: string; message: string;
}; }
const reducer = (state: State, action: Action): State => { const reducer = (state: State | null, action: Action): State | null => {
switch (action.type) { switch (action.type) {
case 'SET_DATA': case "SET_DATA":
return action.data; return action.data;
case 'UPDATE_ATTRIBUTE': { case "UPDATE_ATTRIBUTE": {
if (state === null) { if (state === null) {
return null; return null;
} }
return { return {
...state, ...state,
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue) value: setNestedValueByPath(
state.value as Record<string, SerializedObject>,
action.fullAccessPath,
action.newValue,
),
}; };
} }
default: default:
@@ -46,12 +51,19 @@ const reducer = (state: State, action: Action): State => {
}; };
const App = () => { const App = () => {
const [state, dispatch] = useReducer(reducer, null); const [state, dispatch] = useReducer(reducer, null);
const [serviceName, setServiceName] = useState<string | null>(null);
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({}); const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
const [isInstantUpdate, setIsInstantUpdate] = useState(false); const [isInstantUpdate, setIsInstantUpdate] = useState(() => {
const saved = localStorage.getItem("isInstantUpdate");
return saved !== null ? JSON.parse(saved) : false;
});
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showNotification, setShowNotification] = useState(false); const [showNotification, setShowNotification] = useState(() => {
const saved = localStorage.getItem("showNotification");
return saved !== null ? JSON.parse(saved) : false;
});
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [connectionStatus, setConnectionStatus] = useState('connecting'); const [connectionStatus, setConnectionStatus] = useState("connecting");
useEffect(() => { useEffect(() => {
// Allow the user to add a custom css file // Allow the user to add a custom css file
@@ -59,49 +71,62 @@ const App = () => {
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
// If the file exists, create a link element for the custom CSS // If the file exists, create a link element for the custom CSS
const link = document.createElement('link'); const link = document.createElement("link");
link.href = `http://${hostname}:${port}/custom.css`; link.href = `http://${hostname}:${port}/custom.css`;
link.type = 'text/css'; link.type = "text/css";
link.rel = 'stylesheet'; link.rel = "stylesheet";
document.head.appendChild(link); document.head.appendChild(link);
} }
}) })
.catch(console.error); // Handle the error appropriately .catch(console.error); // Handle the error appropriately
socket.on('connect', () => { socket.on("connect", () => {
// Fetch data from the API when the client connects // Fetch data from the API when the client connects
fetch(`http://${hostname}:${port}/service-properties`) fetch(`http://${hostname}:${port}/service-properties`)
.then((response) => response.json()) .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`) fetch(`http://${hostname}:${port}/web-settings`)
.then((response) => response.json()) .then((response) => response.json())
.then((data: Record<string, WebSetting>) => setWebSettings(data)); .then((data: Record<string, WebSetting>) => setWebSettings(data));
setConnectionStatus('connected'); setConnectionStatus("connected");
}); });
socket.on('disconnect', () => { socket.on("disconnect", () => {
setConnectionStatus('disconnected'); setConnectionStatus("disconnected");
setTimeout(() => { setTimeout(() => {
// Only set "reconnecting" is the state is still "disconnected" // Only set "reconnecting" is the state is still "disconnected"
// E.g. when the client has already reconnected // E.g. when the client has already reconnected
setConnectionStatus((currentState) => setConnectionStatus((currentState) =>
currentState === 'disconnected' ? 'reconnecting' : currentState currentState === "disconnected" ? "reconnecting" : currentState,
); );
}, 2000); }, 2000);
}); });
socket.on('notify', onNotify); socket.on("notify", onNotify);
socket.on('log', onLogMessage); socket.on("log", onLogMessage);
return () => { return () => {
socket.off('notify', onNotify); socket.off("notify", onNotify);
socket.off('log', onLogMessage); socket.off("log", onLogMessage);
}; };
}, []); }, []);
// Persist isInstantUpdate and showNotification state changes to localStorage
useEffect(() => {
localStorage.setItem("isInstantUpdate", JSON.stringify(isInstantUpdate));
}, [isInstantUpdate]);
useEffect(() => {
localStorage.setItem("showNotification", JSON.stringify(showNotification));
}, [showNotification]);
// Adding useCallback to prevent notify to change causing a re-render of all // Adding useCallback to prevent notify to change causing a re-render of all
// components // components
const addNotification = useCallback( const addNotification = useCallback(
(message: string, levelname: LevelName = 'DEBUG') => { (message: string, levelname: LevelName = "DEBUG") => {
// Getting the current time in the required format // Getting the current time in the required format
const timeStamp = new Date().toISOString().substring(11, 19); const timeStamp = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it // Adding an id to the notification to provide a way of removing it
@@ -110,15 +135,15 @@ const App = () => {
// Custom logic for notifications // Custom logic for notifications
setNotifications((prevNotifications) => [ setNotifications((prevNotifications) => [
{ levelname, id, message, timeStamp }, { levelname, id, message, timeStamp },
...prevNotifications ...prevNotifications,
]); ]);
}, },
[] [],
); );
const removeNotificationById = (id: number) => { const removeNotificationById = (id: number) => {
setNotifications((prevNotifications) => setNotifications((prevNotifications) =>
prevNotifications.filter((n) => n.id !== id) prevNotifications.filter((n) => n.id !== id),
); );
}; };
@@ -131,9 +156,9 @@ const App = () => {
// Dispatching the update to the reducer // Dispatching the update to the reducer
dispatch({ dispatch({
type: 'UPDATE_ATTRIBUTE', type: "UPDATE_ATTRIBUTE",
fullAccessPath, fullAccessPath,
newValue newValue,
}); });
} }
@@ -149,7 +174,7 @@ const App = () => {
<> <>
<Navbar expand={false} bg="primary" variant="dark" fixed="top"> <Navbar expand={false} bg="primary" variant="dark" fixed="top">
<Container fluid> <Container fluid>
<Navbar.Brand>Data Service App</Navbar.Brand> <Navbar.Brand>{serviceName}</Navbar.Brand>
<Navbar.Toggle aria-controls="offcanvasNavbar" onClick={handleShowSettings} /> <Navbar.Toggle aria-controls="offcanvasNavbar" onClick={handleShowSettings} />
</Container> </Container>
</Navbar> </Navbar>
@@ -188,7 +213,7 @@ const App = () => {
<div className="App navbarOffset"> <div className="App navbarOffset">
<WebSettingsContext.Provider value={webSettings}> <WebSettingsContext.Provider value={webSettings}>
<GenericComponent <GenericComponent
attribute={state as SerializedValue} attribute={state as SerializedObject}
isInstantUpdate={isInstantUpdate} isInstantUpdate={isInstantUpdate}
addNotification={addNotification} addNotification={addNotification}
/> />

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,39 @@
import { useState } from 'react'; import { useEffect, useState } from "react";
import React from 'react'; import React from "react";
import { Card, Collapse } from 'react-bootstrap'; import { Card, Collapse } from "react-bootstrap";
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons'; import { ChevronDown, ChevronRight } from "react-bootstrap-icons";
import { SerializedValue, GenericComponent } from './GenericComponent'; import { GenericComponent } from "./GenericComponent";
import { LevelName } from './NotificationsComponent'; import { LevelName } from "./NotificationsComponent";
import { SerializedObject } from "../types/SerializedObject";
type DataServiceProps = { interface DataServiceProps {
props: DataServiceJSON; props: DataServiceJSON;
isInstantUpdate: boolean; isInstantUpdate: boolean;
addNotification: (message: string, levelname?: LevelName) => void; addNotification: (message: string, levelname?: LevelName) => void;
displayName: string; displayName: string;
id: string; id: string;
}; }
export type DataServiceJSON = Record<string, SerializedValue>; export type DataServiceJSON = Record<string, SerializedObject>;
export const DataServiceComponent = React.memo( export const DataServiceComponent = React.memo(
({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => { ({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => {
const [open, setOpen] = useState(true); // Retrieve the initial state from localStorage, default to true if not found
const [open, setOpen] = useState(() => {
const savedState = localStorage.getItem(`dataServiceComponent-${id}-open`);
return savedState !== null ? JSON.parse(savedState) : true;
});
if (displayName !== '') { // Update localStorage whenever the state changes
useEffect(() => {
localStorage.setItem(`dataServiceComponent-${id}-open`, JSON.stringify(open));
}, [open]);
if (displayName !== "") {
return ( return (
<div className="component dataServiceComponent" id={id}> <div className="component dataServiceComponent" id={id}>
<Card> <Card>
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}> <Card.Header onClick={() => setOpen(!open)} style={{ cursor: "pointer" }}>
{displayName} {open ? <ChevronDown /> : <ChevronRight />} {displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header> </Card.Header>
<Collapse in={open}> <Collapse in={open}>
@@ -55,5 +65,7 @@ export const DataServiceComponent = React.memo(
</div> </div>
); );
} }
} },
); );
DataServiceComponent.displayName = "DataServiceComponent";

View File

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

View File

@@ -0,0 +1,40 @@
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";
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 renderCount = useRenderCount();
const valueArray = Object.values(value);
return (
<div className={"listComponent"} id={id}>
{process.env.NODE_ENV === "development" && <div>Render count: {renderCount}</div>}
<DocStringComponent docString={docString} />
{valueArray.map((item) => {
return (
<GenericComponent
key={item.full_access_path}
attribute={item}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
})}
</div>
);
});
DictComponent.displayName = "DictComponent";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,107 +1,175 @@
import { SerializedValue } from '../components/GenericComponent'; import { SerializedObject } from "../types/SerializedObject";
export type State = { export interface State {
type: string; type: string;
value: Record<string, SerializedValue> | null; name: string;
value: Record<string, SerializedObject> | null;
readonly: boolean; readonly: boolean;
doc: string | null; doc: string | null;
}; }
export function setNestedValueByPath( /**
serializationDict: Record<string, SerializedValue>, * Splits a full access path into its atomic parts, separating attribute names, numeric
path: string, * indices (including floating points), and string keys within indices.
serializedValue: SerializedValue *
): Record<string, SerializedValue> { * @param path The full access path string to be split into components.
const parentPathParts = path.split('.').slice(0, -1); * @returns An array of components that make up the path, including attribute names,
const attrName = path.split('.').pop(); * numeric indices, and string keys as separate elements.
*/
export function parseFullAccessPath(path: string): string[] {
// The pattern matches:
// \w+ - Words
// \[\d+\.\d+\] - Floating point numbers inside brackets
// \[\d+\] - Integers inside brackets
// \["[^"]*"\] - Double-quoted strings inside brackets
// \['[^']*'\] - Single-quoted strings inside brackets
const pattern = /\w+|\[\d+\.\d+\]|\[\d+\]|\["[^"]*"\]|\['[^']*'\]/g;
const matches = path.match(pattern);
if (!attrName) { return matches ?? []; // Return an empty array if no matches found
throw new Error('Invalid path'); }
/**
* Parse a serialized key and convert it to an appropriate type (number or string).
*
* @param serializedKey The serialized key, which might be enclosed in brackets and quotes.
* @returns The processed key as a number or an unquoted string.
*
* Examples:
* console.log(parseSerializedKey("attr_name")); // Outputs: attr_name (string)
* console.log(parseSerializedKey("[123]")); // Outputs: 123 (number)
* console.log(parseSerializedKey("[12.3]")); // Outputs: 12.3 (number)
* console.log(parseSerializedKey("['hello']")); // Outputs: hello (string)
* console.log(parseSerializedKey('["12.34"]')); // Outputs: "12.34" (string)
* console.log(parseSerializedKey('["complex"]'));// Outputs: "complex" (string)
*/
function parseSerializedKey(serializedKey: string): string | number {
// Strip outer brackets if present
if (serializedKey.startsWith("[") && serializedKey.endsWith("]")) {
serializedKey = serializedKey.slice(1, -1);
} }
let currentSerializedValue: SerializedValue; // Strip quotes if the resulting string is quoted
const newSerializationDict: Record<string, SerializedValue> = JSON.parse( if (
JSON.stringify(serializationDict) (serializedKey.startsWith("'") && serializedKey.endsWith("'")) ||
(serializedKey.startsWith('"') && serializedKey.endsWith('"'))
) {
return serializedKey.slice(1, -1);
}
// Try converting to a number if the string is not quoted
const parsedNumber = parseFloat(serializedKey);
if (!isNaN(parsedNumber)) {
return parsedNumber;
}
// Return the original string if it's not a valid number
return serializedKey;
}
function getOrCreateItemInContainer(
container: Record<string | number, SerializedObject> | SerializedObject[],
key: string | number,
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];
}
// Handling the case where the key does not exist
if (Array.isArray(container)) {
// Handling arrays
if (allowAddKey && key === container.length) {
container.push(createEmptySerializedObject());
return container[key];
}
throw new Error(`Index out of bounds: ${key}`);
} else {
// Handling objects
if (allowAddKey) {
container[key] = createEmptySerializedObject();
return container[key];
}
throw new Error(`Key not found: ${key}`);
}
}
/**
* Retrieve an item from a container specified by the passed key. Add an item to the
* container if allowAppend is set to True.
*
* @param container Either a dictionary or list of serialized objects.
* @param key The key name or index (as a string) representing the attribute in the container.
* @param allowAppend Whether to allow appending a new entry if the specified index is out of range by exactly one position.
* @returns The serialized object corresponding to the specified key.
* @throws SerializationPathError If the key is invalid or leads to an access error without append permissions.
* @throws SerializationValueError If the expected structure is incorrect.
*/
function getContainerItemByKey(
container: Record<string, SerializedObject> | SerializedObject[],
key: string,
allowAppend = false,
): SerializedObject {
const processedKey = parseSerializedKey(key);
try {
return getOrCreateItemInContainer(container, processedKey, allowAppend);
} catch (error) {
if (error instanceof RangeError) {
throw new Error(`Index '${processedKey}': ${error.message}`);
} else if (error instanceof Error) {
throw new Error(`Key '${processedKey}': ${error.message}`);
}
throw error; // Re-throw if it's not a known error type
}
}
export function setNestedValueByPath(
serializationDict: Record<string, SerializedObject>,
path: string,
serializedValue: SerializedObject,
): Record<string, SerializedObject> {
const pathParts = parseFullAccessPath(path);
const newSerializationDict: Record<string, SerializedObject> = JSON.parse(
JSON.stringify(serializationDict),
); );
let currentDict = newSerializationDict; let currentDict = newSerializationDict;
try { try {
for (const pathPart of parentPathParts) { for (let i = 0; i < pathParts.length - 1; i++) {
currentSerializedValue = getNextLevelDictByKey(currentDict, pathPart, false); const pathPart = pathParts[i];
// @ts-expect-error The value will be of type SerializedValue as we are still const nextLevelSerializedObject = getContainerItemByKey(
// looping through the parent parts currentDict,
currentDict = currentSerializedValue['value']; pathPart,
false,
);
currentDict = nextLevelSerializedObject["value"] as Record<
string,
SerializedObject
>;
} }
currentSerializedValue = getNextLevelDictByKey(currentDict, attrName, true); const finalPart = pathParts[pathParts.length - 1];
const finalObject = getContainerItemByKey(currentDict, finalPart, true);
Object.assign(finalObject, serializedValue);
Object.assign(currentSerializedValue, serializedValue);
return newSerializationDict; return newSerializationDict;
} catch (error) { } catch (error) {
console.error(error); console.error(`Error occurred trying to change ${path}: ${error}`);
return currentDict;
} }
return {};
} }
function getNextLevelDictByKey( function createEmptySerializedObject(): SerializedObject {
serializationDict: Record<string, SerializedValue>, return {
attrName: string, full_access_path: "",
allowAppend: boolean = false value: null,
): SerializedValue { type: "None",
const [key, index] = parseListAttrAndIndex(attrName); doc: null,
let currentDict: SerializedValue; readonly: false,
};
try {
if (index !== null) {
if (!serializationDict[key] || !Array.isArray(serializationDict[key]['value'])) {
throw new Error(`Expected an array at '${key}', but found something else.`);
}
if (index < serializationDict[key]['value'].length) {
currentDict = serializationDict[key]['value'][index];
} else if (allowAppend && index === serializationDict[key]['value'].length) {
// Appending to list
// @ts-expect-error When the index is not null, I expect an array
serializationDict[key]['value'].push({});
currentDict = serializationDict[key]['value'][index];
} else {
throw new Error(`Index out of range for '${key}[${index}]'.`);
}
} else {
if (!serializationDict[key]) {
throw new Error(`Key '${key}' not found.`);
}
currentDict = serializationDict[key];
}
} catch (error) {
throw new Error(`Error occurred trying to access '${attrName}': ${error}`);
}
if (typeof currentDict !== 'object' || currentDict === null) {
throw new Error(
`Expected a dictionary at '${attrName}', but found type '${typeof currentDict}' instead.`
);
}
return currentDict;
}
function parseListAttrAndIndex(attrString: string): [string, number | null] {
let index: number | null = null;
let attrName = attrString;
if (attrString.includes('[') && attrString.endsWith(']')) {
const parts = attrString.split('[');
attrName = parts[0];
const indexPart = parts[1].slice(0, -1); // Removes the closing ']'
if (!isNaN(parseInt(indexPart))) {
index = parseInt(indexPart);
} else {
console.error(`Invalid index format in key: ${attrString}`);
}
}
return [attrName, index];
} }

View File

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

View File

@@ -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": { "files": [],
"jsx": "react-jsx", "references": [
"allowImportingTsExtensions": true, {
"noEmit": true, "path": "./tsconfig.app.json"
"esModuleInterop": true },
{
"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"],
},
});

1160
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "pydase" name = "pydase"
version = "0.8.1" version = "0.8.4"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases." description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"] authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md" readme = "README.md"
@@ -14,8 +14,7 @@ uvicorn = "^0.27.0"
toml = "^0.10.2" toml = "^0.10.2"
python-socketio = "^5.8.0" python-socketio = "^5.8.0"
confz = "^2.0.0" confz = "^2.0.0"
pint = "^0.22" pint = "^0.24"
pillow = "^10.0.0"
websocket-client = "^1.7.0" websocket-client = "^1.7.0"
aiohttp = "^3.9.3" aiohttp = "^3.9.3"

View File

@@ -75,6 +75,37 @@ def update_value(
) )
class ProxyDict(dict[str, Any]):
def __init__(
self,
original_dict: dict[str, Any],
parent_path: str,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> None:
super().__init__(original_dict)
self._parent_path = parent_path
self._loop = loop
self._sio = sio_client
def __setitem__(self, key: str, value: Any) -> None:
observer_key = key
if isinstance(key, str):
observer_key = f'"{key}"'
full_access_path = f"{self._parent_path}[{observer_key}]"
update_value(self._sio, self._loop, full_access_path, value)
def pop(self, key: str) -> Any: # type: ignore
"""Removes the element from the dictionary on the server. It does not return
any proxy as the corresponding object on the server does not live anymore."""
full_access_path = f"{self._parent_path}.pop"
trigger_method(self._sio, self._loop, full_access_path, [key], {})
class ProxyList(list[Any]): class ProxyList(list[Any]):
def __init__( def __init__(
self, self,
@@ -266,7 +297,17 @@ class ProxyLoader:
sio_client: socketio.AsyncClient, sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop, loop: asyncio.AbstractEventLoop,
) -> Any: ) -> Any:
return loads(serialized_object) return ProxyDict(
{
key: ProxyLoader.loads_proxy(value, sio_client, loop)
for key, value in cast(
dict[str, SerializedObject], serialized_object["value"]
).items()
},
parent_path=serialized_object["full_access_path"],
sio_client=sio_client,
loop=loop,
)
@staticmethod @staticmethod
def update_data_service_proxy( def update_data_service_proxy(

View File

@@ -56,4 +56,9 @@ class ColouredEnum(Enum):
my_service = StatusExample() my_service = StatusExample()
my_service.status = MyStatus.FAILED my_service.status = MyStatus.FAILED
``` ```
Note
----
Each enumeration name and value must be unique. This means that you should use
different colour formats when you want to use a colour multiple times.
""" """

View File

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

View File

@@ -73,7 +73,7 @@ class DataService(AbstractDataService):
if not issubclass( if not issubclass(
value_class, value_class,
(int | float | bool | str | list | Enum | u.Quantity | Observable), (int | float | bool | str | list | dict | Enum | u.Quantity | Observable),
): ):
logger.warning( logger.warning(
"Class '%s' does not inherit from DataService. This may lead to" "Class '%s' does not inherit from DataService. This may lead to"

View File

@@ -37,8 +37,9 @@ class DataServiceObserver(PropertyObserver):
) )
cached_value = cached_value_dict.get("value") cached_value = cached_value_dict.get("value")
if cached_value != dump(value)["value"] and all( if (
part[0] != "_" for part in full_access_path.split(".") all(part[0] != "_" for part in full_access_path.split("."))
and cached_value != dump(value)["value"]
): ):
logger.debug("'%s' changed to '%s'", full_access_path, value) logger.debug("'%s' changed to '%s'", full_access_path, value)

View File

@@ -7,9 +7,10 @@ from typing import TYPE_CHECKING, Any, cast
from pydase.data_service.data_service_cache import DataServiceCache from pydase.data_service.data_service_cache import DataServiceCache
from pydase.utils.helpers import ( from pydase.utils.helpers import (
get_object_attr_from_path, get_object_by_path_parts,
is_property_attribute, is_property_attribute,
parse_list_attr_and_index, parse_full_access_path,
parse_serialized_key,
) )
from pydase.utils.serialization.deserializer import loads from pydase.utils.serialization.deserializer import loads
from pydase.utils.serialization.serializer import ( from pydase.utils.serialization.serializer import (
@@ -236,44 +237,32 @@ class StateManager:
def __update_attribute_by_path( def __update_attribute_by_path(
self, path: str, serialized_value: SerializedObject self, path: str, serialized_value: SerializedObject
) -> None: ) -> None:
parent_path, attr_name = ".".join(path.split(".")[:-1]), path.split(".")[-1] path_parts = parse_full_access_path(path)
target_obj = get_object_by_path_parts(self.service, path_parts[:-1])
# If attr_name corresponds to a list entry, extract the attr_name and the
# index
attr_name, index = parse_list_attr_and_index(attr_name)
# Update path to reflect the attribute without list indices
path = f"{parent_path}.{attr_name}" if parent_path != "" else attr_name
attr_cache_type = get_nested_dict_by_path(self.cache_value, path)["type"] attr_cache_type = get_nested_dict_by_path(self.cache_value, path)["type"]
# Traverse the object according to the path parts # De-serialize the value
target_obj = get_object_attr_from_path(self.service, parent_path)
if attr_cache_type in ("ColouredEnum", "Enum"): if attr_cache_type in ("ColouredEnum", "Enum"):
enum_attr = get_object_attr_from_path(target_obj, attr_name) enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]])
# take the value of the existing enum class # take the value of the existing enum class
if serialized_value["type"] in ("ColouredEnum", "Enum"): if serialized_value["type"] in ("ColouredEnum", "Enum"):
try: try:
setattr( value = enum_attr.__class__[serialized_value["value"]]
target_obj,
attr_name,
enum_attr.__class__[serialized_value["value"]],
)
return
except KeyError: except KeyError:
# This error will arise when setting an enum from another enum class # This error will arise when setting an enum from another enum class
# In this case, we resort to loading the enum and setting it # In this case, we resort to loading the enum and setting it
# directly # directly
pass value = loads(serialized_value)
else:
value = loads(serialized_value) value = loads(serialized_value)
if attr_cache_type == "list": # set the value
list_obj = get_object_attr_from_path(target_obj, attr_name) if isinstance(target_obj, list | dict):
list_obj[index] = value processed_key = parse_serialized_key(path_parts[-1])
target_obj[processed_key] = value # type: ignore
else: else:
setattr(target_obj, attr_name, value) setattr(target_obj, path_parts[-1], value)
def __is_loadable_state_attribute(self, full_access_path: str) -> bool: def __is_loadable_state_attribute(self, full_access_path: str) -> bool:
"""Checks if an attribute defined by a dot-separated path should be loaded from """Checks if an attribute defined by a dot-separated path should be loaded from
@@ -283,20 +272,17 @@ class StateManager:
attributes default to being loadable. attributes default to being loadable.
""" """
parent_path, attr_name = ( path_parts = parse_full_access_path(full_access_path)
".".join(full_access_path.split(".")[:-1]), parent_object = get_object_by_path_parts(self.service, path_parts[:-1])
full_access_path.split(".")[-1],
)
parent_object = get_object_attr_from_path(self.service, parent_path)
if is_property_attribute(parent_object, attr_name): if is_property_attribute(parent_object, path_parts[-1]):
prop = getattr(type(parent_object), attr_name) prop = getattr(type(parent_object), path_parts[-1])
has_decorator = has_load_state_decorator(prop) has_decorator = has_load_state_decorator(prop)
if not has_decorator: if not has_decorator:
logger.debug( logger.debug(
"Property '%s' has no '@load_state' decorator. " "Property '%s' has no '@load_state' decorator. "
"Ignoring value from JSON file...", "Ignoring value from JSON file...",
attr_name, path_parts[-1],
) )
return has_decorator return has_decorator
@@ -314,6 +300,6 @@ class StateManager:
logger.debug( logger.debug(
"Path %a could not be loaded. It does not correspond to an attribute of" "Path %a could not be loaded. It does not correspond to an attribute of"
" the class. Ignoring value from JSON file...", " the class. Ignoring value from JSON file...",
attr_name, path_parts[-1],
) )
return False return False

View File

@@ -21,10 +21,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TaskDefinitionError(Exception):
pass
class TaskStatus(Enum): class TaskStatus(Enum):
RUNNING = "running" RUNNING = "running"
@@ -107,12 +103,13 @@ class TaskManager:
method = getattr(self.service, name) method = getattr(self.service, name)
if inspect.iscoroutinefunction(method): if inspect.iscoroutinefunction(method):
if function_has_arguments(method): if function_has_arguments(method):
raise TaskDefinitionError( logger.info(
"Asynchronous functions (tasks) should be defined without " "Async function %a is defined with at least one argument. If "
f"arguments. The task '{method.__name__}' has at least one " "you want to use it as a task, remove the argument(s) from the "
"argument. Please remove the argument(s) from this function to " "function definition.",
"use it." method.__name__,
) )
continue
# create start and stop methods for each coroutine # create start and stop methods for each coroutine
setattr( setattr(

View File

@@ -1,13 +0,0 @@
{
"files": {
"main.css": "/static/css/main.7ef670d5.css",
"main.js": "/static/js/main.27a065cb.js",
"index.html": "/index.html",
"main.7ef670d5.css.map": "/static/css/main.7ef670d5.css.map",
"main.27a065cb.js.map": "/static/js/main.27a065cb.js.map"
},
"entrypoints": [
"static/css/main.7ef670d5.css",
"static/js/main.27a065cb.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.27a065cb.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-C12UM6g5.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 import logging
from typing import Any 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.observer_pattern.observable.observable_object import ObservableObject
from pydase.utils.helpers import is_property_attribute from pydase.utils.helpers import is_property_attribute
@@ -15,6 +19,7 @@ class Observable(ObservableObject):
for k in set(type(self).__dict__) for k in set(type(self).__dict__)
- set(Observable.__dict__) - set(Observable.__dict__)
- set(self.__dict__) - set(self.__dict__)
- {"__annotations__"}
} }
for name, value in class_attrs.items(): for name, value in class_attrs.items():
if isinstance(value, property) or callable(value): if isinstance(value, property) or callable(value):
@@ -34,6 +39,11 @@ class Observable(ObservableObject):
super().__setattr__(name, value) super().__setattr__(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) self._notify_changed(name, value)
def __getattribute__(self, name: str) -> Any: def __getattribute__(self, name: str) -> Any:

View File

@@ -1,34 +1,44 @@
from __future__ import annotations
import logging import logging
import weakref
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, ClassVar, SupportsIndex from typing import TYPE_CHECKING, Any, ClassVar, SupportsIndex
from pydase.utils.helpers import parse_serialized_key
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable
from pydase.observer_pattern.observer.observer import Observer from pydase.observer_pattern.observer.observer import Observer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ObservableObject(ABC): class ObservableObject(ABC):
_list_mapping: ClassVar[dict[int, "_ObservableList"]] = {} _list_mapping: ClassVar[dict[int, weakref.ReferenceType[_ObservableList]]] = {}
_dict_mapping: ClassVar[dict[int, "_ObservableDict"]] = {} _dict_mapping: ClassVar[dict[int, weakref.ReferenceType[_ObservableDict]]] = {}
def __init__(self) -> None: def __init__(self) -> None:
if not hasattr(self, "_observers"): if not hasattr(self, "_observers"):
self._observers: dict[str, list["ObservableObject | Observer"]] = {} self._observers: dict[str, list[ObservableObject | Observer]] = {}
def add_observer( def add_observer(
self, observer: "ObservableObject | Observer", attr_name: str = "" self, observer: ObservableObject | Observer, attr_name: str = ""
) -> None: ) -> None:
if attr_name not in self._observers: if attr_name not in self._observers:
self._observers[attr_name] = [] self._observers[attr_name] = []
if observer not in self._observers[attr_name]: if observer not in self._observers[attr_name]:
self._observers[attr_name].append(observer) self._observers[attr_name].append(observer)
def _remove_observer(self, observer: "ObservableObject", attribute: str) -> None: def _remove_observer(self, observer: ObservableObject, attribute: str) -> None:
if attribute in self._observers: if attribute in self._observers:
self._observers[attribute].remove(observer) self._observers[attribute].remove(observer)
# remove attribute key from observers dict if list of observers is empty
if not self._observers[attribute]:
del self._observers[attribute]
@abstractmethod @abstractmethod
def _remove_observer_if_observable(self, name: str) -> None: def _remove_observer_if_observable(self, name: str) -> None:
"""Removes the current object as an observer from an observable attribute. """Removes the current object as an observer from an observable attribute.
@@ -81,26 +91,30 @@ class ObservableObject(ABC):
) )
observer._notify_change_start(extended_attr_path) observer._notify_change_start(extended_attr_path)
def _initialise_new_objects(self, attr_name_or_key: Any, value: Any) -> Any: def _initialise_new_objects(self, attr_name_or_key: str, value: Any) -> Any:
new_value = value new_value = value
if isinstance(value, list): if isinstance(value, list):
if id(value) in self._list_mapping: if id(value) in self._list_mapping:
# If the list `value` was already referenced somewhere else # If the list `value` was already referenced somewhere else
new_value = self._list_mapping[id(value)] new_value = self._list_mapping[id(value)]()
else: else:
# convert the builtin list into a ObservableList # convert the builtin list into a ObservableList
new_value = _ObservableList(original_list=value) new_value = _ObservableList(original_list=value)
self._list_mapping[id(value)] = new_value
# Use weakref to allow the GC to collect unused objects
self._list_mapping[id(value)] = weakref.ref(new_value)
elif isinstance(value, dict): elif isinstance(value, dict):
if id(value) in self._dict_mapping: if id(value) in self._dict_mapping:
# If the list `value` was already referenced somewhere else # If the dict `value` was already referenced somewhere else
new_value = self._dict_mapping[id(value)] new_value = self._dict_mapping[id(value)]()
else: else:
# convert the builtin list into a ObservableList # convert the builtin dict into a ObservableDict
new_value = _ObservableDict(original_dict=value) new_value = _ObservableDict(original_dict=value)
self._dict_mapping[id(value)] = new_value
# Use weakref to allow the GC to collect unused objects
self._dict_mapping[id(value)] = weakref.ref(new_value)
if isinstance(new_value, ObservableObject): if isinstance(new_value, ObservableObject):
new_value.add_observer(self, str(attr_name_or_key)) new_value.add_observer(self, attr_name_or_key)
return new_value return new_value
@abstractmethod @abstractmethod
@@ -137,6 +151,9 @@ class _ObservableList(ObservableObject, list[Any]):
for i, item in enumerate(self._original_list): for i, item in enumerate(self._original_list):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item)) super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
def __del__(self) -> None:
self._list_mapping.pop(id(self._original_list))
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override] def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
if hasattr(self, "_observers"): if hasattr(self, "_observers"):
self._remove_observer_if_observable(f"[{key}]") self._remove_observer_if_observable(f"[{key}]")
@@ -149,8 +166,7 @@ class _ObservableList(ObservableObject, list[Any]):
def append(self, __object: Any) -> None: def append(self, __object: Any) -> None:
self._notify_change_start("") self._notify_change_start("")
self._initialise_new_objects(f"[{len(self)}]", __object) super().append(self._initialise_new_objects(f"[{len(self)}]", __object))
super().append(__object)
self._notify_changed("", self) self._notify_changed("", self)
def clear(self) -> None: def clear(self) -> None:
@@ -224,7 +240,7 @@ class _ObservableList(ObservableObject, list[Any]):
return instance_attr_name return instance_attr_name
class _ObservableDict(dict[str, Any], ObservableObject): class _ObservableDict(ObservableObject, dict[str, Any]):
def __init__( def __init__(
self, self,
original_dict: dict[str, Any], original_dict: dict[str, Any],
@@ -233,24 +249,29 @@ class _ObservableDict(dict[str, Any], ObservableObject):
ObservableObject.__init__(self) ObservableObject.__init__(self)
dict.__init__(self) dict.__init__(self)
for key, value in self._original_dict.items(): for key, value in self._original_dict.items():
super().__setitem__(key, self._initialise_new_objects(f"['{key}']", value)) self.__setitem__(key, self._initialise_new_objects(f'["{key}"]', value))
def __del__(self) -> None:
self._dict_mapping.pop(id(self._original_dict))
def __setitem__(self, key: str, value: Any) -> None: def __setitem__(self, key: str, value: Any) -> None:
if not isinstance(key, str): if not isinstance(key, str):
logger.warning("Converting non-string dictionary key %s to string.", key) raise ValueError(
key = str(key) f"Invalid key type: {key} ({type(key).__name__}). In pydase services, "
"dictionary keys must be strings."
)
if hasattr(self, "_observers"): if hasattr(self, "_observers"):
self._remove_observer_if_observable(f"['{key}']") self._remove_observer_if_observable(f'["{key}"]')
value = self._initialise_new_objects(key, value) value = self._initialise_new_objects(f'["{key}"]', value)
self._notify_change_start(f"['{key}']") self._notify_change_start(f'["{key}"]')
super().__setitem__(key, value) super().__setitem__(key, value)
self._notify_changed(f"['{key}']", value) self._notify_changed(f'["{key}"]', value)
def _remove_observer_if_observable(self, name: str) -> None: def _remove_observer_if_observable(self, name: str) -> None:
key = name[2:-2] key = str(parse_serialized_key(name))
current_value = self.get(key, None) current_value = self.get(key, None)
if isinstance(current_value, ObservableObject): if isinstance(current_value, ObservableObject):
@@ -262,3 +283,11 @@ class _ObservableDict(dict[str, Any], ObservableObject):
if observer_attr_name != "": if observer_attr_name != "":
return f"{observer_attr_name}{instance_attr_name}" return f"{observer_attr_name}{instance_attr_name}"
return instance_attr_name return instance_attr_name
def pop(self, key: str) -> Any: # type: ignore[override]
self._remove_observer_if_observable(f'["{key}"]')
popped_item = super().pop(key)
self._notify_changed("", self)
return popped_item

View File

@@ -16,6 +16,7 @@ from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.server.web_server.sio_setup import ( from pydase.server.web_server.sio_setup import (
setup_sio_server, setup_sio_server,
) )
from pydase.utils.helpers import get_path_from_path_parts, parse_full_access_path
from pydase.utils.serialization.serializer import generate_serialized_data_paths from pydase.utils.serialization.serializer import generate_serialized_data_paths
from pydase.version import __version__ from pydase.version import __version__
@@ -131,8 +132,18 @@ class WebServer:
if path in current_web_settings: if path in current_web_settings:
continue continue
# Creating the display name by reversely looping through the path parts
# until an item does not start with a square bracket, and putting the parts
# back together again. This allows for display names like
# >>> 'dict_attr["some.dotted.key"]'
display_name_parts: list[str] = []
for item in parse_full_access_path(path)[::-1]:
display_name_parts.insert(0, item)
if not item.startswith("["):
break
current_web_settings[path] = { current_web_settings[path] = {
"displayName": path.split(".")[-1], "displayName": get_path_from_path_parts(display_name_parts),
"display": True, "display": True,
} }

View File

@@ -1,3 +1,4 @@
import inspect
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
@@ -25,3 +26,17 @@ def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
# Mark the function for frontend display. # Mark the function for frontend display.
func._display_in_frontend = True # type: ignore func._display_in_frontend = True # type: ignore
return func return func
def render_in_frontend(func: Callable[..., Any]) -> bool:
"""Determines if the method should be rendered in the frontend.
It checks if the "@frontend" decorator was used or the method is a coroutine."""
if inspect.iscoroutinefunction(func):
return True
try:
return func._display_in_frontend # type: ignore
except AttributeError:
return False

View File

@@ -1,5 +1,6 @@
import inspect import inspect
import logging import logging
import re
from collections.abc import Callable from collections.abc import Callable
from itertools import chain from itertools import chain
from typing import Any from typing import Any
@@ -7,6 +8,92 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def parse_serialized_key(serialized_key: str) -> str | int | float:
"""
Parse a serialized key and convert it to an appropriate type (int, float, or str).
Args:
serialized_key: str
The serialized key, which might be enclosed in brackets and quotes.
Returns:
int | float | str:
The processed key as an integer, float, or unquoted string.
Examples:
```python
print(parse_serialized_key("attr_name")) # Outputs: attr_name (str)
print(parse_serialized_key("[123]")) # Outputs: 123 (int)
print(parse_serialized_key("[12.3]")) # Outputs: 12.3 (float)
print(parse_serialized_key("['hello']")) # Outputs: hello (str)
print(parse_serialized_key('["12.34"]')) # Outputs: 12.34 (str)
print(parse_serialized_key('["complex"]')) # Outputs: complex (str)
```
"""
# Strip outer brackets if present
if serialized_key.startswith("[") and serialized_key.endswith("]"):
serialized_key = serialized_key[1:-1]
# Strip quotes if the resulting string is quoted
if serialized_key.startswith(("'", '"')) and serialized_key.endswith(("'", '"')):
return serialized_key[1:-1]
# Try converting to float or int if the string is not quoted
try:
return float(serialized_key) if "." in serialized_key else int(serialized_key)
except ValueError:
# Return the original string if it's not a valid number
return serialized_key
def parse_full_access_path(path: str) -> list[str]:
"""
Splits a full access path into its atomic parts, separating attribute names, numeric
indices (including floating points), and string keys within indices.
Args:
path: str
The full access path string to be split into components.
Returns:
list[str]
A list of components that make up the path, including attribute names,
numeric indices, and string keys as separate elements.
"""
# Matches:
# \w+ - Words
# \[\d+\.\d+\] - Floating point numbers inside brackets
# \[\d+\] - Integers inside brackets
# \["[^"]*"\] - Double-quoted strings inside brackets
# \['[^']*'\] - Single-quoted strings inside brackets
pattern = r'\w+|\[\d+\.\d+\]|\[\d+\]|\["[^"]*"\]|\[\'[^\']*\']'
return re.findall(pattern, path)
def get_path_from_path_parts(path_parts: list[str]) -> str:
"""Creates the full access path from its atomic parts.
The reverse function is given by `parse_full_access_path`.
Args:
path_parts: list[str]
A list of components that make up the path, including attribute names,
numeric indices and string keys enclosed in square brackets as separate
elements.
Returns:
str
The full access path corresponding to the path_parts.
"""
path = ""
for path_part in path_parts:
if not path_part.startswith("[") and path != "":
path += "."
path += path_part
return path
def get_attribute_doc(attr: Any) -> str | None: def get_attribute_doc(attr: Any) -> str | None:
"""This function takes an input attribute attr and returns its documentation """This function takes an input attribute attr and returns its documentation
string if it's different from the documentation of its type, otherwise, string if it's different from the documentation of its type, otherwise,
@@ -30,6 +117,20 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items())) return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
def get_object_by_path_parts(target_obj: Any, path_parts: list[str]) -> Any:
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
return target_obj
def get_object_attr_from_path(target_obj: Any, path: str) -> Any: def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
""" """
Traverse the object tree according to the given path. Traverse the object tree according to the given path.
@@ -46,94 +147,8 @@ def get_object_attr_from_path(target_obj: Any, path: str) -> Any:
Raises: Raises:
ValueError: If a list index in the path is not a valid integer. ValueError: If a list index in the path is not a valid integer.
""" """
path_list = path.split(".") if path != "" else [] path_parts = parse_full_access_path(path)
for part in path_list: return get_object_by_path_parts(target_obj, path_parts)
try:
# Try to split the part into attribute and index
attr, index_str = part.split("[", maxsplit=1)
index_str = index_str.replace("]", "")
index = int(index_str)
target_obj = getattr(target_obj, attr)[index]
except ValueError:
# No index, so just get the attribute
target_obj = getattr(target_obj, part)
except AttributeError:
# The attribute doesn't exist
logger.debug("Attribute % does not exist in the object.", part)
return None
return target_obj
def update_value_if_changed(
target: Any, attr_name_or_index: str | int, new_value: Any
) -> None:
"""
Updates the value of an attribute or a list element on a target object if the new
value differs from the current one.
This function supports updating both attributes of an object and elements of a list.
- For objects, the function first checks the current value of the attribute. If the
current value differs from the new value, the function updates the attribute.
- For lists, the function checks the current value at the specified index. If the
current value differs from the new value, the function updates the list element
at the given index.
Args:
target (Any):
The target object that has the attribute or the list.
attr_name_or_index (str | int):
The name of the attribute or the index of the list element.
new_value (Any):
The new value for the attribute or the list element.
"""
if isinstance(target, list) and isinstance(attr_name_or_index, int):
if target[attr_name_or_index] != new_value:
target[attr_name_or_index] = new_value
elif isinstance(attr_name_or_index, str):
# If the type matches and the current value is different from the new value,
# update the attribute.
if getattr(target, attr_name_or_index) != new_value:
setattr(target, attr_name_or_index, new_value)
else:
logger.error("Incompatible arguments: %s, %s.", target, attr_name_or_index)
def parse_list_attr_and_index(attr_string: str) -> tuple[str, int | None]:
"""
Parses an attribute string and extracts a potential list attribute name and its
index.
Logs an error if the index is not a valid digit.
Args:
attr_string (str):
The attribute string to parse. Can be a regular attribute name (e.g.,
'attr_name') or a list attribute with an index (e.g., 'list_attr[2]').
Returns:
tuple[str, Optional[int]]:
A tuple containing the attribute name as a string and the index as an
integer if present, otherwise None.
Examples:
>>> parse_attribute_and_index('list_attr[2]')
('list_attr', 2)
>>> parse_attribute_and_index('attr_name')
('attr_name', None)
"""
index = None
attr_name = attr_string
if "[" in attr_string and attr_string.endswith("]"):
attr_name, index_part = attr_string.split("[", 1)
index_part = index_part.rstrip("]")
if index_part.isdigit():
index = int(index_part)
else:
logger.error("Invalid index format in key: %s", attr_name)
return attr_name, index
def get_component_classes() -> list[type]: def get_component_classes() -> list[type]:
@@ -154,12 +169,12 @@ def get_data_service_class_reference() -> Any:
def is_property_attribute(target_obj: Any, access_path: str) -> bool: def is_property_attribute(target_obj: Any, access_path: str) -> bool:
parent_path, attr_name = ( path_parts = parse_full_access_path(access_path)
".".join(access_path.split(".")[:-1]), target_obj = get_object_by_path_parts(target_obj, path_parts[:-1])
access_path.split(".")[-1],
) # don't have to check if target_obj is dict or list as their content cannot be
target_obj = get_object_attr_from_path(target_obj, parent_path) # properties -> always return False then
return isinstance(getattr(type(target_obj), attr_name, None), property) return isinstance(getattr(type(target_obj), path_parts[-1], None), property)
def function_has_arguments(func: Callable[..., Any]) -> bool: def function_has_arguments(func: Callable[..., Any]) -> bool:
@@ -169,20 +184,4 @@ def function_has_arguments(func: Callable[..., Any]) -> bool:
parameters.pop("self", None) parameters.pop("self", None)
# Check if there are any parameters left which would indicate additional arguments. # Check if there are any parameters left which would indicate additional arguments.
if len(parameters) > 0: return len(parameters) > 0
return True
return False
def render_in_frontend(func: Callable[..., Any]) -> bool:
"""Determines if the method should be rendered in the frontend.
It checks if the "@frontend" decorator was used or the method is a coroutine."""
if inspect.iscoroutinefunction(func):
return True
try:
return func._display_in_frontend # type: ignore
except AttributeError:
return False

View File

@@ -9,12 +9,13 @@ from typing import TYPE_CHECKING, Any, Literal, cast
import pydase.units as u import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.task_manager import TaskStatus from pydase.data_service.task_manager import TaskStatus
from pydase.utils.decorators import render_in_frontend
from pydase.utils.helpers import ( from pydase.utils.helpers import (
get_attribute_doc, get_attribute_doc,
get_component_classes, get_component_classes,
get_data_service_class_reference, get_data_service_class_reference,
parse_list_attr_and_index, parse_full_access_path,
render_in_frontend, parse_serialized_key,
) )
from pydase.utils.serialization.types import ( from pydase.utils.serialization.types import (
DataServiceTypes, DataServiceTypes,
@@ -166,10 +167,11 @@ class Serializer:
def _serialize_dict(obj: dict[str, Any], access_path: str = "") -> SerializedDict: def _serialize_dict(obj: dict[str, Any], access_path: str = "") -> SerializedDict:
readonly = False readonly = False
doc = get_attribute_doc(obj) doc = get_attribute_doc(obj)
value = { value = {}
key: Serializer.serialize_object(val, access_path=f'{access_path}["{key}"]') for key, val in obj.items():
for key, val in obj.items() value[key] = Serializer.serialize_object(
} val, access_path=f'{access_path}["{key}"]'
)
return { return {
"full_access_path": access_path, "full_access_path": access_path,
"type": "dict", "type": "dict",
@@ -301,7 +303,7 @@ def dump(obj: Any) -> SerializedObject:
def set_nested_value_by_path( def set_nested_value_by_path(
serialization_dict: dict[str, SerializedObject], path: str, value: Any serialization_dict: dict[Any, SerializedObject], path: str, value: Any
) -> None: ) -> None:
""" """
Set a value in a nested dictionary structure, which conforms to the serialization Set a value in a nested dictionary structure, which conforms to the serialization
@@ -322,23 +324,24 @@ def set_nested_value_by_path(
serialized representation of the 'value' to the list. serialized representation of the 'value' to the list.
""" """
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1] path_parts = parse_full_access_path(path)
current_dict: dict[str, SerializedObject] = serialization_dict current_dict: dict[Any, SerializedObject] = serialization_dict
try: try:
for path_part in parent_path_parts: for path_part in path_parts[:-1]:
next_level_serialized_object = get_next_level_dict_by_key( next_level_serialized_object = get_container_item_by_key(
current_dict, path_part, allow_append=False current_dict, path_part, allow_append=False
) )
current_dict = cast( current_dict = cast(
dict[str, SerializedObject], next_level_serialized_object["value"] dict[Any, SerializedObject],
next_level_serialized_object["value"],
) )
next_level_serialized_object = get_next_level_dict_by_key( next_level_serialized_object = get_container_item_by_key(
current_dict, attr_name, allow_append=True current_dict, path_parts[-1], allow_append=True
) )
except (SerializationPathError, SerializationValueError, KeyError) as e: except (SerializationPathError, SerializationValueError, KeyError) as e:
logger.error(e) logger.error("Error occured trying to change %a: %s", path, e)
return return
if next_level_serialized_object["type"] == "method": # state change of task if next_level_serialized_object["type"] == "method": # state change of task
@@ -360,149 +363,186 @@ def set_nested_value_by_path(
def get_nested_dict_by_path( def get_nested_dict_by_path(
serialization_dict: dict[str, SerializedObject], serialization_dict: dict[Any, SerializedObject],
path: str, path: str,
) -> SerializedObject: ) -> SerializedObject:
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1] path_parts = parse_full_access_path(path)
current_dict: dict[str, SerializedObject] = serialization_dict current_dict: dict[Any, SerializedObject] = serialization_dict
for path_part in parent_path_parts: for path_part in path_parts[:-1]:
next_level_serialized_object = get_next_level_dict_by_key( next_level_serialized_object = get_container_item_by_key(
current_dict, path_part, allow_append=False current_dict, path_part, allow_append=False
) )
current_dict = cast( current_dict = cast(
dict[str, SerializedObject], next_level_serialized_object["value"] dict[Any, SerializedObject],
next_level_serialized_object["value"],
) )
return get_next_level_dict_by_key(current_dict, attr_name, allow_append=False) return get_container_item_by_key(current_dict, path_parts[-1], allow_append=False)
def get_next_level_dict_by_key( def create_empty_serialized_object() -> SerializedObject:
serialization_dict: dict[str, SerializedObject], """Create a new empty serialized object."""
attr_name: str,
return {
"full_access_path": "",
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
def get_or_create_item_in_container(
container: dict[Any, SerializedObject] | list[SerializedObject],
key: Any,
*,
allow_add_key: bool,
) -> SerializedObject:
"""Ensure the key exists in the dictionary, append if necessary and allowed."""
try:
return container[key]
except IndexError:
if allow_add_key and key == len(container):
cast(list[SerializedObject], container).append(
create_empty_serialized_object()
)
return container[key]
raise
except KeyError:
if allow_add_key:
container[key] = create_empty_serialized_object()
return container[key]
raise
def get_container_item_by_key(
container: dict[Any, SerializedObject] | list[SerializedObject],
key: str,
*, *,
allow_append: bool = False, allow_append: bool = False,
) -> SerializedObject: ) -> SerializedObject:
""" """
Retrieve a nested dictionary entry or list item from a data structure serialized Retrieve an item from a container specified by the passed key. Add an item to the
with `pydase.utils.serializer.Serializer`. container if allow_append is set to True.
If specified keys or indexes do not exist, the function can append new elements to
dictionaries and to lists if `allow_append` is True and the missing element is
exactly the next sequential index (for lists).
Args: Args:
serialization_dict: The base dictionary representing serialized data. container: dict[str, SerializedObject] | list[SerializedObject]
attr_name: The key name representing the attribute in the dictionary, The container representing serialized data.
e.g. 'list_attr[0]' or 'attr' key: str
allow_append: Flag to allow appending a new entry if `index` is out of range by The key name representing the attribute in the dictionary, which may include
one. direct keys or indexes (e.g., 'attr_name', '["key"]' or '[0]').
allow_append: bool
Flag to allow appending a new entry if the specified index is out of range
by exactly one position.
Returns: Returns:
The dictionary or list item corresponding to the attribute and index. SerializedObject
The dictionary or list item corresponding to the specified attribute and
index.
Raises: Raises:
SerializationPathError: If the path composed of `attr_name` and `index` is SerializationPathError:
invalid or leads to an IndexError or KeyError. If the path composed of `attr_name` and any specified index is invalid, or
SerializationValueError: If the expected nested structure is not a dictionary. 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.
""" """
# Check if the key contains an index part like 'attr_name[<index>]' processed_key = parse_serialized_key(key)
attr_name, index = parse_list_attr_and_index(attr_name)
try: try:
if index is not None: return get_or_create_item_in_container(
next_level_serialized_object = cast( container, processed_key, allow_add_key=allow_append
list[SerializedObject], serialization_dict[attr_name]["value"] )
)[index]
else:
next_level_serialized_object = serialization_dict[attr_name]
except IndexError as e: except IndexError as e:
if ( raise SerializationPathError(f"Index '{processed_key}': {e}")
index is not None except KeyError as e:
and allow_append raise SerializationPathError(f"Key '{processed_key}': {e}")
and index
== len(cast(list[SerializedObject], serialization_dict[attr_name]["value"]))
):
# Appending to list
cast(list[SerializedObject], serialization_dict[attr_name]["value"]).append(
{
"full_access_path": "",
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
)
next_level_serialized_object = cast(
list[SerializedObject], serialization_dict[attr_name]["value"]
)[index]
else:
raise SerializationPathError(
f"Error occured trying to change '{attr_name}[{index}]': {e}"
)
except KeyError:
if not allow_append:
raise SerializationPathError(
f"Error occured trying to access the key '{attr_name}': it is either "
"not present in the current dictionary or its value does not contain "
"a 'value' key."
)
serialization_dict[attr_name] = {
"full_access_path": "",
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}
next_level_serialized_object = serialization_dict[attr_name]
if not isinstance(next_level_serialized_object, dict):
raise SerializationValueError(
f"Expected a dictionary at '{attr_name}', but found type "
f"'{type(next_level_serialized_object).__name__}' instead."
)
return next_level_serialized_object
def generate_serialized_data_paths( def get_data_paths_from_serialized_object( # noqa: C901
data: dict[str, Any], parent_path: str = "" serialized_obj: SerializedObject,
parent_path: str = "",
) -> list[str]: ) -> list[str]:
""" """
Generate a list of access paths for all attributes in a dictionary representing Recursively extracts full access paths from a serialized object.
data serialized with `pydase.utils.serializer.Serializer`, excluding those that are
methods. This function handles nested structures, including lists, by generating
paths for each element in the nested lists.
Args: Args:
data (dict[str, Any]): The dictionary representing serialized data, typically serialized_obj (SerializedObject):
produced by `pydase.utils.serializer.Serializer`. The dictionary representing the serialization of an object. Produced by
parent_path (str, optional): The base path to prepend to the keys in the `data` `pydase.utils.serializer.Serializer`.
dictionary to form the access paths. Defaults to an empty string.
Returns: Returns:
list[str]: A list of strings where each string is a dot-notation access path list[str]:
to an attribute in the serialized data. For list elements, the path includes A list of strings, each representing a full access path in the serialized
the index in square brackets. object.
""" """
paths: list[str] = [] paths: list[str] = []
for key, value in data.items():
new_path = f"{parent_path}.{key}" if parent_path else key if isinstance(serialized_obj["value"], list):
for index, value in enumerate(serialized_obj["value"]):
new_path = f"{parent_path}[{index}]"
paths.append(new_path) paths.append(new_path)
if serialized_dict_is_nested_object(value): if serialized_dict_is_nested_object(value):
if isinstance(value["value"], list): paths.extend(get_data_paths_from_serialized_object(value, new_path))
for index, item in enumerate(value["value"]):
indexed_key_path = f"{new_path}[{index}]" elif serialized_dict_is_nested_object(serialized_obj):
paths.append(indexed_key_path) for key, value in cast(
if serialized_dict_is_nested_object(item): dict[str, SerializedObject], serialized_obj["value"]
paths.extend( ).items():
generate_serialized_data_paths( # Serialized dictionaries need to have a different new_path than nested
item["value"], indexed_key_path # classes
) if serialized_obj["type"] == "dict":
) processed_key = key
continue if isinstance(key, str):
paths.extend(generate_serialized_data_paths(value["value"], new_path)) processed_key = f'"{key}"'
new_path = f"{parent_path}[{processed_key}]"
else:
new_path = f"{parent_path}.{key}" if parent_path != "" else key
paths.append(new_path)
if serialized_dict_is_nested_object(value):
paths.extend(get_data_paths_from_serialized_object(value, new_path))
return paths
def generate_serialized_data_paths(
data: dict[str, SerializedObject],
) -> list[str]:
"""
Recursively extracts full access paths from a serialized DataService class instance.
Args:
data (dict[str, SerializedObject]):
The value of the "value" key of a serialized DataService class instance.
Returns:
list[str]:
A list of strings, each representing a full access path in the serialized
object.
"""
paths: list[str] = []
for key, value in data.items():
paths.append(key)
if serialized_dict_is_nested_object(value):
paths.extend(get_data_paths_from_serialized_object(value, key))
return paths return paths
def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool: def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool:
return ( value = serialized_dict["value"]
serialized_dict["type"] != "Quantity" # We are excluding Quantity here as the value corresponding to the "value" key is
and isinstance(serialized_dict["value"], dict) # a dictionary of the form {"magnitude": ..., "unit": ...}
) or isinstance(serialized_dict["value"], list) return serialized_dict["type"] != "Quantity" and (isinstance(value, dict | list))

View File

@@ -12,6 +12,8 @@ def pydase_client() -> Generator[pydase.Client, None, Any]:
class SubService(pydase.DataService): class SubService(pydase.DataService):
name = "SubService" name = "SubService"
subservice_instance = SubService()
class MyService(pydase.DataService): class MyService(pydase.DataService):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -19,6 +21,10 @@ def pydase_client() -> Generator[pydase.Client, None, Any]:
self._my_property = 12.1 self._my_property = 12.1
self.sub_service = SubService() self.sub_service = SubService()
self.list_attr = [1, 2] self.list_attr = [1, 2]
self.dict_attr = {
"foo": subservice_instance,
"dotted.key": subservice_instance,
}
@property @property
def my_property(self) -> float: def my_property(self) -> float:
@@ -104,6 +110,18 @@ def test_list(pydase_client: pydase.Client) -> None:
assert pydase_client.proxy.list_attr == [] assert pydase_client.proxy.list_attr == []
def test_dict(pydase_client: pydase.Client) -> None:
pydase_client.proxy.dict_attr["foo"].name = "foo"
assert pydase_client.proxy.dict_attr["foo"].name == "foo"
assert pydase_client.proxy.dict_attr["dotted.key"].name == "foo"
# pop will not return anything as the server object was deleted
assert pydase_client.proxy.dict_attr.pop("dotted.key") is None
# pop will remove the dictionary entry on the server
assert list(pydase_client.proxy.dict_attr.keys()) == ["foo"]
def test_tab_completion(pydase_client: pydase.Client) -> None: def test_tab_completion(pydase_client: pydase.Client) -> None:
# Tab completion gets its suggestions from the __dir__ class method # Tab completion gets its suggestions from the __dir__ class method
assert all( assert all(

View File

@@ -7,7 +7,6 @@ import pytest
from pydase import DataService from pydase import DataService
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager
from pydase.data_service.task_manager import TaskDefinitionError
from pydase.utils.decorators import FunctionDefinitionError, frontend from pydase.utils.decorators import FunctionDefinitionError, frontend
from pytest import LogCaptureFixture from pytest import LogCaptureFixture
@@ -37,7 +36,8 @@ def test_unexpected_type_change_warning(caplog: LogCaptureFixture) -> None:
def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None: def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
class SubService(DataService): ... class SubService(DataService):
...
class SomeEnum(Enum): class SomeEnum(Enum):
HI = 0 HI = 0
@@ -57,9 +57,11 @@ def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
def name(self) -> str: def name(self) -> str:
return self._name return self._name
def some_method(self) -> None: ... def some_method(self) -> None:
...
async def some_task(self) -> None: ... async def some_task(self) -> None:
...
ServiceClass() ServiceClass()
@@ -118,14 +120,7 @@ def test_protected_and_private_attribute_warning(caplog: LogCaptureFixture) -> N
) not in caplog.text ) not in caplog.text
def test_exposing_methods() -> None: def test_exposing_methods(caplog: LogCaptureFixture) -> None:
class ClassWithTask(pydase.DataService):
async def some_task(self, sleep_time: int) -> None:
pass
with pytest.raises(TaskDefinitionError):
ClassWithTask()
with pytest.raises(FunctionDefinitionError): with pytest.raises(FunctionDefinitionError):
class ClassWithMethod(pydase.DataService): class ClassWithMethod(pydase.DataService):
@@ -133,6 +128,18 @@ def test_exposing_methods() -> None:
def some_method(self, *args: Any) -> str: def some_method(self, *args: Any) -> str:
return "some method" return "some method"
class ClassWithTask(pydase.DataService):
async def some_task(self, sleep_time: int) -> None:
pass
ClassWithTask()
assert (
"Async function 'some_task' is defined with at least one argument. If you want "
"to use it as a task, remove the argument(s) from the function definition."
in caplog.text
)
def test_dynamically_added_attribute(caplog: LogCaptureFixture) -> None: def test_dynamically_added_attribute(caplog: LogCaptureFixture) -> None:
class MyService(DataService): class MyService(DataService):

View File

@@ -1,9 +1,11 @@
import logging import logging
from typing import Any
import pydase import pydase
import pytest import pytest
from pydase.data_service.data_service_observer import DataServiceObserver from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager from pydase.data_service.state_manager import StateManager
from pydase.utils.serialization.serializer import SerializationError
logger = logging.getLogger() 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 'Hello'" not in caplog.text
assert "'list_attr[0].name' changed to 'Hoooo'" 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

@@ -0,0 +1,128 @@
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 __del__(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

View File

@@ -0,0 +1,214 @@
import logging
from typing import Any
import pytest
from pydase.observer_pattern.observable import Observable
from pydase.observer_pattern.observer import Observer
logger = logging.getLogger(__name__)
class MyObserver(Observer):
def on_change(self, full_access_path: str, value: Any) -> None:
logger.info("'%s' changed to '%s'", full_access_path, value)
def test_simple_class_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
dict_attr = {"first": "Hello"}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"] = "Ciao"
instance.dict_attr["second"] = "World"
assert "'dict_attr[\"first\"]' changed to 'Ciao'" in caplog.text
assert "'dict_attr[\"second\"]' changed to 'World'" in caplog.text
def test_instance_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {"first": NestedObservable()}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"].name = "Ciao"
assert "'dict_attr[\"first\"].name' changed to 'Ciao'" in caplog.text
def test_class_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
name = "Hello"
class MyObservable(Observable):
dict_attr = {"first": NestedObservable()}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"].name = "Ciao"
assert "'dict_attr[\"first\"].name' changed to 'Ciao'" in caplog.text
def test_nested_dict_instances(caplog: pytest.LogCaptureFixture) -> None:
dict_instance = {"first": "Hello", "second": "World"}
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_dict_attr = {"nested": dict_instance}
instance = MyObservable()
MyObserver(instance)
instance.nested_dict_attr["nested"]["first"] = "Ciao"
assert "'nested_dict_attr[\"nested\"][\"first\"]' changed to 'Ciao'" in caplog.text
def test_dict_in_list_instance(caplog: pytest.LogCaptureFixture) -> None:
dict_instance = {"first": "Hello", "second": "World"}
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_in_list = [dict_instance]
instance = MyObservable()
MyObserver(instance)
instance.dict_in_list[0]["first"] = "Ciao"
assert "'dict_in_list[0][\"first\"]' changed to 'Ciao'" in caplog.text
def test_list_in_dict_instance(caplog: pytest.LogCaptureFixture) -> None:
list_instance: list[Any] = [1, 2, 3]
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.list_in_dict = {"some_list": list_instance}
instance = MyObservable()
MyObserver(instance)
instance.list_in_dict["some_list"][0] = "Ciao"
assert "'list_in_dict[\"some_list\"][0]' changed to 'Ciao'" in caplog.text
def test_key_type_error(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {1.0: 1.0}
with pytest.raises(ValueError) as exc_info:
MyObservable()
assert (
"Invalid key type: 1.0 (float). In pydase services, dictionary keys must be "
"strings." in str(exc_info)
)
def test_removed_observer_on_class_dict_attr(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
nested_attr = nested_instance
changed_dict_attr = {"nested": nested_instance}
instance = MyObservable()
MyObserver(instance)
instance.changed_dict_attr["nested"] = "Ciao"
assert "'changed_dict_attr[\"nested\"]' changed to 'Ciao'" in caplog.text
caplog.clear()
assert nested_instance._observers == {
"nested_attr": [instance],
}
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_dict_attr[\"nested\"].name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_instance_dict_attr(
caplog: pytest.LogCaptureFixture,
) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_attr = nested_instance
self.changed_dict_attr = {"nested": nested_instance}
instance = MyObservable()
MyObserver(instance)
instance.changed_dict_attr["nested"] = "Ciao"
assert "'changed_dict_attr[\"nested\"]' changed to 'Ciao'" in caplog.text
caplog.clear()
assert nested_instance._observers == {
"nested_attr": [instance],
}
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_dict_attr[\"nested\"].name' changed to 'Hi'" not in caplog.text
def test_dotted_dict_key(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {"dotted.key": 1.0}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["dotted.key"] = "Ciao"
assert "'dict_attr[\"dotted.key\"]' changed to 'Ciao'" in caplog.text
def test_pop(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {"nested": nested_instance}
instance = MyObservable()
MyObserver(instance)
assert instance.dict_attr.pop("nested") == nested_instance
assert nested_instance._observers == {}
assert f"'dict_attr' changed to '{instance.dict_attr}'" in caplog.text

View File

@@ -69,66 +69,6 @@ def test_class_object_list_attribute(caplog: pytest.LogCaptureFixture) -> None:
assert "'list_attr[0].name' changed to 'Ciao'" in caplog.text assert "'list_attr[0].name' changed to 'Ciao'" in caplog.text
def test_simple_instance_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {"first": "Hello"}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"] = "Ciao"
instance.dict_attr["second"] = "World"
assert "'dict_attr['first']' changed to 'Ciao'" in caplog.text
assert "'dict_attr['second']' changed to 'World'" in caplog.text
def test_simple_class_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
dict_attr = {"first": "Hello"}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"] = "Ciao"
instance.dict_attr["second"] = "World"
assert "'dict_attr['first']' changed to 'Ciao'" in caplog.text
assert "'dict_attr['second']' changed to 'World'" in caplog.text
def test_instance_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {"first": NestedObservable()}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"].name = "Ciao"
assert "'dict_attr['first'].name' changed to 'Ciao'" in caplog.text
def test_class_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
name = "Hello"
class MyObservable(Observable):
dict_attr = {"first": NestedObservable()}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"].name = "Ciao"
assert "'dict_attr['first'].name' changed to 'Ciao'" in caplog.text
def test_removed_observer_on_class_list_attr(caplog: pytest.LogCaptureFixture) -> None: def test_removed_observer_on_class_list_attr(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable): class NestedObservable(Observable):
name = "Hello" name = "Hello"
@@ -141,46 +81,27 @@ def test_removed_observer_on_class_list_attr(caplog: pytest.LogCaptureFixture) -
instance = MyObservable() instance = MyObservable()
MyObserver(instance) MyObserver(instance)
assert nested_instance._observers == {
"[0]": [instance.changed_list_attr],
"nested_attr": [instance],
}
instance.changed_list_attr[0] = "Ciao" instance.changed_list_attr[0] = "Ciao"
assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text
caplog.clear() caplog.clear()
assert nested_instance._observers == {
"nested_attr": [instance],
}
instance.nested_attr.name = "Hi" instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_list_attr[0].name' changed to 'Hi'" not in caplog.text assert "'changed_list_attr[0].name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_instance_dict_attr(
caplog: pytest.LogCaptureFixture,
) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_attr = nested_instance
self.changed_dict_attr = {"nested": nested_instance}
instance = MyObservable()
MyObserver(instance)
instance.changed_dict_attr["nested"] = "Ciao"
assert "'changed_dict_attr['nested']' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_dict_attr['nested'].name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_instance_list_attr( def test_removed_observer_on_instance_list_attr(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
@@ -204,84 +125,16 @@ def test_removed_observer_on_instance_list_attr(
assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text
caplog.clear() caplog.clear()
assert nested_instance._observers == {
"nested_attr": [instance],
}
instance.nested_attr.name = "Hi" instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_list_attr[0].name' changed to 'Hi'" not in caplog.text assert "'changed_list_attr[0].name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_class_dict_attr(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_attr = nested_instance
self.changed_dict_attr = {"nested": nested_instance}
instance = MyObservable()
MyObserver(instance)
instance.changed_dict_attr["nested"] = "Ciao"
assert "'changed_dict_attr['nested']' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_dict_attr['nested'].name' changed to 'Hi'" not in caplog.text
def test_nested_dict_instances(caplog: pytest.LogCaptureFixture) -> None:
dict_instance = {"first": "Hello", "second": "World"}
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_dict_attr = {"nested": dict_instance}
instance = MyObservable()
MyObserver(instance)
instance.nested_dict_attr["nested"]["first"] = "Ciao"
assert "'nested_dict_attr['nested']['first']' changed to 'Ciao'" in caplog.text
def test_dict_in_list_instance(caplog: pytest.LogCaptureFixture) -> None:
dict_instance = {"first": "Hello", "second": "World"}
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_in_list = [dict_instance]
instance = MyObservable()
MyObserver(instance)
instance.dict_in_list[0]["first"] = "Ciao"
assert "'dict_in_list[0]['first']' changed to 'Ciao'" in caplog.text
def test_list_in_dict_instance(caplog: pytest.LogCaptureFixture) -> None:
list_instance: list[Any] = [1, 2, 3]
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.list_in_dict = {"some_list": list_instance}
instance = MyObservable()
MyObserver(instance)
instance.list_in_dict["some_list"][0] = "Ciao"
assert "'list_in_dict['some_list'][0]' changed to 'Ciao'" in caplog.text
def test_list_append(caplog: pytest.LogCaptureFixture) -> None: def test_list_append(caplog: pytest.LogCaptureFixture) -> None:
class OtherObservable(Observable): class OtherObservable(Observable):
def __init__(self) -> None: def __init__(self) -> None:
@@ -472,3 +325,51 @@ def test_list_remove(caplog: pytest.LogCaptureFixture) -> None:
# checks if observer key was updated correctly (was index 1) # checks if observer key was updated correctly (was index 1)
other_observable_instance_2.greeting = "Ciao" other_observable_instance_2.greeting = "Ciao"
assert "'my_list[0].greeting' changed to 'Ciao'" in caplog.text assert "'my_list[0].greeting' changed to 'Ciao'" in caplog.text
def test_list_garbage_collection() -> None:
"""Makes sure that the GC collects lists that are not referenced anymore."""
import gc
import json
list_json = """
[1]
"""
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.list_attr = json.loads(list_json)
observable = MyObservable()
list_mapping_length = len(observable._list_mapping)
observable.list_attr = json.loads(list_json)
gc.collect()
assert len(observable._list_mapping) <= list_mapping_length
def test_dict_garbage_collection() -> None:
"""Makes sure that the GC collects dicts that are not referenced anymore."""
import gc
import json
dict_json = """
{
"foo": "bar"
}
"""
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = json.loads(dict_json)
observable = MyObservable()
dict_mapping_length = len(observable._dict_mapping)
observable.dict_attr = json.loads(dict_json)
gc.collect()
assert len(observable._dict_mapping) <= dict_mapping_length

View File

@@ -16,6 +16,7 @@ def test_inherited_property_dependency_resolution() -> None:
_name = "DerivedObservable" _name = "DerivedObservable"
class MyObserver(PropertyObserver): class MyObserver(PropertyObserver):
def on_change(self, full_access_path: str, value: Any) -> None: ... def on_change(self, full_access_path: str, value: Any) -> None:
...
assert MyObserver(DerivedObservable()).property_deps_dict == {"_name": ["name"]} assert MyObserver(DerivedObservable()).property_deps_dict == {"_name": ["name"]}

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import enum import enum
from enum import Enum from enum import Enum
from typing import Any from typing import Any, ClassVar
import pydase import pydase
import pydase.units as u import pydase.units as u
@@ -13,8 +13,10 @@ from pydase.utils.serialization.serializer import (
SerializationPathError, SerializationPathError,
SerializedObject, SerializedObject,
dump, dump,
generate_serialized_data_paths,
get_container_item_by_key,
get_data_paths_from_serialized_object,
get_nested_dict_by_path, get_nested_dict_by_path,
get_next_level_dict_by_key,
serialized_dict_is_nested_object, serialized_dict_is_nested_object,
set_nested_value_by_path, set_nested_value_by_path,
) )
@@ -27,6 +29,26 @@ class MyEnum(enum.Enum):
FINISHED = "finished" FINISHED = "finished"
class MySubclass(pydase.DataService):
attr3 = 1.0
list_attr: ClassVar[list[Any]] = [1.0, 1]
some_quantity: u.Quantity = 1.0 * u.units.A
class ServiceClass(pydase.DataService):
attr1 = 1.0
attr2 = MySubclass()
enum_attr = MyEnum.RUNNING
attr_list: ClassVar[list[Any]] = [0, 1, MySubclass()]
dict_attr: ClassVar[dict[Any, Any]] = {"foo": 1.0, "bar": {"foo": "bar"}}
def my_task(self) -> None:
pass
service_instance = ServiceClass()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"test_input, expected", "test_input, expected",
[ [
@@ -378,7 +400,7 @@ def test_dict_serialization() -> None:
test_dict = { test_dict = {
"int_key": 1, "int_key": 1,
"float_key": 1.0, "1.0": 1.0,
"bool_key": True, "bool_key": True,
"Quantity_key": 1.0 * u.units.s, "Quantity_key": 1.0 * u.units.s,
"DataService_key": MyClass(), "DataService_key": MyClass(),
@@ -420,8 +442,8 @@ def test_dict_serialization() -> None:
"type": "bool", "type": "bool",
"value": True, "value": True,
}, },
"float_key": { "1.0": {
"full_access_path": '["float_key"]', "full_access_path": '["1.0"]',
"doc": None, "doc": None,
"readonly": False, "readonly": False,
"type": "float", "type": "float",
@@ -454,7 +476,8 @@ def test_derived_data_service_serialization() -> None:
def name(self, value: str) -> None: def name(self, value: str) -> None:
self._name = value self._name = value
class DerivedService(BaseService): ... class DerivedService(BaseService):
...
base_service_serialization = dump(BaseService()) base_service_serialization = dump(BaseService())
derived_service_serialization = dump(DerivedService()) derived_service_serialization = dump(DerivedService())
@@ -468,22 +491,125 @@ def test_derived_data_service_serialization() -> None:
@pytest.fixture @pytest.fixture
def setup_dict() -> dict[str, Any]: def setup_dict() -> dict[str, Any]:
class MySubclass(pydase.DataService):
attr3 = 1.0
list_attr = [1.0, 1]
class ServiceClass(pydase.DataService):
attr1 = 1.0
attr2 = MySubclass()
enum_attr = MyEnum.RUNNING
attr_list = [0, 1, MySubclass()]
def my_task(self) -> None:
pass
return ServiceClass().serialize()["value"] # type: ignore return ServiceClass().serialize()["value"] # type: ignore
@pytest.mark.parametrize(
"serialized_object, attr_name, allow_append, expected",
[
(
dump(service_instance)["value"],
"attr1",
False,
{
"doc": None,
"full_access_path": "attr1",
"readonly": False,
"type": "float",
"value": 1.0,
},
),
(
dump(service_instance.attr_list)["value"],
"[0]",
False,
{
"doc": None,
"full_access_path": "[0]",
"readonly": False,
"type": "int",
"value": 0,
},
),
(
dump(service_instance.attr_list)["value"],
"[3]",
True,
{
# we do not know the full_access_path of this entry within the
# serialized object
"full_access_path": "",
"value": None,
"type": "None",
"doc": None,
"readonly": False,
},
),
(
dump(service_instance.attr_list)["value"],
"[3]",
False,
SerializationPathError,
),
(
dump(service_instance.dict_attr)["value"],
"['foo']",
False,
{
"full_access_path": '["foo"]',
"value": 1.0,
"type": "float",
"doc": None,
"readonly": False,
},
),
(
dump(service_instance.dict_attr)["value"],
"['unset_key']",
True,
{
# we do not know the full_access_path of this entry within the
# serialized object
"full_access_path": "",
"value": None,
"type": "None",
"doc": None,
"readonly": False,
},
),
(
dump(service_instance.dict_attr)["value"],
"['unset_key']",
False,
SerializationPathError,
),
(
dump(service_instance)["value"],
"invalid_path",
True,
{
# we do not know the full_access_path of this entry within the
# serialized object
"full_access_path": "",
"value": None,
"type": "None",
"doc": None,
"readonly": False,
},
),
(
dump(service_instance)["value"],
"invalid_path",
False,
SerializationPathError,
),
],
)
def test_get_container_item_by_key(
serialized_object: dict[str, Any], attr_name: str, allow_append: bool, expected: Any
) -> None:
if isinstance(expected, type) and issubclass(expected, Exception):
with pytest.raises(expected):
get_container_item_by_key(
serialized_object, attr_name, allow_append=allow_append
)
else:
nested_dict = get_container_item_by_key(
serialized_object, attr_name, allow_append=allow_append
)
assert nested_dict == expected
def test_update_attribute(setup_dict: dict[str, Any]) -> None: def test_update_attribute(setup_dict: dict[str, Any]) -> None:
set_nested_value_by_path(setup_dict, "attr1", 15) set_nested_value_by_path(setup_dict, "attr1", 15)
assert setup_dict["attr1"]["value"] == 15 assert setup_dict["attr1"]["value"] == 15
@@ -565,8 +691,8 @@ def test_update_invalid_list_index(
) -> None: ) -> None:
set_nested_value_by_path(setup_dict, "attr_list[10]", 30) set_nested_value_by_path(setup_dict, "attr_list[10]", 30)
assert ( assert (
"Error occured trying to change 'attr_list[10]': list index " "Error occured trying to change 'attr_list[10]': Index '10': list index out of "
"out of range" in caplog.text "range" in caplog.text
) )
@@ -580,26 +706,6 @@ def test_update_class_attribute_inside_list(setup_dict: dict[str, Any]) -> None:
assert setup_dict["attr_list"]["value"][2]["value"]["attr3"]["value"] == 50 # noqa assert setup_dict["attr_list"]["value"][2]["value"]["attr3"]["value"] == 50 # noqa
def test_get_next_level_attribute_nested_dict(setup_dict: dict[str, Any]) -> None:
nested_dict = get_next_level_dict_by_key(setup_dict, "attr1")
assert nested_dict == setup_dict["attr1"]
def test_get_next_level_list_entry_nested_dict(setup_dict: dict[str, Any]) -> None:
nested_dict = get_next_level_dict_by_key(setup_dict, "attr_list[0]")
assert nested_dict == setup_dict["attr_list"]["value"][0]
def test_get_next_level_invalid_path_nested_dict(setup_dict: dict[str, Any]) -> None:
with pytest.raises(SerializationPathError):
get_next_level_dict_by_key(setup_dict, "invalid_path")
def test_get_next_level_invalid_list_index(setup_dict: dict[str, Any]) -> None:
with pytest.raises(SerializationPathError):
get_next_level_dict_by_key(setup_dict, "attr_list[10]")
def test_get_attribute(setup_dict: dict[str, Any]) -> None: def test_get_attribute(setup_dict: dict[str, Any]) -> None:
nested_dict = get_nested_dict_by_path(setup_dict, "attr1") nested_dict = get_nested_dict_by_path(setup_dict, "attr1")
assert nested_dict["value"] == 1.0 assert nested_dict["value"] == 1.0
@@ -871,3 +977,89 @@ def test_dynamically_add_attributes(test_input: Any, expected: dict[str, Any]) -
set_nested_value_by_path(serialized_object, "new_attr", test_input) set_nested_value_by_path(serialized_object, "new_attr", test_input)
assert serialized_object == expected assert serialized_object == expected
@pytest.mark.parametrize(
"obj, expected",
[
(
service_instance.attr2,
[
"attr3",
"list_attr",
"list_attr[0]",
"list_attr[1]",
"some_quantity",
],
),
(
service_instance.dict_attr,
[
'["foo"]',
'["bar"]',
'["bar"]["foo"]',
],
),
(
service_instance.attr_list,
[
"[0]",
"[1]",
"[2]",
"[2].attr3",
"[2].list_attr",
"[2].list_attr[0]",
"[2].list_attr[1]",
"[2].some_quantity",
],
),
],
)
def test_get_data_paths_from_serialized_object(obj: Any, expected: list[str]) -> None:
assert get_data_paths_from_serialized_object(dump(obj=obj)) == expected
@pytest.mark.parametrize(
"obj, expected",
[
(
service_instance,
[
"attr1",
"attr2",
"attr2.attr3",
"attr2.list_attr",
"attr2.list_attr[0]",
"attr2.list_attr[1]",
"attr2.some_quantity",
"attr_list",
"attr_list[0]",
"attr_list[1]",
"attr_list[2]",
"attr_list[2].attr3",
"attr_list[2].list_attr",
"attr_list[2].list_attr[0]",
"attr_list[2].list_attr[1]",
"attr_list[2].some_quantity",
"dict_attr",
'dict_attr["foo"]',
'dict_attr["bar"]',
'dict_attr["bar"]["foo"]',
"enum_attr",
"my_task",
],
),
(
service_instance.attr2,
[
"attr3",
"list_attr",
"list_attr[0]",
"list_attr[1]",
"some_quantity",
],
),
],
)
def test_generate_serialized_data_paths(obj: Any, expected: list[str]) -> None:
assert generate_serialized_data_paths(dump(obj=obj)["value"]) == expected

View File

@@ -1,10 +1,113 @@
from typing import Any
import pydase
import pytest import pytest
from pydase.utils.helpers import ( from pydase.utils.helpers import (
get_object_by_path_parts,
get_path_from_path_parts,
is_property_attribute, is_property_attribute,
parse_full_access_path,
parse_serialized_key,
) )
@pytest.mark.parametrize(
"serialized_key, expected",
[
("attr_name", "attr_name"),
("[0]", 0),
("[0.0]", 0.0),
('["some_key"]', "some_key"),
('["12.34"]', "12.34"),
],
)
def test_parse_serialized_key(serialized_key: str, expected: str) -> None:
assert parse_serialized_key(serialized_key) == expected
@pytest.mark.parametrize(
"full_access_path, expected",
[
("attr_name", ["attr_name"]),
("parent.attr_name", ["parent", "attr_name"]),
("nested.parent.attr_name", ["nested", "parent", "attr_name"]),
("nested.parent.attr_name", ["nested", "parent", "attr_name"]),
("attr_name[0]", ["attr_name", "[0]"]),
("parent.attr_name[0]", ["parent", "attr_name", "[0]"]),
("attr_name[0][1]", ["attr_name", "[0]", "[1]"]),
('attr_name[0]["some_key"]', ["attr_name", "[0]", '["some_key"]']),
(
'dict_attr["some_key"].attr_name["other_key"]',
["dict_attr", '["some_key"]', "attr_name", '["other_key"]'],
),
("dict_attr[2.1]", ["dict_attr", "[2.1]"]),
],
)
def test_parse_full_access_path(full_access_path: str, expected: list[str]) -> None:
assert parse_full_access_path(full_access_path) == expected
@pytest.mark.parametrize(
"path_parts, expected",
[
(["attr_name"], "attr_name"),
(["parent", "attr_name"], "parent.attr_name"),
(["nested", "parent", "attr_name"], "nested.parent.attr_name"),
(["nested", "parent", "attr_name"], "nested.parent.attr_name"),
(["attr_name", "[0]"], "attr_name[0]"),
(["parent", "attr_name", "[0]"], "parent.attr_name[0]"),
(["attr_name", "[0]", "[1]"], "attr_name[0][1]"),
(["attr_name", "[0]", '["some_key"]'], 'attr_name[0]["some_key"]'),
(
["dict_attr", '["some_key"]', "attr_name", '["other_key"]'],
'dict_attr["some_key"].attr_name["other_key"]',
),
(["dict_attr", "[2.1]"], "dict_attr[2.1]"),
],
)
def test_get_path_from_path_parts(path_parts: list[str], expected: str) -> None:
assert get_path_from_path_parts(path_parts) == expected
class SubService(pydase.DataService):
name = "SubService"
some_int = 1
some_float = 1.0
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.some_float = 1.0
self.subservice = SubService()
self.list_attr = [1.0, SubService()]
self.dict_attr = {"foo": SubService(), "dotted.key": "float_as_key"}
service_instance = MyService()
@pytest.mark.parametrize(
"path_parts, expected",
[
(["some_float"], service_instance.some_float),
(["subservice"], service_instance.subservice),
(["list_attr", "[0]"], service_instance.list_attr[0]),
(["list_attr", "[1]"], service_instance.list_attr[1]),
(["dict_attr", '["foo"]'], service_instance.dict_attr["foo"]),
(["dict_attr", '["foo"]', "name"], service_instance.dict_attr["foo"].name), # type: ignore
(["dict_attr", '["dotted.key"]'], service_instance.dict_attr["dotted.key"]),
],
)
def test_get_object_by_path_parts(path_parts: list[str], expected: Any) -> None:
assert get_object_by_path_parts(service_instance, path_parts) == expected
def test_get_object_by_path_parts_error(caplog: pytest.LogCaptureFixture) -> None:
assert get_object_by_path_parts(service_instance, ["non_existent_attr"]) is None
assert "Attribute 'non_existent_attr' does not exist in the object." in caplog.text
@pytest.mark.parametrize( @pytest.mark.parametrize(
"attr_name, expected", "attr_name, expected",
[ [
@@ -12,13 +115,29 @@ from pydase.utils.helpers import (
("my_property", True), ("my_property", True),
("my_method", False), ("my_method", False),
("non_existent_attr", False), ("non_existent_attr", False),
("nested_class_instance", False),
("nested_class_instance.my_property", True),
("list_attr", False),
("list_attr[0]", False),
("list_attr[0].my_property", True),
("dict_attr", False),
("dict_attr['foo']", False),
("dict_attr['foo'].my_property", True),
], ],
) )
def test_is_property_attribute(attr_name: str, expected: bool) -> None: def test_is_property_attribute(attr_name: str, expected: bool) -> None:
class NestedClass:
@property
def my_property(self) -> str:
return "I'm a nested property"
# Test Suite # Test Suite
class DummyClass: class DummyClass:
def __init__(self) -> None: def __init__(self) -> None:
self.regular_attribute = "I'm just an attribute" self.regular_attribute = "I'm just an attribute"
self.nested_class_instance = NestedClass()
self.list_attr = [NestedClass()]
self.dict_attr = {"foo": NestedClass()}
@property @property
def my_property(self) -> str: def my_property(self) -> str: