131 Commits

Author SHA1 Message Date
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
87 changed files with 3724 additions and 2964 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/

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

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

29
.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": true,
"source.fixAll": true
}
},
"[yaml]": {
@@ -29,10 +19,9 @@
"[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
}

View File

@@ -531,31 +531,46 @@ 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
## Logging in pydase
You can change the log level of the logger by either
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.
1. (RECOMMENDED) setting the `ENVIRONMENT` environment variable to "production" or "development"
### Changing the Log Level
```bash
ENVIRONMENT="production" python -m <module_using_pydase>
```
You have two primary ways to adjust the log levels in `pydase`:
The production environment will only log messages above "INFO", the development environment (default) logs everything above "DEBUG".
2. calling the `pydase.utils.logging.setup_logging` function with the desired log level
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
from pydase.utils.logging import setup_logging
setup_logging("INFO") # or setup_logging(logging.INFO)
logger = logging.getLogger()
# ... and your log
# 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>
```
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.
**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

@@ -115,13 +115,14 @@ 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;
addNotification: (message: string, levelname?: LevelName) => void;
// Define your component specific props here
value: string;
format: string;
@@ -299,6 +300,7 @@ useEffect(() => {
```
However, you might want to use the `addNotification` at different places. For an example, see the [MethodComponent](../../frontend/src/components/MethodComponent.tsx).
**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.

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,6 +1,6 @@
body {
min-width: 576px;
max-width: 1200px;
max-width: 2000px;
}
input.instantUpdate {
background-color: rgba(255, 0, 0, 0.1);
@@ -17,10 +17,13 @@ input.instantUpdate {
position: fixed !important;
padding: 5px;
}
.notificationToast {
.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;
}
.buttonComponent {

View File

@@ -6,98 +6,35 @@ import {
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 { SerializedValue, setNestedValueByPath, State } from './utils/stateUtils';
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);
return setNestedValueByPath(state, action.fullAccessPath, action.newValue);
}
default:
throw new Error();
@@ -109,8 +46,7 @@ const App = () => {
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
@@ -137,7 +73,7 @@ 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 }));
setConnectionStatus('connected');
});
socket.on('disconnect', () => {
@@ -152,70 +88,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 +155,7 @@ const App = () => {
<Notifications
showNotification={showNotification}
notifications={notifications}
exceptions={exceptions}
removeNotificationById={removeNotificationById}
removeExceptionById={removeExceptionById}
/>
<Offcanvas

View File

@@ -3,6 +3,7 @@ import { runMethod } from '../socket';
import { InputGroup, Form, Button } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface AsyncMethodProps {
name: string;
@@ -11,7 +12,7 @@ interface AsyncMethodProps {
value: Record<string, string>;
docString?: string;
hideOutput?: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const AsyncMethodComponent = React.memo((props: AsyncMethodProps) => {

View File

@@ -3,6 +3,7 @@ import { ToggleButton } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ButtonComponentProps {
name: string;
@@ -11,7 +12,7 @@ interface ButtonComponentProps {
readOnly: boolean;
docString: string;
mapping?: [string, string]; // Enforce a tuple of two strings
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {

View File

@@ -3,6 +3,7 @@ import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ColouredEnumComponentProps {
name: string;
@@ -11,7 +12,7 @@ interface ColouredEnumComponentProps {
docString?: string;
readOnly: boolean;
enumDict: Record<string, string>;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ColouredEnumComponent = React.memo((props: ColouredEnumComponentProps) => {

View File

@@ -4,13 +4,14 @@ import { Card, Collapse } from 'react-bootstrap';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
import { Attribute, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
type DataServiceProps = {
name: string;
props: DataServiceJSON;
parentPath?: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
};
export type DataServiceJSON = Record<string, Attribute>;

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { InputGroup, Form, Row, Col } from 'react-bootstrap';
import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import { LevelName } from './NotificationsComponent';
interface EnumComponentProps {
name: string;
@@ -9,7 +10,7 @@ interface EnumComponentProps {
value: string;
docString?: string;
enumDict: Record<string, string>;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const EnumComponent = React.memo((props: EnumComponentProps) => {

View File

@@ -10,6 +10,7 @@ import { ListComponent } from './ListComponent';
import { DataServiceComponent, DataServiceJSON } from './DataServiceComponent';
import { ImageComponent } from './ImageComponent';
import { ColouredEnumComponent } from './ColouredEnumComponent';
import { LevelName } from './NotificationsComponent';
type AttributeType =
| 'str'
@@ -40,7 +41,7 @@ type GenericComponentProps = {
name: string;
parentPath: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
};
export const GenericComponent = React.memo(

View File

@@ -3,6 +3,7 @@ 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;
@@ -11,7 +12,7 @@ interface ImageComponentProps {
readOnly: boolean;
docString: string;
format: string;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ImageComponent = React.memo((props: ImageComponentProps) => {

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
import { DocStringComponent } from './DocStringComponent';
import { Attribute, GenericComponent } from './GenericComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface ListComponentProps {
name: string;
@@ -9,7 +10,7 @@ interface ListComponentProps {
value: Attribute[];
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const ListComponent = React.memo((props: ListComponentProps) => {

View File

@@ -3,6 +3,7 @@ import { runMethod } from '../socket';
import { Button, InputGroup, Form, Collapse } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface MethodProps {
name: string;
@@ -10,7 +11,7 @@ interface MethodProps {
parameters: Record<string, string>;
docString?: string;
hideOutput?: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const MethodComponent = React.memo((props: MethodProps) => {

View File

@@ -1,70 +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">
{showNotification &&
notifications.map((notification) => (
{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

@@ -4,6 +4,7 @@ import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
// TODO: add button functionality
@@ -23,7 +24,7 @@ interface NumberComponentProps {
value: number,
callback?: (ack: unknown) => void
) => void;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
// TODO: highlight the digit that is being changed by setting both selectionStart and
@@ -122,7 +123,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
// 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
// If emitUpdate is passed, use this instead of the setAttribute from the socket
// Also used when used with a slider
const emitUpdate =
props.customEmitUpdate !== undefined ? props.customEmitUpdate : setAttribute;

View File

@@ -5,6 +5,7 @@ import { DocStringComponent } from './DocStringComponent';
import { Slider } from '@mui/material';
import { NumberComponent } from './NumberComponent';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
interface SliderComponentProps {
name: string;
@@ -16,7 +17,7 @@ interface SliderComponentProps {
docString: string;
stepSize: number;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const SliderComponent = React.memo((props: SliderComponentProps) => {

View File

@@ -4,6 +4,7 @@ import { setAttribute } from '../socket';
import { DocStringComponent } from './DocStringComponent';
import '../App.css';
import { getIdFromFullAccessPath } from '../utils/stringUtils';
import { LevelName } from './NotificationsComponent';
// TODO: add button functionality
@@ -14,7 +15,7 @@ interface StringComponentProps {
readOnly: boolean;
docString: string;
isInstantUpdate: boolean;
addNotification: (message: string) => void;
addNotification: (message: string, levelname?: LevelName) => void;
}
export const StringComponent = React.memo((props: StringComponentProps) => {

View File

@@ -0,0 +1,108 @@
export interface SerializedValue {
type: string;
value: Record<string, unknown> | Array<Record<string, unknown>>;
readonly: boolean;
doc: string | null;
async?: boolean;
parameters?: unknown;
}
export type State = Record<string, SerializedValue> | null;
export function setNestedValueByPath(
serializationDict: Record<string, SerializedValue>,
path: string,
serializedValue: SerializedValue
): Record<string, SerializedValue> {
const parentPathParts = path.split('.').slice(0, -1);
const attrName = path.split('.').pop();
if (!attrName) {
throw new Error('Invalid path');
}
let currentSerializedValue: SerializedValue;
const newSerializationDict: Record<string, SerializedValue> = JSON.parse(
JSON.stringify(serializationDict)
);
let currentDict = newSerializationDict;
try {
for (const pathPart of parentPathParts) {
currentSerializedValue = getNextLevelDictByKey(currentDict, pathPart, false);
// @ts-expect-error The value will be of type SerializedValue as we are still
// looping through the parent parts
currentDict = currentSerializedValue['value'];
}
currentSerializedValue = getNextLevelDictByKey(currentDict, attrName, true);
Object.assign(currentSerializedValue, serializedValue);
return newSerializationDict;
} catch (error) {
console.error(error);
return currentDict;
}
}
function getNextLevelDictByKey(
serializationDict: Record<string, SerializedValue>,
attrName: string,
allowAppend: boolean = false
): SerializedValue {
const [key, index] = parseListAttrAndIndex(attrName);
let currentDict: SerializedValue;
try {
if (index !== null) {
if (!serializationDict[key] || !Array.isArray(serializationDict[key]['value'])) {
throw new Error(`Expected an array at '${key}', but found something else.`);
}
if (index < serializationDict[key]['value'].length) {
currentDict = serializationDict[key]['value'][index];
} else if (allowAppend && index === serializationDict[key]['value'].length) {
// Appending to list
// @ts-expect-error When the index is not null, I expect an array
serializationDict[key]['value'].push({});
currentDict = serializationDict[key]['value'][index];
} else {
throw new Error(`Index out of range for '${key}[${index}]'.`);
}
} else {
if (!serializationDict[key]) {
throw new Error(`Key '${key}' not found.`);
}
currentDict = serializationDict[key];
}
} catch (error) {
throw new Error(`Error occurred trying to access '${attrName}': ${error}`);
}
if (typeof currentDict !== 'object' || currentDict === null) {
throw new Error(
`Expected a dictionary at '${attrName}', but found type '${typeof currentDict}' instead.`
);
}
return currentDict;
}
function parseListAttrAndIndex(attrString: string): [string, number | null] {
let index: number | null = null;
let attrName = attrString;
if (attrString.includes('[') && attrString.endsWith(']')) {
const parts = attrString.split('[');
attrName = parts[0];
const indexPart = parts[1].slice(0, -1); // Removes the closing ']'
if (!isNaN(parseInt(indexPart))) {
index = parseInt(indexPart);
} else {
console.error(`Invalid index format in key: ${attrString}`);
}
}
return [attrName, index];
}

View File

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

992
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.1"
version = "0.4.0"
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"
@@ -19,24 +19,21 @@ confz = "^2.0.0"
pint = "^0.22"
pillow = "^10.0.0"
[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.1.5"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.2"
@@ -48,39 +45,59 @@ 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
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 = [
"E203", # whitespace-before-punctuation
"W292", # missing-newline-at-end-of-file
"PERF203", # try-except-in-loop
]
extend-exclude = [
"docs", "frontend"
]
[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

@@ -57,5 +57,3 @@ class ColouredEnum(Enum):
my_service.status = MyStatus.FAILED
```
"""
pass

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

@@ -13,15 +13,15 @@ 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):
type (Literal["int", "float"], optional):
The type of the slider value. Determines if the value is an integer or float.
Defaults to "float".
@@ -38,25 +38,24 @@ class NumberSlider(DataService):
```
"""
def __init__(
def __init__( # noqa: PLR0913
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: float = 0,
min_: float = 0.0,
max_: float = 100.0,
step_size: float = 1.0,
type_: Literal["int", "float"] = "float",
) -> None:
if type not in {"float", "int"}:
logger.error(f"Unknown type '{type}'. Using 'float'.")
type = "float"
super().__init__()
if type_ not in {"float", "int"}:
logger.error("Unknown type '%s'. Using 'float'.", type_)
type_ = "float"
self._type = type
self._type = type_
self.step_size = step_size
self.value = value
self.min = min
self.max = max
super().__init__()
self.min = min_
self.max = max_
def __setattr__(self, name: str, value: Any) -> None:
if name in ["value", "step_size"]:

View File

@@ -3,7 +3,7 @@ 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"])

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,15 +1,17 @@
import inspect
import logging
import warnings
from enum import Enum
from pathlib import Path
from typing import Any, Optional, get_type_hints
from typing import TYPE_CHECKING, Any, get_type_hints
import rpyc # type: ignore
import rpyc # type: ignore[import-untyped]
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,
@@ -23,9 +25,9 @@ from pydase.utils.serializer import (
generate_serialized_data_paths,
get_nested_dict_by_path,
)
from pydase.utils.warnings import (
warn_if_instance_class_does_not_inherit_from_DataService,
)
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -43,67 +45,93 @@ def process_callable_attribute(attr: Any, args: dict[str, Any]) -> Any:
class DataService(rpyc.Service, AbstractDataService):
def __init__(self, **kwargs: Any) -> None:
self._callback_manager: CallbackManager = CallbackManager(self)
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`.",
"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 | 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
def __set_attribute_based_on_type( # noqa: PLR0913
self,
target_obj: Any,
attr_name: str,
attr: Any,
value: Any,
index: Optional[int],
index: int | None,
path_list: list[str],
) -> None:
if isinstance(attr, Enum):
@@ -154,9 +182,11 @@ class DataService(rpyc.Service, AbstractDataService):
)
if hasattr(self, "_state_manager"):
getattr(self, "_state_manager").save_state()
self._state_manager.save_state()
def load_DataService_from_JSON(self, json_dict: dict[str, Any]) -> None:
def load_DataService_from_JSON( # noqa: N802
self, json_dict: dict[str, Any]
) -> None:
warnings.warn(
"'load_DataService_from_JSON' is deprecated and will be removed in a "
"future version. "
@@ -178,8 +208,9 @@ class DataService(rpyc.Service, AbstractDataService):
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..."
"Attribute '%s' is read-only. Ignoring value from JSON "
"file...",
path,
)
continue
# Split the path into parts
@@ -193,11 +224,14 @@ class DataService(rpyc.Service, AbstractDataService):
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...'
"Attribute type of '%s' changed from '%s' to "
"'%s'. Ignoring value from JSON file...",
path,
value_type,
class_value_type,
)
def serialize(self) -> dict[str, dict[str, Any]]: # noqa
def serialize(self) -> dict[str, dict[str, Any]]:
"""
Serializes the instance into a dictionary, preserving the structure of the
instance.
@@ -216,7 +250,7 @@ class DataService(rpyc.Service, AbstractDataService):
"""
return Serializer.serialize_object(self)["value"]
def update_DataService_attribute(
def update_DataService_attribute( # noqa: N802
self,
path_list: list[str],
attr_name: str,

View File

@@ -1,7 +1,12 @@
import logging
from typing import TYPE_CHECKING, Any
from pydase.utils.serializer import set_nested_value_by_path
from pydase.utils.serializer import (
SerializationPathError,
SerializationValueError,
get_nested_dict_by_path,
set_nested_value_by_path,
)
if TYPE_CHECKING:
from pydase import DataService
@@ -23,13 +28,12 @@ class DataServiceCache:
"""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(self._cache, 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) -> dict[str, Any]:
try:
return get_nested_dict_by_path(self._cache, full_access_path)
except (SerializationPathError, SerializationValueError, KeyError):
return {}

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,107 @@
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_list
from pydase.utils.serializer import 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, dict[str, Any]], None]
] = []
super().__init__(state_manager.service)
def on_change(self, full_access_path: str, value: Any) -> None:
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)
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: dict[str, Any]
) -> None:
value_dict = dump(value)
if cached_value_dict != {}:
if (
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_list(self.observable, prop.split(".")),
)
def add_notification_callback(
self, callback: Callable[[str, Any, dict[str, Any]], 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,7 +3,7 @@ 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
@@ -41,17 +41,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 +96,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
@@ -124,7 +126,7 @@ class StateManager:
with open(self.filename, "w") as f:
json.dump(self.cache, f, indent=4)
else:
logger.error(
logger.info(
"State manager was not initialised with a filename. Skipping "
"'save_state'..."
)
@@ -136,37 +138,36 @@ 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)
nested_class_dict = self._data_service_cache.get_value_dict_from_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
and self.__is_loadable_state_attribute(path)
):
self.set_service_attribute_value_by_path(path, value)
if class_attr_value_type == value_type:
if self.__is_loadable_state_attribute(path):
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..."
"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:
# 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))
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(
@@ -192,7 +193,7 @@ class StateManager:
# 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)
@@ -201,7 +202,7 @@ class StateManager:
if self.__attr_value_has_changed(converted_value, current_value_dict["value"]):
self.__update_attribute_by_path(path, converted_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`.
@@ -217,6 +218,8 @@ class StateManager:
) -> Any:
if current_value_dict["type"] == "Quantity":
return u.convert_to_quantity(value, current_value_dict["value"]["unit"])
if current_value_dict["type"] == "float" and not isinstance(value, float):
return float(value)
return value
def __update_attribute_by_path(self, path: str, value: Any) -> None:
@@ -262,8 +265,9 @@ class StateManager:
has_decorator = has_load_state_decorator(prop)
if not has_decorator:
logger.debug(
f"Property {attr_name!r} has no '@load_state' decorator. "
"Ignoring value from JSON file..."
"Property '%s' has no '@load_state' decorator. "
"Ignoring value from JSON file...",
attr_name,
)
return has_decorator
return True

View File

@@ -3,15 +3,15 @@ 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 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
if TYPE_CHECKING:
from collections.abc import Callable
from .data_service import DataService
logger = logging.getLogger(__name__)
@@ -86,15 +86,9 @@ class TaskManager:
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
def _set_start_and_stop_for_async_methods(self) -> None:
# inspect the methods of the class
for name, method in inspect.getmembers(
self.service, predicate=inspect.iscoroutinefunction
@@ -111,18 +105,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()
@@ -145,7 +139,7 @@ class TaskManager:
return stop_task
def _make_start_task( # noqa
def _make_start_task(
self, name: str, method: Callable[..., Any]
) -> Callable[..., Any]:
"""
@@ -172,15 +166,16 @@ 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
@@ -188,7 +183,7 @@ class TaskManager:
try:
await method(*args, **kwargs)
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
@@ -207,7 +202,7 @@ class TaskManager:
# 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)),
**dict(zip(parameter_names, args_padded, strict=True)),
**kwargs,
}
@@ -227,9 +222,8 @@ class TaskManager:
}
# 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, kwargs_updated)
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.32559665.css",
"main.js": "/static/js/main.6d4f9d3a.js",
"main.css": "/static/css/main.2d8458eb.css",
"main.js": "/static/js/main.7f907b0f.js",
"index.html": "/index.html",
"main.32559665.css.map": "/static/css/main.32559665.css.map",
"main.6d4f9d3a.js.map": "/static/js/main.6d4f9d3a.js.map"
"main.2d8458eb.css.map": "/static/css/main.2d8458eb.css.map",
"main.7f907b0f.js.map": "/static/js/main.7f907b0f.js.map"
},
"entrypoints": [
"static/css/main.32559665.css",
"static/js/main.6d4f9d3a.js"
"static/css/main.2d8458eb.css",
"static/js/main.7f907b0f.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.6d4f9d3a.js"></script><link href="/static/css/main.32559665.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.7f907b0f.js"></script><link href="/static/css/main.2d8458eb.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,71 @@
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__)
}
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}"
return instance_attr_name

View File

@@ -0,0 +1,263 @@
import logging
from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, ClassVar, SupportsIndex
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: Any, 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 list `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, str(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._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(dict[str, Any], ObservableObject):
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():
super().__setitem__(key, self._initialise_new_objects(f"['{key}']", value))
def __setitem__(self, key: str, value: Any) -> None:
if not isinstance(key, str):
logger.warning("Converting non-string dictionary key %s to string.", key)
key = str(key)
if hasattr(self, "_observers"):
self._remove_observer_if_observable(f"['{key}']")
value = self._initialise_new_objects(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 = name[2:-2]
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

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:
if changed_attribute in self.changing_attributes:
self.changing_attributes.remove(changed_attribute)
self.on_change(full_access_path=changed_attribute, value=value)
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 vars(type(obj)).items():
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

@@ -4,19 +4,18 @@ 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 rpyc import ForkingServer, ThreadedServer # type: ignore[import-untyped]
from uvicorn.server import HANDLED_SIGNALS
import pydase.units as u
from pydase import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pydase.version import __version__
from pydase.utils.serializer import dump
from .web_server import WebAPI
@@ -69,7 +68,6 @@ class AdditionalServerProtocol(Protocol):
"""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):
@@ -110,8 +108,6 @@ class Server:
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:
@@ -164,7 +160,7 @@ class Server:
Additional keyword arguments.
"""
def __init__( # noqa: CFQ002
def __init__( # noqa: PLR0913
self,
service: DataService,
host: str = "0.0.0.0",
@@ -172,19 +168,19 @@ class Server:
web_port: int = 8001,
enable_rpc: bool = True,
enable_web: bool = True,
filename: Optional[str | Path] = None,
filename: str | Path | None = None,
use_forking_server: bool = False,
web_settings: dict[str, Any] = {},
additional_servers: list[AdditionalServer] = [],
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
@@ -192,21 +188,11 @@ class Server:
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._state_manager.load_state()
self._observer = DataServiceObserver(self._state_manager)
def run(self) -> None:
"""
@@ -234,7 +220,7 @@ class Server:
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,7 +228,7 @@ 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
self._loop = asyncio.get_running_loop()
@@ -270,28 +256,18 @@ class Server:
port=server["port"],
host=self._host,
state_manager=self._state_manager,
info=self._info,
**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(
self._wapi = WebAPI(
service=self._service,
info=self._info,
state_manager=self._state_manager,
**self._kwargs,
)
@@ -301,39 +277,37 @@ class Server:
)
)
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)}
def sio_callback(
full_access_path: str, value: Any, cached_value_dict: dict[str, Any]
) -> None:
if cached_value_dict != {}:
serialized_value = dump(value)
if cached_value_dict["type"] != "method":
cached_value_dict["type"] = serialized_value["type"]
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}")
cached_value_dict["value"] = serialized_value["value"]
self._loop.create_task(notify())
async def notify() -> None:
try:
await self._wapi.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)
self._service._callback_manager.add_notification_callback(sio_callback)
self._loop.create_task(notify())
self._observer.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
web_server.install_signal_handlers = lambda: None # type: ignore[method-assign]
future_or_task = self._loop.create_task(web_server.serve())
self.servers["web"] = future_or_task
@@ -344,7 +318,7 @@ class Server:
async def shutdown(self) -> None:
logger.info("Shutting down")
logger.info(f"Saving data to {self._state_manager.filename}.")
logger.info("Saving data to %s.", self._state_manager.filename)
if self._state_manager is not None:
self._state_manager.save_state()
@@ -361,9 +335,9 @@ class Server:
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 +345,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 +357,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 +382,7 @@ class Server:
async def emit_exception() -> None:
try:
await self._wapi.sio.emit( # type: ignore
await self._wapi.sio.emit(
"exception",
{
"data": {
@@ -416,7 +392,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

@@ -2,7 +2,7 @@ import logging
from pathlib import Path
from typing import Any, TypedDict
import socketio # type: ignore
import socketio # type: ignore[import-untyped]
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
@@ -12,6 +12,7 @@ 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.utils.logging import SocketIOHandler
from pydase.version import __version__
logger = logging.getLogger(__name__)
@@ -69,39 +70,42 @@ class WebAPI:
__sio_app: socketio.ASGIApp
__fastapi_app: FastAPI
def __init__( # noqa: CFQ002
def __init__( # noqa: PLR0913
self,
service: DataService,
state_manager: StateManager,
frontend: str | Path | None = None,
css: str | Path | None = None,
enable_CORS: bool = True,
info: dict[str, Any] = {},
enable_cors: bool = True,
*args: Any,
**kwargs: Any,
):
) -> None:
self.service = service
self.state_manager = state_manager
self.frontend = frontend
self.css = css
self.enable_CORS = enable_CORS
self.info = info
self.enable_cors = enable_cors
self.args = args
self.kwargs = kwargs
self.setup_socketio()
self.setup_fastapi_app()
self.setup_logging_handler()
def setup_logging_handler(self) -> None:
logger = logging.getLogger()
logger.addHandler(SocketIOHandler(self.__sio))
def setup_socketio(self) -> None:
# the socketio ASGI app, to notify clients when params update
if self.enable_CORS:
if self.enable_cors:
sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
else:
sio = socketio.AsyncServer(async_mode="asgi")
@sio.event # type: ignore
@sio.event
def set_attribute(sid: str, data: UpdateDict) -> Any:
logger.debug(f"Received frontend update: {data}")
logger.debug("Received frontend update: %s", 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)
@@ -109,9 +113,9 @@ class WebAPI:
path=path, value=data["value"]
)
@sio.event # type: ignore
@sio.event
def run_method(sid: str, data: RunMethodDict) -> Any:
logger.debug(f"Running method: {data}")
logger.debug("Running method: %s", 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)
@@ -120,10 +124,10 @@ class WebAPI:
self.__sio = sio
self.__sio_app = socketio.ASGIApp(self.__sio)
def setup_fastapi_app(self) -> None: # noqa
def setup_fastapi_app(self) -> None:
app = FastAPI()
if self.enable_CORS:
if self.enable_cors:
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
@@ -141,10 +145,6 @@ class WebAPI:
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

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

@@ -1,12 +1,12 @@
import inspect
import logging
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 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,9 +26,7 @@ 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:
@@ -59,7 +57,7 @@ def get_object_attr_from_path_list(target_obj: Any, path: list[str]) -> Any:
target_obj = getattr(target_obj, part)
except AttributeError:
# The attribute doesn't exist
logger.debug(f"Attribute {part} does not exist in the object.")
logger.debug("Attribute % does not exist in the object.", part)
return None
return target_obj
@@ -141,10 +139,10 @@ def update_value_if_changed(
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}.")
logger.error("Incompatible arguments: %s, %s.", target, attr_name_or_index)
def parse_list_attr_and_index(attr_string: str) -> tuple[str, Optional[int]]:
def parse_list_attr_and_index(attr_string: str) -> tuple[str, int | None]:
"""
Parses an attribute string and extracts a potential list attribute name and its
index.
@@ -175,7 +173,7 @@ def parse_list_attr_and_index(attr_string: str) -> tuple[str, Optional[int]]:
if index_part.isdigit():
index = int(index_part)
else:
logger.error(f"Invalid index format in key: {attr_name}")
logger.error("Invalid index format in key: %s", attr_name)
return attr_name, index

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

@@ -2,7 +2,7 @@ import inspect
import logging
from collections.abc import Callable
from enum import Enum
from typing import Any, Optional
from typing import Any
import pydase.units as u
from pydase.data_service.abstract_data_service import AbstractDataService
@@ -28,7 +28,7 @@ class Serializer:
def serialize_object(obj: Any) -> dict[str, Any]:
result: dict[str, Any] = {}
if isinstance(obj, AbstractDataService):
result = Serializer._serialize_DataService(obj)
result = Serializer._serialize_data_service(obj)
elif isinstance(obj, list):
result = Serializer._serialize_list(obj)
@@ -38,7 +38,7 @@ class Serializer:
# Special handling for u.Quantity
elif isinstance(obj, u.Quantity):
result = Serializer._serialize_Quantity(obj)
result = Serializer._serialize_quantity(obj)
# Handling for Enums
elif isinstance(obj, Enum):
@@ -83,7 +83,7 @@ class Serializer:
}
@staticmethod
def _serialize_Quantity(obj: u.Quantity) -> dict[str, Any]:
def _serialize_quantity(obj: u.Quantity) -> dict[str, Any]:
obj_type = "Quantity"
readonly = False
doc = get_attribute_doc(obj)
@@ -130,7 +130,7 @@ class Serializer:
# Store parameters and their anotations in a dictionary
sig = inspect.signature(obj)
parameters: dict[str, Optional[str]] = {}
parameters: dict[str, str | None] = {}
for k, v in sig.parameters.items():
annotation = v.annotation
@@ -154,7 +154,7 @@ class Serializer:
}
@staticmethod
def _serialize_DataService(obj: AbstractDataService) -> dict[str, Any]:
def _serialize_data_service(obj: AbstractDataService) -> dict[str, Any]:
readonly = False
doc = get_attribute_doc(obj)
obj_type = type(obj).__name__
@@ -180,9 +180,7 @@ class Serializer:
# 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 {
if key.startswith(("start_", "stop_")) and key.split("_", 1)[1] in {
name
for name, _ in inspect.getmembers(
obj, predicate=inspect.iscoroutinefunction
@@ -259,8 +257,9 @@ def set_nested_value_by_path(
# setting the new value
serialized_value = dump(value)
if "readonly" in current_dict:
if current_dict["type"] != "method":
current_dict["type"] = serialized_value["type"]
current_dict["value"] = serialized_value["value"]
current_dict["type"] = serialized_value["type"]
else:
current_dict.update(serialized_value)
@@ -272,26 +271,18 @@ def get_nested_dict_by_path(
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"]
for path_part in parent_path_parts:
current_dict = get_next_level_dict_by_key(
current_dict, attr_name, allow_append=False
current_dict, path_part, allow_append=False
)
except (SerializationPathError, SerializationValueError, KeyError) as e:
logger.error(e)
return {}
return current_dict
current_dict = current_dict["value"]
return get_next_level_dict_by_key(current_dict, attr_name, allow_append=False)
def get_next_level_dict_by_key(
serialization_dict: dict[str, Any],
attr_name: str,
*,
allow_append: bool = False,
) -> dict[str, Any]:
"""
@@ -365,23 +356,23 @@ def generate_serialized_data_paths(
attribute in the serialized data.
"""
paths = []
paths: list[str] = []
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
paths.extend(generate_serialized_data_paths(value["value"], new_path))
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
paths.extend(
generate_serialized_data_paths(item["value"], indexed_key_path)
)
else:
paths.append(indexed_key_path) # type: ignore
paths.append(indexed_key_path)
else:
paths.append(new_path) # type: ignore
paths.append(new_path)
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."
)

View File

@@ -1,4 +1,4 @@
from importlib.metadata import distribution
__version__ = distribution("pydase").version
__major__, __minor__, __patch__ = [int(v) for v in __version__.split(".")]
__major__, __minor__, __patch__ = (int(v) for v in __version__.split("."))

View File

@@ -1,7 +1,8 @@
from pytest import LogCaptureFixture
from pydase.components.coloured_enum import ColouredEnum
from pydase.data_service.data_service import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
def test_ColouredEnum(caplog: LogCaptureFixture) -> None:
@@ -21,14 +22,16 @@ def test_ColouredEnum(caplog: LogCaptureFixture) -> None:
# do something ...
self._status = value
service = ServiceClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service.status = MyStatus.FAILING
service_instance.status = MyStatus.FAILING
assert "ServiceClass.status changed to MyStatus.FAILING" in caplog.text
assert "'status' changed to 'MyStatus.FAILING'" in caplog.text
def test_warning(caplog: LogCaptureFixture) -> None: # noqa
def test_warning(caplog: LogCaptureFixture) -> None:
class MyStatus(ColouredEnum):
RUNNING = "#00FF00"
FAILING = "#FF0000"
@@ -36,6 +39,9 @@ def test_warning(caplog: LogCaptureFixture) -> None: # noqa
class ServiceClass(DataService):
status = MyStatus.RUNNING
ServiceClass()
assert (
"Warning: Class MyStatus does not inherit from DataService." not in caplog.text
"Class 'MyStatus' does not inherit from DataService. This may lead to "
"unexpected behaviour!" not in caplog.text
)

View File

@@ -1,7 +1,8 @@
from pytest import CaptureFixture, LogCaptureFixture
from pydase.components.number_slider import NumberSlider
from pydase.data_service.data_service import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
def test_NumberSlider(caplog: LogCaptureFixture) -> None:
@@ -9,35 +10,37 @@ def test_NumberSlider(caplog: LogCaptureFixture) -> None:
number_slider = NumberSlider(1, 0, 10, 1)
int_number_slider = NumberSlider(1, 0, 10, 1, "int")
service = ServiceClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert service.number_slider.value == 1
assert isinstance(service.number_slider.value, float)
assert service.number_slider.min == 0
assert isinstance(service.number_slider.min, float)
assert service.number_slider.max == 10
assert isinstance(service.number_slider.max, float)
assert service.number_slider.step_size == 1
assert isinstance(service.number_slider.step_size, float)
assert service_instance.number_slider.value == 1
assert isinstance(service_instance.number_slider.value, float)
assert service_instance.number_slider.min == 0
assert isinstance(service_instance.number_slider.min, float)
assert service_instance.number_slider.max == 10
assert isinstance(service_instance.number_slider.max, float)
assert service_instance.number_slider.step_size == 1
assert isinstance(service_instance.number_slider.step_size, float)
assert service.int_number_slider.value == 1
assert isinstance(service.int_number_slider.value, int)
assert service.int_number_slider.step_size == 1
assert isinstance(service.int_number_slider.step_size, int)
assert service_instance.int_number_slider.value == 1
assert isinstance(service_instance.int_number_slider.value, int)
assert service_instance.int_number_slider.step_size == 1
assert isinstance(service_instance.int_number_slider.step_size, int)
service.number_slider.value = 10.0
service.int_number_slider.value = 10.1
service_instance.number_slider.value = 10.0
service_instance.int_number_slider.value = 10.1
assert "ServiceClass.number_slider.value changed to 10.0" in caplog.text
assert "ServiceClass.int_number_slider.value changed to 10" in caplog.text
assert "'number_slider.value' changed to '10.0'" in caplog.text
assert "'int_number_slider.value' changed to '10'" in caplog.text
caplog.clear()
service.number_slider.min = 1.1
service_instance.number_slider.min = 1.1
assert "ServiceClass.number_slider.min changed to 1.1" in caplog.text
assert "'number_slider.min' changed to '1.1'" in caplog.text
def test_init_error(caplog: LogCaptureFixture) -> None: # noqa
number_slider = NumberSlider(type="str") # type: ignore # noqa
def test_init_error(caplog: LogCaptureFixture) -> None:
number_slider = NumberSlider(type_="str") # type: ignore # noqa
assert "Unknown type 'str'. Using 'float'" in caplog.text

View File

@@ -1,42 +0,0 @@
import logging
from pytest import LogCaptureFixture
import pydase
logger = logging.getLogger()
def test_DataService_task_callback(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
async def my_task(self) -> None:
logger.info("Triggered task.")
async def my_other_task(self) -> None:
logger.info("Triggered other task.")
service = MyService()
service.start_my_task() # type: ignore
service.start_my_other_task() # type: ignore
assert "MyService.my_task changed to {}" in caplog.text
assert "MyService.my_other_task changed to {}" in caplog.text
def test_DataServiceList_task_callback(caplog: LogCaptureFixture) -> None:
class MySubService(pydase.DataService):
async def my_task(self) -> None:
logger.info("Triggered task.")
async def my_other_task(self) -> None:
logger.info("Triggered other task.")
class MyService(pydase.DataService):
sub_services_list = [MySubService() for i in range(2)]
service = MyService()
service.sub_services_list[0].start_my_task() # type: ignore
service.sub_services_list[1].start_my_other_task() # type: ignore
assert "MyService.sub_services_list[0].my_task changed to {}" in caplog.text
assert "MyService.sub_services_list[1].my_other_task changed to {}" in caplog.text

View File

@@ -0,0 +1,116 @@
from enum import Enum
import pydase.units as u
from pydase import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
def test_unexpected_type_change_warning(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
attr_1 = 1.0
current = 1.0 * u.units.A
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr_1 = 2
assert "'attr_1' changed to '2'" in caplog.text
assert (
"Type of 'attr_1' changed from 'float' to 'int'. This may have unwanted "
"side effects! Consider setting it to 'float' directly." in caplog.text
)
service_instance.current = 2
assert "'current' changed to '2'" in caplog.text
assert (
"Type of 'current' changed from 'Quantity' to 'int'. This may have unwanted "
"side effects! Consider setting it to 'Quantity' directly." in caplog.text
)
def test_basic_inheritance_warning(caplog: LogCaptureFixture) -> None:
class SubService(DataService):
...
class SomeEnum(Enum):
HI = 0
class ServiceClass(DataService):
sub_service = SubService()
some_int = 1
some_float = 1.0
some_bool = True
some_quantity = 1.0 * u.units.A
some_list = [1, 2]
some_string = "Hello"
some_enum = SomeEnum.HI
_name = "Service"
@property
def name(self) -> str:
return self._name
def some_method(self) -> None:
...
async def some_task(self) -> None:
...
ServiceClass()
# neither of the attributes, methods or properties cause a warning log
assert "WARNING" not in caplog.text
def test_class_attr_inheritance_warning(caplog: LogCaptureFixture) -> None:
class SubClass:
name = "Hello"
class ServiceClass(DataService):
attr_1 = SubClass()
ServiceClass()
assert (
"Class 'SubClass' does not inherit from DataService. This may lead to "
"unexpected behaviour!"
) in caplog.text
def test_instance_attr_inheritance_warning(caplog: LogCaptureFixture) -> None:
class SubClass:
name = "Hello"
class ServiceClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr_1 = SubClass()
ServiceClass()
assert (
"Class 'SubClass' does not inherit from DataService. This may lead to "
"unexpected behaviour!"
) in caplog.text
def test_protected_and_private_attribute_warning(caplog: LogCaptureFixture) -> None:
class SubClass:
name = "Hello"
class ServiceClass(DataService):
def __init__(self) -> None:
super().__init__()
self._subclass = SubClass()
self.__other_subclass = SubClass()
ServiceClass()
# Protected and private attributes are not checked
assert (
"Class 'SubClass' does not inherit from DataService. This may lead to "
"unexpected behaviour!"
) not in caplog.text

View File

@@ -1,8 +1,8 @@
import logging
import pydase
from pydase.data_service.data_service_cache import DataServiceCache
from pydase.utils.serializer import get_nested_dict_by_path
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
logger = logging.getLogger()
@@ -15,11 +15,55 @@ def test_nested_attributes_cache_callback() -> None:
class_attr = SubClass()
name = "World"
test_service = ServiceClass()
cache = DataServiceCache(test_service)
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
test_service.name = "Peepz"
assert get_nested_dict_by_path(cache.cache, "name")["value"] == "Peepz"
service_instance.name = "Peepz"
assert (
state_manager._data_service_cache.get_value_dict_from_cache("name")["value"]
== "Peepz"
)
test_service.class_attr.name = "Ciao"
assert get_nested_dict_by_path(cache.cache, "class_attr.name")["value"] == "Ciao"
service_instance.class_attr.name = "Ciao"
assert (
state_manager._data_service_cache.get_value_dict_from_cache("class_attr.name")[
"value"
]
== "Ciao"
)
def test_task_status_update() -> None:
class ServiceClass(pydase.DataService):
name = "World"
async def my_method(self) -> None:
pass
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")["type"]
== "method"
)
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[
"value"
]
is None
)
service_instance.start_my_method() # type: ignore
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")["type"]
== "method"
)
assert (
state_manager._data_service_cache.get_value_dict_from_cache("my_method")[
"value"
]
== {}
)

View File

@@ -1,129 +0,0 @@
from typing import Any
from pytest import LogCaptureFixture
import pydase.units as u
from pydase import DataService
def test_class_list_attribute(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
attr = [0, 1]
service_instance = ServiceClass()
service_instance.attr[0] = 1337
assert "ServiceClass.attr[0] changed to 1337" in caplog.text
caplog.clear()
def test_instance_list_attribute(caplog: LogCaptureFixture) -> None:
class SubClass(DataService):
name = "SubClass"
class ServiceClass(DataService):
def __init__(self) -> None:
self.attr: list[Any] = [0, SubClass()]
super().__init__()
service_instance = ServiceClass()
service_instance.attr[0] = "Hello"
assert "ServiceClass.attr[0] changed to Hello" in caplog.text
caplog.clear()
service_instance.attr[1] = SubClass()
assert f"ServiceClass.attr[1] changed to {service_instance.attr[1]}" in caplog.text
caplog.clear()
def test_reused_instance_list_attribute(caplog: LogCaptureFixture) -> None:
some_list = [0, 1, 2]
class ServiceClass(DataService):
def __init__(self) -> None:
self.attr = some_list
self.attr_2 = some_list
self.attr_3 = [0, 1, 2]
super().__init__()
service_instance = ServiceClass()
service_instance.attr[0] = 20
assert service_instance.attr == service_instance.attr_2
assert service_instance.attr != service_instance.attr_3
assert "ServiceClass.attr[0] changed to 20" in caplog.text
assert "ServiceClass.attr_2[0] changed to 20" in caplog.text
def test_nested_reused_instance_list_attribute(caplog: LogCaptureFixture) -> None:
some_list = [0, 1, 2]
class SubClass(DataService):
attr_list = some_list
def __init__(self) -> None:
self.attr_list_2 = some_list
super().__init__()
class ServiceClass(DataService):
def __init__(self) -> None:
self.attr = some_list
self.subclass = SubClass()
super().__init__()
service_instance = ServiceClass()
service_instance.attr[0] = 20
assert service_instance.attr == service_instance.subclass.attr_list
assert "ServiceClass.attr[0] changed to 20" in caplog.text
assert "ServiceClass.subclass.attr_list[0] changed to 20" in caplog.text
assert "ServiceClass.subclass.attr_list_2[0] changed to 20" in caplog.text
def test_protected_list_attribute(caplog: LogCaptureFixture) -> None:
"""Changing protected lists should not emit notifications for the lists themselves,
but still for all properties depending on them.
"""
class ServiceClass(DataService):
_attr = [0, 1]
@property
def list_dependend_property(self) -> int:
return self._attr[0]
service_instance = ServiceClass()
service_instance._attr[0] = 1337
assert "ServiceClass.list_dependend_property changed to 1337" in caplog.text
def test_converting_int_to_float_entries(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
float_list = [0.0]
service_instance = ServiceClass()
service_instance.float_list[0] = 1
assert isinstance(service_instance.float_list[0], float)
assert "ServiceClass.float_list[0] changed to 1.0" in caplog.text
def test_converting_number_to_quantity_entries(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
quantity_list: list[u.Quantity] = [1 * u.units.A]
service_instance = ServiceClass()
service_instance.quantity_list[0] = 4 # type: ignore
assert isinstance(service_instance.quantity_list[0], u.Quantity)
assert "ServiceClass.quantity_list[0] changed to 4.0 A" in caplog.text
caplog.clear()
service_instance.quantity_list[0] = 3.1 * u.units.mA
assert isinstance(service_instance.quantity_list[0], u.Quantity)
assert "ServiceClass.quantity_list[0] changed to 3.1 mA" in caplog.text

View File

@@ -0,0 +1,96 @@
import logging
import pydase
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
logger = logging.getLogger()
def test_static_property_dependencies() -> None:
class SubClass(pydase.DataService):
_name = "SubClass"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
class ServiceClass(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.list_attr = [SubClass()]
self._name = "ServiceClass"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
observer = DataServiceObserver(state_manager)
logger.debug(observer.property_deps_dict)
assert observer.property_deps_dict == {
"list_attr[0]._name": ["list_attr[0].name"],
"_name": ["name"],
}
def test_dynamic_list_property_dependencies() -> None:
class SubClass(pydase.DataService):
_name = "SubClass"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
class ServiceClass(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.list_attr = [SubClass()]
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
observer = DataServiceObserver(state_manager)
assert observer.property_deps_dict == {
"list_attr[0]._name": ["list_attr[0].name"],
}
service_instance.list_attr.append(SubClass())
assert observer.property_deps_dict == {
"list_attr[0]._name": ["list_attr[0].name"],
"list_attr[1]._name": ["list_attr[1].name"],
}
def test_protected_or_private_change_logs(caplog: pytest.LogCaptureFixture) -> None:
class OtherService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._name = "Hi"
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self.subclass = OtherService()
service = MyService()
state_manager = StateManager(service)
DataServiceObserver(state_manager)
service.subclass._name = "Hello"
assert "'subclass._name' changed to 'Hello'" not in caplog.text

View File

@@ -2,16 +2,17 @@ import json
from pathlib import Path
from typing import Any
from pytest import LogCaptureFixture
import pydase
import pydase.units as u
import pytest
from pydase.components.coloured_enum import ColouredEnum
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import (
StateManager,
has_load_state_decorator,
load_state,
)
from pytest import LogCaptureFixture
class SubService(pydase.DataService):
@@ -26,6 +27,7 @@ class State(ColouredEnum):
class Service(pydase.DataService):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.subservice = SubService()
self.some_unit: u.Quantity = 1.2 * u.units.A
self.some_float = 1.0
@@ -33,7 +35,6 @@ class Service(pydase.DataService):
self._property_attr = 1337.0
self._name = "Service"
self.state = State.RUNNING
super().__init__(**kwargs)
@property
def name(self) -> str:
@@ -117,7 +118,7 @@ LOAD_STATE = {
}
def test_save_state(tmp_path: Path):
def test_save_state(tmp_path: Path) -> None:
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"
manager = StateManager(service=Service(), filename=str(file))
@@ -129,7 +130,7 @@ def test_save_state(tmp_path: Path):
assert file.read_text() == json.dumps(CURRENT_STATE, indent=4)
def test_load_state(tmp_path: Path, caplog: LogCaptureFixture):
def test_load_state(tmp_path: Path, caplog: LogCaptureFixture) -> None:
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"
@@ -138,8 +139,9 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture):
json.dump(LOAD_STATE, f, indent=4)
service = Service()
manager = StateManager(service=service, filename=str(file))
manager.load_state()
state_manager = StateManager(service=service, filename=str(file))
DataServiceObserver(state_manager)
state_manager.load_state()
assert service.some_unit == u.Quantity(12, "A") # has changed
assert service.list_attr[0] == 1.4 # has changed
@@ -152,7 +154,7 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture):
assert service.some_float == 1.0 # has not changed due to different type
assert service.subservice.name == "SubService" # didn't change
assert "Service.some_unit changed to 12.0 A!" in caplog.text
assert "'some_unit' changed to '12.0 A'" in caplog.text
assert (
"Property 'name' has no '@load_state' decorator. "
"Ignoring value from JSON file..." in caplog.text
@@ -162,21 +164,23 @@ def test_load_state(tmp_path: Path, caplog: LogCaptureFixture):
"Ignoring value from JSON file..."
) in caplog.text
assert (
"Attribute type of 'removed_attr' changed from 'str' to None. "
"Attribute type of 'removed_attr' changed from 'str' to 'None'. "
"Ignoring value from JSON file..." in caplog.text
)
assert "Value of attribute 'subservice.name' has not changed..." in caplog.text
def test_filename_warning(tmp_path: Path, caplog: LogCaptureFixture):
def test_filename_warning(tmp_path: Path, caplog: LogCaptureFixture) -> None:
file = tmp_path / "test_state.json"
service = Service(filename=str(file))
StateManager(service=service, filename=str(file))
with pytest.warns(DeprecationWarning):
service = Service(filename=str(file))
StateManager(service=service, filename=str(file))
assert f"Overwriting filename {str(file)!r} with {str(file)!r}." in caplog.text
def test_filename_error(caplog: LogCaptureFixture):
def test_filename_error(caplog: LogCaptureFixture) -> None:
service = Service()
manager = StateManager(service=service)
@@ -187,7 +191,7 @@ def test_filename_error(caplog: LogCaptureFixture):
)
def test_readonly_attribute(tmp_path: Path, caplog: LogCaptureFixture):
def test_readonly_attribute(tmp_path: Path, caplog: LogCaptureFixture) -> None:
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"
@@ -205,7 +209,7 @@ def test_readonly_attribute(tmp_path: Path, caplog: LogCaptureFixture):
)
def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture):
def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture) -> None:
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"
@@ -222,7 +226,7 @@ def test_changed_type(tmp_path: Path, caplog: LogCaptureFixture):
) in caplog.text
def test_property_load_state(tmp_path: Path):
def test_property_load_state(tmp_path: Path) -> None:
# Create a StateManager instance with a temporary file
file = tmp_path / "test_state.json"

View File

@@ -1,8 +1,9 @@
import logging
from pytest import LogCaptureFixture
import pydase
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
logger = logging.getLogger()
@@ -10,11 +11,11 @@ logger = logging.getLogger()
def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
class MyService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._autostart_tasks = { # type: ignore
"my_task": (),
"my_other_task": (),
}
super().__init__()
async def my_task(self) -> None:
logger.info("Triggered task.")
@@ -22,11 +23,13 @@ def test_autostart_task_callback(caplog: LogCaptureFixture) -> None:
async def my_other_task(self) -> None:
logger.info("Triggered other task.")
service = MyService()
service._task_manager.start_autostart_tasks()
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks()
assert "MyService.my_task changed to {}" in caplog.text
assert "MyService.my_other_task changed to {}" in caplog.text
assert "'my_task' changed to '{}'" in caplog.text
assert "'my_other_task' changed to '{}'" in caplog.text
def test_DataService_subclass_autostart_task_callback(
@@ -34,11 +37,11 @@ def test_DataService_subclass_autostart_task_callback(
) -> None:
class MySubService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._autostart_tasks = { # type: ignore
"my_task": (),
"my_other_task": (),
}
super().__init__()
async def my_task(self) -> None:
logger.info("Triggered task.")
@@ -49,23 +52,25 @@ def test_DataService_subclass_autostart_task_callback(
class MyService(pydase.DataService):
sub_service = MySubService()
service = MyService()
service._task_manager.start_autostart_tasks()
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks()
assert "MyService.sub_service.my_task changed to {}" in caplog.text
assert "MyService.sub_service.my_other_task changed to {}" in caplog.text
assert "'sub_service.my_task' changed to '{}'" in caplog.text
assert "'sub_service.my_other_task' changed to '{}'" in caplog.text
def test_DataServiceList_subclass_autostart_task_callback(
def test_DataService_subclass_list_autostart_task_callback(
caplog: LogCaptureFixture,
) -> None:
class MySubService(pydase.DataService):
def __init__(self) -> None:
super().__init__()
self._autostart_tasks = { # type: ignore
"my_task": (),
"my_other_task": (),
}
super().__init__()
async def my_task(self) -> None:
logger.info("Triggered task.")
@@ -76,10 +81,12 @@ def test_DataServiceList_subclass_autostart_task_callback(
class MyService(pydase.DataService):
sub_services_list = [MySubService() for i in range(2)]
service = MyService()
service._task_manager.start_autostart_tasks()
service_instance = MyService()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance._task_manager.start_autostart_tasks()
assert "MyService.sub_services_list[0].my_task changed to {}" in caplog.text
assert "MyService.sub_services_list[0].my_other_task changed to {}" in caplog.text
assert "MyService.sub_services_list[1].my_task changed to {}" in caplog.text
assert "MyService.sub_services_list[1].my_other_task changed to {}" in caplog.text
assert "'sub_services_list[0].my_task' changed to '{}'" in caplog.text
assert "'sub_services_list[0].my_other_task' changed to '{}'" in caplog.text
assert "'sub_services_list[1].my_task' changed to '{}'" in caplog.text
assert "'sub_services_list[1].my_other_task' changed to '{}'" in caplog.text

View File

@@ -0,0 +1,173 @@
import logging
from typing import Any
import pytest
from pydase.observer_pattern.observable import Observable
from pydase.observer_pattern.observer import Observer
logger = logging.getLogger(__name__)
class MyObserver(Observer):
def on_change(self, full_access_path: str, value: Any) -> None:
logger.info("'%s' changed to '%s'", full_access_path, value)
def test_constructor_error_message(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
self.attr = 1
super().__init__()
MyObservable()
assert (
"Ensure that super().__init__() is called at the start of the 'MyObservable' "
"constructor! Failing to do so may lead to unexpected behavior." in caplog.text
)
def test_simple_class_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
int_attribute = 10
instance = MyObservable()
observer = MyObserver(instance)
instance.int_attribute = 12
assert "'int_attribute' changed to '12'" in caplog.text
def test_simple_instance_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.int_attribute = 10
instance = MyObservable()
observer = MyObserver(instance)
instance.int_attribute = 12
assert "'int_attribute' changed to '12'" in caplog.text
def test_nested_class_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MySubclass(Observable):
name = "My Subclass"
class MyObservable(Observable):
subclass = MySubclass()
instance = MyObservable()
observer = MyObserver(instance)
instance.subclass.name = "Other name"
assert "'subclass.name' changed to 'Other name'" in caplog.text
def test_nested_instance_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MySubclass(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Subclass name"
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.subclass = MySubclass()
instance = MyObservable()
observer = MyObserver(instance)
instance.subclass.name = "Other name"
assert "'subclass.name' changed to 'Other name'" in caplog.text
def test_removed_observer_on_class_attr(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
nested_attr = nested_instance
changed_attr = nested_instance
instance = MyObservable()
observer = MyObserver(instance)
instance.changed_attr = "Ciao"
assert "'changed_attr' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_attr.name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_instance_attr(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_attr = nested_instance
self.changed_attr = nested_instance
instance = MyObservable()
observer = MyObserver(instance)
instance.changed_attr = "Ciao"
assert "'changed_attr' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_attr.name' changed to 'Hi'" not in caplog.text
def test_property_getter(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self._name = "Hello"
@property
def name(self) -> str:
"""The name property."""
return self._name
instance = MyObservable()
observer = MyObserver(instance)
_ = instance.name
assert "'name' changed to 'Hello'" in caplog.text
def test_property_setter(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self._name = "Hello"
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
instance = MyObservable()
observer = MyObserver(instance)
instance.name = "Ciao"
assert "'name' changed to 'Hello'" not in caplog.text
assert "'name' changed to 'Ciao'" in caplog.text

View File

@@ -0,0 +1,474 @@
import logging
from typing import Any
import pytest
from pydase.observer_pattern.observable import Observable
from pydase.observer_pattern.observer import Observer
logger = logging.getLogger(__name__)
class MyObserver(Observer):
def on_change(self, full_access_path: str, value: Any) -> None:
logger.info("'%s' changed to '%s'", full_access_path, value)
def test_simple_instance_list_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.list_attr = [1, 2]
instance = MyObservable()
MyObserver(instance)
instance.list_attr[0] = 12
assert "'list_attr[0]' changed to '12'" in caplog.text
def test_instance_object_list_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.list_attr = [NestedObservable()]
instance = MyObservable()
MyObserver(instance)
instance.list_attr[0].name = "Ciao"
assert "'list_attr[0].name' changed to 'Ciao'" in caplog.text
def test_simple_class_list_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
list_attr = [1, 2]
instance = MyObservable()
MyObserver(instance)
instance.list_attr[0] = 12
assert "'list_attr[0]' changed to '12'" in caplog.text
def test_class_object_list_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
name = "Hello"
class MyObservable(Observable):
list_attr = [NestedObservable()]
instance = MyObservable()
MyObserver(instance)
instance.list_attr[0].name = "Ciao"
assert "'list_attr[0].name' changed to 'Ciao'" in caplog.text
def test_simple_instance_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {"first": "Hello"}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"] = "Ciao"
instance.dict_attr["second"] = "World"
assert "'dict_attr['first']' changed to 'Ciao'" in caplog.text
assert "'dict_attr['second']' changed to 'World'" in caplog.text
def test_simple_class_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class MyObservable(Observable):
dict_attr = {"first": "Hello"}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"] = "Ciao"
instance.dict_attr["second"] = "World"
assert "'dict_attr['first']' changed to 'Ciao'" in caplog.text
assert "'dict_attr['second']' changed to 'World'" in caplog.text
def test_instance_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_attr = {"first": NestedObservable()}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"].name = "Ciao"
assert "'dict_attr['first'].name' changed to 'Ciao'" in caplog.text
def test_class_dict_attribute(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
name = "Hello"
class MyObservable(Observable):
dict_attr = {"first": NestedObservable()}
instance = MyObservable()
MyObserver(instance)
instance.dict_attr["first"].name = "Ciao"
assert "'dict_attr['first'].name' changed to 'Ciao'" in caplog.text
def test_removed_observer_on_class_list_attr(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
nested_attr = nested_instance
changed_list_attr = [nested_instance]
instance = MyObservable()
MyObserver(instance)
instance.changed_list_attr[0] = "Ciao"
assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_list_attr[0].name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_instance_dict_attr(
caplog: pytest.LogCaptureFixture,
) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_attr = nested_instance
self.changed_dict_attr = {"nested": nested_instance}
instance = MyObservable()
MyObserver(instance)
instance.changed_dict_attr["nested"] = "Ciao"
assert "'changed_dict_attr['nested']' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_dict_attr['nested'].name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_instance_list_attr(
caplog: pytest.LogCaptureFixture,
) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_attr = nested_instance
self.changed_list_attr = [nested_instance]
instance = MyObservable()
MyObserver(instance)
instance.changed_list_attr[0] = "Ciao"
assert "'changed_list_attr[0]' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_list_attr[0].name' changed to 'Hi'" not in caplog.text
def test_removed_observer_on_class_dict_attr(caplog: pytest.LogCaptureFixture) -> None:
class NestedObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.name = "Hello"
nested_instance = NestedObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_attr = nested_instance
self.changed_dict_attr = {"nested": nested_instance}
instance = MyObservable()
MyObserver(instance)
instance.changed_dict_attr["nested"] = "Ciao"
assert "'changed_dict_attr['nested']' changed to 'Ciao'" in caplog.text
caplog.clear()
instance.nested_attr.name = "Hi"
assert "'nested_attr.name' changed to 'Hi'" in caplog.text
assert "'changed_dict_attr['nested'].name' changed to 'Hi'" not in caplog.text
def test_nested_dict_instances(caplog: pytest.LogCaptureFixture) -> None:
dict_instance = {"first": "Hello", "second": "World"}
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.nested_dict_attr = {"nested": dict_instance}
instance = MyObservable()
MyObserver(instance)
instance.nested_dict_attr["nested"]["first"] = "Ciao"
assert "'nested_dict_attr['nested']['first']' changed to 'Ciao'" in caplog.text
def test_dict_in_list_instance(caplog: pytest.LogCaptureFixture) -> None:
dict_instance = {"first": "Hello", "second": "World"}
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.dict_in_list = [dict_instance]
instance = MyObservable()
MyObserver(instance)
instance.dict_in_list[0]["first"] = "Ciao"
assert "'dict_in_list[0]['first']' changed to 'Ciao'" in caplog.text
def test_list_in_dict_instance(caplog: pytest.LogCaptureFixture) -> None:
list_instance: list[Any] = [1, 2, 3]
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.list_in_dict = {"some_list": list_instance}
instance = MyObservable()
MyObserver(instance)
instance.list_in_dict["some_list"][0] = "Ciao"
assert "'list_in_dict['some_list'][0]' changed to 'Ciao'" in caplog.text
def test_list_append(caplog: pytest.LogCaptureFixture) -> None:
class OtherObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.greeting = "Other Observable"
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.my_list = []
observable_instance = MyObservable()
MyObserver(observable_instance)
observable_instance.my_list.append(OtherObservable())
assert f"'my_list' changed to '{observable_instance.my_list}'" in caplog.text
caplog.clear()
observable_instance.my_list.append(OtherObservable())
assert f"'my_list' changed to '{observable_instance.my_list}'" in caplog.text
caplog.clear()
observable_instance.my_list[0].greeting = "Hi"
observable_instance.my_list[1].greeting = "Hello"
assert observable_instance.my_list[0].greeting == "Hi"
assert observable_instance.my_list[1].greeting == "Hello"
assert "'my_list[0].greeting' changed to 'Hi'" in caplog.text
assert "'my_list[1].greeting' changed to 'Hello'" in caplog.text
def test_list_pop(caplog: pytest.LogCaptureFixture) -> None:
class OtherObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.greeting = "Hello there!"
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.my_list = [OtherObservable() for _ in range(2)]
observable_instance = MyObservable()
MyObserver(observable_instance)
popped_instance = observable_instance.my_list.pop(0)
assert len(observable_instance.my_list) == 1
assert f"'my_list' changed to '{observable_instance.my_list}'" in caplog.text
# checks if observer is removed
popped_instance.greeting = "Ciao"
assert "'my_list[0].greeting' changed to 'Ciao'" not in caplog.text
caplog.clear()
# checks if observer keys have been updated (index 1 moved to 0)
observable_instance.my_list[0].greeting = "Hi"
assert "'my_list[0].greeting' changed to 'Hi'" in caplog.text
def test_list_clear(caplog: pytest.LogCaptureFixture) -> None:
class OtherObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.greeting = "Hello there!"
other_observable_instance = OtherObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.my_list = [other_observable_instance]
observable_instance = MyObservable()
MyObserver(observable_instance)
other_observable_instance.greeting = "Hello"
assert "'my_list[0].greeting' changed to 'Hello'" in caplog.text
observable_instance.my_list.clear()
assert len(observable_instance.my_list) == 0
assert "'my_list' changed to '[]'" in caplog.text
# checks if observer has been removed
other_observable_instance.greeting = "Hi"
assert "'my_list[0].greeting' changed to 'Hi'" not in caplog.text
def test_list_extend(caplog: pytest.LogCaptureFixture) -> None:
class OtherObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.greeting = "Hello there!"
other_observable_instance = OtherObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.my_list = []
observable_instance = MyObservable()
MyObserver(observable_instance)
other_observable_instance.greeting = "Hello"
assert "'my_list[0].greeting' changed to 'Hello'" not in caplog.text
observable_instance.my_list.extend([other_observable_instance, OtherObservable()])
assert len(observable_instance.my_list) == 2
assert f"'my_list' changed to '{observable_instance.my_list}'" in caplog.text
# checks if observer has been removed
other_observable_instance.greeting = "Hi"
assert "'my_list[0].greeting' changed to 'Hi'" in caplog.text
observable_instance.my_list[1].greeting = "Ciao"
assert "'my_list[1].greeting' changed to 'Ciao'" in caplog.text
def test_list_insert(caplog: pytest.LogCaptureFixture) -> None:
class OtherObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.greeting = "Hello there!"
other_observable_instance_1 = OtherObservable()
other_observable_instance_2 = OtherObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.my_list = [other_observable_instance_1, OtherObservable()]
observable_instance = MyObservable()
MyObserver(observable_instance)
other_observable_instance_1.greeting = "Hello"
assert "'my_list[0].greeting' changed to 'Hello'" in caplog.text
observable_instance.my_list.insert(0, other_observable_instance_2)
assert len(observable_instance.my_list) == 3
assert f"'my_list' changed to '{observable_instance.my_list}'" in caplog.text
# checks if observer keys have been updated
other_observable_instance_2.greeting = "Hey"
other_observable_instance_1.greeting = "Hi"
observable_instance.my_list[2].greeting = "Ciao"
assert "'my_list[0].greeting' changed to 'Hey'" in caplog.text
assert "'my_list[1].greeting' changed to 'Hi'" in caplog.text
assert "'my_list[2].greeting' changed to 'Ciao'" in caplog.text
def test_list_remove(caplog: pytest.LogCaptureFixture) -> None:
class OtherObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.greeting = "Hello there!"
other_observable_instance_1 = OtherObservable()
other_observable_instance_2 = OtherObservable()
class MyObservable(Observable):
def __init__(self) -> None:
super().__init__()
self.my_list = [other_observable_instance_1, other_observable_instance_2]
observable_instance = MyObservable()
MyObserver(observable_instance)
other_observable_instance_1.greeting = "Hello"
other_observable_instance_2.greeting = "Hi"
caplog.clear()
observable_instance.my_list.remove(other_observable_instance_1)
assert len(observable_instance.my_list) == 1
assert f"'my_list' changed to '{observable_instance.my_list}'" in caplog.text
caplog.clear()
# checks if observer has been removed
other_observable_instance_1.greeting = "Hi"
assert "'my_list[0].greeting' changed to 'Hi'" not in caplog.text
caplog.clear()
# checks if observer key was updated correctly (was index 1)
other_observable_instance_2.greeting = "Ciao"
assert "'my_list[0].greeting' changed to 'Ciao'" in caplog.text

View File

@@ -0,0 +1,25 @@
from typing import Any
import pytest
from pydase.observer_pattern.observable import Observable
from pydase.observer_pattern.observer import Observer
def test_abstract_method_error() -> None:
class MyObserver(Observer):
pass
class MyObservable(Observable):
pass
with pytest.raises(TypeError):
MyObserver(MyObservable())
def test_constructor_error() -> None:
class MyObserver(Observer):
def on_change(self, full_access_path: str, value: Any) -> None:
pass
with pytest.raises(TypeError):
MyObserver()

View File

@@ -1,6 +1,7 @@
from pytest import LogCaptureFixture
from pydase import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
def test_class_attributes(caplog: LogCaptureFixture) -> None:
@@ -11,9 +12,11 @@ def test_class_attributes(caplog: LogCaptureFixture) -> None:
attr_1 = SubClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr_1.name = "Hi"
assert "ServiceClass.attr_1.name changed to Hi" in caplog.text
assert "'attr_1.name' changed to 'Hi'" in caplog.text
def test_instance_attributes(caplog: LogCaptureFixture) -> None:
@@ -22,13 +25,15 @@ def test_instance_attributes(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
def __init__(self) -> None:
self.attr_1 = SubClass()
super().__init__()
self.attr_1 = SubClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr_1.name = "Hi"
assert "ServiceClass.attr_1.name changed to Hi" in caplog.text
assert "'attr_1.name' changed to 'Hi'" in caplog.text
def test_class_attribute(caplog: LogCaptureFixture) -> None:
@@ -36,21 +41,25 @@ def test_class_attribute(caplog: LogCaptureFixture) -> None:
attr = 0
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr = 1
assert "ServiceClass.attr changed to 1" in caplog.text
assert "'attr' changed to '1'" in caplog.text
def test_instance_attribute(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
def __init__(self) -> None:
self.attr = "Hello World"
super().__init__()
self.attr = "Hello World"
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr = "Hello"
assert "ServiceClass.attr changed to Hello" in caplog.text
assert "'attr' changed to 'Hello'" in caplog.text
def test_reused_instance_attributes(caplog: LogCaptureFixture) -> None:
@@ -61,16 +70,19 @@ def test_reused_instance_attributes(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr_1 = subclass_instance
self.attr_2 = subclass_instance
super().__init__()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr_1.name = "Hi"
assert service_instance.attr_1 == service_instance.attr_2
assert "ServiceClass.attr_1.name changed to Hi" in caplog.text
assert "ServiceClass.attr_2.name changed to Hi" in caplog.text
assert "'attr_1.name' changed to 'Hi'" in caplog.text
assert "'attr_2.name' changed to 'Hi'" in caplog.text
def test_reused_attributes_mixed(caplog: LogCaptureFixture) -> None:
@@ -83,15 +95,18 @@ def test_reused_attributes_mixed(caplog: LogCaptureFixture) -> None:
attr_1 = subclass_instance
def __init__(self) -> None:
self.attr_2 = subclass_instance
super().__init__()
self.attr_2 = subclass_instance
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr_1.name = "Hi"
assert service_instance.attr_1 == service_instance.attr_2
assert "ServiceClass.attr_1.name changed to Hi" in caplog.text
assert "ServiceClass.attr_2.name changed to Hi" in caplog.text
assert "'attr_1.name' changed to 'Hi'" in caplog.text
assert "'attr_2.name' changed to 'Hi'" in caplog.text
def test_nested_class_attributes(caplog: LogCaptureFixture) -> None:
@@ -111,15 +126,18 @@ def test_nested_class_attributes(caplog: LogCaptureFixture) -> None:
attr = SubClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr.attr.attr.name = "Hi"
service_instance.attr.attr.name = "Hou"
service_instance.attr.name = "foo"
service_instance.name = "bar"
assert "ServiceClass.attr.attr.attr.name changed to Hi" in caplog.text
assert "ServiceClass.attr.attr.name changed to Hou" in caplog.text
assert "ServiceClass.attr.name changed to foo" in caplog.text
assert "ServiceClass.name changed to bar" in caplog.text
assert "'attr.attr.attr.name' changed to 'Hi'" in caplog.text
assert "'attr.attr.name' changed to 'Hou'" in caplog.text
assert "'attr.name' changed to 'foo'" in caplog.text
assert "'name' changed to 'bar'" in caplog.text
def test_nested_instance_attributes(caplog: LogCaptureFixture) -> None:
@@ -128,32 +146,35 @@ def test_nested_instance_attributes(caplog: LogCaptureFixture) -> None:
class SubSubClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr = SubSubSubClass()
self.name = "Hello"
super().__init__()
class SubClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr = SubSubClass()
self.name = "Hello"
super().__init__()
class ServiceClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr = SubClass()
self.name = "Hello"
super().__init__()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr.attr.attr.name = "Hi"
service_instance.attr.attr.name = "Hou"
service_instance.attr.name = "foo"
service_instance.name = "bar"
assert "ServiceClass.attr.attr.attr.name changed to Hi" in caplog.text
assert "ServiceClass.attr.attr.name changed to Hou" in caplog.text
assert "ServiceClass.attr.name changed to foo" in caplog.text
assert "ServiceClass.name changed to bar" in caplog.text
assert "'attr.attr.attr.name' changed to 'Hi'" in caplog.text
assert "'attr.attr.name' changed to 'Hou'" in caplog.text
assert "'attr.name' changed to 'foo'" in caplog.text
assert "'name' changed to 'bar'" in caplog.text
def test_advanced_nested_class_attributes(caplog: LogCaptureFixture) -> None:
@@ -171,14 +192,19 @@ def test_advanced_nested_class_attributes(caplog: LogCaptureFixture) -> None:
subattr = SubSubClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr.attr.attr.name = "Hi"
assert "ServiceClass.attr.attr.attr.name changed to Hi" in caplog.text
assert "ServiceClass.subattr.attr.name changed to Hi" in caplog.text
assert "'attr.attr.attr.name' changed to 'Hi'" in caplog.text
assert "'subattr.attr.name' changed to 'Hi'" in caplog.text
caplog.clear()
service_instance.subattr.attr.name = "Ho"
assert "ServiceClass.attr.attr.attr.name changed to Ho" in caplog.text
assert "ServiceClass.subattr.attr.name changed to Ho" in caplog.text
assert "'attr.attr.attr.name' changed to 'Ho'" in caplog.text
assert "'subattr.attr.name' changed to 'Ho'" in caplog.text
def test_advanced_nested_instance_attributes(caplog: LogCaptureFixture) -> None:
@@ -187,32 +213,34 @@ def test_advanced_nested_instance_attributes(caplog: LogCaptureFixture) -> None:
class SubSubClass(DataService):
def __init__(self) -> None:
self.attr = SubSubSubClass()
super().__init__()
self.attr = SubSubSubClass()
subsubclass_instance = SubSubClass()
class SubClass(DataService):
def __init__(self) -> None:
self.attr = subsubclass_instance
super().__init__()
self.attr = subsubclass_instance
class ServiceClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr = SubClass()
self.subattr = subsubclass_instance
super().__init__()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service_instance.attr.attr.attr.name = "Hi"
assert "ServiceClass.attr.attr.attr.name changed to Hi" in caplog.text
assert "ServiceClass.subattr.attr.name changed to Hi" in caplog.text
assert "'attr.attr.attr.name' changed to 'Hi'" in caplog.text
assert "'subattr.attr.name' changed to 'Hi'" in caplog.text
caplog.clear()
service_instance.subattr.attr.name = "Ho"
assert "ServiceClass.attr.attr.attr.name changed to Ho" in caplog.text
assert "ServiceClass.subattr.attr.name changed to Ho" in caplog.text
assert "'attr.attr.attr.name' changed to 'Ho'" in caplog.text
assert "'subattr.attr.name' changed to 'Ho'" in caplog.text
caplog.clear()
@@ -224,17 +252,20 @@ def test_advanced_nested_attributes_mixed(caplog: LogCaptureFixture) -> None:
class_attr = SubSubClass()
def __init__(self) -> None:
self.attr_1 = SubSubClass()
super().__init__()
self.attr_1 = SubSubClass()
class ServiceClass(DataService):
class_attr = SubClass()
def __init__(self) -> None:
self.attr = SubClass()
super().__init__()
self.attr = SubClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
# Subclass.attr is the same for all instances
assert service_instance.attr.class_attr == service_instance.class_attr.class_attr
@@ -245,23 +276,23 @@ def test_advanced_nested_attributes_mixed(caplog: LogCaptureFixture) -> None:
assert service_instance.attr.attr_1 != service_instance.class_attr.class_attr
service_instance.class_attr.class_attr.name = "Ho"
assert "ServiceClass.class_attr.class_attr.name changed to Ho" in caplog.text
assert "ServiceClass.attr.class_attr.name changed to Ho" in caplog.text
assert "'class_attr.class_attr.name' changed to 'Ho'" in caplog.text
assert "'attr.class_attr.name' changed to 'Ho'" in caplog.text
caplog.clear()
service_instance.class_attr.attr_1.name = "Ho"
assert "ServiceClass.class_attr.attr_1.name changed to Ho" in caplog.text
assert "ServiceClass.attr.attr_1.name changed to Ho" not in caplog.text
assert "'class_attr.attr_1.name' changed to 'Ho'" in caplog.text
assert "'attr.attr_1.name' changed to 'Ho'" not in caplog.text
caplog.clear()
service_instance.attr.class_attr.name = "Ho"
assert "ServiceClass.class_attr.class_attr.name changed to Ho" in caplog.text
assert "ServiceClass.attr.class_attr.name changed to Ho" in caplog.text
service_instance.attr.class_attr.name = "Hello"
assert "'class_attr.class_attr.name' changed to 'Hello'" in caplog.text
assert "'attr.class_attr.name' changed to 'Hello'" in caplog.text
caplog.clear()
service_instance.attr.attr_1.name = "Ho"
assert "ServiceClass.attr.attr_1.name changed to Ho" in caplog.text
assert "ServiceClass.class_attr.attr_1.name changed to Ho" not in caplog.text
assert "'attr.attr_1.name' changed to 'Ho'" in caplog.text
assert "'class_attr.attr_1.name' changed to 'Ho'" not in caplog.text
caplog.clear()
@@ -277,32 +308,34 @@ def test_class_list_attributes(caplog: LogCaptureFixture) -> None:
attr = subclass_instance
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert service_instance.attr_list[0] != service_instance.attr_list[1]
service_instance.attr_list[0].name = "Ho"
assert "ServiceClass.attr_list[0].name changed to Ho" in caplog.text
assert "ServiceClass.attr_list[1].name changed to Ho" not in caplog.text
assert "'attr_list[0].name' changed to 'Ho'" in caplog.text
assert "'attr_list[1].name' changed to 'Ho'" not in caplog.text
caplog.clear()
service_instance.attr_list[1].name = "Ho"
assert "ServiceClass.attr_list[0].name changed to Ho" not in caplog.text
assert "ServiceClass.attr_list[1].name changed to Ho" in caplog.text
assert "'attr_list[0].name' changed to 'Ho'" not in caplog.text
assert "'attr_list[1].name' changed to 'Ho'" in caplog.text
caplog.clear()
assert service_instance.attr_list_2[0] == service_instance.attr
assert service_instance.attr_list_2[0] == service_instance.attr_list_2[1]
service_instance.attr_list_2[0].name = "Ho"
assert "ServiceClass.attr_list_2[0].name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[1].name changed to Ho" in caplog.text
assert "ServiceClass.attr.name changed to Ho" in caplog.text
service_instance.attr_list_2[0].name = "Ciao"
assert "'attr_list_2[0].name' changed to 'Ciao'" in caplog.text
assert "'attr_list_2[1].name' changed to 'Ciao'" in caplog.text
assert "'attr.name' changed to 'Ciao'" in caplog.text
caplog.clear()
service_instance.attr_list_2[1].name = "Ho"
assert "ServiceClass.attr_list_2[0].name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[1].name changed to Ho" in caplog.text
assert "ServiceClass.attr.name changed to Ho" in caplog.text
service_instance.attr_list_2[1].name = "Bye"
assert "'attr_list_2[0].name' changed to 'Bye'" in caplog.text
assert "'attr_list_2[1].name' changed to 'Bye'" in caplog.text
assert "'attr.name' changed to 'Bye'" in caplog.text
caplog.clear()
@@ -320,17 +353,19 @@ def test_nested_class_list_attributes(caplog: LogCaptureFixture) -> None:
subattr = subsubclass_instance
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert service_instance.attr[0].attr_list[0] == service_instance.subattr
service_instance.attr[0].attr_list[0].name = "Ho"
assert "ServiceClass.attr[0].attr_list[0].name changed to Ho" in caplog.text
assert "ServiceClass.subattr.name changed to Ho" in caplog.text
assert "'attr[0].attr_list[0].name' changed to 'Ho'" in caplog.text
assert "'subattr.name' changed to 'Ho'" in caplog.text
caplog.clear()
service_instance.subattr.name = "Ho"
assert "ServiceClass.attr[0].attr_list[0].name changed to Ho" in caplog.text
assert "ServiceClass.subattr.name changed to Ho" in caplog.text
service_instance.subattr.name = "Hi"
assert "'attr[0].attr_list[0].name' changed to 'Hi'" in caplog.text
assert "'subattr.name' changed to 'Hi'" in caplog.text
caplog.clear()
@@ -342,44 +377,46 @@ def test_instance_list_attributes(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr_list = [SubClass() for _ in range(2)]
self.attr_list_2 = [subclass_instance, subclass_instance]
self.attr = subclass_instance
super().__init__()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert service_instance.attr_list[0] != service_instance.attr_list[1]
service_instance.attr_list[0].name = "Ho"
assert "ServiceClass.attr_list[0].name changed to Ho" in caplog.text
assert "ServiceClass.attr_list[1].name changed to Ho" not in caplog.text
assert "'attr_list[0].name' changed to 'Ho'" in caplog.text
assert "'attr_list[1].name' changed to 'Ho'" not in caplog.text
caplog.clear()
service_instance.attr_list[1].name = "Ho"
assert "ServiceClass.attr_list[0].name changed to Ho" not in caplog.text
assert "ServiceClass.attr_list[1].name changed to Ho" in caplog.text
service_instance.attr_list[1].name = "Hi"
assert "'attr_list[0].name' changed to 'Hi'" not in caplog.text
assert "'attr_list[1].name' changed to 'Hi'" in caplog.text
caplog.clear()
assert service_instance.attr_list_2[0] == service_instance.attr
assert service_instance.attr_list_2[0] == service_instance.attr_list_2[1]
service_instance.attr_list_2[0].name = "Ho"
assert "ServiceClass.attr.name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[0].name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[1].name changed to Ho" in caplog.text
service_instance.attr_list_2[0].name = "Ciao"
assert "'attr.name' changed to 'Ciao'" in caplog.text
assert "'attr_list_2[0].name' changed to 'Ciao'" in caplog.text
assert "'attr_list_2[1].name' changed to 'Ciao'" in caplog.text
caplog.clear()
service_instance.attr_list_2[1].name = "Ho"
assert "ServiceClass.attr.name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[0].name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[1].name changed to Ho" in caplog.text
service_instance.attr_list_2[1].name = "Bye"
assert "'attr.name' changed to 'Bye'" in caplog.text
assert "'attr_list_2[0].name' changed to 'Bye'" in caplog.text
assert "'attr_list_2[1].name' changed to 'Bye'" in caplog.text
caplog.clear()
service_instance.attr.name = "Ho"
assert "ServiceClass.attr.name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[0].name changed to Ho" in caplog.text
assert "ServiceClass.attr_list_2[1].name changed to Ho" in caplog.text
assert "'attr.name' changed to 'Ho'" in caplog.text
assert "'attr_list_2[0].name' changed to 'Ho'" in caplog.text
assert "'attr_list_2[1].name' changed to 'Ho'" in caplog.text
caplog.clear()
@@ -391,26 +428,28 @@ def test_nested_instance_list_attributes(caplog: LogCaptureFixture) -> None:
class SubClass(DataService):
def __init__(self) -> None:
self.attr_list = [subsubclass_instance]
super().__init__()
self.attr_list = [subsubclass_instance]
class ServiceClass(DataService):
class_attr = subsubclass_instance
def __init__(self) -> None:
self.attr = [SubClass()]
super().__init__()
self.attr = [SubClass()]
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert service_instance.attr[0].attr_list[0] == service_instance.class_attr
service_instance.attr[0].attr_list[0].name = "Ho"
assert "ServiceClass.attr[0].attr_list[0].name changed to Ho" in caplog.text
assert "ServiceClass.class_attr.name changed to Ho" in caplog.text
assert "'attr[0].attr_list[0].name' changed to 'Ho'" in caplog.text
assert "'class_attr.name' changed to 'Ho'" in caplog.text
caplog.clear()
service_instance.class_attr.name = "Ho"
assert "ServiceClass.attr[0].attr_list[0].name changed to Ho" in caplog.text
assert "ServiceClass.class_attr.name changed to Ho" in caplog.text
service_instance.class_attr.name = "Hi"
assert "'attr[0].attr_list[0].name' changed to 'Hi'" in caplog.text
assert "'class_attr.name' changed to 'Hi'" in caplog.text
caplog.clear()

View File

@@ -1,6 +1,7 @@
from pytest import LogCaptureFixture
from pydase import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
def test_properties(caplog: LogCaptureFixture) -> None:
@@ -28,17 +29,20 @@ def test_properties(caplog: LogCaptureFixture) -> None:
def current(self, value: float) -> None:
self._current = value
test_service = ServiceClass()
test_service.voltage = 1
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert "ServiceClass.power changed to 1.0" in caplog.text
assert "ServiceClass.voltage changed to 1.0" in caplog.text
service_instance.voltage = 1.0
assert "'power' changed to '1.0'" in caplog.text
assert "'voltage' changed to '1.0'" in caplog.text
caplog.clear()
test_service.current = 12.0
service_instance.current = 12.0
assert "ServiceClass.power changed to 12.0" in caplog.text
assert "ServiceClass.current changed to 12.0" in caplog.text
assert "'power' changed to '12.0'" in caplog.text
assert "'current' changed to '12.0'" in caplog.text
def test_nested_properties(caplog: LogCaptureFixture) -> None:
@@ -61,30 +65,32 @@ def test_nested_properties(caplog: LogCaptureFixture) -> None:
def sub_name(self) -> str:
return f"{self.class_attr.name} {self.name}"
test_service = ServiceClass()
test_service.name = "Peepz"
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert "ServiceClass.name changed to Peepz" in caplog.text
assert "ServiceClass.sub_name changed to Hello Peepz" in caplog.text
assert "ServiceClass.subsub_name changed to Hello Peepz" in caplog.text
service_instance.name = "Peepz"
assert "'name' changed to 'Peepz'" in caplog.text
assert "'sub_name' changed to 'Hello Peepz'" in caplog.text
assert "'subsub_name' changed to 'Hello Peepz'" in caplog.text
caplog.clear()
test_service.class_attr.name = "Hi"
service_instance.class_attr.name = "Hi"
assert service_instance.subsub_name == "Hello Peepz"
assert "ServiceClass.sub_name changed to Hi Peepz" in caplog.text
assert (
"ServiceClass.subsub_name changed to Hello Peepz" in caplog.text
) # registers subclass changes
assert "ServiceClass.class_attr.name changed to Hi" in caplog.text
assert "'sub_name' changed to 'Hi Peepz'" in caplog.text
assert "'subsub_name' " not in caplog.text # subsub_name does not depend on change
assert "'class_attr.name' changed to 'Hi'" in caplog.text
caplog.clear()
test_service.class_attr.class_attr.name = "Ciao"
service_instance.class_attr.class_attr.name = "Ciao"
assert (
"ServiceClass.sub_name changed to Hi Peepz" in caplog.text
) # registers subclass changes
assert "ServiceClass.subsub_name changed to Ciao Peepz" in caplog.text
assert "ServiceClass.class_attr.class_attr.name changed to Ciao" in caplog.text
"'sub_name' changed to" not in caplog.text
) # sub_name does not depend on change
assert "'subsub_name' changed to 'Ciao Peepz'" in caplog.text
assert "'class_attr.class_attr.name' changed to 'Ciao'" in caplog.text
caplog.clear()
@@ -97,17 +103,20 @@ def test_simple_list_properties(caplog: LogCaptureFixture) -> None:
def total_name(self) -> str:
return f"{self.list[0]} {self.name}"
test_service = ServiceClass()
test_service.name = "Peepz"
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert "ServiceClass.name changed to Peepz" in caplog.text
assert "ServiceClass.total_name changed to Hello Peepz" in caplog.text
service_instance.name = "Peepz"
assert "'name' changed to 'Peepz'" in caplog.text
assert "'total_name' changed to 'Hello Peepz'" in caplog.text
caplog.clear()
test_service.list[0] = "Hi"
service_instance.list[0] = "Hi"
assert "ServiceClass.total_name changed to Hi Peepz" in caplog.text
assert "ServiceClass.list[0] changed to Hi" in caplog.text
assert "'total_name' changed to 'Hi Peepz'" in caplog.text
assert "'list[0]' changed to 'Hi'" in caplog.text
def test_class_list_properties(caplog: LogCaptureFixture) -> None:
@@ -122,23 +131,26 @@ def test_class_list_properties(caplog: LogCaptureFixture) -> None:
def total_name(self) -> str:
return f"{self.list[0].name} {self.name}"
test_service = ServiceClass()
test_service.name = "Peepz"
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
assert "ServiceClass.name changed to Peepz" in caplog.text
assert "ServiceClass.total_name changed to Hello Peepz" in caplog.text
service_instance.name = "Peepz"
assert "'name' changed to 'Peepz'" in caplog.text
assert "'total_name' changed to 'Hello Peepz'" in caplog.text
caplog.clear()
test_service.list[0].name = "Hi"
service_instance.list[0].name = "Hi"
assert "ServiceClass.total_name changed to Hi Peepz" in caplog.text
assert "ServiceClass.list[0].name changed to Hi" in caplog.text
assert "'total_name' changed to 'Hi Peepz'" in caplog.text
assert "'list[0].name' changed to 'Hi'" in caplog.text
def test_subclass_properties(caplog: LogCaptureFixture) -> None:
class SubClass(DataService):
name = "Hello"
_voltage = 10.0
_voltage = 11.0
_current = 1.0
@property
@@ -168,14 +180,15 @@ def test_subclass_properties(caplog: LogCaptureFixture) -> None:
def voltage(self) -> float:
return self.class_attr.voltage
test_service = ServiceClass()
test_service.class_attr.voltage = 10.0
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
# using a set here as "ServiceClass.voltage = 10.0" is emitted twice. Once for
# changing voltage, and once for changing power.
assert "ServiceClass.class_attr.voltage changed to 10.0" in caplog.text
assert "ServiceClass.class_attr.power changed to 10.0" in caplog.text
assert "ServiceClass.voltage changed to 10.0" in caplog.text
service_instance.class_attr.voltage = 10.0
assert "'class_attr.voltage' changed to '10.0'" in caplog.text
assert "'class_attr.power' changed to '10.0'" in caplog.text
assert "'voltage' changed to '10.0'" in caplog.text
caplog.clear()
@@ -212,17 +225,20 @@ def test_subclass_properties_2(caplog: LogCaptureFixture) -> None:
def voltage(self) -> float:
return self.class_attr[0].voltage
test_service = ServiceClass()
test_service.class_attr[1].current = 10.0
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
# using a set here as "ServiceClass.voltage = 10.0" is emitted twice. Once for
# changing current, and once for changing power. Note that the voltage property is
# only dependent on class_attr[0] but still emits an update notification. This is
# because every time any item in the list `test_service.class_attr` is changed,
# a notification will be emitted.
assert "ServiceClass.class_attr[1].current changed to 10.0" in caplog.text
assert "ServiceClass.class_attr[1].power changed to 100.0" in caplog.text
assert "ServiceClass.voltage changed to 10.0" in caplog.text
service_instance.class_attr[0].current = 10.0
assert "'class_attr[0].current' changed to '10.0'" in caplog.text
assert "'class_attr[0].power' changed to '100.0'" in caplog.text
caplog.clear()
service_instance.class_attr[0].voltage = 11.0
assert "'class_attr[0].voltage' changed to '11.0'" in caplog.text
assert "'class_attr[0].power' changed to '110.0'" in caplog.text
assert "'voltage' changed to '11.0'" in caplog.text
def test_subsubclass_properties(caplog: LogCaptureFixture) -> None:
@@ -252,25 +268,23 @@ def test_subsubclass_properties(caplog: LogCaptureFixture) -> None:
def power(self) -> float:
return self.class_attr[0].power
test_service = ServiceClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
test_service.class_attr[1].class_attr.voltage = 100.0
assert (
"ServiceClass.class_attr[0].class_attr.voltage changed to 100.0" in caplog.text
)
assert (
"ServiceClass.class_attr[1].class_attr.voltage changed to 100.0" in caplog.text
)
assert "ServiceClass.class_attr[0].power changed to 50.0" in caplog.text
assert "ServiceClass.class_attr[1].power changed to 50.0" in caplog.text
assert "ServiceClass.power changed to 50.0" in caplog.text
service_instance.class_attr[1].class_attr.voltage = 100.0
assert "'class_attr[0].class_attr.voltage' changed to '100.0'" in caplog.text
assert "'class_attr[1].class_attr.voltage' changed to '100.0'" in caplog.text
assert "'class_attr[0].power' changed to '50.0'" in caplog.text
assert "'class_attr[1].power' changed to '50.0'" in caplog.text
assert "'power' changed to '50.0'" in caplog.text
def test_subsubclass_instance_properties(caplog: LogCaptureFixture) -> None:
class SubSubClass(DataService):
def __init__(self) -> None:
self._voltage = 10.0
super().__init__()
self._voltage = 10.0
@property
def voltage(self) -> float:
@@ -282,9 +296,9 @@ def test_subsubclass_instance_properties(caplog: LogCaptureFixture) -> None:
class SubClass(DataService):
def __init__(self) -> None:
super().__init__()
self.attr = [SubSubClass()]
self.current = 0.5
super().__init__()
@property
def power(self) -> float:
@@ -297,12 +311,11 @@ def test_subsubclass_instance_properties(caplog: LogCaptureFixture) -> None:
def power(self) -> float:
return self.class_attr[0].power
test_service = ServiceClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
test_service.class_attr[1].attr[0].voltage = 100.0
# again, changing an item in a list will trigger the callbacks. This is why a
# notification for `ServiceClass.power` is emitted although it did not change its
# value
assert "ServiceClass.class_attr[1].attr[0].voltage changed to 100.0" in caplog.text
assert "ServiceClass.class_attr[1].power changed to 50.0" in caplog.text
assert "ServiceClass.power changed to 5.0" in caplog.text
service_instance.class_attr[0].attr[0].voltage = 100.0
assert "'class_attr[0].attr[0].voltage' changed to '100.0'" in caplog.text
assert "'class_attr[0].power' changed to '50.0'" in caplog.text
assert "'power' changed to '50.0'" in caplog.text

View File

@@ -1,9 +1,10 @@
from typing import Any
from pytest import LogCaptureFixture
import pydase.units as u
from pydase.data_service.data_service import DataService
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
from pytest import LogCaptureFixture
def test_DataService_setattr(caplog: LogCaptureFixture) -> None:
@@ -19,26 +20,28 @@ def test_DataService_setattr(caplog: LogCaptureFixture) -> None:
def current(self, value: Any) -> None:
self._current = value
service = ServiceClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
# You can just set floats to the Quantity objects. The DataService __setattr__ will
# automatically convert this
service.voltage = 10.0 # type: ignore
service.current = 1.5
service_instance.voltage = 10.0 * u.units.V
service_instance.current = 1.5 * u.units.mA
assert service.voltage == 10.0 * u.units.V # type: ignore
assert service.current == 1.5 * u.units.mA
assert "'voltage' changed to '10.0 V'" in caplog.text
assert "'current' changed to '1.5 mA'" in caplog.text
assert "ServiceClass.voltage changed to 10.0 V" in caplog.text
assert "ServiceClass.current changed to 1.5 mA" in caplog.text
assert service_instance.voltage == 10.0 * u.units.V
assert service_instance.current == 1.5 * u.units.mA
caplog.clear()
service.voltage = 12.0 * u.units.V # type: ignore
service.current = 1.51 * u.units.A
assert service.voltage == 12.0 * u.units.V # type: ignore
assert service.current == 1.51 * u.units.A
service_instance.voltage = 12.0 * u.units.V
service_instance.current = 1.51 * u.units.A
assert "ServiceClass.voltage changed to 12.0 V" in caplog.text
assert "ServiceClass.current changed to 1.51 A" in caplog.text
assert "'voltage' changed to '12.0 V'" in caplog.text
assert "'current' changed to '1.51 A'" in caplog.text
assert service_instance.voltage == 12.0 * u.units.V
assert service_instance.current == 1.51 * u.units.A
def test_convert_to_quantity() -> None:
@@ -48,7 +51,7 @@ def test_convert_to_quantity() -> None:
assert u.convert_to_quantity(1.0 * u.units.mV) == 1.0 * u.units.mV
def test_update_DataService_attribute(caplog: LogCaptureFixture) -> None:
def test_set_service_attribute_value_by_path(caplog: LogCaptureFixture) -> None:
class ServiceClass(DataService):
voltage = 1.0 * u.units.V
_current: u.Quantity = 1.0 * u.units.mA
@@ -61,23 +64,25 @@ def test_update_DataService_attribute(caplog: LogCaptureFixture) -> None:
def current(self, value: Any) -> None:
self._current = value
service = ServiceClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service.update_DataService_attribute(
path_list=[], attr_name="voltage", value=1.0 * u.units.mV
state_manager.set_service_attribute_value_by_path(
path="voltage", value=1.0 * u.units.mV
)
assert "'voltage' changed to '1.0 mV'" in caplog.text
caplog.clear()
assert "ServiceClass.voltage changed to 1.0 mV" in caplog.text
state_manager.set_service_attribute_value_by_path(path="voltage", value=2)
service.update_DataService_attribute(path_list=[], attr_name="voltage", value=2)
assert "'voltage' changed to '2.0 mV'" in caplog.text
caplog.clear()
assert "ServiceClass.voltage changed to 2.0 mV" in caplog.text
service.update_DataService_attribute(
path_list=[], attr_name="voltage", value={"magnitude": 123, "unit": "kV"}
state_manager.set_service_attribute_value_by_path(
path="voltage", value={"magnitude": 123, "unit": "kV"}
)
assert "ServiceClass.voltage changed to 123.0 kV" in caplog.text
assert "'voltage' changed to '123.0 kV'" in caplog.text
def test_autoconvert_offset_to_baseunit() -> None:
@@ -104,9 +109,9 @@ def test_loading_from_json(caplog: LogCaptureFixture) -> None:
}
class ServiceClass(DataService):
def __init__(self):
self._unit: u.Quantity = 1 * u.units.A
def __init__(self) -> None:
super().__init__()
self._unit: u.Quantity = 1 * u.units.A
@property
def some_unit(self) -> u.Quantity:
@@ -117,8 +122,10 @@ def test_loading_from_json(caplog: LogCaptureFixture) -> None:
assert isinstance(value, u.Quantity)
self._unit = value
service = ServiceClass()
service_instance = ServiceClass()
state_manager = StateManager(service_instance)
DataServiceObserver(state_manager)
service.load_DataService_from_JSON(JSON_DICT)
service_instance.load_DataService_from_JSON(JSON_DICT)
assert "ServiceClass.some_unit changed to 10.0 A" in caplog.text
assert "'some_unit' changed to '10.0 A'" in caplog.text

View File

@@ -1,6 +1,5 @@
import toml
import pydase.version
import toml
def test_project_version() -> None:

View File

@@ -1,10 +1,10 @@
import asyncio
from enum import Enum
import pytest
from typing import Any
import pydase
import pydase.units as u
import pytest
from pydase.components.coloured_enum import ColouredEnum
from pydase.utils.serializer import (
SerializationPathError,
@@ -32,7 +32,7 @@ from pydase.utils.serializer import (
),
],
)
def test_dump(test_input, expected):
def test_dump(test_input: Any, expected: dict[str, Any]) -> None:
assert dump(test_input) == expected
@@ -43,13 +43,13 @@ def test_enum_serialize() -> None:
class EnumAttribute(pydase.DataService):
def __init__(self) -> None:
self.some_enum = EnumClass.FOO
super().__init__()
self.some_enum = EnumClass.FOO
class EnumPropertyWithoutSetter(pydase.DataService):
def __init__(self) -> None:
self._some_enum = EnumClass.FOO
super().__init__()
self._some_enum = EnumClass.FOO
@property
def some_enum(self) -> EnumClass:
@@ -57,8 +57,8 @@ def test_enum_serialize() -> None:
class EnumPropertyWithSetter(pydase.DataService):
def __init__(self) -> None:
self._some_enum = EnumClass.FOO
super().__init__()
self._some_enum = EnumClass.FOO
@property
def some_enum(self) -> EnumClass:
@@ -401,17 +401,10 @@ def test_get_class_attribute_inside_list(setup_dict):
def test_get_invalid_list_index(setup_dict, caplog: pytest.LogCaptureFixture):
get_nested_dict_by_path(setup_dict, "attr_list[10]")
assert (
"Error occured trying to change 'attr_list[10]': list index "
"out of range" in caplog.text
)
with pytest.raises(SerializationPathError):
get_nested_dict_by_path(setup_dict, "attr_list[10]")
def test_get_invalid_path(setup_dict, caplog: pytest.LogCaptureFixture):
get_nested_dict_by_path(setup_dict, "invalid_path")
assert (
"Error occured trying to access the key 'invalid_path': it is either "
"not present in the current dictionary or its value does not contain "
"a 'value' key." in caplog.text
)
with pytest.raises(SerializationPathError):
get_nested_dict_by_path(setup_dict, "invalid_path")

View File

@@ -1,48 +0,0 @@
from pytest import LogCaptureFixture
from pydase import DataService
def test_setattr_warnings(caplog: LogCaptureFixture) -> None: # noqa
# def test_setattr_warnings(capsys: CaptureFixture) -> None:
class SubClass:
name = "Hello"
class ServiceClass(DataService):
def __init__(self) -> None:
self.attr_1 = SubClass()
super().__init__()
ServiceClass()
assert "Warning: Class SubClass does not inherit from DataService." in caplog.text
def test_private_attribute_warning(caplog: LogCaptureFixture) -> None: # noqa
class ServiceClass(DataService):
def __init__(self) -> None:
self.__something = ""
super().__init__()
ServiceClass()
assert (
" Warning: You should not set private but rather protected attributes! Use "
"_something instead of __something." in caplog.text
)
def test_protected_attribute_warning(caplog: LogCaptureFixture) -> None: # noqa
class SubClass:
name = "Hello"
class ServiceClass(DataService):
def __init__(self) -> None:
self._subclass = SubClass
super().__init__()
ServiceClass()
assert (
"Warning: Class SubClass does not inherit from DataService." not in caplog.text
)