530 Commits

Author SHA1 Message Date
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
Mose Müller
5333acd583 updates version number 2024-04-17 09:34:30 +02:00
Mose Müller
81c05d2e14 Merge pull request #119 from tiqi-group/fix/component_display
Fix/component display
2024-04-17 09:28:32 +02:00
Mose Müller
8832c879a1 npm run build 2024-04-17 09:21:51 +02:00
Mose Müller
ec1f68ae4a using fullAccessPath as Form name for NumberComponent fixing cursor jumps 2024-04-17 09:21:02 +02:00
Mose Müller
f5e108bbe5 fixes readonly coloured enum 2024-04-17 09:11:33 +02:00
Mose Müller
dfe543067f fixes frontend button 2024-04-17 09:07:24 +02:00
Mose Müller
a77dcfdfae Merge pull request #118 from tiqi-group/feat/sio_server_client
Feat/sio server client
2024-04-16 11:48:27 +02:00
Mose Müller
fe01ada733 adds tab completion test for client 2024-04-16 11:29:44 +02:00
Mose Müller
16c1f966ab adds test for dynamically added attribute 2024-04-16 11:15:42 +02:00
Mose Müller
003ee95272 replaces test_image test image url 2024-04-16 11:01:52 +02:00
Mose Müller
dfbf1c61af updates Readme (replaces rpyc with pydase.Client) 2024-04-16 10:55:54 +02:00
Mose Müller
7233e5933b updates client documentation 2024-04-16 10:41:18 +02:00
Mose Müller
09e66400c3 removes rpyc dependency 2024-04-16 10:21:25 +02:00
Mose Müller
6977b795e5 updates pydase.Server docstring 2024-04-16 10:21:25 +02:00
Mose Müller
8911b860d7 updates client list testing 2024-04-16 10:08:42 +02:00
Mose Müller
245b1844c9 adds update_value method to reduce code duplication 2024-04-16 10:08:42 +02:00
Mose Müller
d48ae9f5ad adds trigger_method function to reduce code duplication 2024-04-16 09:59:39 +02:00
Mose Müller
cf637d19ae adds docstring to ProxyClass 2024-04-16 09:49:00 +02:00
Mose Müller
edfb7d0341 Using super() in proxy class constructor 2024-04-16 09:36:34 +02:00
Mose Müller
7b06786307 updates pydase.Client documentation and constructor arguments 2024-04-16 09:34:29 +02:00
Mose Müller
5eeaefdd63 refactors client sio event setup 2024-04-15 08:16:46 +02:00
Mose Müller
f65a0e31c3 udpates client tests 2024-04-09 13:52:41 +02:00
Mose Müller
fbada6d818 adds ProxyList methods 2024-04-09 13:43:43 +02:00
Mose Müller
507f286963 adds blocking kwarg to client
If blocking is true, client init will wait until it could connect to the server.
2024-04-09 13:27:55 +02:00
Mose Müller
c148eba5dd updates client tests 2024-04-09 09:25:47 +02:00
Mose Müller
61c7dc8333 client retries to connect if server is not available. Connection process is not blocking anymore 2024-04-09 09:25:01 +02:00
Mose Müller
a879b09e0b updates version to 0.8.0 2024-04-09 09:25:01 +02:00
Mose Müller
bba21e3241 adds Client tests 2024-04-09 09:25:01 +02:00
Mose Müller
16bd17f75c adds deserializer tests 2024-04-09 09:25:01 +02:00
Mose Müller
ad2800aaf6 improves exception deserialization
Tries to use builtins exceptions if possible.
2024-04-08 11:13:14 +02:00
Mose Müller
d792601663 removes out-dated tests 2024-04-08 10:23:24 +02:00
Mose Müller
166fc57877 adds property observer test 2024-04-08 10:23:24 +02:00
Mose Müller
5b762db535 fixes detection of property dependencies for classes inheriting from other observables
Inheriting from a class that itself has defined properties, you cannot get those properties by calling
vars(type(obj)). Instead, you have to go through all the classes' members and check if they are properties.
You can either do this using dir(type(obj)) and get the members using getattr or just use inspect.getmembers.
2024-04-08 10:23:24 +02:00
Mose Müller
73b2355d35 fixes Client specific errors when setting proxy attributes / methods
Also ignores mypy errors
2024-04-08 10:23:24 +02:00
Mose Müller
6335ea21ad fixes warnings in ProxyClassMixin 2024-04-04 16:30:26 +02:00
Mose Müller
690ecd7317 adds aiohttp to python deps (used for socketio.AsyncClient) 2024-04-04 16:26:28 +02:00
Mose Müller
9cb667581a removes exposed dump method (circular import apparently)
fixes test
2024-04-04 16:26:28 +02:00
Mose Müller
5936e7091e updates sio events
- adds disconnect event which marks the DeviceConnection as disconnected
- updates connect event to notify the observer about the new state and set connected to True
2024-04-04 16:20:31 +02:00
Mose Müller
ad0fd8e833 updates proxy class usage
- ProxyClass class is inheriting from DeviceConnection and is only used for topmost proxy
- classes of nested proxy objects are dynamically created to keep their component types
- adds _initialise method to ProxyClassMixin as I cannot pass sio_client and loop to each
component_class (initialising a class with multiple base classes will pass the arguments passed to
the constructor to each initialiser function)
2024-04-04 16:19:29 +02:00
Mose Müller
473c6660e6 fixes warnings 2024-04-04 11:41:12 +02:00
Mose Müller
5511ebc808 updates client proxy
- will now be changed in place (instead of being overwritten on reconnect, which was the only way
of adding or removing property getters / setters)
- replaces getters/setters and methods of proxy with __setattr__ and __getattribute__ functionality
- replaces ProxyClassFactory with ProxyClass and ProxyLoader. The latter updates the former on
reconnect
- client does not need to be a DataService anymore. It only establishes the connection and holds
the reference to the proxy class.
2024-04-04 11:31:14 +02:00
Mose Müller
439665177d removes unused attribute of ProxyConnection 2024-04-03 10:54:03 +02:00
Mose Müller
c0b25c0581 adds Client to default exports of pydase 2024-04-03 10:47:46 +02:00
Mose Müller
60a7dda60a restructures client to have separate thread for its asyncio loop 2024-04-03 10:28:06 +02:00
Mose Müller
381d98b078 updates is_property_attribute to accept the full_access_path instead of the attr_name only 2024-03-29 08:47:24 +01:00
Mose Müller
658fb13d9d improves Client._notify_changed by not emitting sio events when properties change 2024-03-29 08:44:48 +01:00
Mose Müller
a582dc23ac makes proxyclass reconnection wait time a float to not get warning 2024-03-29 08:44:20 +01:00
Mose Müller
19b24f3060 avoids notifying server when updates are pushed from the server itself 2024-03-28 18:41:01 +01:00
Mose Müller
d100bb5fea udpates Client and ProxyClassFactory
- Client:
  - inherits from DataService now
  - acts as an observer of the proxy class and sends updates to the sio server
- ProxyClassFactory
  - ProxyConnection is now a DeviceConnection -> users will see if the client is connected
2024-03-28 18:41:01 +01:00
Mose Müller
36a70badce fixes observable _construct_extended_attr_path
Passing an empty string resulted in an extended path ending with a "."
2024-03-28 18:13:44 +01:00
Mose Müller
9916d6df60 adds support for dynamically adding attributes to DataService instances 2024-03-28 14:30:09 +01:00
Mose Müller
b4c84da57e npm run build 2024-03-28 11:31:12 +01:00
Mose Müller
ecf0e99318 fixes units test 2024-03-28 11:30:18 +01:00
Mose Müller
10ac007a0c ignores complexity errors 2024-03-28 11:30:07 +01:00
Mose Müller
900017791a fixes loading of removed attributes. Prints debug log instead of raising exception 2024-03-28 11:27:16 +01:00
Mose Müller
edb06b1612 restructures StateManager
- Updates logic of loading the state
- set_service_attribut_value_by_path expects serialized object instead of the value now
- uses loads instead of __convert_value_if_needed now
2024-03-28 10:21:28 +01:00
Mose Müller
bb5205b2e4 get_object_attr_from_path expects string instead of list now 2024-03-28 10:18:43 +01:00
Mose Müller
c02c75aab5 prevents users to override nested ProxyClasses in sio client proxy 2024-03-28 10:09:08 +01:00
Mose Müller
cc3fdfbb27 makes sio client private on ProxyClass 2024-03-28 09:48:25 +01:00
Mose Müller
7d399df158 proxy class will raise exception raised on server when setting value 2024-03-28 09:29:37 +01:00
Mose Müller
92e2c0e8ef fixes deserialization of floats 2024-03-28 08:57:59 +01:00
Mose Müller
65f63e08ae fixes changing Quantity from frontend 2024-03-28 08:55:58 +01:00
Mose Müller
4eddf4b980 todos 2024-03-27 17:50:51 +01:00
Mose Müller
9d7099f116 updates socket.ts (passing access_path to backend) 2024-03-27 17:49:57 +01:00
Mose Müller
3f096bda96 fixes loading enum from json file
Loading from json file happens by name. The sio client will send the whole
enumeration and thus we have to handle both strings and enumerations.
2024-03-27 17:30:37 +01:00
Mose Müller
e56a6e0653 fixing tests 2024-03-27 17:13:40 +01:00
Mose Müller
e71186dce4 updates types 2024-03-27 17:13:37 +01:00
Mose Müller
d1007fad14 removes unused code 2024-03-27 16:52:48 +01:00
Mose Müller
6f2c1f8951 exports dump function from pydase.utils.serialization 2024-03-27 16:37:06 +01:00
Mose Müller
f18880abd5 moves serializer tests into separate module 2024-03-27 16:31:08 +01:00
Mose Müller
9851ccfcdf moves serializer file into serialization module 2024-03-27 16:30:15 +01:00
Mose Müller
f312ec1e51 moving deserializer into serialization module 2024-03-27 16:26:46 +01:00
Mose Müller
7405d2cafc adds serialization module, moves types into separate file 2024-03-27 16:26:24 +01:00
Mose Müller
e6251975b8 adds try...except blocks around update_value and get_value sio events 2024-03-27 16:18:59 +01:00
Mose Müller
780a2466d3 fixes updating a value through sio client 2024-03-27 16:18:04 +01:00
Mose Müller
8979a1885e fixes method execution from frontend, adds simple serialization methods 2024-03-27 16:00:54 +01:00
Mose Müller
fbc4af28ae removes debugging statements 2024-03-27 16:00:24 +01:00
Mose Müller
454b0fb7d1 adds start and stop methods for tasks in socketio client 2024-03-27 15:32:51 +01:00
Mose Müller
9d3264de1f fixes cache update of task status change 2024-03-27 15:32:51 +01:00
Mose Müller
2d6c681690 improves SerializedObject type hint 2024-03-27 15:32:51 +01:00
Mose Müller
612e62d06b updates ProxyClassFactory (go through handled types before components) 2024-03-27 15:20:50 +01:00
Mose Müller
31f280c9cb frontend components pass actual readOnly and docString values to backend 2024-03-27 15:20:50 +01:00
Mose Müller
e4f5374783 fixes docstring when setting nested value by path 2024-03-27 15:20:50 +01:00
Mose Müller
6397307690 restructuring EnumComponent (now for both Enum and ColouredEnum) 2024-03-27 15:20:50 +01:00
Mose Müller
2ce4c9ce9b using new runMethod function 2024-03-27 15:20:50 +01:00
Mose Müller
15cf0bd414 adapting components to new callback function 2024-03-27 15:20:23 +01:00
Mose Müller
ff3a509132 passing fullAccessPath instead of parentPath and name 2024-03-27 15:20:23 +01:00
Mose Müller
1a01222cb3 updates changeCallback and SerializedObject in GenericComponent.tsx 2024-03-27 12:08:10 +01:00
Mose Müller
2eb996b382 updates frontend socket to use new sio events 2024-03-27 12:08:10 +01:00
Mose Müller
8addcd26aa fixes state manager enum handlign 2024-03-27 12:08:10 +01:00
Mose Müller
4db15f2fe8 updates sio events in web server 2024-03-27 12:08:10 +01:00
Mose Müller
27f22d472d updates Deserializer (handle components at last) 2024-03-27 12:06:14 +01:00
Mose Müller
c1aa678384 clients will now receive updates from socketio server and notify the observer 2024-03-27 12:06:14 +01:00
Mose Müller
11670addc4 replaces ClientDeserializer with ProxyClassFactory 2024-03-27 12:06:14 +01:00
Mose Müller
1c663e9a2e updates Deserializer (type hints, adding keyword to argument) 2024-03-27 12:06:14 +01:00
Mose Müller
ada9dcce4a adds websocket-client package 2024-03-27 12:06:14 +01:00
Mose Müller
bd5c162148 adds socketio client code 2024-03-27 12:06:14 +01:00
Mose Müller
4e1ec90dee adds Deserializer, converting SerializedObject objects back to actual objects 2024-03-27 12:06:14 +01:00
Mose Müller
4406acf4dd adds support for serializing exceptions 2024-03-27 12:06:14 +01:00
Mose Müller
1ad917a423 removes rpyc 2024-03-27 12:06:14 +01:00
Mose Müller
57e7deb552 Serializer adds full_access_path to serialized object representation 2024-03-26 10:52:06 +01:00
Mose Müller
d9ea33abb6 adds enum name to serialized object representation 2024-03-26 10:50:16 +01:00
Mose Müller
75c5bc6877 updates to version v0.7.4 2024-03-19 08:28:13 +01:00
Mose Müller
a606194c48 Merge pull request #116 from tiqi-group/feat/customisable_frontend_src
feat: adds option for custom frontend_src directory
2024-03-14 16:45:04 +01:00
Mose Müller
5da7bdea78 updates Readme 2024-03-14 16:43:59 +01:00
Mose Müller
c6a52914c5 adds option for custom frontend_src directory 2024-03-14 16:30:57 +01:00
Mose Müller
ae68a89f48 Merge pull request #115 from tiqi-group/feat/add_custom_css_default_response
feat: add "custom.css" endpoint default Response
2024-03-12 14:41:09 +01:00
Mose Müller
386e69b048 custom.css endpoint defaults to empty Response now 2024-03-12 14:37:12 +01:00
Mose Müller
8310a51a74 Merge pull request #113 from tiqi-group/feat/frontend_display_toggle
Feat: frontend display toggle
2024-03-12 07:40:50 +01:00
Mose Müller
2a8cbf7a4a updates Readme 2024-03-12 07:35:45 +01:00
Mose Müller
857b81d213 updates tests 2024-03-11 15:37:56 +01:00
Mose Müller
25834534ad npm run build 2024-03-11 15:37:56 +01:00
Mose Müller
4a948f9155 adds "display" web settings support to frontend
Components with a "display" equals false in the web settings will not be displayed
in the frontend.
2024-03-11 15:37:56 +01:00
Mose Müller
df42f41f53 adds "display" key in web settings 2024-03-11 15:37:56 +01:00
Mose Müller
b8d421eb90 fix: readonly value is not overwritten anymore when changing attribute type 2024-03-11 15:37:26 +01:00
Mose Müller
877ab42905 fixes webserver (apparently FastAPI need the correct type hints...) 2024-03-07 17:52:03 +01:00
Mose Müller
51ffd8be4d simplifies serializer logic 2024-03-06 18:56:15 +01:00
Mose Müller
a88a0c6133 Updates python dependencies 2024-03-06 18:28:11 +01:00
Mose Müller
390a375777 Merge pull request #111 from tiqi-group/refactor/updates_serialized_object_type_hints
updates type hints for serialized objects
2024-03-06 18:27:21 +01:00
Mose Müller
4aee899dbe updates type hints for serialized objects 2024-03-06 18:23:26 +01:00
Mose Müller
c7d452d7db adds tests for Image component 2024-03-05 16:32:20 +01:00
Mose Müller
b7926b730d updates version to v0.7.3 2024-03-05 16:32:07 +01:00
Mose Müller
0c175fc706 Merge pull request #109 from tiqi-group/fix/task_disappears_after_changing_state
Fix/task disappears after changing state
2024-03-05 16:08:55 +01:00
Mose Müller
7d21bca8b1 adds test for changing task state 2024-03-05 16:05:09 +01:00
Mose Müller
d1628ae8c9 fixes updating task state 2024-03-05 16:05:01 +01:00
Mose Müller
441658ebc1 Merge pull request #108 from tiqi-group/fix/cache_update_on_type_change
Fix/cache update on type change
2024-03-05 14:44:19 +01:00
Mose Müller
99c7ad0ec8 updates serializer tests 2024-03-05 14:28:53 +01:00
Mose Müller
24a01c0982 removes keys from cache entry if they are not part of the new value serialization 2024-03-05 14:17:05 +01:00
Mose Müller
b8a52c2e6a only update cache and execute notification callbacks if attribute is public and has changed 2024-03-05 13:56:02 +01:00
Mose Müller
7aacc21010 removes processing of value from sio_callback (cached value is up-to-date already) 2024-03-05 13:54:24 +01:00
Mose Müller
8787cb0509 get cached value before executing custom notification callbacks 2024-03-05 13:53:41 +01:00
Mose Müller
8971cebfcd adds todos 2024-03-05 13:24:54 +01:00
Mose Müller
f2cf0d9c1a fixes update of cache when the type has changed
When an attribute changes from, say, a quantity to an enumeration, the enum key in the serialization was not added to the
cache, and thus the frontend was not able to render the enum.
2024-03-05 13:23:26 +01:00
Mose Müller
36c863e845 Merge pull request #107 from tiqi-group/fix/update_frontend_before_setting_state
Fix/update frontend before setting state
2024-03-05 13:20:54 +01:00
Mose Müller
836c1e14df npm run build 2024-03-05 13:19:10 +01:00
Mose Müller
dba036c6b3 do not try to update state if it is not yet set
This happens when the backend pushes updates before the frontend has received and set the state when loading the page, first.
2024-03-05 13:19:02 +01:00
Mose Müller
8b1f1ef1b1 updates to version v0.7.2 2024-03-04 17:46:44 +01:00
Mose Müller
698db4881b Merge pull request #106 from tiqi-group/fix/enum_sio_callback
fixes sio callback when attribute changes to an enum which was not present before
2024-03-04 17:38:33 +01:00
Mose Müller
d709d43d75 ignores complexity of sio_server setup (will be changed anyway soon 2024-03-04 17:36:09 +01:00
Mose Müller
691bf809cb fixes sio callback when attribute changes to an enum which was not present before 2024-03-04 17:32:45 +01:00
Mose Müller
86ccdd77f1 updates to version v0.7.1 2024-03-04 11:52:06 +01:00
Mose Müller
f29fb87054 Merge pull request #105 from tiqi-group/fix/enum_rendering
Fix/enum rendering
2024-03-04 11:51:31 +01:00
Mose Müller
cf5bc1e4e6 npm run build 2024-03-04 11:48:22 +01:00
Mose Müller
af36ed6c43 changes rendering of enums 2024-03-04 11:48:01 +01:00
Mose Müller
853472be94 updates enumValue when backend value changes 2024-03-04 11:47:51 +01:00
Mose Müller
f97a138e65 updates version to v0.7.0 2024-02-28 11:37:07 +01:00
Mose Müller
e5d7f4709f Merge pull request #103 from tiqi-group/90-display-the-functions-its-names-differently-in-the-ui
feat: updates functions and how they are rendered
2024-02-28 11:28:04 +01:00
Mose Müller
416ae6f0b4 updates Adding_Components.md to account for new component structure 2024-02-28 11:15:37 +01:00
Mose Müller
8f0a9ad21a npm run build 2024-02-28 11:01:23 +01:00
Mose Müller
6ed6fe5be1 cleanup: changing some frontend components 2024-02-28 10:59:28 +01:00
Mose Müller
9c6323d38f updates Readme 2024-02-28 09:12:34 +01:00
Mose Müller
5c11202e08 removes print statement 2024-02-27 18:04:09 +01:00
Mose Müller
e551af68f9 adds image to Readme 2024-02-27 17:44:08 +01:00
Mose Müller
e213931cb7 npm run build 2024-02-27 17:41:55 +01:00
Mose Müller
fe29530eb6 updates Readme 2024-02-27 17:38:39 +01:00
Mose Müller
151467b36f fixes tests 2024-02-27 17:38:09 +01:00
Mose Müller
990add216c moves frontend decorator into decorators module 2024-02-27 17:35:35 +01:00
Mose Müller
a05b703bb8 adds tests for methods exposed by DataService 2024-02-27 16:38:08 +01:00
Mose Müller
9616c57c38 changes exception raised by @frontend decorator 2024-02-27 16:37:43 +01:00
Mose Müller
a7ce321506 updates / fixes method serialization tests 2024-02-27 16:32:47 +01:00
Mose Müller
a72a551f54 fixes tests for DataServiceCache and TaskManager 2024-02-27 16:19:11 +01:00
Mose Müller
26689d8578 updates AsyncMethodComponent to work with backend 2024-02-27 16:07:54 +01:00
Mose Müller
74fc5d9aab updates task serialization 2024-02-27 16:07:29 +01:00
Mose Müller
da8d07a8b2 frontend decorator uses helper function (function_has_arguments) now 2024-02-27 15:59:35 +01:00
Mose Müller
ca2182c19b tasks are not allowed to have arguments anymore 2024-02-27 15:59:35 +01:00
Mose Müller
b2f828ff6f adds function_has_arguments helper function 2024-02-27 15:30:47 +01:00
Mose Müller
affc63219f removes name from function signature parameter serialization 2024-02-27 14:35:09 +01:00
Mose Müller
a01cf273fe fixes render_in_frontend function 2024-02-27 12:58:43 +01:00
Mose Müller
acd0c80316 updated use of method components 2024-02-27 12:58:28 +01:00
Mose Müller
2337aa9d6d only methods without arguments can be rendered 2024-02-27 12:58:08 +01:00
Mose Müller
b6f6b3058e updates render_in_frontend method (takes async functions into account) 2024-02-27 11:32:18 +01:00
Mose Müller
d33e9f9dbf method serialization contains signature instead of parameter key-value pair 2024-02-27 11:30:00 +01:00
Mose Müller
53676131a6 replaces no_frontend decorator with "frontend" decorator 2024-02-27 11:28:42 +01:00
Mose Müller
7f407ae6e7 extracts method to get default value of function keyword argument 2024-02-27 09:20:22 +01:00
Mose Müller
3c2f425dee adds "no_frontend" decorator for emitting frontend rendering of method
The method serialization now contains a "frontend_render" key with boolean value.
2024-02-27 08:25:11 +01:00
Mose Müller
ccc53c395e adds "name" key-value pair to DataService serialization 2024-02-27 08:13:09 +01:00
Mose Müller
c672989768 Merge pull request #104 from tiqi-group/update/dependencies
Updates fastapi and uvicorn dependenciees
2024-02-26 09:41:37 +01:00
Mose Müller
5ff279d5bd Updates fastapi and uvicorn dependenciees 2024-02-26 09:37:24 +01:00
Mose Müller
883ec6d6ae updates MethodComponent
Keyword arguments have a default value now which is displayed in the frontend. The following types can be rendered now:
- numbers (ints, floats, quantities)
- enums (including coloured enums)

I still have to fix the `convert_argument_to_hinted_types` method to make Quantity and Enums work.
2024-02-21 16:30:47 +01:00
Mose Müller
22fd2d099d stores enum value within component - now usable within method form 2024-02-21 16:20:58 +01:00
Mose Müller
f8926ea823 prevents Enter key within StringComponent to submit form in MethodComponent 2024-02-21 16:09:28 +01:00
Mose Müller
ceed62c8f2 merges NumberInputField back into NumberComponent 2024-02-21 15:46:27 +01:00
Mose Müller
5313ef6e8c fixes StringComponent for use as method argument (adds name to control form) 2024-02-21 15:46:14 +01:00
Mose Müller
2d98ba51f4 moves displayName and id to GenericComponent and pass them as props 2024-02-21 15:45:37 +01:00
Mose Müller
2f2544b978 removes unnecessary props from button 2024-02-21 09:36:29 +01:00
Mose Müller
fffe679bf0 defines changeCallback function in GenericComponent and passes it to components (instead of setAttribute)
The components do not use the setAttribute method themselves anymore. This way, you can provide
the changeCallback function if you want and thus reuse the components.
2024-02-21 08:32:59 +01:00
Mose Müller
2bb02a5558 separating out NumberInputField from NumberComponent (to be used in MethodComponent) 2024-02-20 17:20:20 +01:00
Mose Müller
1c029e301b updates types 2024-02-20 16:39:06 +01:00
Mose Müller
f0384b817c updates method serialization 2024-02-20 14:49:35 +01:00
Mose Müller
8042f9b390 removes card header of root component 2024-02-20 14:49:35 +01:00
Mose Müller
838145a778 allows to use .env file to configure ServiceConfig 2024-02-20 12:54:04 +01:00
Mose Müller
7d753b2fc6 Merge pull request #102 from tiqi-group/fix/dynamic_list_entry_with_property
Fix: dynamic list entry with property
2024-02-20 12:53:08 +01:00
Mose Müller
72f6a8ddee ignores some ruff rule 2024-02-20 12:51:52 +01:00
Mose Müller
dfb6f966aa adds test for dynamic list entries with properties 2024-02-20 12:29:44 +01:00
Mose Müller
dc42bfaa9b removes changed_attribute path after on_change method 2024-02-20 12:29:30 +01:00
Mose Müller
c0ba23b0b2 appending to a list now also triggers _notify_change_start
This helps in understanding if the list entries being added are "changing" themselves. Properties within
the added objects will trigger property changes when they are serialized, so we have to tell the observer
that he should not listen to them.
2024-02-20 12:28:34 +01:00
Mose Müller
bd7a46ddc1 changes are only registered if the containing object is not being changed as a whole 2024-02-20 12:26:43 +01:00
Mose Müller
5bea0892c7 Merge pull request #94 from tiqi-group/92-add-connection-component
feat: adds device connection component
2024-02-15 09:24:17 +01:00
Mose Müller
9631a7d467 adds device connection image 2024-02-15 09:23:14 +01:00
Mose Müller
1e8c7bd141 Merge pull request #101 from tiqi-group/fix/ruff_config_for_2.0
fixes pyproject.toml ruff configuration
2024-02-15 09:11:15 +01:00
Mose Müller
10dc1436d0 fixes pyproject.toml ruff configuration 2024-02-15 09:08:16 +01:00
Mose Müller
551b8f0158 udpates ruff configuration 2024-02-15 09:01:53 +01:00
Mose Müller
25139b3d4d adds device connection test 2024-02-15 08:56:13 +01:00
Mose Müller
6b1227fcbb fixes mypy error 2024-02-15 08:43:08 +01:00
Mose Müller
fd3338f99f updates DeviceConnection Readme section 2024-02-15 08:33:39 +01:00
Mose Müller
c23d0372a5 updates DeviceConnection Readme section 2024-02-14 16:03:09 +01:00
Mose Müller
b646acc994 updates device connection component
DeviceConnection is not an ABC anymore. I have updated the docstring to highlight that the
user should mostly just override the "connect" method, but the "connected" property can also
be overridden if necessary. The user is not required though to override any of those methods
and thus can make use of the "connected" frontend property only.
2024-02-14 15:50:47 +01:00
Mose Müller
9b31362f5b moving device connection component out of module 2024-02-14 14:39:49 +01:00
Mose Müller
63edcffe7e adds DeviceConnection section to Readme 2024-02-01 13:33:22 +01:00
Mose Müller
8c5c6d0f6d npm run build 2024-02-01 13:33:22 +01:00
Mose Müller
71b84525dd updates DeviceConnection docstring 2024-02-01 13:33:22 +01:00
Mose Müller
e78dc2defb moves device_connection.py to device_connection module 2024-02-01 13:33:22 +01:00
Mose Müller
529d61c77d fixes DeviceConnection overlay message when directly exposed 2024-02-01 13:33:22 +01:00
Mose Müller
c7c88178d4 npm run build 2024-02-01 13:33:22 +01:00
Mose Müller
7f082b6f95 fixes border radius of DeviceComponent when directly exposed 2024-02-01 13:33:22 +01:00
Mose Müller
30138bcb45 renaming file containing DeviceConnection, updating component 2024-02-01 13:33:22 +01:00
Mose Müller
1318bbc8a8 update Readme (autostart code) 2024-02-01 13:33:22 +01:00
Mose Müller
ae9761bd11 adds docstring to DeviceConnection 2024-02-01 13:33:22 +01:00
Mose Müller
04d19a853f renaming available to connected 2024-02-01 13:33:22 +01:00
Mose Müller
fc28b83bc5 adds handle_connection autostart task to DeviceConnection 2024-02-01 13:33:22 +01:00
Mose Müller
f1384b25a1 updates DeviceConnection component 2024-02-01 13:33:22 +01:00
Mose Müller
7ef82e61e5 frontend styling 2024-02-01 13:33:22 +01:00
Mose Müller
6d9191fe18 npm run build 2024-02-01 13:33:22 +01:00
Mose Müller
4f71633c5e adds backend DeviceConnection component 2024-02-01 13:33:22 +01:00
Mose Müller
2c95a2496c adds frontend DeviceConnection component 2024-02-01 13:33:22 +01:00
Mose Müller
aca5aab1ef removes unused attribute 2024-02-01 13:25:53 +01:00
Mose Müller
4f1cc4787d Merge pull request #99 from tiqi-group/cleanup/removes_deprecated_code
Cleanup/removes deprecated code
2024-02-01 11:11:43 +01:00
Mose Müller
8efd67d9f3 fixes tests 2024-02-01 10:18:58 +01:00
Mose Müller
34fc0f8739 removes deprecated code 2024-02-01 10:18:49 +01:00
Mose Müller
e60880fd30 Merge pull request #98 from tiqi-group/refactor/passing_full_serialization_dict_to_frontend
Refactor: passing full serialization dict to frontend
2024-02-01 09:27:29 +01:00
Mose Müller
036b0c681a updates version to v0.6.0 (due to breaking changes) 2024-02-01 09:25:47 +01:00
Mose Müller
dd268a4f9b npm run build 2024-02-01 09:18:24 +01:00
Mose Müller
e8638f1f3a fixes tests 2024-02-01 08:45:40 +01:00
Mose Müller
7279fed2aa frontend will can now display any serialization dict 2024-02-01 08:45:40 +01:00
Mose Müller
a2518671da DataService's serialize method now returns whole serialization dict (also passed to frontend) 2024-02-01 08:45:40 +01:00
Mose Müller
bcabd2dc48 Merge pull request #95 from tiqi-group/fix/service_configuration
Fix/service configuration
2024-01-29 15:26:27 +01:00
Mose Müller
7ac9c557c2 updates version to v0.5.2 2024-01-29 15:24:13 +01:00
Mose Müller
656529d1fb fixes service configuration (allow all environment variables) 2024-01-29 15:23:27 +01:00
Mose Müller
14601105a7 Merge pull request #93 from tiqi-group/45-placing-the-explanation-question-mark-next-to-the-variable-instead-of-above
feat: placing the explanation question mark next to the variable instead of above
2024-01-16 14:16:38 +01:00
Mose Müller
484b5131e9 fixing enum serialization for python 3.10 2024-01-16 14:13:36 +01:00
Mose Müller
616a5cea21 npm run build 2024-01-16 13:44:37 +01:00
Mose Müller
300bd6ca9a updates Enum serialization 2024-01-16 13:37:39 +01:00
Mose Müller
3e1517e905 udpates dev-guide for adding components 2024-01-16 13:00:01 +01:00
Mose Müller
0ecaeac3fb replaces js interfaces with types 2024-01-16 12:57:35 +01:00
Mose Müller
0e9832e2f1 updates DocStringComponent placement 2024-01-16 12:55:18 +01:00
Mose Müller
0343abd0b0 Merge pull request #91 from tiqi-group/fix/load_from_file
Fix/load from file
2024-01-09 16:39:59 +01:00
Mose Müller
0c149b85b5 updates version to v0.5.1 2024-01-09 16:39:12 +01:00
Mose Müller
0e331e58ff adds tests for server to check if loading from file is working 2024-01-09 16:36:35 +01:00
Mose Müller
45135927e6 initialises observer before loading state from json file 2024-01-09 16:21:57 +01:00
Mose Müller
d3866010a8 updates version to v0.5.0 2024-01-09 10:01:48 +01:00
Mose Müller
3c0f019af8 Merge pull request #48 from tiqi-group/10-frontend-user-should-be-able-to-add-custom-display-names
Feat: adds web settings file containing display name configuration
2024-01-08 17:17:06 +01:00
Mose Müller
8aa7fd31f8 updates Adding_Components guide 2024-01-08 17:11:55 +01:00
Mose Müller
c9ff3db9e9 Updates Readme 2024-01-08 16:57:46 +01:00
Mose Müller
9e77bae5e7 renaming config option from generate_new_web_settings to generate_web_settings 2024-01-08 16:49:36 +01:00
Mose Müller
6a6d1b27aa updates sio_setup tests (removes mock objects) 2024-01-08 16:35:32 +01:00
Mose Müller
2d3e7d8c1b adds web_server tests 2024-01-08 16:32:36 +01:00
Mose Müller
c7b039beb7 replaces method with read-only property 2024-01-08 16:32:25 +01:00
Mose Müller
62e647c667 generate_new_web_settings will now append to existing config file (not overwrite entries) 2024-01-08 15:45:02 +01:00
Mose Müller
6382be5735 removes index from generated web settings file (move to other PR) 2024-01-08 15:27:46 +01:00
Mose Müller
ea158bf8de adds sio_setup tests 2024-01-08 15:11:03 +01:00
Mose Müller
63ad6d7b93 removes web_settings sio event 2024-01-08 15:10:40 +01:00
Mose Müller
b8e758e479 updates docstring 2024-01-08 15:09:23 +01:00
Mose Müller
a12a708385 udpates Readme 2023-12-21 16:10:44 +01:00
Mose Müller
edb24f5439 Server uses ServiceConfig for web/rpc port default values, configurable through env variables 2023-12-21 15:48:28 +01:00
Mose Müller
2a2b7b800d updates ServiceConfig class 2023-12-21 15:48:28 +01:00
Mose Müller
b6b20c21e4 updates WebServer options to directly default to config class values 2023-12-21 15:25:57 +01:00
Mose Müller
53be794a3c renaming service configuration dir parameter 2023-12-21 13:36:08 +01:00
Mose Müller
a303ba7f0b adds pytest-asyncio to dev dependencies 2023-12-21 13:25:54 +01:00
Mose Müller
2461f85ef0 adds test for starting and stopping tasks 2023-12-21 13:24:54 +01:00
Mose Müller
ca41e12014 updates server to use asyncio.run 2023-12-21 13:13:45 +01:00
Mose Müller
f69723dd58 updates some tests to have a running event loop 2023-12-21 13:11:49 +01:00
Mose Müller
c733026522 fixes task manager loop 2023-12-21 13:11:17 +01:00
Mose Müller
316ce5c7e7 updates type hints 2023-12-21 11:33:00 +01:00
Mose Müller
43c3f746fa npm run build 2023-12-21 11:00:23 +01:00
Mose Müller
fea96c044c removes start_task wrapper 2023-12-21 11:00:19 +01:00
Mose Müller
6543bc6b39 rewrites web server to hot-reload the web settings from the settings file 2023-12-21 10:32:37 +01:00
Mose Müller
ef36c01407 updates serializer and state_manager to deal with serialized methods
I need to get the access path from methods when generating the
web_settings.json file. Thus, methods will not be skipped anymore,
instead, the method checking if the attribute is loadable makes the
distinction.
2023-12-21 10:31:02 +01:00
Mose Müller
9d90fd2b81 displayName of components is now taken from WebSettingsContext 2023-12-21 10:30:21 +01:00
Mose Müller
9fc6d6f910 updates WebSettings.tsx 2023-12-21 10:04:55 +01:00
Mose Müller
805e270107 updates sio_setup to not expect DataService in the parent path 2023-12-21 10:04:55 +01:00
Mose Müller
8e3a1694ce updates frontend components to not have DataService in the fullAccessPath 2023-12-21 10:03:17 +01:00
Mose Müller
32a1d14a40 changes display_name to displayName in web settings 2023-12-21 07:48:03 +01:00
Mose Müller
8940a61d4e adds WebSettings context 2023-12-21 07:48:03 +01:00
Mose Müller
393bde3280 frontend: removes unused stateRef 2023-12-20 16:57:28 +01:00
Mose Müller
eb2da1c5dc adds index to web_settings 2023-12-20 16:52:28 +01:00
Mose Müller
e7b73a99da WebServer uses serializer method now to generate serialized data paths 2023-12-20 10:21:48 +01:00
Mose Müller
392831e0fd uses new serializer method to check if attribute is loadable 2023-12-20 10:16:01 +01:00
Mose Müller
32bda8d910 updates generate_serialized_data_paths method, adds tests 2023-12-20 10:15:25 +01:00
Mose Müller
e106cc4927 adds NumberSlider to state manager tests 2023-12-20 10:14:35 +01:00
Mose Müller
464478cda9 removes helper function to create config folder 2023-12-20 10:14:07 +01:00
Mose Müller
97c026afe0 adds function to initialise web settings (also creating settings if requested), creates web-settings fastapi endpoint 2023-12-19 16:38:46 +01:00
Mose Müller
2f5c415cd5 updates webserver docstring 2023-12-19 16:21:03 +01:00
Mose Müller
728eea09f6 adds configs to WebServer (can also be passed to constructor) 2023-12-19 16:16:13 +01:00
Mose Müller
e3eaf5ffe2 adds ServiceConfig and WebServerConfig 2023-12-19 16:11:32 +01:00
Mose Müller
1dc3b62060 removes usage of rpyc-specific method in WebServer 2023-12-19 14:57:06 +01:00
Mose Müller
8214faf5cb removes ForkingServer rpyc configuration 2023-12-19 13:07:25 +01:00
Mose Müller
232eb53249 renames file 2023-12-19 12:59:18 +01:00
Mose Müller
439f514ea5 fixes WebServer 2023-12-19 12:58:32 +01:00
Mose Müller
c7d63f5139 replaces SioServerWrapper with setup function 2023-12-19 12:58:32 +01:00
Mose Müller
f64b5c35ab renaming sio_server file 2023-12-19 12:56:03 +01:00
Mose Müller
bb4de988e9 updates Server docstring 2023-12-19 11:44:50 +01:00
Mose Müller
36a8e916f6 updates kwargs passed to servers 2023-12-19 11:44:36 +01:00
Mose Müller
1a00f37372 fixes exception emission to web clients 2023-12-19 11:43:29 +01:00
Mose Müller
6630173cec fixes mypy issue 2023-12-19 11:43:29 +01:00
Mose Müller
08a62b2119 updates WebServer docstring 2023-12-19 11:43:29 +01:00
Mose Müller
37ae34ecc0 makes WebServer functions protected 2023-12-19 11:42:39 +01:00
Mose Müller
8b78099178 udpates AdditionalServerProtocol and WebServer
updates WebServer
2023-12-19 11:42:39 +01:00
Mose Müller
3186e04cc1 creates web_server module with WebServer complying with AdditionalServerProtocol 2023-12-19 10:59:24 +01:00
Mose Müller
055acbe591 using get_running_loop instead of soon-deprecated get_event_loop 2023-12-19 10:55:38 +01:00
Mose Müller
0d08c2ce0d removes unnecessary condition check 2023-12-19 10:55:07 +01:00
Mose Müller
68cc5b693e adds socketio event for web_settings 2023-12-18 12:04:33 +01:00
Mose Müller
4fcd5b4d44 adds helper function to create config folder 2023-12-18 12:04:31 +01:00
Mose Müller
9cbc639d0f updates vscode settings 2023-12-18 12:03:20 +01:00
Mose Müller
a48cce32e4 chore: formatting tests 2023-12-18 11:59:20 +01:00
Mose Müller
8c24f5dd67 updates version number 2023-12-13 11:29:28 +01:00
Mose Müller
1c4a878aa8 Merge pull request #86 from tiqi-group/9-add-units-support-for-numberslider
updates Readme explaining how to use units with number sliders
2023-12-13 11:25:58 +01:00
Mose Müller
31967d0d43 updates Readme explaining how to use units with number sliders 2023-12-13 11:23:44 +01:00
Mose Müller
b4edc31030 Merge pull request #84 from tiqi-group/75-numberslider-component-is-not-working
75 numberslider component is not working
2023-12-13 11:12:56 +01:00
Mose Müller
ff7c92547e updates Readme 2023-12-13 11:09:18 +01:00
Mose Müller
fab91f3221 updates number slider test file 2023-12-13 10:39:26 +01:00
Mose Müller
bd77995d96 npm run build 2023-12-13 10:36:00 +01:00
Mose Müller
729f375901 adds support for quantities in slider component (passing object instead of number) 2023-12-13 10:35:28 +01:00
Mose Müller
e643dd6f5c adds number object types to NumberComponent 2023-12-13 10:34:32 +01:00
Mose Müller
53f4cf6690 removes setters for min, max and step_size in NumberSlider, updates docstring 2023-12-13 09:30:21 +01:00
Mose Müller
c0c8591fc4 updates number slider component 2023-12-11 17:46:08 +01:00
Mose Müller
13fba6d3d6 npm run build 2023-12-11 17:30:12 +01:00
Mose Müller
dc4c9ff58f removes unused customEmitUpdate prop from NumberComponent 2023-12-11 17:30:12 +01:00
Mose Müller
83cd07feee updates SliderComponent to emit attribute updates (instead of full state dict) 2023-12-11 17:30:12 +01:00
Mose Müller
09f73a2b1d Merge pull request #83 from tiqi-group/feat/improve_data_service_serialization
fixed serialization of class deriving from class which derives from DataService
2023-12-11 17:28:11 +01:00
Mose Müller
88886e3fd6 fixed serialization of class deriving from class which derives from DataService 2023-12-11 17:25:03 +01:00
Mose Müller
49984b7c2e updates version to v0.4.0 2023-12-11 14:13:14 +01:00
Mose Müller
39270561b9 updates Readme with logging information 2023-12-11 14:06:02 +01:00
Mose Müller
8ac2c39908 fix: dont log private and protected attribute changes 2023-12-11 12:52:58 +01:00
Mose Müller
0694a3d1ee fix: removes inheritance warning for functions 2023-12-11 12:28:37 +01:00
Mose Müller
c15ad54e2d updates test 2023-12-11 11:55:55 +01:00
Mose Müller
71721b1286 fix: remove inheritance warning for lists 2023-12-11 11:53:55 +01:00
Mose Müller
74ceb7f05c fix image component warning 2023-12-11 11:51:24 +01:00
Mose Müller
06d11fff49 Merge pull request #80 from tiqi-group/feat/improve_inheritance_warning
Feat: improves DataService inheritance warning
2023-12-11 09:25:03 +01:00
Mose Müller
6d23151d32 updates tests 2023-12-11 09:22:38 +01:00
Mose Müller
0faf347376 moves inheritance warning into DataService, improves logic 2023-12-11 09:15:08 +01:00
Mose Müller
a5fddf7e45 Merge pull request #79 from tiqi-group/feat/improves_state_manager_debug_messages
fix: improves debug message for properties (load_state decorator)
2023-12-07 11:44:46 +01:00
Mose Müller
83c763bd20 improves debug message for properties (load_state decorator) 2023-12-07 11:39:56 +01:00
Mose Müller
9778541ee4 Merge pull request #77 from tiqi-group/69-add-support-for-adding-objects-to-a-list
69 add support for adding objects to a list
2023-12-06 18:04:49 +01:00
Mose Müller
8e641c1b84 implements clear, insert, remove, extend and pop for observable lists 2023-12-06 18:02:26 +01:00
Mose Müller
f6bf229c8c updates ruff config (and workflow) 2023-12-06 17:25:09 +01:00
Mose Müller
5a76d76d2b adds test for (dynamic / static) property dependencies 2023-12-06 09:17:43 +01:00
Mose Müller
3169531a24 updates property dependencies when changing to an observable object 2023-12-06 09:17:43 +01:00
Mose Müller
4bd0092fbf adds warnings for non-overridden observable-list methods 2023-12-06 09:17:43 +01:00
Mose Müller
569e343e89 overrides append in _ObservableList 2023-12-06 09:17:43 +01:00
Mose Müller
f2b2ef8dcd Merge pull request #78 from tiqi-group/feat/removes_private_attr_set_warning
Feat: removes warning if private attribute is set
2023-12-06 09:17:08 +01:00
Mose Müller
f70ac05df6 ruff does not check tests anymore 2023-12-06 09:16:02 +01:00
Mose Müller
e3367efda1 removes corresponding test 2023-12-06 09:12:00 +01:00
Mose Müller
3d2de7109b removes warning if setting private attributes (should work now) 2023-12-06 09:11:14 +01:00
Mose Müller
534ff4c149 updates pyproject toml (ruff config) 2023-12-06 09:07:19 +01:00
Mose Müller
0e47f6c4d3 Merge pull request #76 from tiqi-group/72-support-for-dynamic-attribute-handling-and-collection-management
72 support for dynamic attribute handling and collection management
2023-12-06 09:05:54 +01:00
Mose Müller
b4ef8201f3 adds tests for data service type change warnings 2023-12-06 09:04:08 +01:00
Mose Müller
a97a55712e adds warning message when super().__init__() is not called at the start of the constructor 2023-12-06 08:49:24 +01:00
Mose Müller
e8a0a7c000 adds Observer Pattern documentation 2023-12-05 16:17:12 +01:00
Mose Müller
6f0d43aa5a chore: formatting 2023-12-05 12:50:31 +01:00
Mose Müller
0e210b8ba6 renames test file 2023-12-05 12:50:02 +01:00
Mose Müller
329e0acd81 adds observer_pattern tests 2023-12-05 12:48:58 +01:00
Mose Müller
f97cd7eb4e adds observers to observer namespace 2023-12-05 12:48:46 +01:00
Mose Müller
3c168243bb removes unused type: ignore statements 2023-12-05 11:50:06 +01:00
Mose Müller
0944a404dc moves property-related stuff from DataServiceObserver to PropertyObserver 2023-12-05 11:48:13 +01:00
Mose Müller
a9c6070ca3 reduces complexity of DataServiceObserver functions 2023-12-05 11:35:58 +01:00
Mose Müller
75ee71cbf8 fixes warnings tests 2023-12-05 11:24:17 +01:00
Mose Müller
1e55a4d914 npm run build 2023-12-05 10:49:57 +01:00
Mose Müller
aab2b4ee77 updates frontend reducer to accept new sio_callback event data 2023-12-05 10:49:33 +01:00
Mose Müller
52d571e551 updates Server (adds Observer, updates sio_callback) 2023-12-05 10:49:00 +01:00
Mose Müller
bb415af460 creates deepcopy of cached dict instead of copy, removes warnings for methods 2023-12-05 10:48:30 +01:00
Mose Müller
c3c1669cf9 __convert_value_if_needed now also converts to float if needed 2023-12-05 10:20:12 +01:00
Mose Müller
5378396958 updates units tests 2023-12-05 10:14:01 +01:00
Mose Müller
b66e964155 adds warning to DataService when types change, types will not be converted anymore 2023-12-05 10:12:57 +01:00
Mose Müller
4fc25c6752 improves check for updated value in Observer 2023-12-05 10:12:18 +01:00
Mose Müller
44cd9597cb adds warnings if types change in cache 2023-12-05 10:12:00 +01:00
Mose Müller
e48a7067ec removes duplicate code from DataServiceObserver (already in Observer) 2023-12-05 10:11:12 +01:00
Mose Müller
8919f6106a adds add_notification_callback method to DataServiceObserver 2023-12-05 10:10:35 +01:00
Mose Müller
89b5a9cc9e updates tests 2023-12-04 17:23:42 +01:00
Mose Müller
0aa1595da4 updates data service observer 2023-12-04 17:23:39 +01:00
Mose Müller
8f8b3e3bcf updates __getattribute__ of Observable 2023-12-04 17:16:01 +01:00
Mose Müller
43e6adcb2e removes unnecessary "..." literal 2023-12-04 14:21:51 +01:00
Mose Müller
3992f491c9 updates data service observer's cache dict check 2023-12-04 13:36:16 +01:00
Mose Müller
df571a8260 uses cache method to retrieve value dict in state manager 2023-12-04 13:36:16 +01:00
Mose Müller
53713794d6 updates method to get value dict from cache 2023-12-04 13:36:16 +01:00
Mose Müller
06e642972f fixes task manager notifications 2023-12-04 13:36:16 +01:00
Mose Müller
a7ec7c1536 fixes number slider constructor 2023-12-04 13:36:16 +01:00
Mose Müller
c891642bda updates tests 2023-12-04 13:36:16 +01:00
Mose Müller
cc105106ee removes try catch from serializer function to not log error but rather raise exception 2023-12-04 13:36:16 +01:00
Mose Müller
7c7bb193e4 reusing util function 2023-12-04 13:36:16 +01:00
Mose Müller
92e79579ff chore: type hints 2023-12-04 13:36:16 +01:00
Mose Müller
5d2d34bea3 adds DataServiceObserver 2023-12-04 13:36:16 +01:00
Mose Müller
3497962fca updates data service cache (methods to set and get values) 2023-12-04 13:36:16 +01:00
Mose Müller
114a1c6fdc removes data service list and callback manager, make DataService an Observable 2023-12-04 13:36:16 +01:00
Mose Müller
1d2ac57ba7 udpates observable list and dict types 2023-12-04 13:36:16 +01:00
Mose Müller
99dea381a3 adds first version of observer_pattern module 2023-12-04 13:36:16 +01:00
Mose Müller
e6e5ac84b4 resets default host to 0.0.0.0 2023-12-04 08:42:52 +01:00
Mose Müller
246148f513 updates vscode folder 2023-11-30 11:31:29 +01:00
Mose Müller
eb0c819037 removes reportUnknownParameterType (pyright), disallows any generics (mypy) 2023-11-30 09:49:29 +01:00
Mose Müller
f5d8775141 removes reportUnknownMemberType from pyright config 2023-11-30 09:20:58 +01:00
Mose Müller
1ec034a62e updates pyproject config (removes black and isort) 2023-11-30 09:12:51 +01:00
Mose Müller
93f0627534 removes Optional typing and unused comments 2023-11-30 09:01:39 +01:00
Mose Müller
ad2ae704e9 updates ruff config 2023-11-30 09:01:26 +01:00
Mose Müller
de5340d6fd updates python-package testing workflow 2023-11-29 15:51:13 +01:00
Mose Müller
b80a3ec6a1 updates pyright and mypy config 2023-11-29 15:50:36 +01:00
Mose Müller
f3853ef836 removes poetry.toml (user specific file, use your global config instead) 2023-11-29 15:35:42 +01:00
Mose Müller
56ae9086b5 poetry: makes dev and docs groups optional, removes venv and venvPath from pyright config 2023-11-29 15:34:49 +01:00
Mose Müller
5a2371353a replaces state manager error with info log when no filename was provided 2023-11-28 16:39:27 +01:00
Mose Müller
09a55f50bd Create bug_report.md issue template 2023-11-28 16:31:41 +01:00
Mose Müller
abafd1a2b2 Merge pull request #74 from tiqi-group/cleanup/ruff_linting
Cleanup: switching to ruff linter and formatter
2023-11-28 15:23:53 +01:00
Mose Müller
145ff89072 fix ruff errors 2023-11-28 15:21:27 +01:00
Mose Müller
ba5b4e7be4 updates github linting workflow (ruff instead of flake8) 2023-11-28 15:20:17 +01:00
Mose Müller
8ee49469d6 removes flake8 config 2023-11-28 15:18:12 +01:00
Mose Müller
6997c4a842 updates python dependencies 2023-11-28 15:17:59 +01:00
Mose Müller
598449e893 implement ruff recommendations 2023-11-28 15:17:23 +01:00
Mose Müller
4802f19720 removes unused web_settings kwarg from server 2023-11-28 15:17:13 +01:00
Mose Müller
a04bd14e50 fix number slider test 2023-11-28 14:58:53 +01:00
Mose Müller
c60730f21b removes unused "info" endpoint from web server 2023-11-28 14:57:45 +01:00
Mose Müller
d5cd97ea57 updates utils.logging 2023-11-28 14:53:51 +01:00
Mose Müller
0136885207 updates callback manager 2023-11-28 14:53:48 +01:00
Mose Müller
c04e048e21 updates NumberSlider (constructor kwargs) 2023-11-28 14:41:28 +01:00
Mose Müller
9e9d3f17bc implements ruff suggestions 2023-11-27 17:37:37 +01:00
Mose Müller
e576f6eb80 updates ruff config 2023-11-27 17:37:37 +01:00
Mose Müller
e57fe10c9e Removes unnecessary pass statement 2023-11-27 17:36:28 +01:00
Mose Müller
f27f513bf8 Updates gitignore 2023-11-27 17:16:15 +01:00
Mose Müller
de4e4ed178 update python deps 2023-11-27 17:16:15 +01:00
Mose Müller
cb2687a4b9 only import Callable when TYPE_CHECKING 2023-11-27 17:16:15 +01:00
Mose Müller
ab794d780b implements logging suggestions (no f-strings) 2023-11-27 17:16:15 +01:00
Mose Müller
617eed4d96 implements ruff suggestions 2023-11-27 17:16:15 +01:00
Mose Müller
d517bd0489 updates Adding_Components.md 2023-11-27 16:29:25 +01:00
Mose Müller
d0869b707b Merge pull request #73 from tiqi-group/feat/notify_frontend_about_logged_errors
Adds capability of notifying frontend about logged errors
2023-11-27 16:17:33 +01:00
Mose Müller
eab99df9d1 npm run build 2023-11-27 16:16:15 +01:00
Mose Müller
9d36f99404 adds CRITICAL log level 2023-11-27 16:15:53 +01:00
Mose Müller
7b7ef0eb97 npm run build 2023-11-27 16:09:39 +01:00
Mose Müller
92f14c6788 updates App.css 2023-11-27 16:09:12 +01:00
Mose Müller
4746470aee error toasts always show even when showNotifications is false 2023-11-27 16:08:49 +01:00
Mose Müller
f5627e6a2f frontend: error toast only goes away when clicked 2023-11-27 16:08:08 +01:00
Mose Müller
a769f4eb3b updates SocketIOHandler 2023-11-27 16:01:36 +01:00
Mose Müller
3970d5a17b removes unused import 2023-11-27 15:58:05 +01:00
Mose Müller
a89db46d5e updates VS Code settings.json 2023-11-27 15:43:36 +01:00
Mose Müller
f67591c7ac npm run build 2023-11-27 15:42:33 +01:00
Mose Müller
fdcaa1c1ed udpates App.css 2023-11-27 15:41:40 +01:00
Mose Müller
613b1dd6a4 updates addNotification type hints in components 2023-11-27 15:41:30 +01:00
Mose Müller
914997cc6b updates App.tsx to use new NotificationComponent 2023-11-27 15:41:02 +01:00
Mose Müller
667bb949cc rewrites NotificationsComponent to handle various notification levels 2023-11-27 15:40:25 +01:00
Mose Müller
acaac6f0a6 initialises SocketIOHandler in web server 2023-11-27 15:39:00 +01:00
Mose Müller
e9df89765d adds SocketIOHandler emitting error messages via socketio.AsyncServer 2023-11-27 15:38:35 +01:00
Mose Müller
123edb9e86 frontend: removes unused code from stateUtils 2023-11-27 15:37:58 +01:00
Mose Müller
69328d6f68 fix: sio_callback creates correct full_access_path now 2023-11-27 13:38:28 +01:00
Mose Müller
0cd3a7e8a8 Merge pull request #71 from tiqi-group/fix/update_task_status
Fix: update task status
2023-11-16 10:26:22 +01:00
Mose Müller
abd77e053d removes debug msg 2023-11-16 10:23:53 +01:00
Mose Müller
ebb8b4be8b adds cache test for task status update 2023-11-16 10:22:13 +01:00
Mose Müller
a83e0c6c7f only update type value in serialized dict if its not a method 2023-11-16 09:42:41 +01:00
Mose Müller
64dc09faf7 Merge pull request #70 from tiqi-group/feat/emit_serialized_value_to_frontend
Feat: emit serialized object to frontend
2023-11-16 09:24:13 +01:00
Mose Müller
e2fb9ebae5 npm run build 2023-11-16 09:15:22 +01:00
Mose Müller
4a43bda5e2 frontend: updates reducer to process serialized values 2023-11-16 09:14:48 +01:00
Mose Müller
f693fa9ba2 frontend: adds stateUtils module 2023-11-16 09:14:01 +01:00
Mose Müller
9820bda4b5 webserver sio callback emits serialized value to frontend clients now 2023-11-16 09:13:37 +01:00
Mose Müller
f5116607b9 replaces lambda functions with functions in callback manager 2023-11-16 09:10:23 +01:00
Mose Müller
0ea997384c chore: type hints, mypy issues 2023-11-16 08:33:54 +01:00
Mose Müller
28410a97f5 udpates DataServiceList (constructor and attributes) 2023-11-16 08:33:17 +01:00
Mose Müller
f6eef7085e updates frontend packages 2023-11-16 08:13:29 +01:00
Mose Müller
a76035f443 Merge pull request #68 from tiqi-group/fix/only_load_state_properties_can_be_updated
fix: only load state properties can be updated
2023-11-09 17:35:28 +01:00
Mose Müller
2ab4d1c00a updates to v0.3.1 2023-11-09 17:33:03 +01:00
Mose Müller
a9d577820f updates tests 2023-11-09 17:32:35 +01:00
Mose Müller
f5e6dca16a moves check for load_state decorator to load_state method in StateManager 2023-11-09 17:32:30 +01:00
Mose Müller
4a45d0d438 npm run build 2023-11-09 17:10:56 +01:00
Mose Müller
3cc6399f60 frontend: update style (fix button appearance) 2023-11-09 17:10:21 +01:00
Mose Müller
dc1c7e80f4 docs: updates Readme TOC 2023-11-09 16:05:31 +01:00
131 changed files with 10449 additions and 5685 deletions

View File

@@ -1,8 +0,0 @@
[flake8]
ignore = E501,W503,FS003,F403,F405,E203
include = src
max-line-length = 88
max-doc-length = 88
max-complexity = 7
max-expression-complexity = 7
use_class_attributes_order_strict_mode=True

25
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,25 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'bug'
assignees: ''
---
## Describe the bug
A clear and concise description of what the bug is.
## To Reproduce
Provide steps to reproduce the behaviour, including a minimal code snippet (if applicable):
```python
# Minimal code snippet that reproduces the error
```
## Expected behaviour
A clear and concise description of what you expected to happen.
## Screenshot/Video
If applicable, add visual content that helps explain your problem.
## Additional context
Add any other context about the problem here.

View File

@@ -20,6 +20,9 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: chartboost/ruff-action@v1
with:
src: "./src"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
@@ -28,14 +31,13 @@ jobs:
run: |
python -m pip install --upgrade pip
python -m pip install poetry
poetry install
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
poetry run flake8 src/pydase --count --show-source --statistics
poetry install --with dev
- name: Test with pytest
run: |
poetry run pytest
- name: Test with pyright
run: |
poetry run pyright src/pydase
poetry run pyright
- name: Test with mypy
run: |
poetry run mypy src

3
.gitignore vendored
View File

@@ -128,6 +128,9 @@ venv.bak/
.dmypy.json
dmypy.json
# ruff
.ruff_cache/
# Pyre type checker
.pyre/

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.mypy-type-checker"
]
}

