mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-12-18 12:11:20 +01:00
Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49984b7c2e | ||
|
|
39270561b9 | ||
|
|
8ac2c39908 | ||
|
|
0694a3d1ee | ||
|
|
c15ad54e2d | ||
|
|
71721b1286 | ||
|
|
74ceb7f05c | ||
|
|
06d11fff49 | ||
|
|
6d23151d32 | ||
|
|
0faf347376 | ||
|
|
a5fddf7e45 | ||
|
|
83c763bd20 | ||
|
|
9778541ee4 | ||
|
|
8e641c1b84 | ||
|
|
f6bf229c8c | ||
|
|
5a76d76d2b | ||
|
|
3169531a24 | ||
|
|
4bd0092fbf | ||
|
|
569e343e89 | ||
|
|
f2b2ef8dcd | ||
|
|
f70ac05df6 | ||
|
|
e3367efda1 | ||
|
|
3d2de7109b | ||
|
|
534ff4c149 | ||
|
|
0e47f6c4d3 | ||
|
|
b4ef8201f3 | ||
|
|
a97a55712e | ||
|
|
e8a0a7c000 | ||
|
|
6f0d43aa5a | ||
|
|
0e210b8ba6 | ||
|
|
329e0acd81 | ||
|
|
f97cd7eb4e | ||
|
|
3c168243bb | ||
|
|
0944a404dc | ||
|
|
a9c6070ca3 | ||
|
|
75ee71cbf8 | ||
|
|
1e55a4d914 | ||
|
|
aab2b4ee77 | ||
|
|
52d571e551 | ||
|
|
bb415af460 | ||
|
|
c3c1669cf9 | ||
|
|
5378396958 | ||
|
|
b66e964155 | ||
|
|
4fc25c6752 | ||
|
|
44cd9597cb | ||
|
|
e48a7067ec | ||
|
|
8919f6106a | ||
|
|
89b5a9cc9e | ||
|
|
0aa1595da4 | ||
|
|
8f8b3e3bcf | ||
|
|
43e6adcb2e | ||
|
|
3992f491c9 | ||
|
|
df571a8260 | ||
|
|
53713794d6 | ||
|
|
06e642972f | ||
|
|
a7ec7c1536 | ||
|
|
c891642bda | ||
|
|
cc105106ee | ||
|
|
7c7bb193e4 | ||
|
|
92e79579ff | ||
|
|
5d2d34bea3 | ||
|
|
3497962fca | ||
|
|
114a1c6fdc | ||
|
|
1d2ac57ba7 | ||
|
|
99dea381a3 | ||
|
|
e6e5ac84b4 | ||
|
|
246148f513 | ||
|
|
eb0c819037 | ||
|
|
f5d8775141 | ||
|
|
1ec034a62e | ||
|
|
93f0627534 | ||
|
|
ad2ae704e9 | ||
|
|
de5340d6fd | ||
|
|
b80a3ec6a1 | ||
|
|
f3853ef836 | ||
|
|
56ae9086b5 | ||
|
|
5a2371353a | ||
|
|
09a55f50bd | ||
|
|
abafd1a2b2 | ||
|
|
145ff89072 | ||
|
|
ba5b4e7be4 | ||
|
|
8ee49469d6 | ||
|
|
6997c4a842 | ||
|
|
598449e893 | ||
|
|
4802f19720 | ||
|
|
a04bd14e50 | ||
|
|
c60730f21b | ||
|
|
d5cd97ea57 | ||
|
|
0136885207 | ||
|
|
c04e048e21 | ||
|
|
9e9d3f17bc | ||
|
|
e576f6eb80 | ||
|
|
e57fe10c9e | ||
|
|
f27f513bf8 | ||
|
|
de4e4ed178 | ||
|
|
cb2687a4b9 | ||
|
|
ab794d780b | ||
|
|
617eed4d96 | ||
|
|
d517bd0489 | ||
|
|
d0869b707b | ||
|
|
eab99df9d1 | ||
|
|
9d36f99404 | ||
|
|
7b7ef0eb97 | ||
|
|
92f14c6788 | ||
|
|
4746470aee | ||
|
|
f5627e6a2f | ||
|
|
a769f4eb3b | ||
|
|
3970d5a17b | ||
|
|
a89db46d5e | ||
|
|
f67591c7ac | ||
|
|
fdcaa1c1ed | ||
|
|
613b1dd6a4 | ||
|
|
914997cc6b | ||
|
|
667bb949cc | ||
|
|
acaac6f0a6 | ||
|
|
e9df89765d | ||
|
|
123edb9e86 | ||
|
|
69328d6f68 | ||
|
|
0cd3a7e8a8 | ||
|
|
abd77e053d | ||
|
|
ebb8b4be8b | ||
|
|
a83e0c6c7f | ||
|
|
64dc09faf7 | ||
|
|
e2fb9ebae5 | ||
|
|
4a43bda5e2 | ||
|
|
f693fa9ba2 | ||
|
|
9820bda4b5 | ||
|
|
f5116607b9 | ||
|
|
0ea997384c | ||
|
|
28410a97f5 | ||
|
|
f6eef7085e |
8
.flake8
8
.flake8
@@ -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
25
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
14
.github/workflows/python-package.yml
vendored
14
.github/workflows/python-package.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -128,6 +128,9 @@ venv.bak/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance"
|
||||
]
|
||||
}
|
||||
29
.vscode/settings.json
vendored
29
.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
|
||||
45
README.md
45
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
27
docs/dev-guide/Observer_Pattern_Implementation.md
Normal file
27
docs/dev-guide/Observer_Pattern_Implementation.md
Normal 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.
|
||||
1523
frontend/package-lock.json
generated
1523
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
108
frontend/src/utils/stateUtils.ts
Normal file
108
frontend/src/utils/stateUtils.ts
Normal 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];
|
||||
}
|
||||
@@ -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
992
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
@@ -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
|
||||
|
||||
@@ -57,5 +57,3 @@ class ColouredEnum(Enum):
|
||||
my_service.status = MyStatus.FAILED
|
||||
```
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
107
src/pydase/data_service/data_service_observer.py
Normal file
107
src/pydase/data_service/data_service_observer.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
6
src/pydase/frontend/static/css/main.2d8458eb.css
Normal file
6
src/pydase/frontend/static/css/main.2d8458eb.css
Normal file
File diff suppressed because one or more lines are too long
1
src/pydase/frontend/static/css/main.2d8458eb.css.map
Normal file
1
src/pydase/frontend/static/css/main.2d8458eb.css.map
Normal file
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
3
src/pydase/frontend/static/js/main.7f907b0f.js
Normal file
3
src/pydase/frontend/static/js/main.7f907b0f.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
1
src/pydase/frontend/static/js/main.7f907b0f.js.map
Normal file
1
src/pydase/frontend/static/js/main.7f907b0f.js.map
Normal file
File diff suppressed because one or more lines are too long
0
src/pydase/observer_pattern/__init__.py
Normal file
0
src/pydase/observer_pattern/__init__.py
Normal file
3
src/pydase/observer_pattern/observable/__init__.py
Normal file
3
src/pydase/observer_pattern/observable/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from pydase.observer_pattern.observable.observable import Observable
|
||||
|
||||
__all__ = ["Observable"]
|
||||
71
src/pydase/observer_pattern/observable/observable.py
Normal file
71
src/pydase/observer_pattern/observable/observable.py
Normal 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
|
||||
263
src/pydase/observer_pattern/observable/observable_object.py
Normal file
263
src/pydase/observer_pattern/observable/observable_object.py
Normal 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
|
||||
7
src/pydase/observer_pattern/observer/__init__.py
Normal file
7
src/pydase/observer_pattern/observer/__init__.py
Normal 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",
|
||||
]
|
||||
31
src/pydase/observer_pattern/observer/observer.py
Normal file
31
src/pydase/observer_pattern/observer/observer.py
Normal 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
|
||||
95
src/pydase/observer_pattern/observer/property_observer.py
Normal file
95
src/pydase/observer_pattern/observer/property_observer.py
Normal 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)
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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("."))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
== {}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
96
tests/data_service/test_data_service_observer.py
Normal file
96
tests/data_service/test_data_service_observer.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
173
tests/observer_pattern/observable/test_observable.py
Normal file
173
tests/observer_pattern/observable/test_observable.py
Normal 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
|
||||
474
tests/observer_pattern/observable/test_observable_object.py
Normal file
474
tests/observer_pattern/observable/test_observable_object.py
Normal 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
|
||||
25
tests/observer_pattern/observer/test_observer.py
Normal file
25
tests/observer_pattern/observer/test_observer.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import toml
|
||||
|
||||
import pydase.version
|
||||
import toml
|
||||
|
||||
|
||||
def test_project_version() -> None:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user