2
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"type": "python",
"request": "launch",
"module": "foo",
"justMyCode": true,
"justMyCode": false,
"env": {
"ENVIRONMENT": "development"
}

31
.vscode/settings.json vendored
View File

@@ -1,25 +1,15 @@
{
"autoDocstring.docstringFormat": "google",
"autoDocstring.startOnNewLine": true,
"autoDocstring.generateDocstringOnEnter": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.rulers": [
88
],
"python.defaultInterpreterPath": ".venv/bin/python",
"python.formatting.provider": "black",
"python.linting.lintOnSave": true,
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.rulers": [
88
],
"editor.tabSize": 4,
"editor.detectIndentation": false,
"editor.codeActionsOnSave": {
"source.organizeImports": true
"source.organizeImports": "explicit",
"source.fixAll": "explicit"
}
},
"[yaml]": {
@@ -29,12 +19,11 @@
"[typescript][javascript][vue][typescriptreact]": {
"editor.tabSize": 2,
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.formatOnPaste": false, // required
"editor.formatOnType": false, // required
"editor.formatOnSave": true, // optional
"editor.formatOnSaveMode": "file", // required to format on save
"editor.formatOnPaste": false,
"editor.formatOnType": false,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
}
}
}

556
README.md
View File

@@ -11,21 +11,32 @@
- [Defining a DataService](#defining-a-dataservice)
- [Running the Server](#running-the-server)
- [Accessing the Web Interface](#accessing-the-web-interface)
- [Connecting to the Service using rpyc](#connecting-to-the-service-using-rpyc)
- [Connecting to the Service via Python Client](#connecting-to-the-service-via-python-client)
- [Tab Completion Support](#tab-completion-support)
- [Integration within Another Service](#integration-within-another-service)
- [Understanding the Component System](#understanding-the-component-system)
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
- [Method Components](#method-components)
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
- [`DeviceConnection`](#deviceconnection)
- [Customizing Connection Logic](#customizing-connection-logic)
- [Reconnection Interval](#reconnection-interval)
- [`Image`](#image)
- [`NumberSlider`](#numberslider)
- [`ColouredEnum`](#colouredenum)
- [Extending with New Components](#extending-with-new-components)
- [Customizing Web Interface Style](#customizing-web-interface-style)
- [Understanding Service Persistence](#understanding-service-persistence)
- [Controlling Property State Loading with `@load_state`](#controlling-property-state-loading-with-load_state)
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
- [Understanding Units in pydase](#understanding-units-in-pydase)
- [Changing the Log Level](#changing-the-log-level)
- [Configuring pydase via Environment Variables](#configuring-pydase-via-environment-variables)
- [Customizing the Web Interface](#customizing-the-web-interface)
- [Enhancing the Web Interface Style with Custom CSS](#enhancing-the-web-interface-style-with-custom-css)
- [Tailoring Frontend Component Layout](#tailoring-frontend-component-layout)
- [Specifying a Custom Frontend Source](#specifying-a-custom-frontend-source)
- [Logging in pydase](#logging-in-pydase)
- [Changing the Log Level](#changing-the-log-level)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
@@ -35,20 +46,19 @@
<!-- no toc -->
- [Simple data service definition through class-based interface](#defining-a-dataService)
- [Integrated web interface for interactive access and control of your data service](#accessing-the-web-interface)
- [Support for `rpyc` connections, allowing for programmatic control and interaction with your service](#connecting-to-the-service-using-rpyc)
- [Support for programmatic control and interaction with your service](#connecting-to-the-service-via-python-client)
- [Component system bridging Python backend with frontend visual representation](#understanding-the-component-system)
- [Customizable styling for the web interface through user-defined CSS](#customizing-web-interface-style)
- [Saving and restoring the service state for service persistence](#understanding-service-persistence)
- [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
- [Support for units](#understanding-units-in-pydase)
<!-- * Event-based callback functionality for real-time updates
- Support for additional servers for specific use-cases -->
<!-- Support for additional servers for specific use-cases -->
## Installation
<!--installation-start-->
Install pydase using [`poetry`](https://python-poetry.org/):
Install `pydase` using [`poetry`](https://python-poetry.org/):
```bash
poetry add pydase
@@ -66,16 +76,17 @@ pip install pydase
<!--usage-start-->
Using `pydase` involves three main steps: defining a `DataService` subclass, running the server, and then connecting to the service either programmatically using `rpyc` or through the web interface.
Using `pydase` involves three main steps: defining a `DataService` subclass, running the server, and then connecting to the service either programmatically using `pydase.Client` or through the web interface.
### Defining a DataService
To use pydase, you'll first need to create a class that inherits from `DataService`. This class represents your custom data service, which will be exposed via RPC (using rpyc) and a web server. Your class can implement class / instance attributes and synchronous and asynchronous tasks.
To use pydase, you'll first need to create a class that inherits from `DataService`. This class represents your custom data service, which will be exposed via a web server. Your class can implement class / instance attributes and synchronous and asynchronous tasks.
Here's an example:
```python
from pydase import DataService, Server
from pydase.utils.decorators import frontend
class Device(DataService):
@@ -113,6 +124,7 @@ class Device(DataService):
# run code to set power state
self._power = value
@frontend
def reset(self) -> None:
self.current = 0.0
self.voltage = 0.0
@@ -149,23 +161,51 @@ Once the server is running, you can access the web interface in a browser:
In this interface, you can interact with the properties of your `Device` service.
### Connecting to the Service using rpyc
### Connecting to the Service via Python Client
You can also connect to the service using `rpyc`. Here's an example on how to establish a connection and interact with the service:
You can connect to the service using the `pydase.Client`. Below is an example of how to establish a connection to a service and interact with it:
```python
import rpyc
import pydase
# Connect to the service
conn = rpyc.connect("<ip_addr>", 18871)
client = conn.root
# Replace the hostname and port with the IP address and the port of the machine where
# the service is running, respectively
client_proxy = pydase.Client(hostname="<ip_addr>", port=8001).proxy
# Interact with the service
client.voltage = 5.0
print(client.voltage) # prints 5.0
# After the connection, interact with the service attributes as if they were local
client_proxy.voltage = 5.0
print(client_proxy.voltage) # Expected output: 5.0
```
In this example, replace `<ip_addr>` with the IP address of the machine where the service is running. After establishing a connection, you can interact with the service attributes as if they were local attributes.
This example demonstrates setting and retrieving the `voltage` attribute through the client proxy.
The proxy acts as a local representative of the remote service, enabling straightforward interaction.
The proxy class dynamically synchronizes with the server's exposed attributes. This synchronization allows the proxy to be automatically updated with any attributes or methods that the server exposes, essentially mirroring the server's API. This dynamic updating enables users to interact with the remote service as if they were working with a local object.
#### Tab Completion Support
In interactive environments such as Python interpreters and Jupyter notebooks, the proxy class supports tab completion, which allows users to explore available methods and attributes.
#### Integration within Another Service
You can also integrate a client proxy within another service. Here's how you can set it up:
```python
import pydase
class MyService(pydase.DataService):
# Initialize the client without blocking the constructor
proxy = pydase.Client(hostname="<ip_addr>", port=8001, block_until_connected=False).proxy
if __name__ == "__main__":
service = MyService()
# Create a server that exposes this service; adjust the web_port as needed
server = pydase.Server(service, web_port=8002). run()
```
In this setup, the `MyService` class has a `proxy` attribute that connects to a `pydase` service located at `<ip_addr>:8001`.
The `block_until_connected=False` argument allows the service to start up even if the initial connection attempt fails.
This configuration is particularly useful in distributed systems where services may start in any order.
<!--usage-end-->
@@ -183,14 +223,39 @@ In `pydase`, components are fundamental building blocks that bridge the Python b
- `int` and `float`: Manifested as the `NumberComponent`.
- `bool`: Rendered as a `ButtonComponent`.
- `list`: Each item displayed individually, named after the list attribute and its index.
- `dict`: Each key-value pair displayed individually, named after the dictionary attribute and its key. **Note** that the dictionary keys must be strings.
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
### Method Components
Within the `DataService` class of `pydase`, only methods devoid of arguments can be represented in the frontend, classified into two distinct categories
Methods within the `DataService` class have frontend representations:
1. [**Tasks**](#understanding-tasks-in-pydase): Argument-free asynchronous functions, identified within `pydase` as tasks, are inherently designed for frontend interaction. These tasks are automatically rendered with a start/stop button, allowing users to initiate or halt the task execution directly from the web interface.
2. **Synchronous Methods with `@frontend` Decorator**: Synchronous methods without arguments can also be presented in the frontend. For this, they have to be decorated with the `@frontend` decorator.
- Regular Methods: These are rendered as a `MethodComponent` in the frontend, allowing users to execute the method via an "execute" button.
- Asynchronous Methods: These are manifested as the `AsyncMethodComponent` with "start"/"stop" buttons to manage the execution of [tasks](#understanding-tasks-in-pydase).
```python
import pydase
import pydase.components
import pydase.units as u
from pydase.utils.decorators import frontend
class MyService(pydase.DataService):
@frontend
def exposed_method(self) -> None:
...
async def my_task(self) -> None:
while True:
# ...
```
![Method Components](docs/images/method_components.png)
You can still define synchronous tasks with arguments and call them using a python client. However, decorating them with the `@frontend` decorator will raise a `FunctionDefinitionError`. Defining a task with arguments will raise a `TaskDefinitionError`.
I decided against supporting function arguments for functions rendered in the frontend due to the following reasons:
1. Feature Request Pitfall: supporting function arguments create a bottomless pit of feature requests. As users encounter the limitations of supported types, demands for extending support to more complex types would grow.
2. Complexity in Supported Argument Types: while simple types like `int`, `float`, `bool` and `str` could be easily supported, more complicated types are not (representation, (de-)serialization).
### DataService Instances (Nested Classes)
@@ -204,9 +269,9 @@ from pydase import DataService, Server
class Channel(DataService):
def __init__(self, channel_id: int) -> None:
super().__init__()
self._channel_id = channel_id
self._current = 0.0
super().__init__()
@property
def current(self) -> float:
@@ -222,9 +287,8 @@ class Channel(DataService):
class Device(DataService):
def __init__(self) -> None:
self.channels = [Channel(i) for i in range(2)]
super().__init__()
self.channels = [Channel(i) for i in range(2)]
if __name__ == "__main__":
@@ -245,6 +309,89 @@ The custom components in `pydase` have two main parts:
Below are the components available in the `pydase.components` module, accompanied by their Python usage:
#### `DeviceConnection`
The `DeviceConnection` component acts as a base class within the `pydase` framework for managing device connections. It provides a structured approach to handle connections by offering a customizable `connect` method and a `connected` property. This setup facilitates the implementation of automatic reconnection logic, which periodically attempts reconnection whenever the connection is lost.
In the frontend, this class abstracts away the direct interaction with the `connect` method and the `connected` property. Instead, it showcases user-defined attributes, methods, and properties. When the `connected` status is `False`, the frontend displays an overlay that prompts manual reconnection through the `connect()` method. Successful reconnection removes the overlay.
```python
import pydase.components
import pydase.units as u
class Device(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
self._voltage = 10 * u.units.V
def connect(self) -> None:
if not self._connected:
self._connected = True
@property
def voltage(self) -> float:
return self._voltage
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.device = Device()
if __name__ == "__main__":
service_instance = MyService()
pydase.Server(service_instance).run()
```
![DeviceConnection Component](docs/images/DeviceConnection_component.png)
##### Customizing Connection Logic
Users are encouraged to primarily override the `connect` method to tailor the connection process to their specific device. This method should adjust the `self._connected` attribute based on the outcome of the connection attempt:
```python
import pydase.components
class MyDeviceConnection(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
# Add any necessary initialization code here
def connect(self) -> None:
# Implement device-specific connection logic here
# Update self._connected to `True` if the connection is successful,
# or `False` if unsuccessful
...
```
Moreover, if the connection status requires additional logic, users can override the `connected` property:
```python
import pydase.components
class MyDeviceConnection(pydase.components.DeviceConnection):
def __init__(self) -> None:
super().__init__()
# Add any necessary initialization code here
def connect(self) -> None:
# Implement device-specific connection logic here
# Ensure self._connected reflects the connection status accurately
...
@property
def connected(self) -> bool:
# Implement custom logic to accurately report connection status
return self._connected
```
##### Reconnection Interval
The `DeviceConnection` component automatically executes a task that checks for device availability at a default interval of 10 seconds. This interval is adjustable by modifying the `_reconnection_wait_time` attribute on the class instance.
#### `Image`
This component provides a versatile interface for displaying images within the application. Users can update and manage images from various sources, including local paths, URLs, and even matplotlib figures.
@@ -254,7 +401,6 @@ The component offers methods to load images seamlessly, ensuring that visual con
```python
import matplotlib.pyplot as plt
import numpy as np
import pydase
from pydase.components.image import Image
@@ -285,26 +431,174 @@ if __name__ == "__main__":
#### `NumberSlider`
This component provides an interactive slider interface for adjusting numerical values on the frontend. It supports both floats and integers. The values adjusted on the frontend are synchronized with the backend in real-time, ensuring consistent data representation.
The `NumberSlider` component in the `pydase` package provides an interactive slider interface for adjusting numerical values on the frontend. It is designed to support both numbers and quantities and ensures that values adjusted on the frontend are synchronized with the backend.
The slider can be customized with initial values, minimum and maximum limits, and step sizes to fit various use cases.
To utilize the `NumberSlider`, users should implement a class that derives from `NumberSlider`. This class can then define the initial values, minimum and maximum limits, step sizes, and additional logic as needed.
Here's an example of how to implement and use a custom slider:
```python
import pydase
from pydase.components import NumberSlider
import pydase.components
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: float = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
) -> None:
super().__init__(value, min_, max_, step_size)
@property
def min(self) -> float:
return self._min
@min.setter
def min(self, value: float) -> None:
self._min = value
@property
def max(self) -> float:
return self._max
@max.setter
def max(self, value: float) -> None:
self._max = value
@property
def step_size(self) -> float:
return self._step_size
@step_size.setter
def step_size(self, value: float) -> None:
self._step_size = value
@property
def value(self) -> float:
"""Slider value."""
return self._value
@value.setter
def value(self, value: float) -> None:
if value < self._min or value > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = value
class MyService(pydase.DataService):
slider = NumberSlider(value=3.5, min=0, max=10, step_size=0.1, type="float")
def __init__(self) -> None:
super().__init__()
self.voltage = MySlider()
if __name__ == "__main__":
service = MyService()
pydase.Server(service).run()
service_instance = MyService()
service_instance.voltage.value = 5
print(service_instance.voltage.value) # Output: 5
pydase.Server(service_instance).run()
```
In this example, `MySlider` overrides the `min`, `max`, `step_size`, and `value` properties. Users can make any of these properties read-only by omitting the corresponding setter method.
![Slider Component](docs/images/Slider_component.png)
- Accessing parent class resources in `NumberSlider`
In scenarios where you need the slider component to interact with or access resources from its parent class, you can achieve this by passing a callback function to it. This method avoids directly passing the entire parent class instance (`self`) and offers a more encapsulated approach. The callback function can be designed to utilize specific attributes or methods of the parent class, allowing the slider to perform actions or retrieve data in response to slider events.
Here's an illustrative example:
```python
from collections.abc import Callable
import pydase
import pydase.components
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: float,
on_change: Callable[[float], None],
) -> None:
super().__init__(value=value)
self._on_change = on_change
# ... other properties ...
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, new_value: float) -> None:
if new_value < self._min or new_value > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = new_value
self._on_change(new_value)
class MyService(pydase.DataService):
def __init__(self) -> None:
self.voltage = MySlider(
5,
on_change=self.handle_voltage_change,
)
def handle_voltage_change(self, new_voltage: float) -> None:
print(f"Voltage changed to: {new_voltage}")
# Additional logic here
if __name__ == "__main__":
service_instance = MyService()
my_service.voltage.value = 7 # Output: "Voltage changed to: 7"
pydase.Server(service_instance).run()
```
- Incorporating units in `NumberSlider`
The `NumberSlider` is capable of [displaying units](#understanding-units-in-pydase) alongside values, enhancing its usability in contexts where unit representation is crucial. When utilizing `pydase.units`, you can specify units for the slider's value, allowing the component to reflect these units in the frontend.
Here's how to implement a `NumberSlider` with unit display:
```python
import pydase
import pydase.components
import pydase.units as u
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: u.Quantity = 0.0 * u.units.V,
) -> None:
super().__init__(value)
@property
def value(self) -> u.Quantity:
return self._value
@value.setter
def value(self, value: u.Quantity) -> None:
if value.m < self._min or value.m > self._max:
raise ValueError("Value is either below allowed min or above max value.")
self._value = value
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.voltage = MySlider()
if __name__ == "__main__":
service_instance = MyService()
service_instance.voltage.value = 5 * u.units.V
print(service_instance.voltage.value) # Output: 5 V
pydase.Server(service_instance).run()
```
#### `ColouredEnum`
This component provides a way to visually represent different states or categories in a data service using colour-coded options. It behaves similarly to a standard `Enum`, but the values encode colours in a format understood by CSS. The colours can be defined using various methods like Hexadecimal, RGB, HSL, and more.
@@ -345,37 +639,15 @@ my_service.status = MyStatus.FAILED
![ColouredEnum Component](docs/images/ColouredEnum_component.png)
**Note** that each enumeration name and value must be unique.
This means that you should use different colour formats when you want to use a colour multiple times.
#### Extending with New Components
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
<!-- Component User Guide End -->
## Customizing Web Interface Style
`pydase` allows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
Here's how you can use this feature:
1. Prepare your custom CSS file with the desired styles.
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
```python
from pydase import Server, DataService
class Device(DataService):
# ... your service definition ...
if __name__ == "__main__":
service = MyService()
server = Server(service, css="path/to/your/custom.css").run()
```
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
## Understanding Service Persistence
`pydase` allows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.
@@ -427,9 +699,9 @@ Note: If the service class structure has changed since the last time its state w
## Understanding Tasks in pydase
In `pydase`, a task is defined as an asynchronous function contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions.
In `pydase`, a task is defined as an asynchronous function without arguments contained in a class that inherits from `DataService`. These tasks usually contain a while loop and are designed to carry out periodic functions.
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. The core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and an `rpyc` client, giving you flexible and powerful control over your service's operation.
For example, a task might be used to periodically read sensor data, update a database, or perform any other recurring job. One core feature of `pydase` is its ability to automatically generate start and stop functions for these tasks. This allows you to control task execution via both the frontend and python clients, giving you flexible and powerful control over your service's operation.
Another powerful feature of `pydase` is its ability to automatically start tasks upon initialization of the service. By specifying the tasks and their arguments in the `_autostart_tasks` dictionary in your service class's `__init__` method, `pydase` will automatically start these tasks when the server is started. Here's an example:
@@ -438,9 +710,9 @@ from pydase import DataService, Server
class SensorService(DataService):
def __init__(self):
self.readout_frequency = 1.0
self._autostart_tasks = {"read_sensor_data": ()} # args passed to the function go there
super().__init__()
self.readout_frequency = 1.0
self._autostart_tasks["read_sensor_data"] = ()
def _process_data(self, data: ...) -> None:
...
@@ -460,22 +732,22 @@ if __name__ == "__main__":
Server(service).run()
```
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. The readout frequency can be updated using the `readout_frequency` attribute.
By listing it in the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed.
As with all tasks, `pydase` will also generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task.
In this example, `read_sensor_data` is a task that continuously reads data from a sensor. By adding it to the `_autostart_tasks` dictionary, it will automatically start running when `Server(service).run()` is executed.
As with all tasks, `pydase` will generate `start_read_sensor_data` and `stop_read_sensor_data` methods, which can be called to manually start and stop the data reading task. The readout frequency can be updated using the `readout_frequency` attribute.
## Understanding Units in pydase
`pydase` integrates with the [`pint`](https://pint.readthedocs.io/en/stable/) package to allow you to work with physical quantities within your service. This enables you to define attributes with units, making your service more expressive and ensuring consistency in the handling of physical quantities.
You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality. These quantities can be set and accessed like regular attributes, and `pydase` will automatically handle the conversion between floats and quantities with units.
You can define quantities in your `DataService` subclass using `pydase`'s `units` functionality.
Here's an example:
```python
from typing import Any
from pydase import DataService, Server
import pydase.units as u
from pydase import DataService, Server
class ServiceClass(DataService):
@@ -487,17 +759,15 @@ class ServiceClass(DataService):
return self._current
@current.setter
def current(self, value: Any) -> None:
def current(self, value: u.Quantity) -> None:
self._current = value
if __name__ == "__main__":
service = ServiceClass()
# You can just set floats to the Quantity objects. The DataService __setattr__ will
# automatically convert this
service.voltage = 10.0
service.current = 1.5
service.voltage = 10.0 * u.units.V
service.current = 1.5 * u.units.mA
Server(service).run()
```
@@ -530,31 +800,141 @@ 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/).
## Changing the Log Level
## Configuring pydase via Environment Variables
You can change the log level of the logger by either
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.
1. (RECOMMENDED) setting the `ENVIRONMENT` environment variable to "production" or "development"
`pydase` offers various configurable options:
- **`ENVIRONMENT`**: Sets the operation mode to either "development" or "production". Affects logging behaviour (see [logging section](#logging-in-pydase)).
- **`SERVICE_CONFIG_DIR`**: Specifies the directory for service configuration files, like `web_settings.json`. This directory can also be used to hold user-defined configuration files. Default is the `config` folder in the service root folder. The variable can be accessed through:
```python
import pydase.config
pydase.config.ServiceConfig().config_dir
```
- **`SERVICE_WEB_PORT`**: Defines the port number for the web server. This has to be different for each services running on the same host. Default is 8001.
- **`SERVICE_RPC_PORT`**: Defines the port number for the rpc server. This has to be different for each services running on the same host. Default is 18871.
- **`GENERATE_WEB_SETTINGS`**: When set to true, generates / updates the `web_settings.json` file. If the file already exists, only new entries are appended.
Some of those settings can also be altered directly in code when initializing the server:
```python
import pathlib
from pydase import Server
from your_service_module import YourService
server = Server(
YourService(),
web_port=8080,
rpc_port=18880,
config_dir=pathlib.Path("other_config_dir"), # note that you need to provide an argument of type pathlib.Path
generate_web_settings=True
).run()
```
## Customizing the Web Interface
### Enhancing the Web Interface Style with Custom CSS
`pydase` allows you to enhance the user experience by customizing the web interface's appearance. You can apply your own styles globally across the web interface by passing a custom CSS file to the server during initialization.
Here's how you can use this feature:
1. Prepare your custom CSS file with the desired styles.
2. When initializing your server, use the `css` parameter of the `Server` class to specify the path to your custom CSS file.
```python
from pydase import Server, DataService
class MyService(DataService):
# ... your service definition ...
if __name__ == "__main__":
service = MyService()
server = Server(service, css="path/to/your/custom.css").run()
```
This will apply the styles defined in `custom.css` to the web interface, allowing you to maintain branding consistency or improve visual accessibility.
Please ensure that the CSS file path is accessible from the server's running location. Relative or absolute paths can be used depending on your setup.
### Tailoring Frontend Component Layout
`pydase` enables users to customize the frontend layout via the `web_settings.json` file. Each key in the file corresponds to the full access path of public attributes, properties, and methods of the exposed service, using dot-notation.
- **Custom Display Names**: Modify the `"displayName"` value in the file to change how each component appears in the frontend.
- **Control Component Visibility**: Utilize the `"display"` key-value pair to control whether a component is rendered in the frontend. Set the value to `true` to make the component visible or `false` to hide it.
<!-- - **Adjustable Component Order**: The `"displayOrder"` values determine the order of components. Alter these values to rearrange the components as desired. -->
The `web_settings.json` file will be stored in the directory specified by `SERVICE_CONFIG_DIR`. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](#configuring-pydase-via-environment-variables).
### Specifying a Custom Frontend Source
To further personalize your web interface, you can provide `pydase` with a custom frontend GUI. To do so, you can use the `frontend_src` keyword in the `pydase.Server`:
```python
from pathlib import Path
import pydase
class MyService(pydase.DataService):
# Service definition
if __name__ == "__main__":
service = MyService()
pydase.Server(
service,
frontend_src=Path("path/to/your/frontend/directory"),
).run()
```
## Logging in pydase
The `pydase` library organizes its loggers on a per-module basis, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library.
### Changing the Log Level
You have two primary ways to adjust the log levels in `pydase`:
1. directly targeting `pydase` loggers
You can set the log level for any `pydase` logger directly in your code. This method is useful for fine-tuning logging levels for specific modules within `pydase`. For instance, if you want to change the log level of the main `pydase` logger or target a submodule like `pydase.data_service`, you can do so as follows:
```python
# <your_script.py>
import logging
# Set the log level for the main pydase logger
logging.getLogger("pydase").setLevel(logging.INFO)
# Optionally, target a specific submodule logger
# logging.getLogger("pydase.data_service").setLevel(logging.DEBUG)
# Your logger for the current script
logger = logging.getLogger(__name__)
logger.info("My info message.")
```
This approach allows for specific control over different parts of the `pydase` library, depending on your logging needs.
2. using the `ENVIRONMENT` environment variable
For a more global setting that affects the entire `pydase` library, you can utilize the `ENVIRONMENT` environment variable. Setting this variable to "production" will configure all `pydase` loggers to only log messages of level "INFO" and above, filtering out more verbose logging. This is particularly useful for production environments where excessive logging can be overwhelming or unnecessary.
```bash
ENVIRONMENT="production" python -m <module_using_pydase>
```
The production environment will only log messages above "INFO", the development environment (default) logs everything above "DEBUG".
In the absence of this setting, the default behavior is to log everything of level "DEBUG" and above, suitable for development environments where more detailed logs are beneficial.
2. calling the `pydase.utils.logging.setup_logging` function with the desired log level
```python
# <your_script.py>
import logging
from pydase.utils.logging import setup_logging
setup_logging("INFO") # or setup_logging(logging.INFO)
logger = logging.getLogger()
# ... and your log
logger.info("My info message.")
```
**Note**: It is recommended to avoid calling the `pydase.utils.logging.setup_logging` function directly, as this may result in duplicated logging messages.
## Documentation

View File

@@ -18,7 +18,7 @@ For example, for a `Image` component, create a file named `image.py`.
### Step 2: Define the Backend Class
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain methods which can be used to interact with the component from the backend.
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain (public) methods which you can provide for the user to interact with the component from the backend (or python clients).
For the `Image` component, the class may look like this:
@@ -31,21 +31,25 @@ from pydase.data_service.data_service import DataService
class Image(DataService):
def __init__(
self,
image_representation: bytes = b"",
) -> None:
self.image_representation = image_representation
super().__init__()
self._value: str = ""
self._format: str = ""
# need to decode the bytes
def __setattr__(self, __name: str, __value: Any) -> None:
if __name == "value":
if isinstance(__value, bytes):
__value = __value.decode()
return super().__setattr__(__name, __value)
@property
def value(self) -> str:
return self._value
@property
def format(self) -> str:
return self._format
def load_from_path(self, path: Path | str) -> None:
# changing self._value and self._format
...
```
So, changing the `image_representation` will push the updated value to the browsers connected to the service.
So, calling `load_from_path` will push the updated value and format to the browsers clients connected to the service.
### Step 3: Register the Backend Class
@@ -85,10 +89,11 @@ def test_Image(capsys: CaptureFixture) -> None:
class ServiceClass(DataService):
image = Image()
service = ServiceClass()
# ...
```
service_instance = ServiceClass()
service_instance.image.load_from_path("<path/to/image>.png")
assert service_instance.image.format == "PNG"
```
## Adding a Frontend Component to `pydase`
@@ -107,33 +112,41 @@ Write the React component code, following the structure and patterns used in exi
For example, for the `Image` component, a template could look like this:
```tsx
import { setAttribute, runMethod } from '../socket'; // use this when your component should sets values of attributes
// or runs a method, respectively
import { DocStringComponent } from './DocStringComponent';
import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ImageComponentProps {
name: string;
parentPath: string;
readOnly: boolean;
docString: string;
addNotification: (message: string) => void;
// Define your component specific props here
type ImageComponentProps = {
name: string; // needed to create the fullAccessPath
parentPath: string; // needed to create the fullAccessPath
readOnly: boolean; // component changable through frontend?
docString: string; // contains docstring of your component
displayName: string; // name defined in the web_settings.json
id: string; // unique identifier - created from fullAccessPath
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: ( // function used to communicate changes to the backend
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
// component-specific properties
value: string;
format: string;
}
};
export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { name, parentPath, value, docString, format, addNotification } = props;
const { value, docString, format, addNotification, displayName, id } = props;
const renderCount = useRef(0);
const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
const fullAccessPath = parentPath.concat('.' + name);
const id = getIdFromFullAccessPath(fullAccessPath);
const fullAccessPath = [props.parentPath, props.name]
.filter((element) => element)
.join('.');
// Your component logic here
useEffect(() => {
renderCount.current++;
@@ -141,13 +154,11 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
// This will trigger a notification if notifications are enabled.
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
addNotification(`${fullAccessPath} changed.`);
}, [props.value]);
// Your component logic here
return (
<div className={'imageComponent'} id={id}>
<div className="component imageComponent" id={id}>
{/* Add the Card and Collapse components here if you want to be able to expand and
collapse your component. */}
<Card>
@@ -155,14 +166,15 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{name} {open ? <ChevronDown /> : <ChevronRight />}
{displayName}
<DocStringComponent docString={docString} />
{open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
{/* Your component TSX here */}
</Card.Body>
</Collapse>
@@ -174,56 +186,98 @@ export const ImageComponent = React.memo((props: ImageComponentProps) => {
### Step 3: Emitting Updates to the Backend
React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for smooth communication of these changes. To handle updates, we primarily use two events: `setAttribute` for updating attributes, and `runMethod` for executing backend methods. Below is a detailed guide on how to emit these events from your frontend component:
React components in the frontend often need to send updates to the backend, particularly when user interactions modify the component's state or data. In `pydase`, we use `socketio` for communicating these changes.<br>
There are two different events a component might want to trigger: updating an attribute or triggering a method. Below is a guide on how to emit these events from your frontend component:
1. **Setup for emitting events**:
First, ensure you've imported the necessary functions from the `socket` module for both updating attributes and executing methods:
1. **Updating Attributes**
```tsx
import { setAttribute, runMethod } from '../socket';
```
Updating the value of an attribute or property in the backend is a very common requirement. However, we want to define components in a reusable way, i.e. they can be linked to the backend but also be used without emitting change events.<br>
This is why we pass a `changeCallback` function as a prop to the component which it can use to communicate changes. If no function is passed, the component can be used in forms, for example.
2. **Event Parameters**:
The `changeCallback` function takes the following arguments:
- When using **`setAttribute`**, we send three main pieces of data:
- `name`: The name of the attribute within the `DataService` instance to update.
- `parentPath`: The access path for the parent object of the attribute to be updated.
- `value`: The new value for the attribute, which must match the backend attribute type.
- For **`runMethod`**, the parameters are slightly different:
- `name`: The name of the method to be executed in the backend.
- `parentPath`: Similar to `setAttribute`, it's the access path to the object containing the method.
- `kwargs`: A dictionary of keyword arguments that the method requires.
3. **Implementation**:
For illustation, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
import { setAttribute } from '../socket';
// ... (other imports)
- `value`: the new value for the attribute, which must match the backend attribute type.
- `attributeName`: the name of the attribute within the `DataService` instance to update. Defaults to the `name` prop of the component.
- `prefix`: the access path for the parent object of the attribute to be updated. Defaults to the `parentPath` prop of the component.
- `callback`: the function that will be called when the server sends an acknowledgement. Defaults to `undefined`
For illustration, take the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
// file: frontend/src/components/ButtonComponent.tsx
// ... (import statements)
type ButtonComponentProps = {
// ...
changeCallback?: (
value: unknown,
attributeName?: string,
prefix?: string,
callback?: (ack: unknown) => void
) => void;
};
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
// ...
const { name, parentPath, value } = props;
const {
// ...
changeCallback = () => {},
} = props;
const setChecked = (checked: boolean) => {
setAttribute(name, parentPath, checked);
changeCallback(checked);
};
return (
<ToggleButton
checked={value}
value={parentPath}
// ... other props
onChange={(e) => setChecked(e.currentTarget.checked)}>
<p>{name}</p>
{/* component TSX */}
</ToggleButton>
);
});
```
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `setAttribute`.
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `changeCallback`.
2. **Triggering Methods**
To trigger method through your component, you can either use the `MethodComponent` (which will render a button in the frontend), or use the low-level `runMethod` function. Its parameters are slightly different to the `changeCallback` function:
- `name`: the name of the method to be executed in the backend.
- `parentPath`: the access path to the object containing the method.
- `kwargs`: a dictionary of keyword arguments that the method requires.
To see how to use the `MethodComponent` in your component, have a look at the `DeviceConnection.tsx` file. Here is an example that demonstrates the usage of the `runMethod` function (also, have a look at the `MethodComponent.tsx` file):
```tsx
import { runMethod } from '../socket';
// ... (other imports)
type ComponentProps = {
name: string;
parentPath: string;
// ...
};
export const Component = React.memo((props: ComponentProps) => {
const {
name,
parentPath,
// ...
} = props;
// ...
const someFunction = () => {
// ...
runMethod(name, parentPath, {});
};
return (
{/* component TSX */}
);
});
```
### Step 4: Add the New Component to the GenericComponent
@@ -270,15 +324,17 @@ Inside the `GenericComponent` function, add a new conditional branch to render t
<ImageComponent
name={name}
parentPath={parentPath}
readOnly={attribute.readonly}
docString={attribute.doc}
docString={attribute.value['value'].doc}
displayName={displayName}
id={id}
addNotification={addNotification}
changeCallback={changeCallback}
// Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string}
/>
);
} else {
} else if (...) {
// other code
```
@@ -293,12 +349,15 @@ For example, updating an `Image` component corresponds to setting a very long st
To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this:
```tsx
const fullAccessPath = [parentPath, name].filter((element) => element).join('.');
useEffect(() => {
addNotification(`${parentPath}.${name} changed.`);
addNotification(`${fullAccessPath} changed.`);
}, [props.value]);
```
However, you might want to use the `addNotification` at different places. For an example, see the [MethodComponent](../../frontend/src/components/MethodComponent.tsx).
However, you might want to use the `addNotification` at different places. For an example, see the `MethodComponent`.
**Note**: you can specify the notification level by passing a string of type `LevelName` (one of 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'). The default value is 'DEBUG'.
### Step 6: Write Tests for the Component (TODO)

View File

@@ -0,0 +1,27 @@
# Observer Pattern Implementation in Pydase
## Overview
The Observer Pattern is a fundamental design pattern in the `pydase` package, serving as the central communication mechanism for state updates to clients connected to a service.
## How it Works
### The Observable Class
The `Observable` class is at the core of the pattern. It maintains a list of observers and is responsible for notifying them about state changes. It does so by overriding the following methods:
- `__setattr__`: This function emits a notification before and after a new value is set. These two notifications are important to track which attributes are being set to avoid endless recursion (e.g. when accessing a property within another property). Moreover, when setting an attribute to another observable, the former class will add itself as an observer to the latter class, ensuring that nested classes are properly observed.
- `__getattribute__`: This function notifies the observers when a property getter is called, allowing for monitoring state changes in remote devices, as opposed to local instance attributes.
### Custom Collection Classes
To handle collections (like lists and dictionaries), the `Observable` class converts them into custom collection classes `_ObservableList` and `_ObservableDict` that notify observers of any changes in their state. For this, they have to override the methods changing the state, e.g., `__setitem__` or `append` for lists.
### The Observer Class
The `Observer` is the final element in the chain of observers. The notifications of attribute changes it receives include the full access path (in dot-notation) and the new value. It implements logic to handle state changes, like caching, error logging for type changes, etc. This can be extended by custom notification callbacks (implemented using `add_notification_callback` in `DataServiceObserver`). This enables the user to perform specific actions in response to changes. In `pydase`, the web server adds an additional notification callback that emits the websocket events (`sio_callback`).
Furthermore, the `DataServiceObserver` implements logic to reload the values of properties when an attribute change occurs that a property depends on.
- **Dynamic Inspection**: The observer dynamically inspects the observable object (recursively) to create a mapping of properties and their dependencies. This mapping is constructed based on the class or instance attributes used within the source code of the property getters.
- **Dependency Management**: When a change in an attribute occurs, `DataServiceObserver` updates any properties that depend on this attribute. This ensures that the overall state remains consistent and up-to-date, especially in complex scenarios where properties depend on other instance attribute or properties.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@fsouza/prettierd": "^0.25.1",
"@mui/material": "^5.14.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
@@ -46,7 +47,7 @@
"@types/node": "^20.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.9.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",

View File

@@ -1,25 +1,52 @@
body {
min-width: 576px;
max-width: 1200px;
max-width: 2000px;
}
input.instantUpdate {
background-color: rgba(255, 0, 0, 0.1);
}
.numberComponentButton {
padding: 0.15em 6px !important;
font-size: 0.70rem !important;
}
.navbarOffset {
padding-top: 60px !important;
right: 20;
}
/* .toastContainer {
position: fixed;
} */
.notificationToast {
.toastContainer {
position: fixed !important;
padding: 5px;
}
.debugToast,
.infoToast {
background-color: rgba(114, 214, 253, 0.5) !important;
}
.exceptionToast {
.warningToast {
background-color: rgba(255, 181, 44, 0.603) !important;
}
.errorToast,
.criticalToast {
background-color: rgba(216, 41, 18, 0.678) !important;
}
}
.component {
position: relative;
float: left !important;
padding: 5px !important;
z-index: 1;
}
.dataServiceComponent {
width: 100%;
}
.deviceConnectionComponent {
position: relative;
float: left !important;
width: 100%;
z-index: 1;
}
.overlayContent {
position: absolute;
inset: 5px; /* (see https://developer.mozilla.org/en-US/docs/Web/CSS/inset) */
background: rgba(155, 155, 155, 0.75);
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column; /* Stack children vertically */
border-radius: var(--bs-border-radius);
border: var(--bs-border-width) solid var(--bs-border-color-translucent)
}

View File

@@ -1,103 +1,44 @@
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { useCallback, useEffect, useReducer, useState } from 'react';
import { Navbar, Form, Offcanvas, Container } from 'react-bootstrap';
import { hostname, port, socket } from './socket';
import {
DataServiceComponent,
DataServiceJSON
} from './components/DataServiceComponent';
import './App.css';
import { Notifications } from './components/NotificationsComponent';
import {
Notifications,
Notification,
LevelName
} from './components/NotificationsComponent';
import { ConnectionToast } from './components/ConnectionToast';
import { setNestedValueByPath, State } from './utils/stateUtils';
import { WebSettingsContext, WebSetting } from './WebSettings';
import { SerializedValue, GenericComponent } from './components/GenericComponent';
type ValueType = boolean | string | number | object;
type State = DataServiceJSON | null;
type Action =
| { type: 'SET_DATA'; data: DataServiceJSON }
| { type: 'UPDATE_ATTRIBUTE'; parentPath: string; name: string; value: ValueType };
type UpdateMessage = {
data: { parent_path: string; name: string; value: object };
};
type ExceptionMessage = {
data: { exception: string; type: string };
};
/**
* A function to update a specific property in a deeply nested object.
* The property to be updated is specified by a path array.
*
* Each path element can be a regular object key or an array index of the
* form "attribute[index]", where "attribute" is the key of the array in
* the object and "index" is the index of the element in the array.
*
* For array indices, the element at the specified index in the array is
* updated.
*
* If the property to be updated is an object or an array, it is updated
* recursively.
*/
function updateNestedObject(path: Array<string>, obj: object, value: ValueType) {
// Base case: If the path is empty, return the new value.
// This means we've reached the nested property to be updated.
if (path.length === 0) {
return value;
}
// Recursive case: If the path is not empty, split it into the first key and the rest
// of the path.
const [first, ...rest] = path;
// Check if 'first' is an array index.
const indexMatch = first.match(/^(\w+)\[(\d+)\]$/);
// If 'first' is an array index of the form "attribute[index]", then update the
// element at the specified index in the array. Otherwise, update the property
// specified by 'first' in the object.
if (indexMatch) {
const attribute = indexMatch[1];
const index = parseInt(indexMatch[2]);
if (Array.isArray(obj[attribute]?.value)) {
return {
...obj,
[attribute]: {
...obj[attribute],
value: obj[attribute].value.map((item, i) =>
i === index
? {
...item,
value: updateNestedObject(rest, item.value || {}, value)
}
: item
)
}
};
} else {
throw new Error(
`Expected ${attribute}.value to be an array, but received ${typeof obj[
attribute
]?.value}`
);
}
} else {
return {
...obj,
[first]: {
...obj[first],
value: updateNestedObject(rest, obj[first]?.value || {}, value)
}
| { type: 'SET_DATA'; data: State }
| {
type: 'UPDATE_ATTRIBUTE';
fullAccessPath: string;
newValue: SerializedValue;
};
}
}
type UpdateMessage = {
data: { full_access_path: string; value: SerializedValue };
};
type LogMessage = {
levelname: LevelName;
message: string;
};
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'SET_DATA':
return action.data;
case 'UPDATE_ATTRIBUTE': {
const path = action.parentPath.split('.').slice(1).concat(action.name);
return updateNestedObject(path, state, action.value);
if (state === null) {
return null;
}
return {
...state,
value: setNestedValueByPath(state.value, action.fullAccessPath, action.newValue)
};
}
default:
throw new Error();
@@ -105,19 +46,13 @@ const reducer = (state: State, action: Action): State => {
};
const App = () => {
const [state, dispatch] = useReducer(reducer, null);
const stateRef = useRef(state); // Declare a reference to hold the current state
const [webSettings, setWebSettings] = useState<Record<string, WebSetting>>({});
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [notifications, setNotifications] = useState([]);
const [exceptions, setExceptions] = useState([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [connectionStatus, setConnectionStatus] = useState('connecting');
// Keep the state reference up to date
useEffect(() => {
stateRef.current = state;
}, [state]);
useEffect(() => {
// Allow the user to add a custom css file
fetch(`http://${hostname}:${port}/custom.css`)
@@ -137,7 +72,10 @@ const App = () => {
// Fetch data from the API when the client connects
fetch(`http://${hostname}:${port}/service-properties`)
.then((response) => response.json())
.then((data: DataServiceJSON) => dispatch({ type: 'SET_DATA', data }));
.then((data: State) => dispatch({ type: 'SET_DATA', data }));
fetch(`http://${hostname}:${port}/web-settings`)
.then((response) => response.json())
.then((data: Record<string, WebSetting>) => setWebSettings(data));
setConnectionStatus('connected');
});
socket.on('disconnect', () => {
@@ -152,70 +90,55 @@ const App = () => {
});
socket.on('notify', onNotify);
socket.on('exception', onException);
socket.on('log', onLogMessage);
return () => {
socket.off('notify', onNotify);
socket.off('exception', onException);
socket.off('log', onLogMessage);
};
}, []);
// Adding useCallback to prevent notify to change causing a re-render of all
// components
const addNotification = useCallback((text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
const addNotification = useCallback(
(message: string, levelname: LevelName = 'DEBUG') => {
// Getting the current time in the required format
const timeStamp = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
}, []);
// Custom logic for notifications
setNotifications((prevNotifications) => [
{ levelname, id, message, timeStamp },
...prevNotifications
]);
},
[]
);
const notifyException = (text: string) => {
// Getting the current time in the required format
const timeString = new Date().toISOString().substring(11, 19);
// Adding an id to the notification to provide a way of removing it
const id = Math.random();
// Custom logic for notifications
setExceptions((prevNotifications) => [
{ id, text, time: timeString },
...prevNotifications
]);
};
const removeNotificationById = (id: number) => {
setNotifications((prevNotifications) =>
prevNotifications.filter((n) => n.id !== id)
);
};
const removeExceptionById = (id: number) => {
setExceptions((prevNotifications) => prevNotifications.filter((n) => n.id !== id));
};
const handleCloseSettings = () => setShowSettings(false);
const handleShowSettings = () => setShowSettings(true);
function onNotify(value: UpdateMessage) {
// Extracting data from the notification
const { parent_path: parentPath, name, value: newValue } = value.data;
const { full_access_path: fullAccessPath, value: newValue } = value.data;
// Dispatching the update to the reducer
dispatch({
type: 'UPDATE_ATTRIBUTE',
parentPath,
name,
value: newValue
fullAccessPath,
newValue
});
}
function onException(value: ExceptionMessage) {
const newException = `${value.data.type}: ${value.data.exception}.`;
notifyException(newException);
function onLogMessage(value: LogMessage) {
addNotification(value.message, value.levelname);
}
// While the data is loading
@@ -234,9 +157,7 @@ const App = () => {
<Notifications
showNotification={showNotification}
notifications={notifications}
exceptions={exceptions}
removeNotificationById={removeNotificationById}
removeExceptionById={removeExceptionById}
/>
<Offcanvas
@@ -265,12 +186,13 @@ const App = () => {
</Offcanvas>
<div className="App navbarOffset">
<DataServiceComponent
name={''}
props={state as DataServiceJSON}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
<WebSettingsContext.Provider value={webSettings}>
<GenericComponent
attribute={state as SerializedValue}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
</WebSettingsContext.Provider>
</div>
<ConnectionToast connectionStatus={connectionStatus} />
</>

View File

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

View File

@@ -1,107 +1,90 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { runMethod } from '../socket';
import { InputGroup, Form, Button } from 'react-bootstrap';
import { Form, Button, InputGroup, Spinner } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface AsyncMethodProps {
name: string;
parentPath: string;
parameters: Record<string, string>;
value: Record<string, string>;
type AsyncMethodProps = {
fullAccessPath: string;
value: 'RUNNING' | null;
docString?: string;
hideOutput?: boolean;
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
render: boolean;
};
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {
const { name, parentPath, docString, value: runningTask, addNotification } = props;
const {
fullAccessPath,
docString,
value: runningTask,
addNotification,
displayName,
id
} = props;
// Conditional rendering based on the 'render' prop.
if (!props.render) {
return null;
}
const renderCount = useRef(0);
const formRef = useRef(null);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const [spinning, setSpinning] = useState(false);
const name = fullAccessPath.split('.').at(-1);
const parentPath = fullAccessPath.slice(0, -(name.length + 1));
useEffect(() => {
renderCount.current++;
// updates the value of each form control that has a matching name in the
// runningTask object
if (runningTask) {
const formElement = formRef.current;
if (formElement) {
Object.entries(runningTask).forEach(([name, value]) => {
const inputElement = formElement.elements.namedItem(name);
if (inputElement) {
inputElement.value = value;
}
});
}
}
}, [runningTask]);
useEffect(() => {
let message: string;
if (runningTask === null) {
message = `${parentPath}.${name} task was stopped.`;
message = `${fullAccessPath} task was stopped.`;
} else {
const runningTaskEntries = Object.entries(runningTask)
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
message = `${parentPath}.${name} was started with parameters { ${runningTaskEntries} }.`;
message = `${fullAccessPath} was started.`;
}
addNotification(message);
setSpinning(false);
}, [props.value]);
const execute = async (event: React.FormEvent) => {
event.preventDefault();
let method_name: string;
const kwargs: Record<string, unknown> = {};
if (runningTask !== undefined && runningTask !== null) {
method_name = `stop_${name}`;
} else {
Object.keys(props.parameters).forEach(
(name) => (kwargs[name] = event.target[name].value)
);
method_name = `start_${name}`;
}
runMethod(method_name, parentPath, kwargs);
const accessPath = [parentPath, method_name].filter((element) => element).join('.');
setSpinning(true);
runMethod(accessPath);
};
const args = Object.entries(props.parameters).map(([name, type], index) => {
const form_name = `${name} (${type})`;
const value = runningTask && runningTask[name];
const isRunning = value !== undefined && value !== null;
return (
<InputGroup key={index}>
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
<Form.Control
type="text"
name={name}
defaultValue={isRunning ? value : ''}
disabled={isRunning}
/>
</InputGroup>
);
});
return (
<div className="align-items-center asyncMethodComponent" id={id}>
<div className="component asyncMethodComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
<div>Render count: {renderCount.current}</div>
)}
<h5>
Function: {name}
<DocStringComponent docString={docString} />
</h5>
<Form onSubmit={execute} ref={formRef}>
{args}
<Button id={`button-${id}`} name={name} value={parentPath} type="submit">
{runningTask ? 'Stop' : 'Start'}
</Button>
<InputGroup>
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
<Button id={`button-${id}`} type="submit">
{spinning ? (
<Spinner size="sm" role="status" aria-hidden="true" />
) : runningTask === 'RUNNING' ? (
'Stop '
) : (
'Start '
)}
</Button>
</InputGroup>
</Form>
</div>
);

View File

@@ -1,24 +1,33 @@
import React, { useEffect, useRef } from 'react';
import { ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { SerializedValue } from './GenericComponent';
import { LevelName } from './NotificationsComponent';
interface ButtonComponentProps {
name: string;
parentPath?: string;
type ButtonComponentProps = {
fullAccessPath: string;
value: boolean;
readOnly: boolean;
docString: string;
mapping?: [string, string]; // Enforce a tuple of two strings
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName: string;
id: string;
};
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
const { name, parentPath, value, readOnly, docString, mapping, addNotification } =
props;
const buttonName = mapping ? (value ? mapping[0] : mapping[1]) : name;
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const {
value,
fullAccessPath,
readOnly,
docString,
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
// const buttonName = props.mapping ? (value ? props.mapping[0] : props.mapping[1]) : name;
const renderCount = useRef(0);
@@ -27,29 +36,35 @@ export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
});
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
addNotification(`${fullAccessPath} changed to ${value}.`);
}, [props.value]);
const setChecked = (checked: boolean) => {
setAttribute(name, parentPath, checked);
changeCallback({
type: 'bool',
value: checked,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
};
return (
<div className={'buttonComponent'} id={id}>
<div className={'component buttonComponent'} id={id}>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} />
<ToggleButton
id={`toggle-check-${id}`}
type="checkbox"
variant={value ? 'success' : 'secondary'}
checked={value}
value={parentPath}
value={displayName}
disabled={readOnly}
onChange={(e) => setChecked(e.currentTarget.checked)}>
<p>{buttonName}</p>
{displayName}
<DocStringComponent docString={docString} />
</ToggleButton>
</div>
);

View File

@@ -1,76 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
interface ColouredEnumComponentProps {
name: string;
parentPath: string;
value: string;
docString?: string;
readOnly: boolean;
enumDict: Record<string, string>;
addNotification: (message: string) => void;
}
export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => {
const {
name,
parentPath: parentPath,
value,
docString,
enumDict,
readOnly,
addNotification
} = props;
const renderCount = useRef(0);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
const handleValueChange = (newValue: string) => {
setAttribute(name, parentPath, newValue);
};
return (
<div className={'enumComponent'} id={id}>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
<Row>
<Col className="d-flex align-items-center">
<InputGroup.Text>{name}</InputGroup.Text>
{readOnly ? (
// Display the Form.Control when readOnly is true
<Form.Control
value={value}
disabled={true}
style={{ backgroundColor: enumDict[value] }}
/>
) : (
// Display the Form.Select when readOnly is false
<Form.Select
aria-label="coloured-enum-select"
value={value}
style={{ backgroundColor: enumDict[value] }}
onChange={(event) => handleValueChange(event.target.value)}>
{Object.entries(enumDict).map(([key]) => (
<option key={key} value={key}>
{key}
</option>
))}
</Form.Select>
)}
</Col>
</Row>
</div>
);
});

View File

@@ -68,7 +68,7 @@ export const ConnectionToast = React.memo(
const { message, bg, delay } = getToastContent();
return (
<ToastContainer position="bottom-center">
<ToastContainer position="bottom-center" className="toastContainer">
<Toast
show={show}
onClose={handleClose}

View File

@@ -2,61 +2,58 @@ import { useState } from 'react';
import React from 'react';
import { Card, Collapse } from 'react-bootstrap';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { Attribute, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { SerializedValue, GenericComponent } from './GenericComponent';
import { LevelName } from './NotificationsComponent';
type DataServiceProps = {
name: string;
props: DataServiceJSON;
parentPath?: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
};
export type DataServiceJSON = Record<string, Attribute>;
export type DataServiceJSON = Record<string, SerializedValue>;
export const DataServiceComponent = React.memo(
({
name,
props,
parentPath = 'DataService',
isInstantUpdate,
addNotification
}: DataServiceProps) => {
({ props, isInstantUpdate, addNotification, displayName, id }: DataServiceProps) => {
const [open, setOpen] = useState(true);
let fullAccessPath = parentPath;
if (name) {
fullAccessPath = parentPath.concat('.' + name);
}
const id = getIdFromFullAccessPath(fullAccessPath);
return (
<div className="dataServiceComponent" id={id}>
<Card className="mb-3">
<Card.Header
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{fullAccessPath} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{Object.entries(props).map(([key, value]) => {
return (
if (displayName !== '') {
return (
<div className="component dataServiceComponent" id={id}>
<Card>
<Card.Header onClick={() => setOpen(!open)} style={{ cursor: 'pointer' }}>
{displayName} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{Object.entries(props).map(([key, value]) => (
<GenericComponent
key={key}
attribute={value}
name={key}
parentPath={fullAccessPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
})}
</Card.Body>
</Collapse>
</Card>
</div>
);
))}
</Card.Body>
</Collapse>
</Card>
</div>
);
} else {
return (
<div className="component dataServiceComponent" id={id}>
{Object.entries(props).map(([key, value]) => (
<GenericComponent
key={key}
attribute={value}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
))}
</div>
);
}
}
);

View File

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

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from './DocStringComponent';
import { SerializedValue, GenericComponent } from './GenericComponent';
import { LevelName } from './NotificationsComponent';
type DictComponentProps = {
value: Record<string, SerializedValue>;
docString: string;
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 = useRef(0);
const valueArray = Object.values(value);
useEffect(() => {
renderCount.current++;
}, [props]);
return (
<div className={'listComponent'} id={id}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} />
{valueArray.map((item) => {
return (
<GenericComponent
key={item.full_access_path}
attribute={item}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>
);
})}
</div>
);
});

View File

@@ -1,9 +1,9 @@
import { Badge, Tooltip, OverlayTrigger } from 'react-bootstrap';
import React from 'react';
interface DocStringProps {
type DocStringProps = {
docString?: string;
}
};
export const DocStringComponent = React.memo((props: DocStringProps) => {
const { docString } = props;

View File

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

View File

@@ -1,15 +1,21 @@
import React from 'react';
import React, { useContext } from 'react';
import { ButtonComponent } from './ButtonComponent';
import { NumberComponent } from './NumberComponent';
import { SliderComponent } from './SliderComponent';
import { EnumComponent } from './EnumComponent';
import { EnumComponent, EnumSerialization } from './EnumComponent';
import { MethodComponent } from './MethodComponent';
import { AsyncMethodComponent } from './AsyncMethodComponent';
import { StringComponent } from './StringComponent';
import { ListComponent } from './ListComponent';
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { DeviceConnectionComponent } from './DeviceConnection';
import { ImageComponent } from './ImageComponent';
import { ColouredEnumComponent } from './ColouredEnumComponent';
import { LevelName } from './NotificationsComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { WebSettingsContext } from '../WebSettings';
import { updateValue } from '../socket';
import { DictComponent } from './DictComponent';
import { parseFullAccessPath } from '../utils/stateUtils';
type AttributeType =
| 'str'
@@ -17,188 +23,249 @@ type AttributeType =
| 'float'
| 'int'
| 'Quantity'
| 'None'
| 'list'
| 'dict'
| 'method'
| 'DataService'
| 'DeviceConnection'
| 'Enum'
| 'NumberSlider'
| 'Image'
| 'ColouredEnum';
type ValueType = boolean | string | number | object;
export interface Attribute {
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;
parameters?: Record<string, string>;
async?: boolean;
frontend_render?: boolean;
enum?: Record<string, string>;
}
};
type GenericComponentProps = {
attribute: Attribute;
name: string;
parentPath: string;
attribute: SerializedValue;
isInstantUpdate: boolean;
addNotification: (message: string) => 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);
};
export const GenericComponent = React.memo(
({
attribute,
name,
parentPath,
isInstantUpdate,
addNotification
}: GenericComponentProps) => {
({ attribute, isInstantUpdate, addNotification }: GenericComponentProps) => {
const { full_access_path: fullAccessPath } = attribute;
const id = getIdFromFullAccessPath(fullAccessPath);
const webSettings = useContext(WebSettingsContext);
let displayName = createDisplayNameFromAccessPath(fullAccessPath);
if (webSettings[fullAccessPath]) {
if (webSettings[fullAccessPath].display === false) {
return null;
}
if (webSettings[fullAccessPath].displayName) {
displayName = webSettings[fullAccessPath].displayName;
}
}
function changeCallback(
value: SerializedValue,
callback: (ack: unknown) => void = undefined
) {
updateValue(value, callback);
}
if (attribute.type === 'bool') {
return (
<ButtonComponent
name={name}
parentPath={parentPath}
fullAccessPath={fullAccessPath}
docString={attribute.doc}
readOnly={attribute.readonly}
value={Boolean(attribute.value)}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'float' || attribute.type === 'int') {
return (
<NumberComponent
name={name}
type={attribute.type}
parentPath={parentPath}
fullAccessPath={fullAccessPath}
docString={attribute.doc}
readOnly={attribute.readonly}
value={Number(attribute.value)}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'Quantity') {
return (
<NumberComponent
name={name}
type="float"
parentPath={parentPath}
type="Quantity"
fullAccessPath={fullAccessPath}
docString={attribute.doc}
readOnly={attribute.readonly}
value={Number(attribute.value['magnitude'])}
unit={attribute.value['unit']}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'NumberSlider') {
return (
<SliderComponent
name={name}
parentPath={parentPath}
docString={attribute.doc}
fullAccessPath={fullAccessPath}
docString={attribute.value['value'].doc}
readOnly={attribute.readonly}
value={attribute.value['value']['value']}
min={attribute.value['min']['value']}
max={attribute.value['max']['value']}
stepSize={attribute.value['step_size']['value']}
value={attribute.value['value']}
min={attribute.value['min']}
max={attribute.value['max']}
stepSize={attribute.value['step_size']}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'Enum') {
} else if (attribute.type === 'Enum' || attribute.type === 'ColouredEnum') {
return (
<EnumComponent
name={name}
parentPath={parentPath}
docString={attribute.doc}
value={String(attribute.value)}
enumDict={attribute.enum}
attribute={attribute as EnumSerialization}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'method') {
if (!attribute.async) {
return (
<MethodComponent
name={name}
parentPath={parentPath}
fullAccessPath={fullAccessPath}
docString={attribute.doc}
parameters={attribute.parameters}
addNotification={addNotification}
displayName={displayName}
id={id}
render={attribute.frontend_render}
/>
);
} else {
return (
<AsyncMethodComponent
name={name}
parentPath={parentPath}
fullAccessPath={fullAccessPath}
docString={attribute.doc}
parameters={attribute.parameters}
value={attribute.value as Record<string, string>}
value={attribute.value as 'RUNNING' | null}
addNotification={addNotification}
displayName={displayName}
id={id}
render={attribute.frontend_render}
/>
);
}
} else if (attribute.type === 'str') {
return (
<StringComponent
name={name}
fullAccessPath={fullAccessPath}
value={attribute.value as string}
readOnly={attribute.readonly}
docString={attribute.doc}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
changeCallback={changeCallback}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'DataService') {
return (
<DataServiceComponent
name={name}
props={attribute.value as DataServiceJSON}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'DeviceConnection') {
return (
<DeviceConnectionComponent
fullAccessPath={fullAccessPath}
props={attribute.value as DataServiceJSON}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
displayName={displayName}
id={id}
/>
);
} else if (attribute.type === 'list') {
return (
<ListComponent
name={name}
value={attribute.value as Attribute[]}
value={attribute.value as SerializedValue[]}
docString={attribute.doc}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
id={id}
/>
);
} else if (attribute.type === 'dict') {
return (
<DictComponent
value={attribute.value as Record<string, SerializedValue>}
docString={attribute.doc}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
id={id}
/>
);
} else if (attribute.type === 'Image') {
return (
<ImageComponent
name={name}
parentPath={parentPath}
value={attribute.value['value']['value'] as string}
readOnly={attribute.readonly}
docString={attribute.doc}
fullAccessPath={fullAccessPath}
docString={attribute.value['value'].doc}
displayName={displayName}
id={id}
addNotification={addNotification}
// Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string}
addNotification={addNotification}
/>
);
} else if (attribute.type === 'ColouredEnum') {
console.log(attribute);
return (
<ColouredEnumComponent
name={name}
parentPath={parentPath}
docString={attribute.doc}
value={String(attribute.value)}
readOnly={attribute.readonly}
enumDict={attribute.enum}
addNotification={addNotification}
/>
);
} else {
return <div key={name}>{name}</div>;
return <div key={fullAccessPath}>{fullAccessPath}</div>;
}
}
);

View File

@@ -2,49 +2,49 @@ import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ImageComponentProps {
name: string;
parentPath: string;
type ImageComponentProps = {
fullAccessPath: string;
value: string;
readOnly: boolean;
docString: string;
format: string;
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
};
export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { name, parentPath, value, docString, format, addNotification } = props;
const { fullAccessPath, value, docString, format, addNotification, displayName, id } =
props;
const renderCount = useRef(0);
const [open, setOpen] = useState(true);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
addNotification(`${parentPath}.${name} changed.`);
addNotification(`${fullAccessPath} changed.`);
}, [props.value]);
return (
<div className={'imageComponent'} id={id}>
<div className="component imageComponent" id={id}>
<Card>
<Card.Header
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{name} {open ? <ChevronDown /> : <ChevronRight />}
{displayName}
<DocStringComponent docString={docString} />
{open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
{/* Your component JSX here */}
{format === '' && value === '' ? (
<p>No image set in the backend.</p>
) : (

View File

@@ -1,23 +1,20 @@
import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from './DocStringComponent';
import { Attribute, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { SerializedValue, GenericComponent } from './GenericComponent';
import { LevelName } from './NotificationsComponent';
interface ListComponentProps {
name: string;
parentPath?: string;
value: Attribute[];
type ListComponentProps = {
value: SerializedValue[];
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
id: string;
};
export const ListComponent = React.memo((props: ListComponentProps) => {
const { name, parentPath, value, docString, isInstantUpdate, addNotification } =
props;
const { value, docString, isInstantUpdate, addNotification, id } = props;
const renderCount = useRef(0);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
useEffect(() => {
renderCount.current++;
@@ -26,16 +23,14 @@ export const ListComponent = React.memo((props: ListComponentProps) => {
return (
<div className={'listComponent'} id={id}>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} />
{value.map((item, index) => {
{value.map((item) => {
return (
<GenericComponent
key={`${name}[${index}]`}
key={item.full_access_path}
attribute={item}
name={`${name}[${index}]`}
parentPath={parentPath}
isInstantUpdate={isInstantUpdate}
addNotification={addNotification}
/>

View File

@@ -1,111 +1,57 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { runMethod } from '../socket';
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
import { Button, Form } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface MethodProps {
name: string;
parentPath: string;
parameters: Record<string, string>;
type MethodProps = {
fullAccessPath: string;
docString?: string;
hideOutput?: boolean;
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
displayName: string;
id: string;
render: boolean;
};
export const MethodComponent = React.memo((props: MethodProps) => {
const { name, parentPath, docString, addNotification } = props;
const { fullAccessPath, docString, addNotification, displayName, id } = props;
// Conditional rendering based on the 'render' prop.
if (!props.render) {
return null;
}
const renderCount = useRef(0);
const [hideOutput, setHideOutput] = useState(false);
// Add a new state variable to hold the list of function calls
const [functionCalls, setFunctionCalls] = useState([]);
const id = getIdFromFullAccessPath(parentPath.concat('.' + name));
const formRef = useRef(null);
useEffect(() => {
renderCount.current++;
if (props.hideOutput !== undefined) {
setHideOutput(props.hideOutput);
}
});
const triggerNotification = () => {
const message = `Method ${fullAccessPath} was triggered.`;
const triggerNotification = (args: Record<string, string>) => {
const argsString = Object.entries(args)
.map(([key, value]) => `${key}: "${value}"`)
.join(', ');
let message = `Method ${parentPath}.${name} was triggered`;
if (argsString === '') {
message += '.';
} else {
message += ` with arguments {${argsString}}.`;
}
addNotification(message);
};
const execute = async (event: React.FormEvent) => {
event.preventDefault();
runMethod(fullAccessPath);
const kwargs = {};
Object.keys(props.parameters).forEach(
(name) => (kwargs[name] = event.target[name].value)
);
runMethod(name, parentPath, kwargs, (ack) => {
// Update the functionCalls state with the new call if we get an acknowledge msg
if (ack !== undefined) {
setFunctionCalls((prevCalls) => [
...prevCalls,
{ name, args: kwargs, result: ack }
]);
}
});
triggerNotification(kwargs);
triggerNotification();
};
const args = Object.entries(props.parameters).map(([name, type], index) => {
const form_name = `${name} (${type})`;
return (
<InputGroup key={index}>
<InputGroup.Text className="component-label">{form_name}</InputGroup.Text>
<Form.Control type="text" name={name} />
</InputGroup>
);
useEffect(() => {
renderCount.current++;
});
return (
<div className="align-items-center methodComponent" id={id}>
<div className="component methodComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
<div>Render count: {renderCount.current}</div>
)}
<h5 onClick={() => setHideOutput(!hideOutput)} style={{ cursor: 'pointer' }}>
Function: {name}
<DocStringComponent docString={docString} />
</h5>
<Form onSubmit={execute}>
{args}
<div>
<Button variant="primary" type="submit">
Execute
</Button>
</div>
<Form onSubmit={execute} ref={formRef}>
<Button className="component" variant="primary" type="submit">
{`${displayName} `}
<DocStringComponent docString={docString} />
</Button>
</Form>
<Collapse in={!hideOutput}>
<div id="function-output">
{functionCalls.map((call, index) => (
<div key={index}>
<div style={{ color: 'grey', fontSize: 'small' }}>
{Object.entries(call.args)
.map(([key, val]) => `${key}=${JSON.stringify(val)}`)
.join(', ') +
' => ' +
JSON.stringify(call.result)}
</div>
</div>
))}
</div>
</Collapse>
</div>
);
});

View File

@@ -1,73 +1,71 @@
import React from 'react';
import { ToastContainer, Toast } from 'react-bootstrap';
export type LevelName = 'CRITICAL' | 'ERROR' | 'WARNING' | 'INFO' | 'DEBUG';
export type Notification = {
id: number;
time: string;
text: string;
timeStamp: string;
message: string;
levelname: LevelName;
};
type NotificationProps = {
showNotification: boolean;
notifications: Notification[];
exceptions: Notification[];
removeNotificationById: (id: number) => void;
removeExceptionById: (id: number) => void;
};
export const Notifications = React.memo((props: NotificationProps) => {
const {
showNotification,
notifications,
exceptions,
removeExceptionById,
removeNotificationById
} = props;
const { showNotification, notifications, removeNotificationById } = props;
return (
<ToastContainer
className="navbarOffset toastContainer"
position="top-end"
style={{ position: 'fixed' }}>
{showNotification &&
notifications.map((notification) => (
<ToastContainer className="navbarOffset toastContainer" position="top-end">
{notifications.map((notification) => {
// Determine if the toast should be shown
const shouldShow =
notification.levelname === 'ERROR' ||
notification.levelname === 'CRITICAL' ||
(showNotification &&
['WARNING', 'INFO', 'DEBUG'].includes(notification.levelname));
if (!shouldShow) {
return null;
}
return (
<Toast
className="notificationToast"
className={notification.levelname.toLowerCase() + 'Toast'}
key={notification.id}
onClose={() => removeNotificationById(notification.id)}
onClick={() => {
removeNotificationById(notification.id);
}}
onClick={() => removeNotificationById(notification.id)}
onMouseLeave={() => {
removeNotificationById(notification.id);
if (notification.levelname !== 'ERROR') {
removeNotificationById(notification.id);
}
}}
show={true}
autohide={true}
delay={2000}>
<Toast.Header closeButton={false} className="notificationToast text-right">
<strong className="me-auto">Notification</strong>
<small>{notification.time}</small>
autohide={
notification.levelname === 'WARNING' ||
notification.levelname === 'INFO' ||
notification.levelname === 'DEBUG'
}
delay={
notification.levelname === 'WARNING' ||
notification.levelname === 'INFO' ||
notification.levelname === 'DEBUG'
? 2000
: undefined
}>
<Toast.Header
closeButton={false}
className={notification.levelname.toLowerCase() + 'Toast text-right'}>
<strong className="me-auto">{notification.levelname}</strong>
<small>{notification.timeStamp}</small>
</Toast.Header>
<Toast.Body>{notification.text}</Toast.Body>
<Toast.Body>{notification.message}</Toast.Body>
</Toast>
))}
{exceptions.map((exception) => (
<Toast
className="exceptionToast"
key={exception.id}
onClose={() => removeExceptionById(exception.id)}
onClick={() => {
removeExceptionById(exception.id);
}}
show={true}
autohide={false}>
<Toast.Header closeButton className="exceptionToast text-right">
<strong className="me-auto">Exception</strong>
<small>{exception.time}</small>
</Toast.Header>
<Toast.Body>{exception.text}</Toast.Body>
</Toast>
))}
);
})}
</ToastContainer>
);
});

View File

@@ -1,30 +1,48 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Form, InputGroup } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
import { SerializedValue } from './GenericComponent';
// TODO: add button functionality
interface NumberComponentProps {
name: string;
type: 'float' | 'int';
parentPath?: string;
export type QuantityObject = {
type: 'Quantity';
readonly: boolean;
value: {
magnitude: number;
unit: string;
};
doc?: string;
};
export type IntObject = {
type: 'int';
readonly: boolean;
value: number;
doc?: string;
};
export type FloatObject = {
type: 'float';
readonly: boolean;
value: number;
doc?: string;
};
export type NumberObject = IntObject | FloatObject | QuantityObject;
type NumberComponentProps = {
type: 'float' | 'int' | 'Quantity';
fullAccessPath: string;
value: number;
readOnly: boolean;
docString: string;
isInstantUpdate: boolean;
unit?: string;
showName?: boolean;
customEmitUpdate?: (
name: string,
parent_path: string,
value: number,
callback?: (ack: unknown) => void
) => void;
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName?: string;
id: string;
};
// TODO: highlight the digit that is being changed by setting both selectionStart and
// selectionEnd
@@ -109,90 +127,54 @@ const handleDeleteKey = (
return { value, selectionStart };
};
const handleNumericKey = (
key: string,
value: string,
selectionStart: number,
selectionEnd: number
) => {
// Check if a number key or a decimal point key is pressed
if (key === '.' && value.includes('.')) {
// Check if value already contains a decimal. If so, ignore input.
console.warn('Invalid input! Ignoring...');
return { value, selectionStart };
}
let newValue = value;
// Add the new key at the cursor's position
if (selectionEnd > selectionStart) {
// If there is a selection, replace it with the key
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
} else {
// otherwise, append the key after the selection start
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
}
return { value: newValue, selectionStart: selectionStart + 1 };
};
export const NumberComponent = React.memo((props: NumberComponentProps) => {
const {
name,
parentPath,
fullAccessPath,
value,
readOnly,
type,
docString,
isInstantUpdate,
unit,
addNotification
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
// Whether to show the name infront of the component (false if used with a slider)
const showName = props.showName !== undefined ? props.showName : true;
// If emitUpdate is passed, use this instead of the emit_update from the socket
// Also used when used with a slider
const emitUpdate =
props.customEmitUpdate !== undefined ? props.customEmitUpdate : setAttribute;
const renderCount = useRef(0);
// Create a state for the cursor position
const [cursorPosition, setCursorPosition] = useState(null);
// Create a state for the input string
const [inputString, setInputString] = useState(props.value.toString());
const fullAccessPath = parentPath.concat('.' + name);
const id = getIdFromFullAccessPath(fullAccessPath);
const [inputString, setInputString] = useState(value.toString());
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++;
// Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(
fullAccessPath
)[0] as HTMLInputElement;
if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange(cursorPosition, cursorPosition);
}
});
useEffect(() => {
// Parse the input string to a number for comparison
const numericInputString =
props.type === 'int' ? parseInt(inputString) : parseFloat(inputString);
// Only update the inputString if it's different from the prop value
if (props.value !== numericInputString) {
setInputString(props.value.toString());
}
// emitting notification
let notificationMsg = `${parentPath}.${name} changed to ${props.value}`;
if (unit === undefined) {
notificationMsg += '.';
} else {
notificationMsg += ` ${unit}.`;
}
addNotification(notificationMsg);
}, [props.value]);
const handleNumericKey = (
key: string,
value: string,
selectionStart: number,
selectionEnd: number
) => {
// Check if a number key or a decimal point key is pressed
if (key === '.' && (value.includes('.') || props.type === 'int')) {
// Check if value already contains a decimal. If so, ignore input.
// eslint-disable-next-line no-console
console.warn('Invalid input! Ignoring...');
return { value, selectionStart };
}
let newValue = value;
// Add the new key at the cursor's position
if (selectionEnd > selectionStart) {
// If there is a selection, replace it with the key
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
} else {
// otherwise, append the key after the selection start
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
}
return { value: newValue, selectionStart: selectionStart + 1 };
};
const handleKeyDown = (event) => {
const { key, target } = event;
if (
@@ -235,7 +217,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
selectionStart,
selectionEnd
));
} else if (key === '.') {
} else if (key === '.' && (type === 'float' || type === 'Quantity')) {
({ value: newValue, selectionStart } = handleNumericKey(
key,
value,
@@ -262,7 +244,20 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
selectionEnd
));
} else if (key === 'Enter' && !isInstantUpdate) {
emitUpdate(name, parentPath, Number(newValue));
let updatedValue: number | Record<string, unknown> = Number(newValue);
if (type === 'Quantity') {
updatedValue = {
magnitude: Number(newValue),
unit: unit
};
}
changeCallback({
type: type,
value: updatedValue,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
return;
} else {
console.debug(key);
@@ -271,7 +266,20 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// Update the input value and maintain the cursor position
if (isInstantUpdate) {
emitUpdate(name, parentPath, Number(newValue));
let updatedValue: number | Record<string, unknown> = Number(newValue);
if (type === 'Quantity') {
updatedValue = {
magnitude: Number(newValue),
unit: unit
};
}
changeCallback({
type: type,
value: updatedValue,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
}
setInputString(newValue);
@@ -283,31 +291,73 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
const handleBlur = () => {
if (!isInstantUpdate) {
// If not in "instant update" mode, emit an update when the input field loses focus
emitUpdate(name, parentPath, Number(inputString));
let updatedValue: number | Record<string, unknown> = Number(inputString);
if (type === 'Quantity') {
updatedValue = {
magnitude: Number(inputString),
unit: unit
};
}
changeCallback({
type: type,
value: updatedValue,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
}
};
useEffect(() => {
// Parse the input string to a number for comparison
const numericInputString =
type === 'int' ? parseInt(inputString) : parseFloat(inputString);
// Only update the inputString if it's different from the prop value
if (value !== numericInputString) {
setInputString(value.toString());
}
// emitting notification
let notificationMsg = `${fullAccessPath} changed to ${props.value}`;
if (unit === undefined) {
notificationMsg += '.';
} else {
notificationMsg += ` ${unit}.`;
}
addNotification(notificationMsg);
}, [value]);
useEffect(() => {
// Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(id)[0] as HTMLInputElement;
if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange(cursorPosition, cursorPosition);
}
});
return (
<div className="numberComponent" id={id}>
{process.env.NODE_ENV === 'development' && showName && (
<p>Render count: {renderCount.current}</p>
<div className="component numberComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} />
<div className="d-flex">
<InputGroup>
{showName && <InputGroup.Text>{name}</InputGroup.Text>}
<Form.Control
type="text"
value={inputString}
disabled={readOnly}
name={fullAccessPath}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
/>
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
</InputGroup>
</div>
<InputGroup>
{displayName && (
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
)}
<Form.Control
type="text"
value={inputString}
disabled={readOnly}
onChange={() => {}}
name={id}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={isInstantUpdate && !readOnly ? 'instantUpdate' : ''}
/>
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
</InputGroup>
</div>
);
});

View File

@@ -1,148 +1,155 @@
import React, { useEffect, useRef, useState } from 'react';
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { Slider } from '@mui/material';
import { NumberComponent } from './NumberComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { NumberComponent, NumberObject } from './NumberComponent';
import { LevelName } from './NotificationsComponent';
import { SerializedValue } from './GenericComponent';
interface SliderComponentProps {
name: string;
min: number;
max: number;
parentPath?: string;
value: number;
type SliderComponentProps = {
fullAccessPath: string;
min: NumberObject;
max: NumberObject;
value: NumberObject;
readOnly: boolean;
docString: string;
stepSize: number;
stepSize: NumberObject;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName: string;
id: string;
};
export const SliderComponent = React.memo((props: SliderComponentProps) => {
const renderCount = useRef(0);
const [open, setOpen] = useState(false);
const {
name,
parentPath,
fullAccessPath,
value,
min,
max,
stepSize,
readOnly,
docString,
isInstantUpdate,
addNotification
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
const fullAccessPath = parentPath.concat('.' + name);
const id = getIdFromFullAccessPath(fullAccessPath);
useEffect(() => {
renderCount.current++;
});
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
addNotification(`${fullAccessPath} changed to ${value.value}.`);
}, [props.value]);
useEffect(() => {
addNotification(`${parentPath}.${name}.min changed to ${min}.`);
addNotification(`${fullAccessPath}.min changed to ${min.value}.`);
}, [props.min]);
useEffect(() => {
addNotification(`${parentPath}.${name}.max changed to ${max}.`);
addNotification(`${fullAccessPath}.max changed to ${max.value}.`);
}, [props.max]);
useEffect(() => {
addNotification(`${parentPath}.${name}.stepSize changed to ${stepSize}.`);
addNotification(`${fullAccessPath}.stepSize changed to ${stepSize.value}.`);
}, [props.stepSize]);
const emitSliderUpdate = (
name: string,
parentPath: string,
value: number,
callback?: (ack: unknown) => void,
min: number = props.min,
max: number = props.max,
stepSize: number = props.stepSize
) => {
setAttribute(
name,
parentPath,
{
value: value,
min: min,
max: max,
step_size: stepSize
},
callback
);
};
const handleOnChange = (event, newNumber: number | number[]) => {
// This will never be the case as we do not have a range slider. However, we should
// make sure this is properly handled.
if (Array.isArray(newNumber)) {
newNumber = newNumber[0];
}
emitSliderUpdate(name, parentPath, newNumber);
changeCallback({
type: value.type,
value: newNumber,
full_access_path: `${fullAccessPath}.value`,
readonly: value.readonly,
doc: docString
});
};
const handleValueChange = (newValue: number, valueType: string) => {
switch (valueType) {
case 'min':
emitSliderUpdate(name, parentPath, value, undefined, newValue);
break;
case 'max':
emitSliderUpdate(name, parentPath, value, undefined, min, newValue);
break;
case 'stepSize':
emitSliderUpdate(name, parentPath, value, undefined, min, max, newValue);
break;
default:
break;
}
const handleValueChange = (
newValue: number,
name: string,
valueObject: NumberObject
) => {
changeCallback({
type: valueObject.type,
value: newValue,
full_access_path: `${fullAccessPath}.${name}`,
readonly: valueObject.readonly
});
};
const deconstructNumberDict = (
numberDict: NumberObject
): [number, boolean, string | null] => {
let numberMagnitude: number;
let numberUnit: string | null = null;
const numberReadOnly = numberDict.readonly;
if (numberDict.type === 'int' || numberDict.type === 'float') {
numberMagnitude = numberDict.value;
} else if (numberDict.type === 'Quantity') {
numberMagnitude = numberDict.value.magnitude;
numberUnit = numberDict.value.unit;
}
return [numberMagnitude, numberReadOnly, numberUnit];
};
const [valueMagnitude, valueReadOnly, valueUnit] = deconstructNumberDict(value);
const [minMagnitude, minReadOnly] = deconstructNumberDict(min);
const [maxMagnitude, maxReadOnly] = deconstructNumberDict(max);
const [stepSizeMagnitude, stepSizeReadOnly] = deconstructNumberDict(stepSize);
return (
<div className="sliderComponent" id={id}>
<div className="component sliderComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} />
<Row>
<Col xs="auto" xl="auto">
<InputGroup.Text>{name}</InputGroup.Text>
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
</Col>
<Col xs="5" xl>
<Slider
style={{ margin: '0px 0px 10px 0px' }}
aria-label="Always visible"
// valueLabelDisplay="on"
disabled={readOnly}
value={value}
disabled={valueReadOnly}
value={valueMagnitude}
onChange={(event, newNumber) => handleOnChange(event, newNumber)}
min={min}
max={max}
step={stepSize}
min={minMagnitude}
max={maxMagnitude}
step={stepSizeMagnitude}
marks={[
{ value: min, label: `${min}` },
{ value: max, label: `${max}` }
{ value: minMagnitude, label: `${minMagnitude}` },
{ value: maxMagnitude, label: `${maxMagnitude}` }
]}
/>
</Col>
<Col xs="3" xl>
<NumberComponent
isInstantUpdate={isInstantUpdate}
parentPath={parentPath}
name={name}
docString=""
readOnly={readOnly}
fullAccessPath={`${fullAccessPath}.value`}
docString={docString}
readOnly={valueReadOnly}
type="float"
value={value}
showName={false}
customEmitUpdate={emitSliderUpdate}
addNotification={() => null}
value={valueMagnitude}
unit={valueUnit}
addNotification={() => {}}
changeCallback={changeCallback}
id={id + '-value'}
/>
</Col>
<Col xs="auto">
@@ -177,8 +184,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Min Value</Form.Label>
<Form.Control
type="number"
value={min}
onChange={(e) => handleValueChange(Number(e.target.value), 'min')}
value={minMagnitude}
disabled={minReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), 'min', min)}
/>
</Col>
@@ -186,8 +194,9 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Max Value</Form.Label>
<Form.Control
type="number"
value={max}
onChange={(e) => handleValueChange(Number(e.target.value), 'max')}
value={maxMagnitude}
disabled={maxReadOnly}
onChange={(e) => handleValueChange(Number(e.target.value), 'max', max)}
/>
</Col>
@@ -195,8 +204,11 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
<Form.Label>Step Size</Form.Label>
<Form.Control
type="number"
value={stepSize}
onChange={(e) => handleValueChange(Number(e.target.value), 'stepSize')}
value={stepSizeMagnitude}
disabled={stepSizeReadOnly}
onChange={(e) =>
handleValueChange(Number(e.target.value), 'step_size', stepSize)
}
/>
</Col>
</Row>

View File

@@ -1,30 +1,38 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, InputGroup } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
import { SerializedValue } from './GenericComponent';
// TODO: add button functionality
interface StringComponentProps {
name: string;
parentPath?: string;
type StringComponentProps = {
fullAccessPath: string;
value: string;
readOnly: boolean;
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
}
addNotification: (message: string, levelname?: LevelName) => void;
changeCallback?: (value: SerializedValue, callback?: (ack: unknown) => void) => void;
displayName: string;
id: string;
};
export const StringComponent = React.memo((props: StringComponentProps) => {
const { name, parentPath, readOnly, docString, isInstantUpdate, addNotification } =
props;
const {
fullAccessPath,
readOnly,
docString,
isInstantUpdate,
addNotification,
changeCallback = () => {},
displayName,
id
} = props;
const renderCount = useRef(0);
const [inputString, setInputString] = useState(props.value);
const fullAccessPath = parentPath.concat('.' + name);
const id = getIdFromFullAccessPath(fullAccessPath);
useEffect(() => {
renderCount.current++;
@@ -35,41 +43,56 @@ export const StringComponent = React.memo((props: StringComponentProps) => {
if (props.value !== inputString) {
setInputString(props.value);
}
addNotification(`${parentPath}.${name} changed to ${props.value}.`);
addNotification(`${fullAccessPath} changed to ${props.value}.`);
}, [props.value]);
const handleChange = (event) => {
setInputString(event.target.value);
if (isInstantUpdate) {
setAttribute(name, parentPath, event.target.value);
changeCallback(event.target.value);
}
};
const handleKeyDown = (event) => {
if (event.key === 'Enter' && !isInstantUpdate) {
setAttribute(name, parentPath, inputString);
changeCallback({
type: 'str',
value: inputString,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
event.preventDefault();
}
};
const handleBlur = () => {
if (!isInstantUpdate) {
setAttribute(name, parentPath, inputString);
changeCallback({
type: 'str',
value: inputString,
full_access_path: fullAccessPath,
readonly: readOnly,
doc: docString
});
}
};
return (
<div className={'stringComponent'} id={id}>
<div className="component stringComponent" id={id}>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
<div>Render count: {renderCount.current}</div>
)}
<DocStringComponent docString={docString} />
<InputGroup>
<InputGroup.Text>{name}</InputGroup.Text>
<InputGroup.Text>
{displayName}
<DocStringComponent docString={docString} />
</InputGroup.Text>
<Form.Control
type="text"
name={id}
value={inputString}
disabled={readOnly}
name={name}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}

View File

@@ -1,4 +1,6 @@
import { io } from 'socket.io-client';
import { SerializedValue } from './components/GenericComponent';
import { serializeDict, serializeList } from './utils/serializationUtils';
export const hostname =
process.env.NODE_ENV === 'development' ? `localhost` : window.location.hostname;
@@ -9,28 +11,44 @@ console.debug('Websocket: ', URL);
export const socket = io(URL, { path: '/ws/socket.io', transports: ['websocket'] });
export const setAttribute = (
name: string,
parentPath: string,
value: unknown,
export const updateValue = (
serializedObject: SerializedValue,
callback?: (ack: unknown) => void
) => {
if (callback) {
socket.emit('set_attribute', { name, parent_path: parentPath, value }, callback);
socket.emit(
'update_value',
{ access_path: serializedObject['full_access_path'], value: serializedObject },
callback
);
} else {
socket.emit('set_attribute', { name, parent_path: parentPath, value });
socket.emit('update_value', {
access_path: serializedObject['full_access_path'],
value: serializedObject
});
}
};
export const runMethod = (
name: string,
parentPath: string,
kwargs: Record<string, unknown>,
accessPath: string,
args: unknown[] = [],
kwargs: Record<string, unknown> = {},
callback?: (ack: unknown) => void
) => {
const serializedArgs = serializeList(args);
const serializedKwargs = serializeDict(kwargs);
if (callback) {
socket.emit('run_method', { name, parent_path: parentPath, kwargs }, callback);
socket.emit(
'trigger_method',
{ access_path: accessPath, args: serializedArgs, kwargs: serializedKwargs },
callback
);
} else {
socket.emit('run_method', { name, parent_path: parentPath, kwargs });
socket.emit('trigger_method', {
access_path: accessPath,
args: serializedArgs,
kwargs: serializedKwargs
});
}
};

View File

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

View File

@@ -0,0 +1,172 @@
import { SerializedValue } from '../components/GenericComponent';
export type State = {
type: string;
value: Record<string, SerializedValue> | null;
readonly: boolean;
doc: string | null;
};
/**
* Splits a full access path into its atomic parts, separating attribute names, numeric
* indices (including floating points), and string keys within indices.
*
* @param path The full access path string to be split into components.
* @returns An array of components that make up the path, including attribute names,
* 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);
return matches ?? []; // Return an empty array if no matches found
}
/**
* 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);
}
// Strip quotes if the resulting string is quoted
if (
(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, SerializedValue> | SerializedValue[],
key: string | number,
allowAddKey: boolean
): SerializedValue {
// Check if the key exists and return the item if it does
if (key in container) {
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, SerializedValue> | SerializedValue[],
key: string,
allowAppend: boolean = false
): SerializedValue {
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, SerializedValue>,
path: string,
serializedValue: SerializedValue
): Record<string, SerializedValue> {
const pathParts = parseFullAccessPath(path);
const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
JSON.stringify(serializationDict)
);
let currentDict = newSerializationDict;
try {
for (let i = 0; i < pathParts.length - 1; i++) {
const pathPart = pathParts[i];
const nextLevelSerializedObject = getContainerItemByKey(
currentDict,
pathPart,
false
);
currentDict = nextLevelSerializedObject['value'] as Record<
string,
SerializedValue
>;
}
const finalPart = pathParts[pathParts.length - 1];
const finalObject = getContainerItemByKey(currentDict, finalPart, true);
Object.assign(finalObject, serializedValue);
return newSerializationDict;
} catch (error) {
console.error(`Error occurred trying to change ${path}: ${error}`);
}
}
function createEmptySerializedObject(): SerializedValue {
return {
full_access_path: '',
value: undefined,
type: 'None',
doc: null,
readonly: false
};
}

View File

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

View File

@@ -10,6 +10,7 @@ nav:
- Developer Guide: dev-guide/README.md
- API Reference: dev-guide/api.md
- Adding Components: dev-guide/Adding_Components.md
- Observer Pattern Implementation: dev-guide/Observer_Pattern_Implementation.md # <-- New section
- About:
- Release Notes: about/release-notes.md
- Contributing: about/contributing.md

2095
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
[virtualenvs]
in-project = true

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydase"
version = "0.3.0"
version = "0.8.2"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md"
@@ -9,34 +9,32 @@ packages = [{ include = "pydase", from = "src" }]
[tool.poetry.dependencies]
python = "^3.10"
rpyc = "^5.3.1"
fastapi = "^0.100.0"
uvicorn = "^0.22.0"
fastapi = "^0.108.0"
uvicorn = "^0.27.0"
toml = "^0.10.2"
python-socketio = "^5.8.0"
websockets = "^11.0.3"
confz = "^2.0.0"
pint = "^0.22"
pillow = "^10.0.0"
websocket-client = "^1.7.0"
aiohttp = "^3.9.3"
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
types-toml = "^0.10.8.6"
pytest = "^7.4.0"
pytest-cov = "^4.1.0"
mypy = "^1.4.1"
black = "^23.1.0"
isort = "^5.12.0"
flake8 = "^5.0.4"
flake8-use-fstring = "^1.4"
flake8-functions = "^0.0.7"
flake8-comprehensions = "^3.11.1"
flake8-pep585 = "^0.1.7"
flake8-pep604 = "^0.1.0"
flake8-eradicate = "^1.4.0"
matplotlib = "^3.7.2"
pyright = "^1.1.323"
pytest-mock = "^3.11.1"
ruff = "^0.2.0"
pytest-asyncio = "^0.23.2"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.2"
@@ -48,39 +46,60 @@ pymdown-extensions = "^10.1"
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
target-version = "py310" # Always generate Python 3.10-compatible code
extend-exclude = [
"docs", "frontend"
]
[tool.ruff.lint]
select = [
"ASYNC", # flake8-async
"C4", # flake8-comprehensions
"C901", # mccabe complex-structure
"E", # pycodestyle errors
"ERA", # eradicate
"F", # pyflakes
"FLY", # flynt
"G", # flake8-logging-format
"I", # isort
"ICN", # flake8-import-conventions
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"N", # pep8-naming
"NPY", # NumPy-specific rules
"PERF", # perflint
"PIE", # flake8-pie
"PL", # pylint
"PYI", # flake8-pyi
"Q", # flake8-quotes
"RET", # flake8-return
"RUF", # Ruff-specific rules
"SIM", # flake8-simplify
"TID", # flake8-tidy-imports
"TCH", # flake8-type-checking
"UP", # pyupgrade
"YTT", # flake8-2020
"W", # pycodestyle warnings
]
ignore = [
"RUF006", # asyncio-dangling-task
"PERF203", # try-except-in-loop
]
[tool.ruff.lint.mccabe]
max-complexity = 7
[tool.pyright]
include = ["src/pydase"]
exclude = ["**/node_modules", "**/__pycache__", "docs", "frontend", "tests"]
venvPath = "."
venv = ".venv"
typeCheckingMode = "basic"
reportUnknownMemberType = true
reportUnknownParameterType = true
[tool.black]
line-length = 88
exclude = '''
/(
\.git
| \.mypy_cache
| \.tox
| venv
| \.venv
| _build
| buck-out
| build
| dist
)/
'''
[tool.isort]
profile = "black"
[tool.mypy]
mypy_path = "src/"
show_error_codes = true
disallow_untyped_defs = true
disallow_untyped_calls = true
disallow_incomplete_defs = true
disallow_any_generics = true
check_untyped_defs = true
ignore_missing_imports = false

View File

@@ -1,3 +1,4 @@
from pydase.client.client import Client
from pydase.data_service import DataService
from pydase.server import Server
from pydase.utils.logging import setup_logging
@@ -7,4 +8,5 @@ setup_logging()
__all__ = [
"DataService",
"Server",
"Client",
]

View File

@@ -0,0 +1,3 @@
from pydase.client.client import Client
__all__ = ["Client"]

151
src/pydase/client/client.py Normal file
View File

@@ -0,0 +1,151 @@
import asyncio
import logging
import threading
from typing import TypedDict, cast
import socketio # type: ignore
import pydase.components
from pydase.client.proxy_loader import ProxyClassMixin, ProxyLoader
from pydase.utils.serialization.deserializer import loads
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
logger = logging.getLogger(__name__)
class NotifyDataDict(TypedDict):
full_access_path: str
value: SerializedObject
class NotifyDict(TypedDict):
data: NotifyDataDict
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
asyncio.set_event_loop(loop)
loop.run_forever()
class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
"""
A proxy class that serves as the interface for interacting with device connections
via a socket.io client in an asyncio environment.
Args:
sio_client (socketio.AsyncClient):
The socket.io client instance used for asynchronous communication with the
pydase service server.
loop (asyncio.AbstractEventLoop):
The event loop in which the client operations are managed and executed.
This class is used to create a proxy object that behaves like a local representation
of a remote pydase service, facilitating direct interaction as if it were local
while actually communicating over network protocols.
It can also be used as an attribute of a pydase service itself, e.g.
```python
import pydase
class MyService(pydase.DataService):
proxy = pydase.Client(
hostname="...", port=8001, block_until_connected=False
).proxy
if __name__ == "__main__":
service = MyService()
server = pydase.Server(service, web_port=8002).run()
```
"""
def __init__(
self, sio_client: socketio.AsyncClient, loop: asyncio.AbstractEventLoop
) -> None:
super().__init__()
self._initialise(sio_client=sio_client, loop=loop)
class Client:
"""
A client for connecting to a remote pydase service using socket.io. This client
handles asynchronous communication with a service, manages events such as
connection, disconnection, and updates, and ensures that the proxy object is
up-to-date with the server state.
Attributes:
proxy (ProxyClass):
A proxy object representing the remote service, facilitating interaction as
if it were local.
Args:
hostname (str):
Hostname of the exposed service this client attempts to connect to.
Default is "localhost".
port (int):
Port of the exposed service this client attempts to connect on.
Default is 8001.
block_until_connected (bool):
If set to True, the constructor will block until the connection to the
service has been established. This is useful for ensuring the client is
ready to use immediately after instantiation. Default is True.
"""
def __init__(
self,
hostname: str,
port: int,
block_until_connected: bool = True,
):
self._hostname = hostname
self._port = port
self._sio = socketio.AsyncClient()
self._loop = asyncio.new_event_loop()
self.proxy = ProxyClass(sio_client=self._sio, loop=self._loop)
self._thread = threading.Thread(
target=asyncio_loop_thread, args=(self._loop,), daemon=True
)
self._thread.start()
connection_future = asyncio.run_coroutine_threadsafe(
self._connect(), self._loop
)
if block_until_connected:
connection_future.result()
async def _connect(self) -> None:
logger.debug("Connecting to server '%s:%s' ...", self._hostname, self._port)
await self._setup_events()
await self._sio.connect(
f"ws://{self._hostname}:{self._port}",
socketio_path="/ws/socket.io",
transports=["websocket"],
retry=True,
)
async def _setup_events(self) -> None:
self._sio.on("connect", self._handle_connect)
self._sio.on("disconnect", self._handle_disconnect)
self._sio.on("notify", self._handle_update)
async def _handle_connect(self) -> None:
logger.debug("Connected to '%s:%s' ...", self._hostname, self._port)
serialized_object = cast(
SerializedDataService, await self._sio.call("service_serialization")
)
ProxyLoader.update_data_service_proxy(
self.proxy, serialized_object=serialized_object
)
serialized_object["type"] = "DeviceConnection"
self.proxy._notify_changed("", loads(serialized_object))
self.proxy._connected = True
async def _handle_disconnect(self) -> None:
logger.debug("Disconnected from '%s:%s' ...", self._hostname, self._port)
self.proxy._connected = False
async def _handle_update(self, data: NotifyDict) -> None:
self.proxy._notify_changed(
data["data"]["full_access_path"],
loads(data["data"]["value"]),
)

View File

@@ -0,0 +1,409 @@
import asyncio
import logging
from collections.abc import Iterable
from copy import copy
from typing import TYPE_CHECKING, Any, cast
import socketio # type: ignore
from typing_extensions import SupportsIndex
from pydase.utils.serialization.deserializer import Deserializer, loads
from pydase.utils.serialization.serializer import dump
from pydase.utils.serialization.types import SerializedObject
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
class ProxyAttributeError(Exception): ...
def trigger_method(
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
access_path: str,
args: list[Any],
kwargs: dict[str, Any],
) -> Any:
async def async_trigger_method() -> Any:
return await sio_client.call(
"trigger_method",
{
"access_path": access_path,
"args": dump(args),
"kwargs": dump(kwargs),
},
)
result: SerializedObject | None = asyncio.run_coroutine_threadsafe(
async_trigger_method(),
loop=loop,
).result()
if result is not None:
return ProxyLoader.loads_proxy(
serialized_object=result, sio_client=sio_client, loop=loop
)
return None
def update_value(
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
access_path: str,
value: Any,
) -> Any:
async def set_result() -> Any:
return await sio_client.call(
"update_value",
{
"access_path": access_path,
"value": dump(value),
},
)
result: SerializedObject | None = asyncio.run_coroutine_threadsafe(
set_result(),
loop=loop,
).result()
if result is not None:
ProxyLoader.loads_proxy(
serialized_object=result, sio_client=sio_client, loop=loop
)
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]):
def __init__(
self,
original_list: list[Any],
parent_path: str,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> None:
super().__init__(original_list)
self._parent_path = parent_path
self._loop = loop
self._sio = sio_client
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
full_access_path = f"{self._parent_path}[{key}]"
update_value(self._sio, self._loop, full_access_path, value)
def append(self, __object: Any) -> None:
full_access_path = f"{self._parent_path}.append"
trigger_method(self._sio, self._loop, full_access_path, [__object], {})
def clear(self) -> None:
full_access_path = f"{self._parent_path}.clear"
trigger_method(self._sio, self._loop, full_access_path, [], {})
def extend(self, __iterable: Iterable[Any]) -> None:
full_access_path = f"{self._parent_path}.extend"
trigger_method(self._sio, self._loop, full_access_path, [__iterable], {})
def insert(self, __index: SupportsIndex, __object: Any) -> None:
full_access_path = f"{self._parent_path}.insert"
trigger_method(self._sio, self._loop, full_access_path, [__index, __object], {})
def pop(self, __index: SupportsIndex = -1) -> Any:
full_access_path = f"{self._parent_path}.pop"
return trigger_method(self._sio, self._loop, full_access_path, [__index], {})
def remove(self, __value: Any) -> None:
full_access_path = f"{self._parent_path}.remove"
trigger_method(self._sio, self._loop, full_access_path, [__value], {})
class ProxyClassMixin:
def __init__(self) -> None:
# declare before DataService init to avoid warning messaged
self._observers: dict[str, Any] = {}
self._proxy_getters: dict[str, Callable[..., Any]] = {}
self._proxy_setters: dict[str, Callable[..., Any]] = {}
self._proxy_methods: dict[str, Callable[..., Any]] = {}
def _initialise(
self,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> None:
self._loop = loop
self._sio = sio_client
def __dir__(self) -> list[str]:
"""Used to provide tab completion on CLI / notebook"""
static_dir = super().__dir__()
return sorted({*static_dir, *self._proxy_getters, *self._proxy_methods.keys()})
def __getattribute__(self, name: str) -> Any:
try:
if name in super().__getattribute__("_proxy_getters"):
return super().__getattribute__("_proxy_getters")[name]()
if name in super().__getattribute__("_proxy_methods"):
return super().__getattribute__("_proxy_methods")[name]
except AttributeError:
pass
return super().__getattribute__(name)
def __setattr__(self, name: str, value: Any) -> None:
try:
if name in super().__getattribute__("_proxy_setters"):
return super().__getattribute__("_proxy_setters")[name](value)
if name in super().__getattribute__("_proxy_getters"):
raise ProxyAttributeError(
f"Proxy attribute {name!r} of {type(self).__name__!r} is readonly!"
)
except AttributeError:
pass
return super().__setattr__(name, value)
def _handle_serialized_method(
self, attr_name: str, serialized_object: SerializedObject
) -> None:
def add_prefix_to_last_path_element(s: str, prefix: str) -> str:
parts = s.split(".")
parts[-1] = f"{prefix}_{parts[-1]}"
return ".".join(parts)
if serialized_object["type"] == "method":
if serialized_object["async"] is True:
start_method = copy(serialized_object)
start_method["full_access_path"] = add_prefix_to_last_path_element(
start_method["full_access_path"], "start"
)
stop_method = copy(serialized_object)
stop_method["full_access_path"] = add_prefix_to_last_path_element(
stop_method["full_access_path"], "stop"
)
self._add_method_proxy(f"start_{attr_name}", start_method)
self._add_method_proxy(f"stop_{attr_name}", stop_method)
else:
self._add_method_proxy(attr_name, serialized_object)
def _add_method_proxy(
self, attr_name: str, serialized_object: SerializedObject
) -> None:
def method_proxy(*args: Any, **kwargs: Any) -> Any:
return trigger_method(
self._sio,
self._loop,
serialized_object["full_access_path"],
list(args),
kwargs,
)
dict.__setitem__(self._proxy_methods, attr_name, method_proxy)
def _add_attr_proxy(
self, attr_name: str, serialized_object: SerializedObject
) -> None:
self._add_getattr_proxy(attr_name, serialized_object=serialized_object)
if not serialized_object["readonly"]:
self._add_setattr_proxy(attr_name, serialized_object=serialized_object)
def _add_setattr_proxy(
self, attr_name: str, serialized_object: SerializedObject
) -> None:
self._add_getattr_proxy(attr_name, serialized_object=serialized_object)
if not serialized_object["readonly"]:
def setter_proxy(value: Any) -> None:
update_value(
self._sio, self._loop, serialized_object["full_access_path"], value
)
dict.__setitem__(self._proxy_setters, attr_name, setter_proxy) # type: ignore
def _add_getattr_proxy(
self, attr_name: str, serialized_object: SerializedObject
) -> None:
def getter_proxy() -> Any:
async def get_result() -> Any:
return await self._sio.call(
"get_value", serialized_object["full_access_path"]
)
result = asyncio.run_coroutine_threadsafe(
get_result(),
loop=self._loop,
).result()
return ProxyLoader.loads_proxy(result, self._sio, self._loop)
dict.__setitem__(self._proxy_getters, attr_name, getter_proxy) # type: ignore
class ProxyLoader:
@staticmethod
def load_list_proxy(
serialized_object: SerializedObject,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> Any:
return ProxyList(
[
ProxyLoader.loads_proxy(item, sio_client, loop)
for item in cast(list[SerializedObject], serialized_object["value"])
],
parent_path=serialized_object["full_access_path"],
sio_client=sio_client,
loop=loop,
)
@staticmethod
def load_dict_proxy(
serialized_object: SerializedObject,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> Any:
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
def update_data_service_proxy(
proxy_class: ProxyClassMixin,
serialized_object: SerializedObject,
) -> Any:
proxy_class._proxy_getters.clear()
proxy_class._proxy_setters.clear()
proxy_class._proxy_methods.clear()
for key, value in cast(
dict[str, SerializedObject], serialized_object["value"]
).items():
type_handler: dict[str | None, None | Callable[..., Any]] = {
None: None,
"int": proxy_class._add_attr_proxy,
"float": proxy_class._add_attr_proxy,
"bool": proxy_class._add_attr_proxy,
"str": proxy_class._add_attr_proxy,
"NoneType": proxy_class._add_attr_proxy,
"Quantity": proxy_class._add_attr_proxy,
"Enum": proxy_class._add_attr_proxy,
"ColouredEnum": proxy_class._add_attr_proxy,
"method": proxy_class._handle_serialized_method,
"list": proxy_class._add_getattr_proxy,
"dict": proxy_class._add_getattr_proxy,
}
# First go through handled types (as ColouredEnum is also within the
# components)
handler = type_handler.get(value["type"])
if handler:
handler(key, value)
else:
proxy_class._add_getattr_proxy(key, value)
@staticmethod
def load_data_service_proxy(
serialized_object: SerializedObject,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> Any:
# Custom types like Components or DataService classes
component_class = cast(
type, Deserializer.get_component_class(serialized_object["type"])
)
class_bases = (
ProxyClassMixin,
component_class,
)
proxy_base_class: type[ProxyClassMixin] = type(
serialized_object["name"], # type: ignore
class_bases,
{},
)
proxy_class_instance = proxy_base_class()
proxy_class_instance._initialise(sio_client=sio_client, loop=loop)
ProxyLoader.update_data_service_proxy(
proxy_class=proxy_class_instance, serialized_object=serialized_object
)
return proxy_class_instance
@staticmethod
def load_default(
serialized_object: SerializedObject,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> Any:
return loads(serialized_object)
@staticmethod
def loads_proxy(
serialized_object: SerializedObject,
sio_client: socketio.AsyncClient,
loop: asyncio.AbstractEventLoop,
) -> Any:
type_handler: dict[str | None, None | Callable[..., Any]] = {
"int": ProxyLoader.load_default,
"float": ProxyLoader.load_default,
"bool": ProxyLoader.load_default,
"str": ProxyLoader.load_default,
"NoneType": ProxyLoader.load_default,
"Quantity": ProxyLoader.load_default,
"Enum": ProxyLoader.load_default,
"ColouredEnum": ProxyLoader.load_default,
"Exception": ProxyLoader.load_default,
"list": ProxyLoader.load_list_proxy,
"dict": ProxyLoader.load_dict_proxy,
}
# First go through handled types (as ColouredEnum is also within the components)
handler = type_handler.get(serialized_object["type"])
if handler:
return handler(
serialized_object=serialized_object, sio_client=sio_client, loop=loop
)
return ProxyLoader.load_data_service_proxy(
serialized_object=serialized_object, sio_client=sio_client, loop=loop
)

View File

@@ -28,6 +28,7 @@ print(my_service.voltage.value) # Output: 5
"""
from pydase.components.coloured_enum import ColouredEnum
from pydase.components.device_connection import DeviceConnection
from pydase.components.image import Image
from pydase.components.number_slider import NumberSlider
@@ -35,4 +36,5 @@ __all__ = [
"NumberSlider",
"Image",
"ColouredEnum",
"DeviceConnection",
]

View File

@@ -56,6 +56,9 @@ class ColouredEnum(Enum):
my_service = StatusExample()
my_service.status = MyStatus.FAILED
```
"""
pass
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

@@ -0,0 +1,77 @@
import asyncio
import pydase.data_service
class DeviceConnection(pydase.data_service.DataService):
"""
Base class for device connection management within the pydase framework.
This class serves as the foundation for subclasses that manage connections to
specific devices. It implements automatic reconnection logic that periodically
checks the device's availability and attempts to reconnect if the connection is
lost. The frequency of these checks is controlled by the `_reconnection_wait_time`
attribute.
Subclassing
-----------
Users should primarily override the `connect` method to establish a connection
to the device. This method should update the `self._connected` attribute to reflect
the connection status:
>>> class MyDeviceConnection(DeviceConnection):
... def connect(self) -> None:
... # Implementation to connect to the device
... # Update self._connected to `True` if connection is successful,
... # `False` otherwise
... ...
Optionally, if additional logic is needed to determine the connection status,
the `connected` property can also be overridden:
>>> class MyDeviceConnection(DeviceConnection):
... @property
... def connected(self) -> bool:
... # Custom logic to determine connection status
... return some_custom_condition
...
Frontend Representation
-----------------------
In the frontend, this class is represented without directly exposing the `connect`
method and `connected` attribute. Instead, user-defined attributes, methods, and
properties are displayed. When `self.connected` is `False`, the frontend component
shows an overlay that allows manual triggering of the `connect()` method. This
overlay disappears once the connection is successfully re-established.
"""
def __init__(self) -> None:
super().__init__()
self._connected = False
self._autostart_tasks["_handle_connection"] = () # type: ignore
self._reconnection_wait_time = 10.0
def connect(self) -> None:
"""Tries connecting to the device and changes `self._connected` status
accordingly. This method is called every `self._reconnection_wait_time` seconds
when `self.connected` is False. Users should override this method to implement
device-specific connection logic.
"""
@property
def connected(self) -> bool:
"""Indicates if the device is currently connected or was recently connected.
Users may override this property to incorporate custom logic for determining
the connection status.
"""
return self._connected
async def _handle_connection(self) -> None:
"""Automatically tries reconnecting to the device if it is not connected.
This method leverages the `connect` method and the `connected` property to
manage the connection status.
"""
while True:
if not self.connected:
self.connect()
await asyncio.sleep(self._reconnection_wait_time)

View File

@@ -2,10 +2,10 @@ import base64
import io
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
from urllib.request import urlopen
import PIL.Image # type: ignore
import PIL.Image # type: ignore[import-untyped]
from pydase.data_service.data_service import DataService
@@ -19,9 +19,9 @@ class Image(DataService):
def __init__(
self,
) -> None:
super().__init__()
self._value: str = ""
self._format: str = ""
super().__init__()
@property
def value(self) -> str:
@@ -33,19 +33,19 @@ class Image(DataService):
def load_from_path(self, path: Path | str) -> None:
with PIL.Image.open(path) as image:
self._load_from_PIL(image)
self._load_from_pil(image)
def load_from_matplotlib_figure(self, fig: "Figure", format_: str = "png") -> None:
buffer = io.BytesIO()
fig.savefig(buffer, format=format_) # type: ignore
fig.savefig(buffer, format=format_)
value_ = base64.b64encode(buffer.getvalue())
self._load_from_base64(value_, format_)
def load_from_url(self, url: str) -> None:
image = PIL.Image.open(urlopen(url))
self._load_from_PIL(image)
self._load_from_pil(image)
def load_from_base64(self, value_: bytes, format_: Optional[str] = None) -> None:
def load_from_base64(self, value_: bytes, format_: str | None = None) -> None:
if format_ is None:
format_ = self._get_image_format_from_bytes(value_)
if format_ is None:
@@ -60,7 +60,7 @@ class Image(DataService):
self._value = value
self._format = format_
def _load_from_PIL(self, image: PIL.Image.Image) -> None:
def _load_from_pil(self, image: PIL.Image.Image) -> None:
if image.format is not None:
format_ = image.format
buffer = io.BytesIO()

View File

@@ -1,5 +1,5 @@
import logging
from typing import Any, Literal
from typing import Any
from pydase.data_service.data_service import DataService
@@ -13,23 +13,68 @@ class NumberSlider(DataService):
Parameters:
-----------
value (float | int, optional):
value (float, optional):
The initial value of the slider. Defaults to 0.
min (float, optional):
The minimum value of the slider. Defaults to 0.
max (float, optional):
The maximum value of the slider. Defaults to 100.
step_size (float | int, optional):
step_size (float, optional):
The increment/decrement step size of the slider. Defaults to 1.0.
type (Literal["int"] | Literal["float"], optional):
The type of the slider value. Determines if the value is an integer or float.
Defaults to "float".
Example:
--------
```python
class MyService(DataService):
voltage = NumberSlider(1, 0, 10, 0.1, "int")
class MySlider(pydase.components.NumberSlider):
def __init__(
self,
value: float = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
) -> None:
super().__init__(value, min_, max_, step_size)
@property
def min(self) -> float:
return self._min
@min.setter
def min(self, value: float) -> None:
self._min = value
@property
def max(self) -> float:
return self._max
@max.setter
def max(self, value: float) -> None:
self._max = value
@property
def step_size(self) -> float:
return self._step_size
@step_size.setter
def step_size(self, value: float) -> None:
self._step_size = value
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, value: float) -> None:
if value < self._min or value > self._max:
raise ValueError(
"Value is either below allowed min or above max value."
)
self._value = value
class MyService(pydase.DataService):
def __init__(self) -> None:
self.voltage = MyService()
# Modifying or accessing the voltage value:
my_service = MyService()
@@ -40,28 +85,37 @@ class NumberSlider(DataService):
def __init__(
self,
value: float | int = 0,
min: float = 0.0,
max: float = 100.0,
step_size: float | int = 1.0,
type: Literal["int"] | Literal["float"] = "float",
value: Any = 0.0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
) -> None:
if type not in {"float", "int"}:
logger.error(f"Unknown type '{type}'. Using 'float'.")
type = "float"
self._type = type
self.step_size = step_size
self.value = value
self.min = min
self.max = max
super().__init__()
self._step_size = step_size
self._value = value
self._min = min_
self._max = max_
def __setattr__(self, name: str, value: Any) -> None:
if name in ["value", "step_size"]:
value = int(value) if self._type == "int" else float(value)
elif not name.startswith("_"):
value = float(value)
@property
def min(self) -> float:
"""The min property."""
return self._min
return super().__setattr__(name, value)
@property
def max(self) -> float:
"""The min property."""
return self._max
@property
def step_size(self) -> float:
"""The min property."""
return self._step_size
@property
def value(self) -> Any:
"""The value property."""
return self._value
@value.setter
def value(self, value: Any) -> None:
self._value = value

View File

@@ -1,9 +1,23 @@
from pathlib import Path
from typing import Literal
from confz import BaseConfig, EnvSource
class OperationMode(BaseConfig): # type: ignore
environment: Literal["development"] | Literal["production"] = "development"
class OperationMode(BaseConfig): # type: ignore[misc]
environment: Literal["development", "production"] = "development"
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
class ServiceConfig(BaseConfig): # type: ignore[misc]
config_dir: Path = Path("config")
web_port: int = 8001
CONFIG_SOURCES = EnvSource(allow_all=True, prefix="SERVICE_", file=".env")
class WebServerConfig(BaseConfig): # type: ignore[misc]
generate_web_settings: bool = False
CONFIG_SOURCES = EnvSource(allow=["GENERATE_WEB_SETTINGS"])

View File

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

View File

@@ -1,413 +0,0 @@
from __future__ import annotations
import inspect
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.utils.helpers import get_class_and_instance_attributes
from .data_service_list import DataServiceList
if TYPE_CHECKING:
from .data_service import DataService
logger = logging.getLogger(__name__)
class CallbackManager:
_notification_callbacks: list[Callable[[str, str, Any], Any]] = []
"""
A list of callback functions that are executed when a change occurs in the
DataService instance. These functions are intended to handle or respond to these
changes in some way, such as emitting a socket.io message to the frontend.
Each function in this list should be a callable that accepts three parameters:
- parent_path (str): The path to the parent of the attribute that was changed.
- name (str): The name of the attribute that was changed.
- value (Any): The new value of the attribute.
A callback function can be added to this list using the add_notification_callback
method. Whenever a change in the DataService instance occurs (or in its nested
DataService or DataServiceList instances), the emit_notification method is invoked,
which in turn calls all the callback functions in _notification_callbacks with the
appropriate arguments.
This implementation follows the observer pattern, with the DataService instance as
the "subject" and the callback functions as the "observers".
"""
_list_mapping: dict[int, DataServiceList] = {}
"""
A dictionary mapping the id of the original lists to the corresponding
DataServiceList instances.
This is used to ensure that all references to the same list within the DataService
object point to the same DataServiceList, so that any modifications to that list can
be tracked consistently. The keys of the dictionary are the ids of the original
lists, and the values are the DataServiceList instances that wrap these lists.
"""
def __init__(self, service: DataService) -> None:
self.callbacks: set[Callable[[str, Any], None]] = set()
self.service = service
def _register_list_change_callbacks( # noqa: C901
self, obj: "AbstractDataService", parent_path: str
) -> None:
"""
This method ensures that notifications are emitted whenever a public list
attribute of a DataService instance changes. These notifications pertain solely
to the list item changes, not to changes in attributes of objects within the
list.
The method works by converting all list attributes (both at the class and
instance levels) into DataServiceList objects. Each DataServiceList is then
assigned a callback that is triggered whenever an item in the list is updated.
The callback emits a notification, but only if the DataService instance was the
root instance when the callback was registered.
This method operates recursively, processing the input object and all nested
attributes that are instances of DataService. While navigating the structure,
it constructs a path for each attribute that traces back to the root. This path
is included in any emitted notifications to facilitate identification of the
source of a change.
Parameters:
-----------
obj: DataService
The target object to be processed. All list attributes (and those of its
nested DataService attributes) will be converted into DataServiceList
objects.
parent_path: str
The access path for the parent object. Used to construct the full access
path for the notifications.
"""
# Convert all list attributes (both class and instance) to DataServiceList
attrs = get_class_and_instance_attributes(obj)
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, AbstractDataService):
new_path = f"{parent_path}.{attr_name}"
self._register_list_change_callbacks(attr_value, new_path)
elif isinstance(attr_value, list):
# Create callback for current attr_name
# Default arguments solve the late binding problem by capturing the
# value at the time the lambda is defined, not when it is called. This
# prevents attr_name from being overwritten in the next loop iteration.
callback = (
lambda index, value, attr_name=attr_name: self.service._callback_manager.emit_notification(
parent_path=parent_path,
name=f"{attr_name}[{index}]",
value=value,
)
if self.service == self.service.__root__
# Skip private and protected lists
and not cast(str, attr_name).startswith("_")
else None
)
# Check if attr_value is already a DataServiceList or in the mapping
if isinstance(attr_value, DataServiceList):
attr_value.add_callback(callback)
continue
if id(attr_value) in self._list_mapping:
# If the list `attr_value` was already referenced somewhere else
notifying_list = self._list_mapping[id(attr_value)]
notifying_list.add_callback(callback)
else:
# convert the builtin list into a DataServiceList and add the
# callback
notifying_list = DataServiceList(attr_value, callback=[callback])
self._list_mapping[id(attr_value)] = notifying_list
setattr(obj, attr_name, notifying_list)
# recursively add callbacks to list attributes of DataService instances
for i, item in enumerate(attr_value):
if isinstance(item, AbstractDataService):
new_path = f"{parent_path}.{attr_name}[{i}]"
self._register_list_change_callbacks(item, new_path)
def _register_DataService_instance_callbacks(
self, obj: "AbstractDataService", parent_path: str
) -> None:
"""
This function is a key part of the observer pattern implemented by the
DataService class.
Its purpose is to allow the system to automatically send out notifications
whenever an attribute of a DataService instance is updated, which is especially
useful when the DataService instance is part of a nested structure.
It works by recursively registering callbacks for a given DataService instance
and all of its nested attributes. Each callback is responsible for emitting a
notification when the attribute it is attached to is modified.
This function ensures that only the root DataService instance (the one directly
exposed to the user or another system via rpyc) emits notifications.
Each notification contains a 'parent_path' that traces the attribute's location
within the nested DataService structure, starting from the root. This makes it
easier for observers to determine exactly where a change has occurred.
Parameters:
-----------
obj: DataService
The target object on which callbacks are to be registered.
parent_path: str
The access path for the parent object. This is used to construct the full
access path for the notifications.
"""
# Create and register a callback for the object
# only emit the notification when the call was registered by the root object
callback: Callable[[str, Any], None] = (
lambda name, value: obj._callback_manager.emit_notification(
parent_path=parent_path, name=name, value=value
)
if self.service == obj.__root__
and not name.startswith("_") # we are only interested in public attributes
and not isinstance(
getattr(type(obj), name, None), property
) # exlude proerty notifications -> those are handled in separate callbacks
else None
)
obj._callback_manager.callbacks.add(callback)
# Recursively register callbacks for all nested attributes of the object
attrs = get_class_and_instance_attributes(obj)
for nested_attr_name, nested_attr in attrs.items():
if isinstance(nested_attr, DataServiceList):
self._register_list_callbacks(
nested_attr, parent_path, nested_attr_name
)
elif isinstance(nested_attr, AbstractDataService):
self._register_service_callbacks(
nested_attr, parent_path, nested_attr_name
)
def _register_list_callbacks(
self, nested_attr: list[Any], parent_path: str, attr_name: str
) -> None:
"""Handles registration of callbacks for list attributes"""
for i, list_item in enumerate(nested_attr):
if isinstance(list_item, AbstractDataService):
self._register_service_callbacks(
list_item, parent_path, f"{attr_name}[{i}]"
)
def _register_service_callbacks(
self, nested_attr: "AbstractDataService", parent_path: str, attr_name: str
) -> None:
"""Handles registration of callbacks for DataService attributes"""
# as the DataService is an attribute of self, change the root object
# use the dictionary to not trigger callbacks on initialised objects
nested_attr.__dict__["__root__"] = self.service.__root__
new_path = f"{parent_path}.{attr_name}"
self._register_DataService_instance_callbacks(nested_attr, new_path)
def __register_recursive_parameter_callback(
self,
obj: "AbstractDataService | DataServiceList",
callback: Callable[[str | int, Any], None],
) -> None:
"""
Register callback to a DataService or DataServiceList instance and its nested
instances.
For a DataService, this method traverses its attributes and recursively adds the
callback for nested DataService or DataServiceList instances. For a
DataServiceList,
the callback is also triggered when an item gets reassigned.
"""
if isinstance(obj, DataServiceList):
# emits callback when item in list gets reassigned
obj.add_callback(callback=callback)
obj_list: DataServiceList | list[AbstractDataService] = obj
else:
obj_list = [obj]
# this enables notifications when a class instance was changed (-> item is
# changed, not reassigned)
for item in obj_list:
if isinstance(item, AbstractDataService):
item._callback_manager.callbacks.add(callback)
for attr_name in set(dir(item)) - set(dir(object)) - {"__root__"}:
attr_value = getattr(item, attr_name)
if isinstance(attr_value, (AbstractDataService, DataServiceList)):
self.__register_recursive_parameter_callback(
attr_value, callback
)
def _register_property_callbacks( # noqa: C901
self,
obj: "AbstractDataService",
parent_path: str,
) -> None:
"""
Register callbacks to notify when properties or their dependencies change.
This method cycles through all attributes (both class and instance level) of the
input `obj`. For each attribute that is a property, it identifies dependencies
used in the getter method and creates a callback for each one.
The method is recursive for attributes that are of type DataService or
DataServiceList. It attaches the callback directly to DataServiceList items or
propagates it through nested DataService instances.
"""
attrs = get_class_and_instance_attributes(obj)
for attr_name, attr_value in attrs.items():
if isinstance(attr_value, AbstractDataService):
self._register_property_callbacks(
attr_value, parent_path=f"{parent_path}.{attr_name}"
)
elif isinstance(attr_value, DataServiceList):
for i, item in enumerate(attr_value):
if isinstance(item, AbstractDataService):
self._register_property_callbacks(
item, parent_path=f"{parent_path}.{attr_name}[{i}]"
)
if isinstance(attr_value, property):
dependencies = attr_value.fget.__code__.co_names # type: ignore
source_code_string = inspect.getsource(attr_value.fget) # type: ignore
for dependency in dependencies:
# check if the dependencies are attributes of obj
# This doesn't have to be the case like, for example, here:
# >>> @property
# >>> def power(self) -> float:
# >>> return self.class_attr.voltage * self.current
#
# The dependencies for this property are:
# > ('class_attr', 'voltage', 'current')
if f"self.{dependency}" not in source_code_string:
continue
# use `obj` instead of `type(obj)` to get DataServiceList
# instead of list
dependency_value = getattr(obj, dependency)
if isinstance(
dependency_value, (DataServiceList, AbstractDataService)
):
callback = (
lambda name, value, dependent_attr=attr_name: obj._callback_manager.emit_notification(
parent_path=parent_path,
name=dependent_attr,
value=getattr(obj, dependent_attr),
)
if self.service == obj.__root__
else None
)
self.__register_recursive_parameter_callback(
dependency_value,
callback=callback,
)
else:
callback = (
lambda name, _, dep_attr=attr_name, dep=dependency: obj._callback_manager.emit_notification( # type: ignore
parent_path=parent_path,
name=dep_attr,
value=getattr(obj, dep_attr),
)
if name == dep and self.service == obj.__root__
else None
)
# Add to callbacks
obj._callback_manager.callbacks.add(callback)
def _register_start_stop_task_callbacks(
self, obj: "AbstractDataService", parent_path: str
) -> None:
"""
This function registers callbacks for start and stop methods of async functions.
These callbacks are stored in the '_task_status_change_callbacks' attribute and
are called when the status of a task changes.
Parameters:
-----------
obj: AbstractDataService
The target object on which callbacks are to be registered.
parent_path: str
The access path for the parent object. This is used to construct the full
access path for the notifications.
"""
# Create and register a callback for the object
# only emit the notification when the call was registered by the root object
callback: Callable[[str, dict[str, Any] | None], None] = (
lambda name, status: obj._callback_manager.emit_notification(
parent_path=parent_path, name=name, value=status
)
if self.service == obj.__root__
and not name.startswith("_") # we are only interested in public attributes
else None
)
obj._task_manager.task_status_change_callbacks.append(callback)
# Recursively register callbacks for all nested attributes of the object
attrs: dict[str, Any] = get_class_and_instance_attributes(obj)
for nested_attr_name, nested_attr in attrs.items():
if isinstance(nested_attr, DataServiceList):
for i, item in enumerate(nested_attr):
if isinstance(item, AbstractDataService):
self._register_start_stop_task_callbacks(
item, parent_path=f"{parent_path}.{nested_attr_name}[{i}]"
)
if isinstance(nested_attr, AbstractDataService):
self._register_start_stop_task_callbacks(
nested_attr, parent_path=f"{parent_path}.{nested_attr_name}"
)
def register_callbacks(self) -> None:
self._register_list_change_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
self._register_DataService_instance_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
self._register_property_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
self._register_start_stop_task_callbacks(
self.service, f"{self.service.__class__.__name__}"
)
def emit_notification(self, parent_path: str, name: str, value: Any) -> None:
logger.debug(f"{parent_path}.{name} changed to {value}!")
for callback in self._notification_callbacks:
try:
callback(parent_path, name, value)
except Exception as e:
logger.error(e)
def add_notification_callback(
self, callback: Callable[[str, str, Any], None]
) -> None:
"""
Adds a new notification callback function to the list of callbacks.
This function is intended to be used for registering a function that will be
called whenever a the value of an attribute changes.
Args:
callback (Callable[[str, str, Any], None]): The callback function to
register.
It should accept three parameters:
- parent_path (str): The parent path of the parameter.
- name (str): The name of the changed parameter.
- value (Any): The value of the parameter.
"""
self._notification_callbacks.append(callback)

View File

@@ -1,203 +1,98 @@
import inspect
import logging
import warnings
from enum import Enum
from pathlib import Path
from typing import Any, Optional, get_type_hints
import rpyc # type: ignore
from typing import Any
import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.callback_manager import CallbackManager
from pydase.data_service.task_manager import TaskManager
from pydase.observer_pattern.observable.observable import (
Observable,
)
from pydase.utils.helpers import (
convert_arguments_to_hinted_types,
get_class_and_instance_attributes,
get_object_attr_from_path_list,
is_property_attribute,
parse_list_attr_and_index,
update_value_if_changed,
)
from pydase.utils.serializer import (
from pydase.utils.serialization.serializer import (
SerializedObject,
Serializer,
generate_serialized_data_paths,
get_nested_dict_by_path,
)
from pydase.utils.warnings import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
logger = logging.getLogger(__name__)
def process_callable_attribute(attr: Any, args: dict[str, Any]) -> Any:
converted_args_or_error_msg = convert_arguments_to_hinted_types(
args, get_type_hints(attr)
)
return (
attr(**converted_args_or_error_msg)
if not isinstance(converted_args_or_error_msg, str)
else converted_args_or_error_msg
)
class DataService(rpyc.Service, AbstractDataService):
def __init__(self, **kwargs: Any) -> None:
self._callback_manager: CallbackManager = CallbackManager(self)
class DataService(AbstractDataService):
def __init__(self) -> None:
super().__init__()
self._task_manager = TaskManager(self)
if not hasattr(self, "_autostart_tasks"):
self._autostart_tasks = {}
self.__root__: "DataService" = self
"""Keep track of the root object. This helps to filter the emission of
notifications."""
filename = kwargs.pop("filename", None)
if filename is not None:
warnings.warn(
"The 'filename' argument is deprecated and will be removed in a future version. "
"Please pass the 'filename' argument to `pydase.Server`.",
DeprecationWarning,
stacklevel=2,
)
self._filename: str | Path = filename
self._callback_manager.register_callbacks()
self.__check_instance_classes()
self._initialised = True
def __setattr__(self, __name: str, __value: Any) -> None:
# converting attributes that are not properties
if not isinstance(getattr(type(self), __name, None), property):
current_value = getattr(self, __name, None)
# parse ints into floats if current value is a float
if isinstance(current_value, float) and isinstance(__value, int):
__value = float(__value)
# Check and warn for unexpected type changes in attributes
self._warn_on_type_change(__name, __value)
if isinstance(current_value, u.Quantity):
__value = u.convert_to_quantity(__value, str(current_value.u))
# every class defined by the user should inherit from DataService if it is
# assigned to a public attribute
if not __name.startswith("_") and not inspect.isfunction(__value):
self.__warn_if_not_observable(__value)
# Set the attribute
super().__setattr__(__name, __value)
if self.__dict__.get("_initialised") and not __name == "_initialised":
for callback in self._callback_manager.callbacks:
callback(__name, __value)
elif __name.startswith(f"_{self.__class__.__name__}__"):
def _warn_on_type_change(self, attr_name: str, new_value: Any) -> None:
if is_property_attribute(self, attr_name):
return
current_value = getattr(self, attr_name, None)
if self._is_unexpected_type_change(current_value, new_value):
logger.warning(
f"Warning: You should not set private but rather protected attributes! "
f"Use {__name.replace(f'_{self.__class__.__name__}__', '_')} instead "
f"of {__name.replace(f'_{self.__class__.__name__}__', '__')}."
"Type of '%s' changed from '%s' to '%s'. This may have unwanted "
"side effects! Consider setting it to '%s' directly.",
attr_name,
type(current_value).__name__,
type(new_value).__name__,
type(current_value).__name__,
)
def _is_unexpected_type_change(self, current_value: Any, new_value: Any) -> bool:
return (
isinstance(current_value, float)
and not isinstance(new_value, float)
or (
isinstance(current_value, u.Quantity)
and not isinstance(new_value, u.Quantity)
)
)
def __warn_if_not_observable(self, __value: Any) -> None:
value_class = __value if inspect.isclass(__value) else __value.__class__
if not issubclass(
value_class,
(int | float | bool | str | list | dict | Enum | u.Quantity | Observable),
):
logger.warning(
"Class '%s' does not inherit from DataService. This may lead to"
" unexpected behaviour!",
value_class.__name__,
)
def __check_instance_classes(self) -> None:
for attr_name, attr_value in get_class_and_instance_attributes(self).items():
# every class defined by the user should inherit from DataService if it is
# assigned to a public attribute
if not attr_name.startswith("_"):
warn_if_instance_class_does_not_inherit_from_DataService(attr_value)
if (
not attr_name.startswith("_")
and not inspect.isfunction(attr_value)
and not isinstance(attr_value, property)
):
self.__warn_if_not_observable(attr_value)
def __set_attribute_based_on_type( # noqa:CFQ002
self,
target_obj: Any,
attr_name: str,
attr: Any,
value: Any,
index: Optional[int],
path_list: list[str],
) -> None:
if isinstance(attr, Enum):
update_value_if_changed(target_obj, attr_name, attr.__class__[value])
elif isinstance(attr, list) and index is not None:
update_value_if_changed(attr, index, value)
elif isinstance(attr, DataService) and isinstance(value, dict):
for key, v in value.items():
self.update_DataService_attribute([*path_list, attr_name], key, v)
elif callable(attr):
process_callable_attribute(attr, value["args"])
else:
update_value_if_changed(target_obj, attr_name, value)
def _rpyc_getattr(self, name: str) -> Any:
if name.startswith("_"):
# disallow special and private attributes
raise AttributeError("cannot access private/special names")
# allow all other attributes
return getattr(self, name)
def _rpyc_setattr(self, name: str, value: Any) -> None:
if name.startswith("_"):
# disallow special and private attributes
raise AttributeError("cannot access private/special names")
# check if the attribute has a setter method
attr = getattr(self, name, None)
if isinstance(attr, property) and attr.fset is None:
raise AttributeError(f"{name} attribute does not have a setter method")
# allow all other attributes
setattr(self, name, value)
def write_to_file(self) -> None:
"""
Serialize the DataService instance and write it to a JSON file.
This method is deprecated and will be removed in a future version.
Service persistence is handled by `pydase.Server` now, instead.
"""
warnings.warn(
"'write_to_file' is deprecated and will be removed in a future version. "
"Service persistence is handled by `pydase.Server` now, instead.",
DeprecationWarning,
stacklevel=2,
)
if hasattr(self, "_state_manager"):
getattr(self, "_state_manager").save_state()
def load_DataService_from_JSON(self, json_dict: dict[str, Any]) -> None:
warnings.warn(
"'load_DataService_from_JSON' is deprecated and will be removed in a "
"future version. "
"Service persistence is handled by `pydase.Server` now, instead.",
DeprecationWarning,
stacklevel=2,
)
# Traverse the serialized representation and set the attributes of the class
serialized_class = self.serialize()
for path in generate_serialized_data_paths(json_dict):
nested_json_dict = get_nested_dict_by_path(json_dict, path)
value = nested_json_dict["value"]
value_type = nested_json_dict["type"]
nested_class_dict = get_nested_dict_by_path(serialized_class, path)
class_value_type = nested_class_dict.get("type", None)
if class_value_type == value_type:
class_attr_is_read_only = nested_class_dict["readonly"]
if class_attr_is_read_only:
logger.debug(
f'Attribute "{path}" is read-only. Ignoring value from JSON '
"file..."
)
continue
# Split the path into parts
parts = path.split(".")
attr_name = parts[-1]
# Convert dictionary into Quantity
if class_value_type == "Quantity":
value = u.convert_to_quantity(value)
self.update_DataService_attribute(parts[:-1], attr_name, value)
else:
logger.info(
f'Attribute type of "{path}" changed from "{value_type}" to '
f'"{class_value_type}". Ignoring value from JSON file...'
)
def serialize(self) -> dict[str, dict[str, Any]]: # noqa
def serialize(self) -> SerializedObject:
"""
Serializes the instance into a dictionary, preserving the structure of the
instance.
@@ -214,38 +109,4 @@ class DataService(rpyc.Service, AbstractDataService):
Returns:
dict: The serialized instance.
"""
return Serializer.serialize_object(self)["value"]
def update_DataService_attribute(
self,
path_list: list[str],
attr_name: str,
value: Any,
) -> None:
warnings.warn(
"'update_DataService_attribute' is deprecated and will be removed in a "
"future version. "
"Service state management is handled by `pydase.data_service.state_manager`"
"now, instead.",
DeprecationWarning,
stacklevel=2,
)
# 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)
# Traverse the object according to the path parts
target_obj = get_object_attr_from_path_list(self, path_list)
# If the attribute is a property, change it using the setter without getting the
# property value (would otherwise be bad for expensive getter methods)
if is_property_attribute(target_obj, attr_name):
setattr(target_obj, attr_name, value)
return
attr = get_object_attr_from_path_list(target_obj, [attr_name])
if attr is None:
return
self.__set_attribute_based_on_type(
target_obj, attr_name, attr, value, index, path_list
)
return Serializer.serialize_object(self)

View File

@@ -1,7 +1,13 @@
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from pydase.utils.serializer import set_nested_value_by_path
from pydase.utils.serialization.serializer import (
SerializationPathError,
SerializationValueError,
SerializedObject,
get_nested_dict_by_path,
set_nested_value_by_path,
)
if TYPE_CHECKING:
from pydase import DataService
@@ -11,25 +17,37 @@ logger = logging.getLogger(__name__)
class DataServiceCache:
def __init__(self, service: "DataService") -> None:
self._cache: dict[str, Any] = {}
self._cache: SerializedObject
self.service = service
self._initialize_cache()
@property
def cache(self) -> dict[str, Any]:
def cache(self) -> SerializedObject:
return self._cache
def _initialize_cache(self) -> None:
"""Initializes the cache and sets up the callback."""
logger.debug("Initializing cache.")
self._cache = self.service.serialize()
self.service._callback_manager.add_notification_callback(self.update_cache)
def update_cache(self, parent_path: str, name: str, value: Any) -> None:
# Remove the part before the first "." in the parent_path
parent_path = ".".join(parent_path.split(".")[1:])
def update_cache(self, full_access_path: str, value: Any) -> None:
set_nested_value_by_path(
cast(dict[str, SerializedObject], self._cache["value"]),
full_access_path,
value,
)
# Construct the full path
full_path = f"{parent_path}.{name}" if parent_path else name
set_nested_value_by_path(self._cache, full_path, value)
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
try:
return get_nested_dict_by_path(
cast(dict[str, SerializedObject], self._cache["value"]),
full_access_path,
)
except (SerializationPathError, SerializationValueError, KeyError):
return {
"full_access_path": full_access_path,
"value": None,
"type": "None",
"doc": None,
"readonly": False,
}

View File

@@ -1,72 +0,0 @@
from collections.abc import Callable
from typing import Any
import pydase.units as u
from pydase.utils.warnings import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
class DataServiceList(list):
"""
DataServiceList is a list with additional functionality to trigger callbacks
whenever an item is set. This can be used to track changes in the list items.
The class takes the same arguments as the list superclass during initialization,
with an additional optional 'callback' argument that is a list of functions.
These callbacks are stored and executed whenever an item in the DataServiceList
is set via the __setitem__ method. The callbacks receive the index of the changed
item and its new value as arguments.
The original list that is passed during initialization is kept as a private
attribute to prevent it from being garbage collected.
Additional callbacks can be added after initialization using the `add_callback`
method.
Attributes:
callbacks (list):
List of callback functions to be executed on item set.
"""
def __init__(
self,
*args: list[Any],
callback: list[Callable[[int, Any], None]] | None = None,
**kwargs: Any,
) -> None:
self.callbacks: list[Callable[[int, Any], None]] = []
if isinstance(callback, list):
self.callbacks = callback
for item in args[0]:
warn_if_instance_class_does_not_inherit_from_DataService(item)
# prevent gc to delete the passed list by keeping a reference
self._original_list = args[0]
super().__init__(*args, **kwargs) # type: ignore
def __setitem__(self, key: int, value: Any) -> None: # type: ignore
current_value = self.__getitem__(key)
# parse ints into floats if current value is a float
if isinstance(current_value, float) and isinstance(value, int):
value = float(value)
if isinstance(current_value, u.Quantity):
value = u.convert_to_quantity(value, str(current_value.u))
super().__setitem__(key, value) # type: ignore
for callback in self.callbacks:
callback(key, value)
def add_callback(self, callback: Callable[[int, Any], None]) -> None:
"""
Add a new callback function to be executed on item set.
Args:
callback (Callable[[int, Any], None]): Callback function that takes two
arguments - index of the changed item and its new value.
"""
self.callbacks.append(callback)

View File

@@ -0,0 +1,123 @@
import logging
from collections.abc import Callable
from copy import deepcopy
from typing import Any
from pydase.data_service.state_manager import StateManager
from pydase.observer_pattern.observable.observable_object import ObservableObject
from pydase.observer_pattern.observer.property_observer import (
PropertyObserver,
)
from pydase.utils.helpers import get_object_attr_from_path
from pydase.utils.serialization.serializer import SerializedObject, dump
logger = logging.getLogger(__name__)
class DataServiceObserver(PropertyObserver):
def __init__(self, state_manager: StateManager) -> None:
self.state_manager = state_manager
self._notification_callbacks: list[
Callable[[str, Any, SerializedObject], None]
] = []
super().__init__(state_manager.service)
def on_change(self, full_access_path: str, value: Any) -> None:
if any(
full_access_path.startswith(changing_attribute)
and full_access_path != changing_attribute
for changing_attribute in self.changing_attributes
):
return
cached_value_dict = deepcopy(
self.state_manager._data_service_cache.get_value_dict_from_cache(
full_access_path
)
)
cached_value = cached_value_dict.get("value")
if cached_value != dump(value)["value"] and all(
part[0] != "_" for part in full_access_path.split(".")
):
logger.debug("'%s' changed to '%s'", full_access_path, value)
self._update_cache_value(full_access_path, value, cached_value_dict)
cached_value_dict = deepcopy(
self.state_manager._data_service_cache.get_value_dict_from_cache(
full_access_path
)
)
for callback in self._notification_callbacks:
callback(full_access_path, value, cached_value_dict)
if isinstance(value, ObservableObject):
self._update_property_deps_dict()
self._notify_dependent_property_changes(full_access_path)
def _update_cache_value(
self,
full_access_path: str,
value: Any,
cached_value_dict: SerializedObject | dict[str, Any],
) -> None:
value_dict = dump(value)
if (
cached_value_dict != {}
and cached_value_dict["type"] != "method"
and cached_value_dict["type"] != value_dict["type"]
):
logger.warning(
"Type of '%s' changed from '%s' to '%s'. This could have unwanted "
"side effects! Consider setting it to '%s' directly.",
full_access_path,
cached_value_dict["type"],
value_dict["type"],
cached_value_dict["type"],
)
self.state_manager._data_service_cache.update_cache(
full_access_path,
value,
)
def _notify_dependent_property_changes(self, changed_attr_path: str) -> None:
changed_props = self.property_deps_dict.get(changed_attr_path, [])
for prop in changed_props:
# only notify about changing attribute if it is not currently being
# "changed" e.g. when calling the getter of a property within another
# property
if prop not in self.changing_attributes:
self._notify_changed(
prop,
get_object_attr_from_path(self.observable, prop),
)
def add_notification_callback(
self, callback: Callable[[str, Any, SerializedObject], None]
) -> None:
"""
Registers a callback function to be invoked upon attribute changes in the
observed object.
This method allows for the addition of custom callback functions that will be
executed whenever there is a change in the value of an observed attribute. The
callback function is called with detailed information about the change, enabling
external logic to respond to specific state changes within the observable
object.
Args:
callback (Callable[[str, Any, dict[str, Any]]): The callback function to be
registered. The function should have the following signature:
- full_access_path (str): The full dot-notation access path of the
changed attribute. This path indicates the location of the changed
attribute within the observable object's structure.
- value (Any): The new value of the changed attribute.
- cached_value_dict (dict[str, Any]): A dictionary representing the
cached state of the attribute prior to the change. This can be useful
for understanding the nature of the change and for historical
comparison.
"""
self._notification_callbacks.append(callback)

View File

@@ -3,18 +3,22 @@ import logging
import os
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, cast
from typing import TYPE_CHECKING, Any, cast
import pydase.units as u
from pydase.data_service.data_service_cache import DataServiceCache
from pydase.utils.helpers import (
get_object_attr_from_path_list,
parse_list_attr_and_index,
get_object_by_path_parts,
is_property_attribute,
parse_full_access_path,
parse_serialized_key,
)
from pydase.utils.serializer import (
dump,
from pydase.utils.serialization.deserializer import loads
from pydase.utils.serialization.serializer import (
SerializationPathError,
SerializedObject,
generate_serialized_data_paths,
get_nested_dict_by_path,
serialized_dict_is_nested_object,
)
if TYPE_CHECKING:
@@ -41,17 +45,17 @@ def load_state(func: Callable[..., Any]) -> Callable[..., Any]:
... self._name = value
"""
func._load_state = True
func._load_state = True # type: ignore[attr-defined]
return func
def has_load_state_decorator(prop: property):
def has_load_state_decorator(prop: property) -> bool:
"""Determines if the property's setter method is decorated with the `@load_state`
decorator.
"""
try:
return getattr(prop.fset, "_load_state")
return prop.fset._load_state # type: ignore[union-attr]
except AttributeError:
return False
@@ -96,13 +100,15 @@ class StateManager:
update.
"""
def __init__(self, service: "DataService", filename: Optional[str | Path] = None):
def __init__(
self, service: "DataService", filename: str | Path | None = None
) -> None:
self.filename = getattr(service, "_filename", None)
if filename is not None:
if self.filename is not None:
logger.warning(
f"Overwriting filename {self.filename!r} with {filename!r}."
"Overwriting filename '%s' with '%s'.", self.filename, filename
)
self.filename = filename
@@ -110,10 +116,17 @@ class StateManager:
self._data_service_cache = DataServiceCache(self.service)
@property
def cache(self) -> dict[str, Any]:
def cache(self) -> SerializedObject:
"""Returns the cached DataService state."""
return self._data_service_cache.cache
@property
def cache_value(self) -> dict[str, SerializedObject]:
"""Returns the "value" value of the DataService serialization."""
return cast(
dict[str, SerializedObject], self._data_service_cache.cache["value"]
)
def save_state(self) -> None:
"""
Saves the DataService's current state to a JSON file defined by `self.filename`.
@@ -122,9 +135,9 @@ class StateManager:
if self.filename is not None:
with open(self.filename, "w") as f:
json.dump(self.cache, f, indent=4)
json.dump(self.cache_value, f, indent=4)
else:
logger.error(
logger.info(
"State manager was not initialised with a filename. Skipping "
"'save_state'..."
)
@@ -136,40 +149,44 @@ class StateManager:
"""
# Traverse the serialized representation and set the attributes of the class
json_dict = self._get_state_dict_from_JSON_file()
json_dict = self._get_state_dict_from_json_file()
if json_dict == {}:
logger.debug("Could not load the service state.")
return
for path in generate_serialized_data_paths(json_dict):
nested_json_dict = get_nested_dict_by_path(json_dict, path)
nested_class_dict = get_nested_dict_by_path(self.cache, path)
value, value_type = nested_json_dict["value"], nested_json_dict["type"]
class_attr_value_type = nested_class_dict.get("type", None)
if class_attr_value_type == value_type:
self.set_service_attribute_value_by_path(path, value)
else:
logger.info(
f"Attribute type of {path!r} changed from {value_type!r} to "
f"{class_attr_value_type!r}. Ignoring value from JSON file..."
if self.__is_loadable_state_attribute(path):
nested_json_dict = get_nested_dict_by_path(json_dict, path)
nested_class_dict = self._data_service_cache.get_value_dict_from_cache(
path
)
def _get_state_dict_from_JSON_file(self) -> dict[str, Any]:
if self.filename is not None:
# Check if the file specified by the filename exists
if os.path.exists(self.filename):
with open(self.filename, "r") as f:
# Load JSON data from file and update class attributes with these
# values
return cast(dict[str, Any], json.load(f))
value_type = nested_json_dict["type"]
class_attr_value_type = nested_class_dict.get("type", None)
if class_attr_value_type == value_type:
self.set_service_attribute_value_by_path(path, nested_json_dict)
else:
logger.info(
"Attribute type of '%s' changed from '%s' to "
"'%s'. Ignoring value from JSON file...",
path,
value_type,
class_attr_value_type,
)
def _get_state_dict_from_json_file(self) -> dict[str, Any]:
if self.filename is not None and os.path.exists(self.filename):
with open(self.filename) as f:
# Load JSON data from file and update class attributes with these
# values
return cast(dict[str, Any], json.load(f))
return {}
def set_service_attribute_value_by_path(
self,
path: str,
value: Any,
serialized_value: SerializedObject,
) -> None:
"""
Sets the value of an attribute in the service managed by the `StateManager`
@@ -185,67 +202,104 @@ class StateManager:
value: The new value to set for the attribute.
"""
current_value_dict = get_nested_dict_by_path(self.cache, path)
current_value_dict = get_nested_dict_by_path(self.cache_value, path)
# This will also filter out methods as they are 'read-only'
if current_value_dict["readonly"]:
logger.debug(f"Attribute {path!r} is read-only. Ignoring new value...")
logger.debug("Attribute '%s' is read-only. Ignoring new value...", path)
return
converted_value = self.__convert_value_if_needed(value, current_value_dict)
if "full_access_path" not in serialized_value:
# Backwards compatibility for JSON files not containing the
# full_access_path
logger.warning(
"The format of your JSON file is out-of-date. This might lead "
"to unexpected errors. Please consider updating it."
)
serialized_value["full_access_path"] = current_value_dict[
"full_access_path"
]
# only set value when it has changed
if self.__attr_value_has_changed(converted_value, current_value_dict["value"]):
self.__update_attribute_by_path(path, converted_value)
if self.__attr_value_has_changed(serialized_value, current_value_dict):
self.__update_attribute_by_path(path, serialized_value)
else:
logger.debug(f"Value of attribute {path!r} has not changed...")
logger.debug("Value of attribute '%s' has not changed...", path)
def __attr_value_has_changed(self, value_object: Any, current_value: Any) -> bool:
"""Check if the serialized value of `value_object` differs from `current_value`.
def __attr_value_has_changed(
self, serialized_new_value: Any, serialized_current_value: Any
) -> bool:
return not (
serialized_new_value["type"] == serialized_current_value["type"]
and serialized_new_value["value"] == serialized_current_value["value"]
)
The method serializes `value_object` to compare it, which is mainly
necessary for handling Quantity objects.
def __update_attribute_by_path(
self, path: str, serialized_value: SerializedObject
) -> None:
path_parts = parse_full_access_path(path)
target_obj = get_object_by_path_parts(self.service, path_parts[:-1])
attr_cache_type = get_nested_dict_by_path(self.cache_value, path)["type"]
# De-serialize the value
if attr_cache_type in ("ColouredEnum", "Enum"):
enum_attr = get_object_by_path_parts(target_obj, [path_parts[-1]])
# take the value of the existing enum class
if serialized_value["type"] in ("ColouredEnum", "Enum"):
try:
value = enum_attr.__class__[serialized_value["value"]]
except KeyError:
# This error will arise when setting an enum from another enum class
# In this case, we resort to loading the enum and setting it
# directly
value = loads(serialized_value)
else:
value = loads(serialized_value)
# set the value
if isinstance(target_obj, list | dict):
processed_key = parse_serialized_key(path_parts[-1])
target_obj[processed_key] = value # type: ignore
else:
setattr(target_obj, path_parts[-1], value)
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
storage.
For properties, it verifies the presence of the '@load_state' decorator. Regular
attributes default to being loadable.
"""
return dump(value_object)["value"] != current_value
path_parts = parse_full_access_path(full_access_path)
parent_object = get_object_by_path_parts(self.service, path_parts[:-1])
def __convert_value_if_needed(
self, value: Any, current_value_dict: dict[str, Any]
) -> Any:
if current_value_dict["type"] == "Quantity":
return u.convert_to_quantity(value, current_value_dict["value"]["unit"])
return value
if is_property_attribute(parent_object, path_parts[-1]):
prop = getattr(type(parent_object), path_parts[-1])
has_decorator = has_load_state_decorator(prop)
if not has_decorator:
logger.debug(
"Property '%s' has no '@load_state' decorator. "
"Ignoring value from JSON file...",
path_parts[-1],
)
return has_decorator
def __update_attribute_by_path(self, path: str, value: Any) -> None:
parent_path_list, attr_name = path.split(".")[:-1], path.split(".")[-1]
try:
cached_serialization_dict = get_nested_dict_by_path(
self.cache_value, full_access_path
)
# 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)
if cached_serialization_dict["value"] == "method":
return False
# Update path to reflect the attribute without list indices
path = ".".join([*parent_path_list, attr_name])
attr_cache_type = get_nested_dict_by_path(self.cache, path)["type"]
# Traverse the object according to the path parts
target_obj = get_object_attr_from_path_list(self.service, parent_path_list)
if self.__attr_value_should_change(target_obj, attr_name):
if attr_cache_type in ("ColouredEnum", "Enum"):
enum_attr = get_object_attr_from_path_list(target_obj, [attr_name])
setattr(target_obj, attr_name, enum_attr.__class__[value])
elif attr_cache_type == "list":
list_obj = get_object_attr_from_path_list(target_obj, [attr_name])
list_obj[index] = value
else:
setattr(target_obj, attr_name, value)
def __attr_value_should_change(self, parent_object: Any, attr_name: str) -> bool:
# If the attribute is a property, change it using the setter without getting
# the property value (would otherwise be bad for expensive getter methods)
prop = getattr(type(parent_object), attr_name, None)
if isinstance(prop, property):
return has_load_state_decorator(prop)
return True
# nested objects cannot be loaded
return not serialized_dict_is_nested_object(cached_serialization_dict)
except SerializationPathError:
logger.debug(
"Path %a could not be loaded. It does not correspond to an attribute of"
" the class. Ignoring value from JSON file...",
path_parts[-1],
)
return False

View File

@@ -3,23 +3,30 @@ from __future__ import annotations
import asyncio
import inspect
import logging
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING, Any, TypedDict
from enum import Enum
from typing import TYPE_CHECKING, Any
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.data_service_list import DataServiceList
from pydase.utils.helpers import get_class_and_instance_attributes
from pydase.utils.helpers import (
function_has_arguments,
get_class_and_instance_attributes,
is_property_attribute,
)
if TYPE_CHECKING:
from collections.abc import Callable
from .data_service import DataService
logger = logging.getLogger(__name__)
class TaskDict(TypedDict):
task: asyncio.Task[None]
kwargs: dict[str, Any]
class TaskDefinitionError(Exception):
pass
class TaskStatus(Enum):
RUNNING = "running"
class TaskManager:
@@ -78,30 +85,40 @@ class TaskManager:
def __init__(self, service: DataService) -> None:
self.service = service
self._loop = asyncio.get_event_loop()
self.tasks: dict[str, TaskDict] = {}
self.tasks: dict[str, asyncio.Task[None]] = {}
"""A dictionary to keep track of running tasks. The keys are the names of the
tasks and the values are TaskDict instances which include the task itself and
its kwargs.
"""
self.task_status_change_callbacks: list[
Callable[[str, dict[str, Any] | None], Any]
] = []
"""A list of callback functions to be invoked when the status of a task (start
or stop) changes."""
self._set_start_and_stop_for_async_methods()
def _set_start_and_stop_for_async_methods(self) -> None: # noqa: C901
# inspect the methods of the class
for name, method in inspect.getmembers(
self.service, predicate=inspect.iscoroutinefunction
):
# create start and stop methods for each coroutine
setattr(self.service, f"start_{name}", self._make_start_task(name, method))
setattr(self.service, f"stop_{name}", self._make_stop_task(name))
@property
def _loop(self) -> asyncio.AbstractEventLoop:
return asyncio.get_running_loop()
def _set_start_and_stop_for_async_methods(self) -> None:
for name in dir(self.service):
# circumvents calling properties
if is_property_attribute(self.service, name):
continue
method = getattr(self.service, name)
if inspect.iscoroutinefunction(method):
if function_has_arguments(method):
raise TaskDefinitionError(
"Asynchronous functions (tasks) should be defined without "
f"arguments. The task '{method.__name__}' has at least one "
"argument. Please remove the argument(s) from this function to "
"use it."
)
# create start and stop methods for each coroutine
setattr(
self.service, f"start_{name}", self._make_start_task(name, method)
)
setattr(self.service, f"stop_{name}", self._make_stop_task(name))
def _initiate_task_startup(self) -> None:
if self.service._autostart_tasks is not None:
@@ -111,18 +128,18 @@ class TaskManager:
start_method(*args)
else:
logger.warning(
f"No start method found for service '{service_name}'"
"No start method found for service '%s'", service_name
)
def start_autostart_tasks(self) -> None:
self._initiate_task_startup()
attrs = get_class_and_instance_attributes(self.service)
for _, attr_value in attrs.items():
for attr_value in attrs.values():
if isinstance(attr_value, AbstractDataService):
attr_value._task_manager.start_autostart_tasks()
elif isinstance(attr_value, DataServiceList):
for i, item in enumerate(attr_value):
elif isinstance(attr_value, list):
for item in attr_value:
if isinstance(item, AbstractDataService):
item._task_manager.start_autostart_tasks()
@@ -141,11 +158,11 @@ class TaskManager:
# cancel the task
task = self.tasks.get(name, None)
if task is not None:
self._loop.call_soon_threadsafe(task["task"].cancel)
self._loop.call_soon_threadsafe(task.cancel)
return stop_task
def _make_start_task( # noqa
def _make_start_task(
self, name: str, method: Callable[..., Any]
) -> Callable[..., Any]:
"""
@@ -160,8 +177,7 @@ class TaskManager:
method (callable): The coroutine to be turned into an asyncio task.
"""
@wraps(method)
def start_task(*args: Any, **kwargs: Any) -> None:
def start_task() -> None:
def task_done_callback(task: asyncio.Task[None], name: str) -> None:
"""Handles tasks that have finished.
@@ -172,48 +188,29 @@ class TaskManager:
self.tasks.pop(name, None)
# emit the notification that the task was stopped
for callback in self.task_status_change_callbacks:
callback(name, None)
self.service._notify_changed(name, None)
exception = task.exception()
if exception is not None:
# Handle the exception, or you can re-raise it.
logger.error(
f"Task '{name}' encountered an exception: "
f"{type(exception).__name__}: {exception}"
"Task '%s' encountered an exception: %s: %s",
name,
type(exception).__name__,
exception,
)
raise exception
async def task(*args: Any, **kwargs: Any) -> None:
async def task() -> None:
try:
await method(*args, **kwargs)
await method()
except asyncio.CancelledError:
logger.info(f"Task {name} was cancelled")
logger.info("Task '%s' was cancelled", name)
if not self.tasks.get(name):
# Get the signature of the coroutine method to start
sig = inspect.signature(method)
# Create a list of the parameter names from the method signature.
parameter_names = list(sig.parameters.keys())
# Extend the list of positional arguments with None values to match
# the length of the parameter names list. This is done to ensure
# that zip can pair each parameter name with a corresponding value.
args_padded = list(args) + [None] * (len(parameter_names) - len(args))
# Create a dictionary of keyword arguments by pairing the parameter
# names with the values in 'args_padded'. Then merge this dictionary
# with the 'kwargs' dictionary. If a parameter is specified in both
# 'args_padded' and 'kwargs', the value from 'kwargs' is used.
kwargs_updated = {
**dict(zip(parameter_names, args_padded)),
**kwargs,
}
# creating the task and adding the task_done_callback which checks
# if an exception has occured during the task execution
task_object = self._loop.create_task(task(*args, **kwargs))
task_object = self._loop.create_task(task())
task_object.add_done_callback(
lambda task: task_done_callback(task, name)
)
@@ -221,15 +218,11 @@ class TaskManager:
# Store the task and its arguments in the '__tasks' dictionary. The
# key is the name of the method, and the value is a dictionary
# containing the task object and the updated keyword arguments.
self.tasks[name] = {
"task": task_object,
"kwargs": kwargs_updated,
}
self.tasks[name] = task_object
# emit the notification that the task was started
for callback in self.task_status_change_callbacks:
callback(name, kwargs_updated)
self.service._notify_changed(name, TaskStatus.RUNNING)
else:
logger.error(f"Task `{name}` is already running!")
logger.error("Task '%s' is already running!", name)
return start_task

View File

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

View File

@@ -1 +1 @@
<!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.08edc629.js"></script><link href="/static/css/main.c444b055.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"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.57f8ec4c.js"></script><link href="/static/css/main.7ef670d5.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

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

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

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,6 @@
http://jedwatson.github.io/classnames
*/
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @license React
* react-dom.production.min.js

File diff suppressed because one or more lines are too long

View File

View File

@@ -0,0 +1,3 @@
from pydase.observer_pattern.observable.observable import Observable
__all__ = ["Observable"]

View File

@@ -0,0 +1,76 @@
import logging
from typing import Any
from pydase.observer_pattern.observable.observable_object import ObservableObject
from pydase.utils.helpers import is_property_attribute
logger = logging.getLogger(__name__)
class Observable(ObservableObject):
def __init__(self) -> None:
super().__init__()
class_attrs = {
k: type(self).__dict__[k]
for k in set(type(self).__dict__)
- set(Observable.__dict__)
- set(self.__dict__)
- {"__annotations__"}
}
for name, value in class_attrs.items():
if isinstance(value, property) or callable(value):
continue
self.__dict__[name] = self._initialise_new_objects(name, value)
def __setattr__(self, name: str, value: Any) -> None:
if not hasattr(self, "_observers") and name != "_observers":
logger.warning(
"Ensure that super().__init__() is called at the start of the '%s' "
"constructor! Failing to do so may lead to unexpected behavior.",
type(self).__name__,
)
self._observers = {}
value = self._handle_observable_setattr(name, value)
super().__setattr__(name, value)
self._notify_changed(name, value)
def __getattribute__(self, name: str) -> Any:
if is_property_attribute(self, name):
self._notify_change_start(name)
value = super().__getattribute__(name)
if is_property_attribute(self, name):
self._notify_changed(name, value)
return value
def _handle_observable_setattr(self, name: str, value: Any) -> Any:
if name == "_observers":
return value
self._remove_observer_if_observable(name)
value = self._initialise_new_objects(name, value)
self._notify_change_start(name)
return value
def _remove_observer_if_observable(self, name: str) -> None:
if not is_property_attribute(self, name):
current_value = getattr(self, name, None)
if isinstance(current_value, ObservableObject):
current_value._remove_observer(self, name)
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
if observer_attr_name != "":
return (
f"{observer_attr_name}.{instance_attr_name}"
if instance_attr_name != ""
else observer_attr_name
)
return instance_attr_name

View File

@@ -0,0 +1,276 @@
import logging
from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, ClassVar, SupportsIndex
from pydase.utils.helpers import parse_serialized_key
if TYPE_CHECKING:
from pydase.observer_pattern.observer.observer import Observer
logger = logging.getLogger(__name__)
class ObservableObject(ABC):
_list_mapping: ClassVar[dict[int, "_ObservableList"]] = {}
_dict_mapping: ClassVar[dict[int, "_ObservableDict"]] = {}
def __init__(self) -> None:
if not hasattr(self, "_observers"):
self._observers: dict[str, list["ObservableObject | Observer"]] = {}
def add_observer(
self, observer: "ObservableObject | Observer", attr_name: str = ""
) -> None:
if attr_name not in self._observers:
self._observers[attr_name] = []
if observer not in self._observers[attr_name]:
self._observers[attr_name].append(observer)
def _remove_observer(self, observer: "ObservableObject", attribute: str) -> None:
if attribute in self._observers:
self._observers[attribute].remove(observer)
@abstractmethod
def _remove_observer_if_observable(self, name: str) -> None:
"""Removes the current object as an observer from an observable attribute.
This method is called before an attribute of the observable object is
changed. If the current value of the attribute is an instance of
`ObservableObject`, this method removes the current object from its list
of observers. This is a crucial step to avoid unwanted notifications from
the old value of the attribute.
"""
def _notify_changed(self, changed_attribute: str, value: Any) -> None:
"""Notifies all observers about changes to an attribute.
This method iterates through all observers registered for the object and
invokes their notification method. It is called whenever an attribute of
the observable object is changed.
Args:
changed_attribute (str): The name of the changed attribute.
value (Any): The value that the attribute was set to.
"""
for attr_name, observer_list in self._observers.items():
for observer in observer_list:
extendend_attr_path = self._construct_extended_attr_path(
attr_name, changed_attribute
)
observer._notify_changed(extendend_attr_path, value)
def _notify_change_start(self, changing_attribute: str) -> None:
"""Notify observers that an attribute or item change process has started.
This method is called at the start of the process of modifying an attribute in
the observed `Observable` object. It registers the attribute as currently
undergoing a change. This registration helps in managing and tracking changes as
they occur, especially in scenarios where the order of changes or their state
during the transition is significant.
Args:
changing_attribute (str): The name of the attribute that is starting to
change. This is typically the full access path of the attribute in the
`Observable`.
value (Any): The value that the attribute is being set to.
"""
for attr_name, observer_list in self._observers.items():
for observer in observer_list:
extended_attr_path = self._construct_extended_attr_path(
attr_name, changing_attribute
)
observer._notify_change_start(extended_attr_path)
def _initialise_new_objects(self, attr_name_or_key: str, value: Any) -> Any:
new_value = value
if isinstance(value, list):
if id(value) in self._list_mapping:
# If the list `value` was already referenced somewhere else
new_value = self._list_mapping[id(value)]
else:
# convert the builtin list into a ObservableList
new_value = _ObservableList(original_list=value)
self._list_mapping[id(value)] = new_value
elif isinstance(value, dict):
if id(value) in self._dict_mapping:
# If the dict `value` was already referenced somewhere else
new_value = self._dict_mapping[id(value)]
else:
# convert the builtin list into a ObservableList
new_value = _ObservableDict(original_dict=value)
self._dict_mapping[id(value)] = new_value
if isinstance(new_value, ObservableObject):
new_value.add_observer(self, attr_name_or_key)
return new_value
@abstractmethod
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
"""
Constructs the extended attribute path for notification purposes, which is used
in the observer pattern to specify the full path of an observed attribute.
This abstract method is implemented by the classes inheriting from
`ObservableObject`.
Args:
observer_attr_name (str): The name of the attribute in the observer that
holds a reference to the instance. Equals `""` if observer itself is of type
`Observer`.
instance_attr_name (str): The name of the attribute within the instance that
has changed.
Returns:
str: The constructed extended attribute path.
"""
class _ObservableList(ObservableObject, list[Any]):
def __init__(
self,
original_list: list[Any],
) -> None:
self._original_list = original_list
ObservableObject.__init__(self)
list.__init__(self, self._original_list)
for i, item in enumerate(self._original_list):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
def __setitem__(self, key: int, value: Any) -> None: # type: ignore[override]
if hasattr(self, "_observers"):
self._remove_observer_if_observable(f"[{key}]")
value = self._initialise_new_objects(f"[{key}]", value)
self._notify_change_start(f"[{key}]")
super().__setitem__(key, value)
self._notify_changed(f"[{key}]", value)
def append(self, __object: Any) -> None:
self._notify_change_start("")
self._initialise_new_objects(f"[{len(self)}]", __object)
super().append(__object)
self._notify_changed("", self)
def clear(self) -> None:
self._remove_self_from_observables()
super().clear()
self._notify_changed("", self)
def extend(self, __iterable: Iterable[Any]) -> None:
self._remove_self_from_observables()
try:
super().extend(__iterable)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
def insert(self, __index: SupportsIndex, __object: Any) -> None:
self._remove_self_from_observables()
try:
super().insert(__index, __object)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
def pop(self, __index: SupportsIndex = -1) -> Any:
self._remove_self_from_observables()
try:
popped_item = super().pop(__index)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
return popped_item
def remove(self, __value: Any) -> None:
self._remove_self_from_observables()
try:
super().remove(__value)
finally:
for i, item in enumerate(self):
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
self._notify_changed("", self)
def _remove_self_from_observables(self) -> None:
for i in range(len(self)):
self._remove_observer_if_observable(f"[{i}]")
def _remove_observer_if_observable(self, name: str) -> None:
key = int(name[1:-1])
current_value = self.__getitem__(key)
if isinstance(current_value, ObservableObject):
current_value._remove_observer(self, name)
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
if observer_attr_name != "":
return f"{observer_attr_name}{instance_attr_name}"
return instance_attr_name
class _ObservableDict(ObservableObject, dict[str, Any]):
def __init__(
self,
original_dict: dict[str, Any],
) -> None:
self._original_dict = original_dict
ObservableObject.__init__(self)
dict.__init__(self)
for key, value in self._original_dict.items():
self.__setitem__(key, self._initialise_new_objects(f'["{key}"]', value))
def __setitem__(self, key: str, value: Any) -> None:
if not isinstance(key, str):
raise ValueError(
f"Invalid key type: {key} ({type(key).__name__}). In pydase services, "
"dictionary keys must be strings."
)
if hasattr(self, "_observers"):
self._remove_observer_if_observable(f'["{key}"]')
value = self._initialise_new_objects(f'["{key}"]', value)
self._notify_change_start(f'["{key}"]')
super().__setitem__(key, value)
self._notify_changed(f'["{key}"]', value)
def _remove_observer_if_observable(self, name: str) -> None:
key = str(parse_serialized_key(name))
current_value = self.get(key, None)
if isinstance(current_value, ObservableObject):
current_value._remove_observer(self, name)
def _construct_extended_attr_path(
self, observer_attr_name: str, instance_attr_name: str
) -> str:
if observer_attr_name != "":
return f"{observer_attr_name}{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

@@ -0,0 +1,7 @@
from pydase.observer_pattern.observer.observer import Observer
from pydase.observer_pattern.observer.property_observer import PropertyObserver
__all__ = [
"Observer",
"PropertyObserver",
]

View File

@@ -0,0 +1,31 @@
import logging
from abc import ABC, abstractmethod
from typing import Any
from pydase.observer_pattern.observable import Observable
logger = logging.getLogger(__name__)
class Observer(ABC):
def __init__(self, observable: Observable) -> None:
self.observable = observable
self.observable.add_observer(self)
self.changing_attributes: list[str] = []
def _notify_changed(self, changed_attribute: str, value: Any) -> None:
self.on_change(full_access_path=changed_attribute, value=value)
if changed_attribute in self.changing_attributes:
self.changing_attributes.remove(changed_attribute)
def _notify_change_start(self, changing_attribute: str) -> None:
self.changing_attributes.append(changing_attribute)
self.on_change_start(changing_attribute)
@abstractmethod
def on_change(self, full_access_path: str, value: Any) -> None:
...
def on_change_start(self, full_access_path: str) -> None:
return

View File

@@ -0,0 +1,95 @@
import inspect
import logging
import re
from typing import Any
from pydase.observer_pattern.observable.observable import Observable
from pydase.observer_pattern.observer.observer import Observer
logger = logging.getLogger(__name__)
def reverse_dict(original_dict: dict[str, list[str]]) -> dict[str, list[str]]:
reversed_dict: dict[str, list[str]] = {
value: [] for values in original_dict.values() for value in values
}
for key, values in original_dict.items():
for value in values:
reversed_dict[value].append(key)
return reversed_dict
def get_property_dependencies(prop: property, prefix: str = "") -> list[str]:
source_code_string = inspect.getsource(prop.fget) # type: ignore[arg-type]
pattern = r"self\.([^\s\{\}]+)"
matches = re.findall(pattern, source_code_string)
return [prefix + match for match in matches if "(" not in match]
class PropertyObserver(Observer):
def __init__(self, observable: Observable) -> None:
super().__init__(observable)
self._update_property_deps_dict()
def _update_property_deps_dict(self) -> None:
self.property_deps_dict = reverse_dict(
self._get_properties_and_their_dependencies(self.observable)
)
def _get_properties_and_their_dependencies(
self, obj: Observable, prefix: str = ""
) -> dict[str, list[str]]:
deps: dict[str, Any] = {}
self._process_observable_properties(obj, deps, prefix)
self._process_nested_observables_properties(obj, deps, prefix)
return deps
def _process_observable_properties(
self, obj: Observable, deps: dict[str, Any], prefix: str
) -> None:
for k, value in inspect.getmembers(type(obj)):
prefix = (
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
)
key = f"{prefix}{k}"
if isinstance(value, property):
deps[key] = get_property_dependencies(value, prefix)
def _process_nested_observables_properties(
self, obj: Observable, deps: dict[str, Any], prefix: str
) -> None:
for k, value in vars(obj).items():
prefix = (
f"{prefix}." if prefix != "" and not prefix.endswith(".") else prefix
)
parent_path = f"{prefix}{k}"
if isinstance(value, Observable):
new_prefix = f"{parent_path}."
deps.update(
self._get_properties_and_their_dependencies(value, new_prefix)
)
elif isinstance(value, list | dict):
self._process_collection_item_properties(value, deps, parent_path)
def _process_collection_item_properties(
self,
collection: list[Any] | dict[str, Any],
deps: dict[str, Any],
parent_path: str,
) -> None:
if isinstance(collection, list):
for i, item in enumerate(collection):
if isinstance(item, Observable):
new_prefix = f"{parent_path}[{i}]"
deps.update(
self._get_properties_and_their_dependencies(item, new_prefix)
)
elif isinstance(collection, dict):
for key, val in collection.items():
if isinstance(val, Observable):
new_prefix = f"{parent_path}['{key}']"
deps.update(
self._get_properties_and_their_dependencies(val, new_prefix)
)

View File

@@ -1,3 +1,7 @@
from pydase.server.server import Server
from pydase.server.web_server.web_server import WebServer
__all__ = ["Server"]
__all__ = [
"Server",
"WebServer",
]

View File

@@ -3,22 +3,17 @@ import logging
import os
import signal
import threading
from concurrent.futures import ThreadPoolExecutor
from enum import Enum
from pathlib import Path
from types import FrameType
from typing import Any, Optional, Protocol, TypedDict
from typing import Any, Protocol, TypedDict
import uvicorn
from rpyc import ForkingServer, ThreadedServer # type: ignore
from uvicorn.server import HANDLED_SIGNALS
import pydase.units as u
from pydase import DataService
from pydase.config import ServiceConfig
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.version import __version__
from .web_server import WebAPI
from pydase.server.web_server import WebServer
logger = logging.getLogger(__name__)
@@ -32,44 +27,34 @@ class AdditionalServerProtocol(Protocol):
any server implementing it should have an __init__ method for initialization and a
serve method for starting the server.
Parameters:
-----------
service: DataService
The instance of DataService that the server will use. This could be the main
application or a specific service that the server will provide.
port: int
The port number at which the server will be accessible. This should be a valid
port number, typically in the range 1024-65535.
host: str
The hostname or IP address at which the server will be hosted. This could be a
local address (like '127.0.0.1' for localhost) or a public IP address.
state_manager: StateManager
The state manager managing the state cache and persistence of the exposed
service.
**kwargs: Any
Any additional parameters required for initializing the server. These parameters
are specific to the server's implementation.
Args:
data_service_observer:
Observer for the DataService, handling state updates and communication to
connected clients through injected callbacks. Can be utilized to access the
service and state manager, and to add custom state-update callbacks.
host:
Hostname or IP address where the server is accessible. Commonly '0.0.0.0' to
bind to all network interfaces.
port:
Port number on which the server listens. Typically in the range 1024-65535
(non-standard ports).
**kwargs:
Any additional parameters required for initializing the server. These
parameters are specific to the server's implementation.
"""
def __init__(
self,
service: DataService,
port: int,
data_service_observer: DataServiceObserver,
host: str,
state_manager: StateManager,
port: int,
**kwargs: Any,
) -> None:
...
) -> None: ...
async def serve(self) -> Any:
"""Starts the server. This method should be implemented as an asynchronous
method, which means that it should be able to run concurrently with other tasks.
"""
...
class AdditionalServer(TypedDict):
@@ -91,121 +76,96 @@ class Server:
"""
The `Server` class provides a flexible server implementation for the `DataService`.
Parameters:
-----------
service: DataService
The DataService instance that this server will manage.
host: str
The host address for the server. Default is '0.0.0.0', which means all available
network interfaces.
rpc_port: int
The port number for the RPC server. Default is 18871.
web_port: int
The port number for the web server. Default is 8001.
enable_rpc: bool
Whether to enable the RPC server. Default is True.
enable_web: bool
Whether to enable the web server. Default is True.
filename: str | Path | None
Filename of the file managing the service state persistence. Defaults to None.
use_forking_server: bool
Whether to use ForkingServer for multiprocessing. Default is False.
web_settings: dict[str, Any]
Additional settings for the web server. Default is {} (an empty dictionary).
additional_servers : list[AdditionalServer]
A list of additional servers to run alongside the main server. Each entry in the
list should be a dictionary with the following structure:
Args:
service: DataService
The DataService instance that this server will manage.
host: str
The host address for the server. Default is '0.0.0.0', which means all
available network interfaces.
web_port: int
The port number for the web server. Default is
`pydase.config.ServiceConfig().web_port`.
enable_web: bool
Whether to enable the web server. Default is True.
filename: str | Path | None
Filename of the file managing the service state persistence.
Defaults to None.
additional_servers : list[AdditionalServer]
A list of additional servers to run alongside the main server. Each entry in
the list should be a dictionary with the following structure:
- server: A class that adheres to the AdditionalServerProtocol. This
class should have an `__init__` method that accepts the DataService
instance, port, host, and optional keyword arguments, and a `serve`
method that is a coroutine responsible for starting the server.
- port: The port on which the additional server will be running.
- kwargs: A dictionary containing additional keyword arguments that will
be passed to the server's `__init__` method.
- server: A class that adheres to the AdditionalServerProtocol. This class
should have an `__init__` method that accepts the DataService instance,
port, host, and optional keyword arguments, and a `serve` method that is a
coroutine responsible for starting the server.
- port: The port on which the additional server will be running.
- kwargs: A dictionary containing additional keyword arguments that will be
passed to the server's `__init__` method.
Here's an example of how you might define an additional server:
Here's an example of how you might define an additional server:
```python
class MyCustomServer:
def __init__(
self,
data_service_observer: DataServiceObserver,
host: str,
port: int,
**kwargs: Any,
) -> None:
self.observer = data_service_observer
self.state_manager = self.observer.state_manager
self.service = self.state_manager.service
self.port = port
self.host = host
# handle any additional arguments...
async def serve(self):
# code to start the server...
```
>>> class MyCustomServer:
... def __init__(
... self,
... service: DataService,
... port: int,
... host: str,
... state_manager: StateManager,
... **kwargs: Any
... ):
... self.service = service
... self.state_manager = state_manager
... self.port = port
... self.host = host
... # handle any additional arguments...
...
... async def serve(self):
... # code to start the server...
And here's how you might add it to the `additional_servers` list when
creating a `Server` instance:
And here's how you might add it to the `additional_servers` list when creating a
`Server` instance:
>>> server = Server(
... service=my_data_service,
... additional_servers=[
... {
... "server": MyCustomServer,
... "port": 12345,
... "kwargs": {"some_arg": "some_value"}
... }
... ],
... )
... server.run()
**kwargs: Any
Additional keyword arguments.
```python
server = Server(
service=my_data_service,
additional_servers=[
{
"server": MyCustomServer,
"port": 12345,
"kwargs": {"some_arg": "some_value"}
}
],
)
server.run()
```
**kwargs: Any
Additional keyword arguments.
"""
def __init__( # noqa: CFQ002
def __init__( # noqa: PLR0913
self,
service: DataService,
host: str = "0.0.0.0",
rpc_port: int = 18871,
web_port: int = 8001,
enable_rpc: bool = True,
web_port: int = ServiceConfig().web_port,
enable_web: bool = True,
filename: Optional[str | Path] = None,
use_forking_server: bool = False,
web_settings: dict[str, Any] = {},
additional_servers: list[AdditionalServer] = [],
filename: str | Path | None = None,
additional_servers: list[AdditionalServer] | None = None,
**kwargs: Any,
) -> None:
if additional_servers is None:
additional_servers = []
self._service = service
self._host = host
self._rpc_port = rpc_port
self._web_port = web_port
self._enable_rpc = enable_rpc
self._enable_web = enable_web
self._web_settings = web_settings
self._kwargs = kwargs
self._loop: asyncio.AbstractEventLoop
self._rpc_server_type = ForkingServer if use_forking_server else ThreadedServer
self._additional_servers = additional_servers
self.should_exit = False
self.servers: dict[str, asyncio.Future[Any]] = {}
self.executor: ThreadPoolExecutor | None = None
self._info: dict[str, Any] = {
"name": self._service.get_service_name(),
"version": __version__,
"rpc_port": self._rpc_port,
"web_port": self._web_port,
"enable_rpc": self._enable_rpc,
"enable_web": self._enable_web,
"web_settings": self._web_settings,
"additional_servers": [],
**kwargs,
}
self._state_manager = StateManager(self._service, filename)
if getattr(self._service, "_filename", None) is not None:
self._service._state_manager = self._state_manager
self._observer = DataServiceObserver(self._state_manager)
self._state_manager.load_state()
def run(self) -> None:
@@ -213,28 +173,13 @@ class Server:
Initializes the asyncio event loop and starts the server.
This method should be called to start the server after it's been instantiated.
Raises
------
Exception
If there's an error while running the server, the error will be propagated
after the server is shut down.
"""
try:
self._loop = asyncio.get_event_loop()
except RuntimeError:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self.serve())
except Exception:
self._loop.run_until_complete(self.shutdown())
raise
asyncio.run(self.serve())
async def serve(self) -> None:
process_id = os.getpid()
logger.info(f"Started server process [{process_id}]")
logger.info("Started server process [%s]", process_id)
await self.startup()
if self.should_exit:
@@ -242,99 +187,36 @@ class Server:
await self.main_loop()
await self.shutdown()
logger.info(f"Finished server process [{process_id}]")
logger.info("Finished server process [%s]", process_id)
async def startup(self) -> None: # noqa: C901
async def startup(self) -> None:
self._loop = asyncio.get_running_loop()
self._loop.set_exception_handler(self.custom_exception_handler)
self.install_signal_handlers()
self._service._task_manager.start_autostart_tasks()
if self._enable_rpc:
self.executor = ThreadPoolExecutor()
self._rpc_server = self._rpc_server_type(
self._service,
port=self._rpc_port,
protocol_config={
"allow_all_attrs": True,
"allow_setattr": True,
},
)
future_or_task = self._loop.run_in_executor(
executor=self.executor, func=self._rpc_server.start
)
self.servers["rpyc"] = future_or_task
for server in self._additional_servers:
addin_server = server["server"](
self._service,
port=server["port"],
data_service_observer=self._observer,
host=self._host,
state_manager=self._state_manager,
info=self._info,
port=server["port"],
**server["kwargs"],
)
server_name = (
addin_server.__module__ + "." + addin_server.__class__.__name__
)
self._info["additional_servers"].append(
{
"name": server_name,
"port": server["port"],
"host": self._host,
**server["kwargs"],
}
)
future_or_task = self._loop.create_task(addin_server.serve())
self.servers[server_name] = future_or_task
if self._enable_web:
self._wapi: WebAPI = WebAPI(
service=self._service,
info=self._info,
state_manager=self._state_manager,
self._web_server = WebServer(
data_service_observer=self._observer,
host=self._host,
port=self._web_port,
**self._kwargs,
)
web_server = uvicorn.Server(
uvicorn.Config(
self._wapi.fastapi_app, host=self._host, port=self._web_port
)
)
def sio_callback(parent_path: str, name: str, value: Any) -> None:
# TODO: an error happens when an attribute is set to a list
# > File "/usr/lib64/python3.11/json/encoder.py", line 180, in default
# > raise TypeError(f'Object of type {o.__class__.__name__} '
# > TypeError: Object of type list is not JSON serializable
notify_value = value
if isinstance(value, Enum):
notify_value = value.name
if isinstance(value, u.Quantity):
notify_value = {"magnitude": value.m, "unit": str(value.u)}
async def notify() -> None:
try:
await self._wapi.sio.emit( # type: ignore
"notify",
{
"data": {
"parent_path": parent_path,
"name": name,
"value": notify_value,
}
},
)
except Exception as e:
logger.warning(f"Failed to send notification: {e}")
self._loop.create_task(notify())
self._service._callback_manager.add_notification_callback(sio_callback)
# overwrite uvicorn's signal handlers, otherwise it will bogart SIGINT and
# SIGTERM, which makes it impossible to escape out of
web_server.install_signal_handlers = lambda: None # type: ignore
future_or_task = self._loop.create_task(web_server.serve())
future_or_task = self._loop.create_task(self._web_server.serve())
self.servers["web"] = future_or_task
async def main_loop(self) -> None:
@@ -344,26 +226,21 @@ class Server:
async def shutdown(self) -> None:
logger.info("Shutting down")
logger.info(f"Saving data to {self._state_manager.filename}.")
if self._state_manager is not None:
self._state_manager.save_state()
logger.info("Saving data to %s.", self._state_manager.filename)
self._state_manager.save_state()
await self.__cancel_servers()
await self.__cancel_tasks()
if hasattr(self, "_rpc_server") and self._enable_rpc:
logger.debug("Closing rpyc server.")
self._rpc_server.close()
async def __cancel_servers(self) -> None:
for server_name, task in self.servers.items():
task.cancel()
try:
await task
except asyncio.CancelledError:
logger.debug(f"Cancelled {server_name} server.")
logger.debug("Cancelled '%s' server.", server_name)
except Exception as e:
logger.warning(f"Unexpected exception: {e}.")
logger.warning("Unexpected exception: %s", e)
async def __cancel_tasks(self) -> None:
for task in asyncio.all_tasks(self._loop):
@@ -371,9 +248,9 @@ class Server:
try:
await task
except asyncio.CancelledError:
logger.debug(f"Cancelled task {task.get_coro()}.")
logger.debug("Cancelled task '%s'.", task.get_coro())
except Exception as e:
logger.warning(f"Unexpected exception: {e}.")
logger.exception("Unexpected exception: %s", e)
def install_signal_handlers(self) -> None:
if threading.current_thread() is not threading.main_thread():
@@ -383,13 +260,15 @@ class Server:
for sig in HANDLED_SIGNALS:
signal.signal(sig, self.handle_exit)
def handle_exit(self, sig: int = 0, frame: Optional[FrameType] = None) -> None:
def handle_exit(self, sig: int = 0, frame: FrameType | None = None) -> None:
if self.should_exit and sig == signal.SIGINT:
logger.warning(f"Received signal {sig}, forcing exit...")
logger.warning("Received signal '%s', forcing exit...", sig)
os._exit(1)
else:
self.should_exit = True
logger.warning(f"Received signal {sig}, exiting... (CTRL+C to force quit)")
logger.warning(
"Received signal '%s', exiting... (CTRL+C to force quit)", sig
)
def custom_exception_handler(
self, loop: asyncio.AbstractEventLoop, context: dict[str, Any]
@@ -406,7 +285,7 @@ class Server:
async def emit_exception() -> None:
try:
await self._wapi.sio.emit( # type: ignore
await self._web_server._sio.emit(
"exception",
{
"data": {
@@ -416,7 +295,7 @@ class Server:
},
)
except Exception as e:
logger.warning(f"Failed to send notification: {e}")
logger.exception("Failed to send notification: %s", e)
loop.create_task(emit_exception())
else:

View File

@@ -1,175 +0,0 @@
import logging
from pathlib import Path
from typing import Any, TypedDict
import socketio # type: ignore
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydase import DataService
from pydase.data_service.data_service import process_callable_attribute
from pydase.data_service.state_manager import StateManager
from pydase.utils.helpers import get_object_attr_from_path_list
from pydase.version import __version__
logger = logging.getLogger(__name__)
class UpdateDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for updating attributes in a
DataService.
Attributes:
----------
name : str
The name of the attribute to be updated in the DataService instance.
If the attribute is part of a nested structure, this would be the name of the
attribute in the last nested object. For example, for an attribute access path
'attr1.list_attr[0].attr2', 'attr2' would be the name.
parent_path : str
The access path for the parent object of the attribute to be updated. This is
used to construct the full access path for the attribute. For example, for an
attribute access path 'attr1.list_attr[0].attr2', 'attr1.list_attr[0]' would be
the parent_path.
value : Any
The new value to be assigned to the attribute. The type of this value should
match the type of the attribute to be updated.
"""
name: str
parent_path: str
value: Any
class RunMethodDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for running methods from the
exposed DataService.
Attributes:
name (str): The name of the method to be run.
parent_path (str): The access path for the parent object of the method to be
run. This is used to construct the full access path for the method. For
example, for an method with access path 'attr1.list_attr[0].method_name',
'attr1.list_attr[0]' would be the parent_path.
kwargs (dict[str, Any]): The arguments passed to the method.
"""
name: str
parent_path: str
kwargs: dict[str, Any]
class WebAPI:
__sio_app: socketio.ASGIApp
__fastapi_app: FastAPI
def __init__( # noqa: CFQ002
self,
service: DataService,
state_manager: StateManager,
frontend: str | Path | None = None,
css: str | Path | None = None,
enable_CORS: bool = True,
info: dict[str, Any] = {},
*args: Any,
**kwargs: Any,
):
self.service = service
self.state_manager = state_manager
self.frontend = frontend
self.css = css
self.enable_CORS = enable_CORS
self.info = info
self.args = args
self.kwargs = kwargs
self.setup_socketio()
self.setup_fastapi_app()
def setup_socketio(self) -> None:
# the socketio ASGI app, to notify clients when params update
if self.enable_CORS:
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
else:
sio = socketio.AsyncServer(async_mode="asgi")
@sio.event # type: ignore
def set_attribute(sid: str, data: UpdateDict) -> Any:
logger.debug(f"Received frontend update: {data}")
path_list = [*data["parent_path"].split("."), data["name"]]
path_list.remove("DataService") # always at the start, does not do anything
path = ".".join(path_list)
return self.state_manager.set_service_attribute_value_by_path(
path=path, value=data["value"]
)
@sio.event # type: ignore
def run_method(sid: str, data: RunMethodDict) -> Any:
logger.debug(f"Running method: {data}")
path_list = [*data["parent_path"].split("."), data["name"]]
path_list.remove("DataService") # always at the start, does not do anything
method = get_object_attr_from_path_list(self.service, path_list)
return process_callable_attribute(method, data["kwargs"])
self.__sio = sio
self.__sio_app = socketio.ASGIApp(self.__sio)
def setup_fastapi_app(self) -> None: # noqa
app = FastAPI()
if self.enable_CORS:
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/ws", self.__sio_app)
@app.get("/version")
def version() -> str:
return __version__
@app.get("/name")
def name() -> str:
return self.service.get_service_name()
@app.get("/info")
def info() -> dict[str, Any]:
return self.info
@app.get("/service-properties")
def service_properties() -> dict[str, Any]:
return self.state_manager.cache
# exposing custom.css file provided by user
if self.css is not None:
@app.get("/custom.css")
async def styles() -> FileResponse:
return FileResponse(str(self.css))
app.mount(
"/",
StaticFiles(
directory=Path(__file__).parent.parent / "frontend",
html=True,
),
)
self.__fastapi_app = app
@property
def sio(self) -> socketio.AsyncServer:
return self.__sio
@property
def fastapi_app(self) -> FastAPI:
return self.__fastapi_app

View File

@@ -0,0 +1,3 @@
from pydase.server.web_server.web_server import WebServer
__all__ = ["WebServer"]

View File

@@ -0,0 +1,172 @@
import asyncio
import logging
from typing import Any, TypedDict
import click
import socketio # type: ignore[import-untyped]
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.utils.helpers import get_object_attr_from_path
from pydase.utils.logging import SocketIOHandler
from pydase.utils.serialization.deserializer import Deserializer
from pydase.utils.serialization.serializer import SerializedObject, dump
logger = logging.getLogger(__name__)
class UpdateDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for updating attributes in a
DataService.
Attributes:
----------
access_path : string
The full access path of the attribute to be updated.
value : SerializedObject
The serialized new value to be assigned to the attribute.
"""
access_path: str
value: SerializedObject
class TriggerMethodDict(TypedDict):
access_path: str
args: SerializedObject
kwargs: SerializedObject
class RunMethodDict(TypedDict):
"""
A TypedDict subclass representing a dictionary used for running methods from the
exposed DataService.
Attributes:
name (str): The name of the method to be run.
parent_path (str): The access path for the parent object of the method to be
run. This is used to construct the full access path for the method. For
example, for an method with access path 'attr1.list_attr[0].method_name',
'attr1.list_attr[0]' would be the parent_path.
kwargs (dict[str, Any]): The arguments passed to the method.
"""
name: str
parent_path: str
kwargs: dict[str, Any]
def setup_sio_server(
observer: DataServiceObserver,
enable_cors: bool,
loop: asyncio.AbstractEventLoop,
) -> socketio.AsyncServer:
"""
Sets up and configures a Socket.IO asynchronous server.
Args:
observer (DataServiceObserver):
The observer managing state updates and communication.
enable_cors (bool):
Flag indicating whether CORS should be enabled for the server.
loop (asyncio.AbstractEventLoop):
The event loop in which the server will run.
Returns:
socketio.AsyncServer: The configured Socket.IO asynchronous server.
"""
state_manager = observer.state_manager
if enable_cors:
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
else:
sio = socketio.AsyncServer(async_mode="asgi")
setup_sio_events(sio, state_manager)
setup_logging_handler(sio)
# Add notification callback to observer
def sio_callback(
full_access_path: str, value: Any, cached_value_dict: SerializedObject
) -> None:
if cached_value_dict != {}:
async def notify() -> None:
try:
await sio.emit(
"notify",
{
"data": {
"full_access_path": full_access_path,
"value": cached_value_dict,
}
},
)
except Exception as e:
logger.warning("Failed to send notification: %s", e)
loop.create_task(notify())
observer.add_notification_callback(sio_callback)
return sio
def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) -> None: # noqa: C901
@sio.event # type: ignore
async def connect(sid: str, environ: Any) -> None:
logging.debug("Client [%s] connected", click.style(str(sid), fg="cyan"))
@sio.event # type: ignore
async def disconnect(sid: str) -> None:
logging.debug("Client [%s] disconnected", click.style(str(sid), fg="cyan"))
@sio.event # type: ignore
async def service_serialization(sid: str) -> SerializedObject:
logging.debug(
"Client [%s] requested service serialization",
click.style(str(sid), fg="cyan"),
)
return state_manager.cache
@sio.event
async def update_value(sid: str, data: UpdateDict) -> SerializedObject | None: # type: ignore
path = data["access_path"]
try:
state_manager.set_service_attribute_value_by_path(
path=path, serialized_value=data["value"]
)
except Exception as e:
logger.exception(e)
return dump(e)
@sio.event
async def get_value(sid: str, access_path: str) -> SerializedObject:
try:
return state_manager._data_service_cache.get_value_dict_from_cache(
access_path
)
except Exception as e:
logger.exception(e)
return dump(e)
@sio.event
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
try:
method = get_object_attr_from_path(
state_manager.service, data["access_path"]
)
args = Deserializer.deserialize(data["args"])
kwargs: dict[str, Any] = Deserializer.deserialize(data["kwargs"])
return dump(method(*args, **kwargs))
except Exception as e:
logger.error(e)
return dump(e)
def setup_logging_handler(sio: socketio.AsyncServer) -> None:
logger = logging.getLogger()
logger.addHandler(SocketIOHandler(sio))

View File

@@ -0,0 +1,201 @@
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
import socketio # type: ignore[import-untyped]
import uvicorn
from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydase.config import ServiceConfig, WebServerConfig
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.server.web_server.sio_setup import (
setup_sio_server,
)
from pydase.utils.helpers import get_path_from_path_parts, parse_full_access_path
from pydase.utils.serialization.serializer import generate_serialized_data_paths
from pydase.version import __version__
logger = logging.getLogger(__name__)
class WebServer:
"""
Represents a web server that adheres to the AdditionalServerProtocol, designed to
work with a DataService instance. This server facilitates client-server
communication and state management through web protocols and socket connections.
The WebServer class initializes and manages a web server environment using FastAPI
and Socket.IO, allowing for HTTP and WebSocket communications. It incorporates CORS
(Cross-Origin Resource Sharing) support, custom CSS, and serves a frontend static
files directory. It also initializes web server settings based on configuration
files or generates default settings if necessary.
Configuration for the web server (like service configuration directory and whether
to generate new web settings) is determined in the following order of precedence:
1. Values provided directly to the constructor.
2. Environment variable settings (via configuration classes like
`pydase.config.ServiceConfig` and `pydase.config.WebServerConfig`).
3. Default values defined in the configuration classes.
Args:
data_service_observer (DataServiceObserver): Observer for the DataService,
handling state updates and communication to connected clients.
host (str): Hostname or IP address where the server is accessible. Commonly
'0.0.0.0' to bind to all network interfaces.
port (int): Port number on which the server listens. Typically in the range
1024-65535 (non-standard ports).
css (str | Path | None, optional): Path to a custom CSS file for styling the
frontend. If None, no custom styles are applied. Defaults to None.
enable_cors (bool, optional): Flag to enable or disable CORS policy. When True,
CORS is enabled, allowing cross-origin requests. Defaults to True.
config_dir (Path | None, optional): Path to the configuration
directory where the web settings will be stored. Defaults to
`pydase.config.ServiceConfig().config_dir`.
generate_new_web_settings (bool | None, optional): Flag to enable or disable
generation of new web settings if the configuration file is missing. Defaults
to `pydase.config.WebServerConfig().generate_new_web_settings`.
**kwargs (Any): Additional unused keyword arguments.
"""
def __init__( # noqa: PLR0913
self,
data_service_observer: DataServiceObserver,
host: str,
port: int,
css: str | Path | None = None,
enable_cors: bool = True,
config_dir: Path = ServiceConfig().config_dir,
generate_web_settings: bool = WebServerConfig().generate_web_settings,
frontend_src: Path = Path(__file__).parent.parent.parent / "frontend",
) -> None:
self.observer = data_service_observer
self.state_manager = self.observer.state_manager
self.service = self.state_manager.service
self.port = port
self.host = host
self.css = css
self.enable_cors = enable_cors
self.frontend_src = frontend_src
self._service_config_dir = config_dir
self._generate_web_settings = generate_web_settings
self._loop: asyncio.AbstractEventLoop
self._initialise_configuration()
async def serve(self) -> None:
self._loop = asyncio.get_running_loop()
self._setup_socketio()
self._setup_fastapi_app()
self.web_server = uvicorn.Server(
uvicorn.Config(self.__fastapi_app, host=self.host, port=self.port)
)
# overwrite uvicorn's signal handlers, otherwise it will bogart SIGINT and
# SIGTERM, which makes it impossible to escape out of
self.web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
await self.web_server.serve()
def _initialise_configuration(self) -> None:
logger.debug("Initialising web server configuration...")
file_path = self._service_config_dir / "web_settings.json"
if self._generate_web_settings:
# File does not exist, create it with default content
logger.debug("Generating web settings file...")
file_path.parent.mkdir(
parents=True, exist_ok=True
) # Ensure directory exists
file_path.write_text(json.dumps(self.web_settings, indent=4))
def _get_web_settings_from_file(self) -> dict[str, dict[str, Any]]:
file_path = self._service_config_dir / "web_settings.json"
web_settings = {}
# File exists, read its content
if file_path.exists():
logger.debug(
"Reading configuration from file '%s' ...", file_path.absolute()
)
web_settings = json.loads(file_path.read_text())
return web_settings
@property
def web_settings(self) -> dict[str, dict[str, Any]]:
current_web_settings = self._get_web_settings_from_file()
for path in generate_serialized_data_paths(self.state_manager.cache_value):
if path in current_web_settings:
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] = {
"displayName": get_path_from_path_parts(display_name_parts),
"display": True,
}
return current_web_settings
def _setup_socketio(self) -> None:
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
self.__sio_app = socketio.ASGIApp(self._sio)
def _setup_fastapi_app(self) -> None: # noqa: C901
app = FastAPI()
if self.enable_cors:
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/ws", self.__sio_app)
@app.get("/version")
def version() -> str:
return __version__
@app.get("/name")
def name() -> str:
return type(self.service).__name__
@app.get("/service-properties")
def service_properties() -> dict[str, Any]:
return self.state_manager.cache # type: ignore
@app.get("/web-settings")
def web_settings() -> dict[str, Any]:
return self.web_settings
# exposing custom.css file provided by user
@app.get("/custom.css")
async def styles() -> Response:
if self.css is not None:
return FileResponse(str(self.css))
return Response(content="", media_type="text/css")
app.mount(
"/",
StaticFiles(
directory=self.frontend_src,
html=True,
),
)
self.__fastapi_app = app

View File

@@ -15,7 +15,7 @@ class QuantityDict(TypedDict):
def convert_to_quantity(
value: QuantityDict | float | int | Quantity, unit: str = ""
value: QuantityDict | float | Quantity, unit: str = ""
) -> Quantity:
"""
Convert a given value into a pint.Quantity object with the specified unit.
@@ -53,4 +53,4 @@ def convert_to_quantity(
quantity = float(value["magnitude"]) * Unit(value["unit"])
else:
quantity = value
return quantity # type: ignore
return quantity

View File

@@ -0,0 +1,42 @@
import inspect
from collections.abc import Callable
from typing import Any
from pydase.utils.helpers import function_has_arguments
class FunctionDefinitionError(Exception):
pass
def frontend(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator to mark a DataService method for frontend rendering. Ensures that the
method does not contain arguments, as they are not supported for frontend rendering.
"""
if function_has_arguments(func):
raise FunctionDefinitionError(
"The @frontend decorator requires functions without arguments. Function "
f"'{func.__name__}' has at least one argument. "
"Please remove the argument(s) from this function to use it with the "
"@frontend decorator."
)
# Mark the function for frontend display.
func._display_in_frontend = True # type: ignore
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,12 +1,100 @@
import inspect
import logging
import re
from collections.abc import Callable
from itertools import chain
from typing import Any, Optional
from typing import Any
logger = logging.getLogger(__name__)
def get_attribute_doc(attr: Any) -> Optional[str]:
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:
"""This function takes an input attribute attr and returns its documentation
string if it's different from the documentation of its type, otherwise,
it returns None.
@@ -26,18 +114,30 @@ def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
loops.
"""
attrs = dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
attrs.pop("__root__")
return attrs
return dict(chain(type(obj).__dict__.items(), obj.__dict__.items()))
def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
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:
"""
Traverse the object tree according to the given path.
Args:
target_obj: The root object to start the traversal from.
path: A list of attribute names representing the path to traverse.
path: Access path of the object.
Returns:
The attribute at the end of the path. If the path includes a list index,
@@ -47,152 +147,43 @@ def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
Raises:
ValueError: If a list index in the path is not a valid integer.
"""
for part in path:
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(f"Attribute {part} does not exist in the object.")
return None
return target_obj
path_parts = parse_full_access_path(path)
return get_object_by_path_parts(target_obj, path_parts)
def convert_arguments_to_hinted_types(
args: dict[str, Any], type_hints: dict[str, Any]
) -> dict[str, Any] | str:
def get_component_classes() -> list[type]:
"""
Convert the given arguments to their types hinted in the type_hints dictionary.
This function attempts to convert each argument in the args dictionary to the type
specified for the argument in the type_hints dictionary. If the conversion is
successful, the function replaces the original argument in the args dictionary with
the converted argument.
If a ValueError is raised during the conversion of an argument, the function logs
an error message and returns the error message as a string.
Args:
args: A dictionary of arguments to be converted. The keys are argument names
and the values are the arguments themselves.
type_hints: A dictionary of type hints for the arguments. The keys are
argument names and the values are the hinted types.
Returns:
A dictionary of the converted arguments if all conversions are successful,
or an error message string if a ValueError is raised during a conversion.
"""
# Convert arguments to their hinted types
for arg_name, arg_value in args.items():
if arg_name in type_hints:
arg_type = type_hints[arg_name]
if isinstance(arg_type, type):
# Attempt to convert the argument to its hinted type
try:
args[arg_name] = arg_type(arg_value)
except ValueError:
msg = (
f"Failed to convert argument '{arg_name}' to type "
f"{arg_type.__name__}"
)
logger.error(msg)
return msg
return args
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(f"Incompatible arguments: {target}, {attr_name_or_index}.")
def parse_list_attr_and_index(attr_string: str) -> tuple[str, Optional[int]]:
"""
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(f"Invalid index format in key: {attr_name}")
return attr_name, index
def get_component_class_names() -> list[str]:
"""
Returns the names of the component classes in a list.
It takes the names from the pydase/components/__init__.py file, so this file should
always be up-to-date with the currently available components.
Returns:
list[str]: List of component class names
Returns references to the component classes in a list.
"""
import pydase.components
return pydase.components.__all__
return [
getattr(pydase.components, cls_name) for cls_name in pydase.components.__all__
]
def is_property_attribute(target_obj: Any, attr_name: str) -> bool:
return isinstance(getattr(type(target_obj), attr_name, None), property)
def get_data_service_class_reference() -> Any:
import pydase.data_service.data_service
return getattr(pydase.data_service.data_service, "DataService")
def is_property_attribute(target_obj: Any, access_path: str) -> bool:
path_parts = parse_full_access_path(access_path)
target_obj = get_object_by_path_parts(target_obj, path_parts[:-1])
# don't have to check if target_obj is dict or list as their content cannot be
# properties -> always return False then
return isinstance(getattr(type(target_obj), path_parts[-1], None), property)
def function_has_arguments(func: Callable[..., Any]) -> bool:
sig = inspect.signature(func)
parameters = dict(sig.parameters)
# Remove 'self' parameter for instance methods.
parameters.pop("self", None)
# Check if there are any parameters left which would indicate additional arguments.
if len(parameters) > 0:
return True
return False

View File

@@ -1,8 +1,9 @@
import asyncio
import logging
import sys
from copy import copy
from typing import Optional
import socketio # type: ignore[import-untyped]
import uvicorn.logging
from uvicorn.config import LOGGING_CONFIG
@@ -18,7 +19,7 @@ class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
for formatting the output, instead of the plain text message.
"""
def formatMessage(self, record: logging.LogRecord) -> str:
def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802
recordcopy = copy(record)
levelname = recordcopy.levelname
seperator = " " * (8 - len(recordcopy.levelname))
@@ -31,10 +32,39 @@ class DefaultFormatter(uvicorn.logging.ColourizedFormatter):
return logging.Formatter.formatMessage(self, recordcopy)
def should_use_colors(self) -> bool:
return sys.stderr.isatty() # pragma: no cover
return sys.stderr.isatty()
def setup_logging(level: Optional[str | int] = None) -> None:
class SocketIOHandler(logging.Handler):
"""
Custom logging handler that emits ERROR and CRITICAL log records to a Socket.IO
server, allowing for real-time logging in applications that use Socket.IO for
communication.
"""
def __init__(self, sio: socketio.AsyncServer) -> None:
super().__init__(logging.ERROR)
self._sio = sio
def format(self, record: logging.LogRecord) -> str:
return f"{record.name}:{record.funcName}:{record.lineno} - {record.message}"
def emit(self, record: logging.LogRecord) -> None:
log_entry = self.format(record)
loop = asyncio.get_event_loop()
loop.create_task(
self._sio.emit(
"log",
{
"levelname": record.levelname,
"message": log_entry,
},
)
)
def setup_logging(level: str | int | None = None) -> None:
"""
Configures the logging settings for the application.
@@ -43,7 +73,7 @@ def setup_logging(level: Optional[str | int] = None) -> None:
with an option to override the level. By default, in a development environment, the
log level is set to DEBUG, whereas in other environments, it is set to INFO.
Parameters:
Args:
level (Optional[str | int]):
A specific log level to set for the application. If None, the log level is
determined based on the application's operation mode. Accepts standard log
@@ -92,7 +122,10 @@ def setup_logging(level: Optional[str | int] = None) -> None:
# add formatter to ch
ch.setFormatter(
DefaultFormatter(
fmt="%(asctime)s.%(msecs)03d | %(levelprefix)s | %(name)s:%(funcName)s:%(lineno)d - %(message)s",
fmt=(
"%(asctime)s.%(msecs)03d | %(levelprefix)s | "
"%(name)s:%(funcName)s:%(lineno)d - %(message)s"
),
datefmt="%Y-%m-%d %H:%M:%S",
)
)
@@ -109,7 +142,8 @@ def setup_logging(level: Optional[str | int] = None) -> None:
"fmt"
] = "%(asctime)s.%(msecs)03d | %(levelprefix)s %(message)s"
LOGGING_CONFIG["formatters"]["default"]["datefmt"] = "%Y-%m-%d %H:%M:%S"
LOGGING_CONFIG["formatters"]["access"][
"fmt"
] = '%(asctime)s.%(msecs)03d | %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s'
LOGGING_CONFIG["formatters"]["access"]["fmt"] = (
"%(asctime)s.%(msecs)03d | %(levelprefix)s %(client_addr)s "
'- "%(request_line)s" %(status_code)s'
)
LOGGING_CONFIG["formatters"]["access"]["datefmt"] = "%Y-%m-%d %H:%M:%S"

View File

@@ -0,0 +1,151 @@
import enum
import logging
from typing import TYPE_CHECKING, Any, NoReturn, cast
import pydase
import pydase.components
import pydase.units as u
from pydase.utils.helpers import get_component_classes
from pydase.utils.serialization.types import SerializedObject
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
class Deserializer:
@classmethod
def deserialize(cls, serialized_object: SerializedObject) -> Any:
type_handler: dict[str | None, None | Callable[..., Any]] = {
None: None,
"int": cls.deserialize_primitive,
"float": cls.deserialize_primitive,
"bool": cls.deserialize_primitive,
"str": cls.deserialize_primitive,
"NoneType": cls.deserialize_primitive,
"Quantity": cls.deserialize_quantity,
"Enum": cls.deserialize_enum,
"ColouredEnum": lambda serialized_object: cls.deserialize_enum(
serialized_object, enum_class=pydase.components.ColouredEnum
),
"list": cls.deserialize_list,
"dict": cls.deserialize_dict,
"method": cls.deserialize_method,
"Exception": cls.deserialize_exception,
}
# First go through handled types (as ColouredEnum is also within the components)
handler = type_handler.get(serialized_object["type"])
if handler:
return handler(serialized_object)
# Custom types like Components or DataService classes
component_class = cls.get_component_class(serialized_object["type"])
if component_class:
return cls.deserialize_component_type(serialized_object, component_class)
return None
@classmethod
def deserialize_primitive(cls, serialized_object: SerializedObject) -> Any:
if serialized_object["type"] == "float":
return float(serialized_object["value"])
return serialized_object["value"]
@classmethod
def deserialize_quantity(cls, serialized_object: SerializedObject) -> Any:
return u.convert_to_quantity(serialized_object["value"]) # type: ignore
@classmethod
def deserialize_enum(
cls,
serialized_object: SerializedObject,
enum_class: type[enum.Enum] = enum.Enum,
) -> Any:
return enum_class(serialized_object["name"], serialized_object["enum"])[ # type: ignore
serialized_object["value"]
]
@classmethod
def deserialize_list(cls, serialized_object: SerializedObject) -> Any:
return [
cls.deserialize(item)
for item in cast(list[SerializedObject], serialized_object["value"])
]
@classmethod
def deserialize_dict(cls, serialized_object: SerializedObject) -> Any:
return {
key: cls.deserialize(value)
for key, value in cast(
dict[str, SerializedObject], serialized_object["value"]
).items()
}
@classmethod
def deserialize_method(cls, serialized_object: SerializedObject) -> Any:
return
@classmethod
def deserialize_exception(cls, serialized_object: SerializedObject) -> NoReturn:
import builtins
try:
exception = getattr(builtins, serialized_object["name"]) # type: ignore
except AttributeError:
exception = type(serialized_object["name"], (Exception,), {}) # type: ignore
raise exception(serialized_object["value"])
@staticmethod
def get_component_class(type_name: str | None) -> type | None:
for component_class in get_component_classes():
if type_name == component_class.__name__:
return component_class
if type_name == "DataService":
import pydase
return pydase.DataService
return None
@classmethod
def create_attr_property(cls, serialized_attr: SerializedObject) -> property:
attr_name = serialized_attr["full_access_path"].split(".")[-1]
def get(self) -> Any: # type: ignore
return getattr(self, f"_{attr_name}")
get.__doc__ = serialized_attr["doc"]
def set(self, value: Any) -> None: # type: ignore
return setattr(self, f"_{attr_name}", value)
if serialized_attr["readonly"]:
return property(get)
return property(get, set)
@classmethod
def deserialize_component_type(
cls, serialized_object: SerializedObject, base_class: type
) -> Any:
def create_proxy_class(serialized_object: SerializedObject) -> type:
class_bases = (base_class,)
class_attrs = {}
# Process and add properties based on the serialized object
for key, value in cast(
dict[str, SerializedObject], serialized_object["value"]
).items():
if value["type"] != "method":
class_attrs[key] = cls.create_attr_property(value)
# Initialize a placeholder for the attribute to avoid AttributeError
class_attrs[f"_{key}"] = cls.deserialize(value)
# Create the dynamic class with the given name and attributes
return type(serialized_object["name"], class_bases, class_attrs) # type: ignore
return create_proxy_class(serialized_object)()
def loads(serialized_object: SerializedObject) -> Any:
return Deserializer.deserialize(serialized_object)

View File

@@ -0,0 +1,548 @@
from __future__ import annotations
import inspect
import logging
import sys
from enum import Enum
from typing import TYPE_CHECKING, Any, Literal, cast
import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.data_service.task_manager import TaskStatus
from pydase.utils.decorators import render_in_frontend
from pydase.utils.helpers import (
get_attribute_doc,
get_component_classes,
get_data_service_class_reference,
parse_full_access_path,
parse_serialized_key,
)
from pydase.utils.serialization.types import (
DataServiceTypes,
SerializedBool,
SerializedDataService,
SerializedDict,
SerializedEnum,
SerializedException,
SerializedFloat,
SerializedInteger,
SerializedList,
SerializedMethod,
SerializedNoneType,
SerializedObject,
SerializedQuantity,
SerializedString,
SignatureDict,
)
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
class SerializationError(Exception):
pass
class SerializationPathError(Exception):
pass
class SerializationValueError(Exception):
pass
class Serializer:
@staticmethod
def serialize_object(obj: Any, access_path: str = "") -> SerializedObject: # noqa: C901
result: SerializedObject
if isinstance(obj, Exception):
result = Serializer._serialize_exception(obj)
elif isinstance(obj, AbstractDataService):
result = Serializer._serialize_data_service(obj, access_path=access_path)
elif isinstance(obj, list):
result = Serializer._serialize_list(obj, access_path=access_path)
elif isinstance(obj, dict):
result = Serializer._serialize_dict(obj, access_path=access_path)
# Special handling for u.Quantity
elif isinstance(obj, u.Quantity):
result = Serializer._serialize_quantity(obj, access_path=access_path)
# Handling for Enums
elif isinstance(obj, Enum):
result = Serializer._serialize_enum(obj, access_path=access_path)
# Methods and coroutines
elif inspect.isfunction(obj) or inspect.ismethod(obj):
result = Serializer._serialize_method(obj, access_path=access_path)
elif isinstance(obj, int | float | bool | str | None):
result = Serializer._serialize_primitive(obj, access_path=access_path)
try:
return result
except UnboundLocalError:
raise SerializationError(
f"Could not serialized object of type {type(obj)}."
)
@staticmethod
def _serialize_primitive(
obj: float | bool | str | None,
access_path: str,
) -> (
SerializedInteger
| SerializedFloat
| SerializedBool
| SerializedString
| SerializedNoneType
):
doc = get_attribute_doc(obj)
return { # type: ignore
"full_access_path": access_path,
"doc": doc,
"readonly": False,
"type": type(obj).__name__,
"value": obj,
}
@staticmethod
def _serialize_exception(obj: Exception) -> SerializedException:
return {
"full_access_path": "",
"doc": None,
"readonly": True,
"type": "Exception",
"value": obj.args[0],
"name": obj.__class__.__name__,
}
@staticmethod
def _serialize_enum(obj: Enum, access_path: str = "") -> SerializedEnum:
import pydase.components.coloured_enum
value = obj.name
doc = obj.__doc__
class_name = type(obj).__name__
if sys.version_info < (3, 11) and doc == "An enumeration.":
doc = None
if isinstance(obj, pydase.components.coloured_enum.ColouredEnum):
obj_type: Literal["ColouredEnum", "Enum"] = "ColouredEnum"
else:
obj_type = "Enum"
return {
"full_access_path": access_path,
"name": class_name,
"type": obj_type,
"value": value,
"readonly": False,
"doc": doc,
"enum": {
name: member.value for name, member in obj.__class__.__members__.items()
},
}
@staticmethod
def _serialize_quantity(
obj: u.Quantity, access_path: str = ""
) -> SerializedQuantity:
doc = get_attribute_doc(obj)
value: u.QuantityDict = {"magnitude": obj.m, "unit": str(obj.u)}
return {
"full_access_path": access_path,
"type": "Quantity",
"value": value,
"readonly": False,
"doc": doc,
}
@staticmethod
def _serialize_dict(obj: dict[str, Any], access_path: str = "") -> SerializedDict:
readonly = False
doc = get_attribute_doc(obj)
value = {}
for key, val in obj.items():
value[key] = Serializer.serialize_object(
val, access_path=f'{access_path}["{key}"]'
)
return {
"full_access_path": access_path,
"type": "dict",
"value": value,
"readonly": readonly,
"doc": doc,
}
@staticmethod
def _serialize_list(obj: list[Any], access_path: str = "") -> SerializedList:
readonly = False
doc = get_attribute_doc(obj)
value = [
Serializer.serialize_object(o, access_path=f"{access_path}[{i}]")
for i, o in enumerate(obj)
]
return {
"full_access_path": access_path,
"type": "list",
"value": value,
"readonly": readonly,
"doc": doc,
}
@staticmethod
def _serialize_method(
obj: Callable[..., Any], access_path: str = ""
) -> SerializedMethod:
readonly = True
doc = get_attribute_doc(obj)
frontend_render = render_in_frontend(obj)
# Store parameters and their anotations in a dictionary
sig = inspect.signature(obj)
sig.return_annotation
signature: SignatureDict = {"parameters": {}, "return_annotation": {}}
for k, v in sig.parameters.items():
default_value = cast(
dict[str, Any], {} if v.default == inspect._empty else dump(v.default)
)
default_value.pop("full_access_path", None)
signature["parameters"][k] = {
"annotation": str(v.annotation),
"default": default_value,
}
return {
"full_access_path": access_path,
"type": "method",
"value": None,
"readonly": readonly,
"doc": doc,
"async": inspect.iscoroutinefunction(obj),
"signature": signature,
"frontend_render": frontend_render,
}
@staticmethod
def _serialize_data_service(
obj: AbstractDataService, access_path: str = ""
) -> SerializedDataService:
readonly = False
doc = get_attribute_doc(obj)
obj_type: DataServiceTypes = "DataService"
obj_name = obj.__class__.__name__
# Get component base class if any
component_base_cls = next(
(cls for cls in get_component_classes() if isinstance(obj, cls)), None
)
if component_base_cls:
obj_type = component_base_cls.__name__ # type: ignore
# Get the set of DataService class attributes
data_service_attr_set = set(dir(get_data_service_class_reference()))
# Get the set of the object attributes
obj_attr_set = set(dir(obj))
# Get the difference between the two sets
derived_only_attr_set = obj_attr_set - data_service_attr_set
value: dict[str, SerializedObject] = {}
# Iterate over attributes, properties, class attributes, and methods
for key in sorted(derived_only_attr_set):
if key.startswith("_"):
continue # Skip attributes that start with underscore
# Skip keys that start with "start_" or "stop_" and end with an async
# method name
if key.startswith(("start_", "stop_")) and key.split("_", 1)[1] in {
name
for name, _ in inspect.getmembers(
obj, predicate=inspect.iscoroutinefunction
)
}:
continue
val = getattr(obj, key)
path = f"{access_path}.{key}" if access_path else key
serialized_object = Serializer.serialize_object(val, access_path=path)
# If there's a running task for this method
if serialized_object["type"] == "method" and key in obj._task_manager.tasks:
serialized_object["value"] = TaskStatus.RUNNING.name
value[key] = serialized_object
# If the DataService attribute is a property
if isinstance(getattr(obj.__class__, key, None), property):
prop: property = getattr(obj.__class__, key)
value[key]["readonly"] = prop.fset is None
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
return {
"full_access_path": access_path,
"name": obj_name,
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
}
def dump(obj: Any) -> SerializedObject:
return Serializer.serialize_object(obj)
def set_nested_value_by_path(
serialization_dict: dict[Any, SerializedObject], path: str, value: Any
) -> None:
"""
Set a value in a nested dictionary structure, which conforms to the serialization
format used by `pydase.utils.serializer.Serializer`, using a dot-notation path.
Args:
serialization_dict:
The base dictionary representing data serialized with
`pydase.utils.serializer.Serializer`.
path:
The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to
set the value.
value:
The new value to set at the specified path.
Note:
- If the index equals the length of the list, the function will append the
serialized representation of the 'value' to the list.
"""
path_parts = parse_full_access_path(path)
current_dict: dict[Any, SerializedObject] = serialization_dict
try:
for path_part in path_parts[:-1]:
next_level_serialized_object = get_container_item_by_key(
current_dict, path_part, allow_append=False
)
current_dict = cast(
dict[Any, SerializedObject],
next_level_serialized_object["value"],
)
next_level_serialized_object = get_container_item_by_key(
current_dict, path_parts[-1], allow_append=True
)
except (SerializationPathError, SerializationValueError, KeyError) as e:
logger.error("Error occured trying to change %a: %s", path, e)
return
if next_level_serialized_object["type"] == "method": # state change of task
next_level_serialized_object["value"] = (
"RUNNING" if isinstance(value, TaskStatus) else None
)
else:
serialized_value = Serializer.serialize_object(value, access_path=path)
serialized_value["readonly"] = next_level_serialized_object["readonly"]
keys_to_keep = set(serialized_value.keys())
next_level_serialized_object.update(serialized_value) # type: ignore
# removes keys that are not present in the serialized new value
for key in list(next_level_serialized_object.keys()):
if key not in keys_to_keep:
next_level_serialized_object.pop(key, None) # type: ignore
def get_nested_dict_by_path(
serialization_dict: dict[Any, SerializedObject],
path: str,
) -> SerializedObject:
path_parts = parse_full_access_path(path)
current_dict: dict[Any, SerializedObject] = serialization_dict
for path_part in path_parts[:-1]:
next_level_serialized_object = get_container_item_by_key(
current_dict, path_part, allow_append=False
)
current_dict = cast(
dict[Any, SerializedObject],
next_level_serialized_object["value"],
)
return get_container_item_by_key(current_dict, path_parts[-1], allow_append=False)
def create_empty_serialized_object() -> SerializedObject:
"""Create a new empty serialized object."""
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,
) -> SerializedObject:
"""
Retrieve an item from a container specified by the passed key. Add an item to the
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:
container: dict[str, SerializedObject] | list[SerializedObject]
The container representing serialized data.
key: str
The key name representing the attribute in the dictionary, which may include
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:
SerializedObject
The dictionary or list item corresponding to the specified attribute and
index.
Raises:
SerializationPathError:
If the path composed of `attr_name` and any specified index is invalid, or
leads to an IndexError or KeyError. This error is also raised if an attempt
to access a nonexistent key or index occurs without permission to append.
SerializationValueError:
If the retrieval results in an object that is expected to be a dictionary
but is not, indicating a mismatch between expected and actual serialized
data structure.
"""
processed_key = parse_serialized_key(key)
try:
return get_or_create_item_in_container(
container, processed_key, allow_add_key=allow_append
)
except IndexError as e:
raise SerializationPathError(f"Index '{processed_key}': {e}")
except KeyError as e:
raise SerializationPathError(f"Key '{processed_key}': {e}")
def get_data_paths_from_serialized_object( # noqa: C901
serialized_obj: SerializedObject,
parent_path: str = "",
) -> list[str]:
"""
Recursively extracts full access paths from a serialized object.
Args:
serialized_obj (SerializedObject):
The dictionary representing the serialization of an object. Produced by
`pydase.utils.serializer.Serializer`.
Returns:
list[str]:
A list of strings, each representing a full access path in the serialized
object.
"""
paths: list[str] = []
if isinstance(serialized_obj["value"], list):
for index, value in enumerate(serialized_obj["value"]):
new_path = f"{parent_path}[{index}]"
paths.append(new_path)
if serialized_dict_is_nested_object(value):
paths.extend(get_data_paths_from_serialized_object(value, new_path))
elif serialized_dict_is_nested_object(serialized_obj):
for key, value in cast(
dict[str, SerializedObject], serialized_obj["value"]
).items():
# Serialized dictionaries need to have a different new_path than nested
# classes
if serialized_obj["type"] == "dict":
processed_key = key
if isinstance(key, str):
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
def serialized_dict_is_nested_object(serialized_dict: SerializedObject) -> bool:
value = serialized_dict["value"]
# We are excluding Quantity here as the value corresponding to the "value" key is
# a dictionary of the form {"magnitude": ..., "unit": ...}
return serialized_dict["type"] != "Quantity" and (isinstance(value, dict | list))

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Literal, TypedDict
if TYPE_CHECKING:
import pydase.units as u
logger = logging.getLogger(__name__)
class SignatureDict(TypedDict):
parameters: dict[str, dict[str, Any]]
return_annotation: dict[str, Any]
class SerializedObjectBase(TypedDict):
full_access_path: str
doc: str | None
readonly: bool
class SerializedInteger(SerializedObjectBase):
value: int
type: Literal["int"]
class SerializedFloat(SerializedObjectBase):
value: float
type: Literal["float"]
class SerializedQuantity(SerializedObjectBase):
value: u.QuantityDict
type: Literal["Quantity"]
class SerializedBool(SerializedObjectBase):
value: bool
type: Literal["bool"]
class SerializedString(SerializedObjectBase):
value: str
type: Literal["str"]
class SerializedEnum(SerializedObjectBase):
name: str
value: str
type: Literal["Enum", "ColouredEnum"]
enum: dict[str, Any]
class SerializedList(SerializedObjectBase):
value: list[SerializedObject]
type: Literal["list"]
class SerializedDict(SerializedObjectBase):
value: dict[str, SerializedObject]
type: Literal["dict"]
class SerializedNoneType(SerializedObjectBase):
value: None
type: Literal["NoneType"]
class SerializedNoValue(SerializedObjectBase):
value: None
type: Literal["None"]
SerializedMethod = TypedDict(
"SerializedMethod",
{
"full_access_path": str,
"value": Literal["RUNNING"] | None,
"type": Literal["method"],
"doc": str | None,
"readonly": bool,
"async": bool,
"signature": SignatureDict,
"frontend_render": bool,
},
)
class SerializedException(SerializedObjectBase):
name: str
value: str
type: Literal["Exception"]
DataServiceTypes = Literal["DataService", "Image", "NumberSlider", "DeviceConnection"]
class SerializedDataService(SerializedObjectBase):
name: str
value: dict[str, SerializedObject]
type: DataServiceTypes
SerializedObject = (
SerializedBool
| SerializedFloat
| SerializedInteger
| SerializedString
| SerializedList
| SerializedDict
| SerializedNoneType
| SerializedMethod
| SerializedException
| SerializedDataService
| SerializedEnum
| SerializedQuantity
| SerializedNoValue
)

View File

@@ -1,387 +0,0 @@
import inspect
import logging
from collections.abc import Callable
from enum import Enum
from typing import Any, Optional
import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService
from pydase.utils.helpers import (
get_attribute_doc,
get_component_class_names,
parse_list_attr_and_index,
)
logger = logging.getLogger(__name__)
class SerializationPathError(Exception):
pass
class SerializationValueError(Exception):
pass
class Serializer:
@staticmethod
def serialize_object(obj: Any) -> dict[str, Any]:
result: dict[str, Any] = {}
if isinstance(obj, AbstractDataService):
result = Serializer._serialize_DataService(obj)
elif isinstance(obj, list):
result = Serializer._serialize_list(obj)
elif isinstance(obj, dict):
result = Serializer._serialize_dict(obj)
# Special handling for u.Quantity
elif isinstance(obj, u.Quantity):
result = Serializer._serialize_Quantity(obj)
# Handling for Enums
elif isinstance(obj, Enum):
result = Serializer._serialize_enum(obj)
# Methods and coroutines
elif inspect.isfunction(obj) or inspect.ismethod(obj):
result = Serializer._serialize_method(obj)
else:
obj_type = type(obj).__name__
value = obj
readonly = False
doc = get_attribute_doc(obj)
result = {
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
}
return result
@staticmethod
def _serialize_enum(obj: Enum) -> dict[str, Any]:
value = obj.name
readonly = False
doc = get_attribute_doc(obj)
if type(obj).__base__.__name__ == "ColouredEnum":
obj_type = "ColouredEnum"
else:
obj_type = "Enum"
return {
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
"enum": {
name: member.value for name, member in obj.__class__.__members__.items()
},
}
@staticmethod
def _serialize_Quantity(obj: u.Quantity) -> dict[str, Any]:
obj_type = "Quantity"
readonly = False
doc = get_attribute_doc(obj)
value = {"magnitude": obj.m, "unit": str(obj.u)}
return {
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
}
@staticmethod
def _serialize_dict(obj: dict[str, Any]) -> dict[str, Any]:
obj_type = "dict"
readonly = False
doc = get_attribute_doc(obj)
value = {key: Serializer.serialize_object(val) for key, val in obj.items()}
return {
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
}
@staticmethod
def _serialize_list(obj: list[Any]) -> dict[str, Any]:
obj_type = "list"
readonly = False
doc = get_attribute_doc(obj)
value = [Serializer.serialize_object(o) for o in obj]
return {
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
}
@staticmethod
def _serialize_method(obj: Callable[..., Any]) -> dict[str, Any]:
obj_type = "method"
value = None
readonly = True
doc = get_attribute_doc(obj)
# Store parameters and their anotations in a dictionary
sig = inspect.signature(obj)
parameters: dict[str, Optional[str]] = {}
for k, v in sig.parameters.items():
annotation = v.annotation
if annotation is not inspect._empty:
if isinstance(annotation, type):
# Handle regular types
parameters[k] = annotation.__name__
else:
# Union, string annotation, Literal types, ...
parameters[k] = str(annotation)
else:
parameters[k] = None
return {
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
"async": inspect.iscoroutinefunction(obj),
"parameters": parameters,
}
@staticmethod
def _serialize_DataService(obj: AbstractDataService) -> dict[str, Any]:
readonly = False
doc = get_attribute_doc(obj)
obj_type = type(obj).__name__
if type(obj).__name__ not in get_component_class_names():
obj_type = "DataService"
# Get the dictionary of the base class
base_set = set(type(obj).__base__.__dict__)
# Get the dictionary of the derived class
derived_set = set(type(obj).__dict__)
# Get the difference between the two dictionaries
derived_only_set = derived_set - base_set
instance_dict = set(obj.__dict__)
# Merge the class and instance dictionaries
merged_set = derived_only_set | instance_dict
value = {}
# Iterate over attributes, properties, class attributes, and methods
for key in sorted(merged_set):
if key.startswith("_"):
continue # Skip attributes that start with underscore
# Skip keys that start with "start_" or "stop_" and end with an async
# method name
if (key.startswith("start_") or key.startswith("stop_")) and key.split(
"_", 1
)[1] in {
name
for name, _ in inspect.getmembers(
obj, predicate=inspect.iscoroutinefunction
)
}:
continue
val = getattr(obj, key)
value[key] = Serializer.serialize_object(val)
# If there's a running task for this method
if key in obj._task_manager.tasks:
task_info = obj._task_manager.tasks[key]
value[key]["value"] = task_info["kwargs"]
# If the DataService attribute is a property
if isinstance(getattr(obj.__class__, key, None), property):
prop: property = getattr(obj.__class__, key)
value[key]["readonly"] = prop.fset is None
value[key]["doc"] = get_attribute_doc(prop) # overwrite the doc
return {
"type": obj_type,
"value": value,
"readonly": readonly,
"doc": doc,
}
def dump(obj: Any) -> dict[str, Any]:
return Serializer.serialize_object(obj)
def set_nested_value_by_path(
serialization_dict: dict[str, Any], path: str, value: Any
) -> None:
"""
Set a value in a nested dictionary structure, which conforms to the serialization
format used by `pydase.utils.serializer.Serializer`, using a dot-notation path.
Args:
serialization_dict:
The base dictionary representing data serialized with
`pydase.utils.serializer.Serializer`.
path:
The dot-notation path (e.g., 'attr1.attr2[0].attr3') indicating where to
set the value.
value:
The new value to set at the specified path.
Note:
- If the index equals the length of the list, the function will append the
serialized representation of the 'value' to the list.
"""
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1]
current_dict: dict[str, Any] = serialization_dict
try:
for path_part in parent_path_parts:
current_dict = get_next_level_dict_by_key(
current_dict, path_part, allow_append=False
)
current_dict = current_dict["value"]
current_dict = get_next_level_dict_by_key(
current_dict, attr_name, allow_append=True
)
except (SerializationPathError, SerializationValueError, KeyError) as e:
logger.error(e)
return
# setting the new value
serialized_value = dump(value)
if "readonly" in current_dict:
current_dict["value"] = serialized_value["value"]
current_dict["type"] = serialized_value["type"]
else:
current_dict.update(serialized_value)
def get_nested_dict_by_path(
serialization_dict: dict[str, Any],
path: str,
) -> dict[str, Any]:
parent_path_parts, attr_name = path.split(".")[:-1], path.split(".")[-1]
current_dict: dict[str, Any] = serialization_dict
try:
for path_part in parent_path_parts:
current_dict = get_next_level_dict_by_key(
current_dict, path_part, allow_append=False
)
current_dict = current_dict["value"]
current_dict = get_next_level_dict_by_key(
current_dict, attr_name, allow_append=False
)
except (SerializationPathError, SerializationValueError, KeyError) as e:
logger.error(e)
return {}
return current_dict
def get_next_level_dict_by_key(
serialization_dict: dict[str, Any],
attr_name: str,
allow_append: bool = False,
) -> dict[str, Any]:
"""
Retrieve a nested dictionary entry or list item from a data structure serialized
with `pydase.utils.serializer.Serializer`.
Args:
serialization_dict: The base dictionary representing serialized data.
attr_name: The key name representing the attribute in the dictionary,
e.g. 'list_attr[0]' or 'attr'
allow_append: Flag to allow appending a new entry if `index` is out of range by
one.
Returns:
The dictionary or list item corresponding to the attribute and index.
Raises:
SerializationPathError: If the path composed of `attr_name` and `index` is
invalid or leads to an IndexError or KeyError.
SerializationValueError: If the expected nested structure is not a dictionary.
"""
# Check if the key contains an index part like 'attr_name[<index>]'
attr_name, index = parse_list_attr_and_index(attr_name)
try:
if index is not None:
serialization_dict = serialization_dict[attr_name]["value"][index]
else:
serialization_dict = serialization_dict[attr_name]
except IndexError as e:
if allow_append and index == len(serialization_dict[attr_name]["value"]):
# Appending to list
serialization_dict[attr_name]["value"].append({})
serialization_dict = serialization_dict[attr_name]["value"][index]
else:
raise SerializationPathError(
f"Error occured trying to change '{attr_name}[{index}]': {e}"
)
except KeyError:
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."
)
if not isinstance(serialization_dict, dict):
raise SerializationValueError(
f"Expected a dictionary at '{attr_name}', but found type "
f"'{type(serialization_dict).__name__}' instead."
)
return serialization_dict
def generate_serialized_data_paths(
data: dict[str, Any], parent_path: str = ""
) -> list[str]:
"""
Generate a list of access paths for all attributes in a dictionary representing
data serialized with `pydase.utils.serializer.Serializer`, excluding those that are
methods.
Args:
data: The dictionary representing serialized data, typically produced by
`pydase.utils.serializer.Serializer`.
parent_path: The base path to prepend to the keys in the `data` dictionary to
form the access paths. Defaults to an empty string.
Returns:
A list of strings where each string is a dot-notation access path to an
attribute in the serialized data.
"""
paths = []
for key, value in data.items():
if value["type"] == "method":
# ignoring methods
continue
new_path = f"{parent_path}.{key}" if parent_path else key
if isinstance(value["value"], dict) and value["type"] != "Quantity":
paths.extend(generate_serialized_data_paths(value["value"], new_path)) # type: ignore
elif isinstance(value["value"], list):
for index, item in enumerate(value["value"]):
indexed_key_path = f"{new_path}[{index}]"
if isinstance(item["value"], dict):
paths.extend( # type: ignore
generate_serialized_data_paths(item["value"], indexed_key_path)
)
else:
paths.append(indexed_key_path) # type: ignore
else:
paths.append(new_path) # type: ignore
return paths

View File

@@ -1,26 +0,0 @@
import logging
from pydase.utils.helpers import get_component_class_names
logger = logging.getLogger(__name__)
def warn_if_instance_class_does_not_inherit_from_DataService(__value: object) -> None:
base_class_name = __value.__class__.__base__.__name__
module_name = __value.__class__.__module__
if (
module_name
not in [
"builtins",
"__builtin__",
"asyncio.unix_events",
"_abc",
]
and base_class_name
not in ["DataService", "list", "Enum"] + get_component_class_names()
and type(__value).__name__ not in ["CallbackManager", "TaskManager", "Quantity"]
):
logger.warning(
f"Warning: Class {type(__value).__name__} does not inherit from DataService."
)

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