mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-12-21 21:51:18 +01:00
Compare commits
34 Commits
dev/image
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a158308686 | ||
|
|
7a55903b01 | ||
|
|
dc432a1238 | ||
|
|
c182e11527 | ||
|
|
fe5b6c591d | ||
|
|
f948605b58 | ||
|
|
55ab705542 | ||
|
|
c970ae89d0 | ||
|
|
23ef229eb1 | ||
|
|
cb94068faf | ||
|
|
2ce8ace227 | ||
|
|
ee124ead89 | ||
|
|
bbee77e231 | ||
|
|
27520864c4 | ||
|
|
e743d89f2e | ||
|
|
050a718e44 | ||
|
|
d2b9dd832f | ||
|
|
d5cb1a1478 | ||
|
|
82f8e1f90c | ||
|
|
c12bb87b2b | ||
|
|
6eafe07ac7 | ||
|
|
76fb674fcd | ||
|
|
e86fd1ffbe | ||
|
|
0626500cdd | ||
|
|
fab526a679 | ||
|
|
ea621c7c4b | ||
|
|
60984b6e13 | ||
|
|
08e0c59ad7 | ||
|
|
f132d71f60 | ||
|
|
fb6fac5f9a | ||
|
|
a2ce2e1116 | ||
|
|
29ebc566bb | ||
|
|
b275446960 | ||
|
|
24f35245bb |
@@ -1,4 +1,6 @@
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
omit =
|
||||
src/pydase/utils/logging.py
|
||||
4
.flake8
4
.flake8
@@ -1,8 +1,8 @@
|
||||
[flake8]
|
||||
ignore = E501,W503,FS003,F403,F405,E203
|
||||
ignore = E501,W503,FS003,F403,F405,E203,UNT001
|
||||
include = src
|
||||
max-line-length = 88
|
||||
max-doc-length = 88
|
||||
max-complexity = 7
|
||||
max-expression-complexity = 5.5
|
||||
use_class_attributes_order_strict_mode=True
|
||||
use_class_attributes_order_strict_mode=True
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
111
.github/workflows/publish-to-pypi.yaml
vendored
Normal file
111
.github/workflows/publish-to-pypi.yaml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install pypa/build
|
||||
run: >-
|
||||
python3 -m
|
||||
pip install
|
||||
build
|
||||
--user
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: python3 -m build
|
||||
- name: Store the distribution packages
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
publish-to-pypi:
|
||||
name: >-
|
||||
Publish Python 🐍 distribution 📦 to PyPI
|
||||
if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/pydase
|
||||
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: mandatory for trusted publishing
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
github-release:
|
||||
name: >-
|
||||
Sign the Python 🐍 distribution 📦 with Sigstore
|
||||
and upload them to GitHub Release
|
||||
needs:
|
||||
- publish-to-pypi
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write # IMPORTANT: mandatory for making GitHub Releases
|
||||
id-token: write # IMPORTANT: mandatory for sigstore
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Sign the dists with Sigstore
|
||||
uses: sigstore/gh-action-sigstore-python@v1.2.3
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
./dist/*.whl
|
||||
- name: Upload artifact signatures to GitHub Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# Upload to GitHub Release using the `gh` CLI.
|
||||
# `dist/` contains the built packages, and the
|
||||
# sigstore-produced signatures and certificates.
|
||||
run: >-
|
||||
gh release upload
|
||||
'${{ github.ref_name }}' dist/**
|
||||
--repo '${{ github.repository }}'
|
||||
|
||||
publish-to-testpypi:
|
||||
name: Publish Python 🐍 distribution 📦 to TestPyPI
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
environment:
|
||||
name: testpypi
|
||||
url: https://test.pypi.org/p/pydase
|
||||
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: mandatory for trusted publishing
|
||||
|
||||
steps:
|
||||
- name: Download all the dists
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
- name: Publish distribution 📦 to TestPyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
44
.github/workflows/python-package.yml
vendored
44
.github/workflows/python-package.yml
vendored
@@ -5,34 +5,36 @@ name: Python package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"]
|
||||
python-version: ['3.9', '3.10', '3.11']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
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
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
poetry run pytest
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
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
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
poetry run pytest
|
||||
- name: Test with pyright
|
||||
run: |
|
||||
poetry run pyright src/pydase
|
||||
|
||||
10
.vscode/launch.json
vendored
10
.vscode/launch.json
vendored
@@ -9,14 +9,20 @@
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "foo",
|
||||
"justMyCode": true
|
||||
"justMyCode": true,
|
||||
"env": {
|
||||
"ENVIRONMENT": "development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bar",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "bar",
|
||||
"justMyCode": true
|
||||
"justMyCode": true,
|
||||
"env": {
|
||||
"ENVIRONMENT": "development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "firefox",
|
||||
|
||||
24
README.md
24
README.md
@@ -52,48 +52,54 @@ To use pydase, you'll first need to create a class that inherits from `DataServi
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
from pydase import DataService
|
||||
from pydase import DataService, Server
|
||||
|
||||
|
||||
class Device(DataService):
|
||||
|
||||
_current = 0.0
|
||||
_voltage = 0.0
|
||||
_power = False
|
||||
|
||||
@property
|
||||
def current(self):
|
||||
def current(self) -> float:
|
||||
# run code to get current
|
||||
return self._current
|
||||
|
||||
@current.setter
|
||||
def current(self, value):
|
||||
def current(self, value: float) -> None:
|
||||
# run code to set current
|
||||
self._current = value
|
||||
|
||||
@property
|
||||
def voltage(self):
|
||||
def voltage(self) -> float:
|
||||
# run code to get voltage
|
||||
return self._voltage
|
||||
|
||||
@voltage.setter
|
||||
def voltage(self, value):
|
||||
def voltage(self, value: float) -> None:
|
||||
# run code to set voltage
|
||||
self._voltage = value
|
||||
|
||||
@property
|
||||
def power(self):
|
||||
def power(self) -> bool:
|
||||
# run code to get power state
|
||||
return self._power
|
||||
|
||||
@power.setter
|
||||
def power(self, value):
|
||||
def power(self, value: bool) -> None:
|
||||
# run code to set power state
|
||||
self._power = value
|
||||
|
||||
def reset(self):
|
||||
def reset(self) -> None:
|
||||
self.current = 0.0
|
||||
self.voltage = 0.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
service = Device()
|
||||
Server(service).run()
|
||||
```
|
||||
|
||||
In the above example, we define a Device class that extends DataService. We define a few properties (current, voltage, power) and their getter and setter methods.
|
||||
|
||||
### Running the Server
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 24 KiB |
@@ -112,7 +112,7 @@ const reducer = (state: State, action: Action): State => {
|
||||
const App = () => {
|
||||
const [state, dispatch] = useReducer(reducer, null);
|
||||
const stateRef = useRef(state); // Declare a reference to hold the current state
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useState(true);
|
||||
const [isInstantUpdate, setIsInstantUpdate] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showNotification, setShowNotification] = useState(true);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, InputGroup, Button } from 'react-bootstrap';
|
||||
import { Form, InputGroup } from 'react-bootstrap';
|
||||
import { emit_update } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import '../App.css';
|
||||
@@ -163,7 +163,12 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
addNotification(notificationMsg);
|
||||
}, [props.value]);
|
||||
|
||||
const handleNumericKey = (key: string, value: string, selectionStart: number) => {
|
||||
const handleNumericKey = (
|
||||
key: string,
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
selectionEnd: number
|
||||
) => {
|
||||
// Check if a number key or a decimal point key is pressed
|
||||
if (key === '.' && (value.includes('.') || props.type === 'int')) {
|
||||
// Check if value already contains a decimal. If so, ignore input.
|
||||
@@ -171,8 +176,18 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
console.warn('Invalid input! Ignoring...');
|
||||
return { value, selectionStart };
|
||||
}
|
||||
|
||||
let newValue = value;
|
||||
|
||||
// Add the new key at the cursor's position
|
||||
const newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
|
||||
if (selectionEnd > selectionStart) {
|
||||
// If there is a selection, replace it with the key
|
||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionEnd);
|
||||
} else {
|
||||
// otherwise, append the key after the selection start
|
||||
newValue = value.slice(0, selectionStart) + key + value.slice(selectionStart);
|
||||
}
|
||||
|
||||
return { value: newValue, selectionStart: selectionStart + 1 };
|
||||
};
|
||||
const handleKeyDown = (event) => {
|
||||
@@ -204,13 +219,15 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
));
|
||||
} else if (key === '.') {
|
||||
({ value: newValue, selectionStart } = handleNumericKey(
|
||||
key,
|
||||
value,
|
||||
selectionStart
|
||||
selectionStart,
|
||||
selectionEnd
|
||||
));
|
||||
} else if (key === 'ArrowUp' || key === 'ArrowDown') {
|
||||
({ value: newValue, selectionStart } = handleArrowKey(
|
||||
@@ -277,16 +294,6 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||
/>
|
||||
{unit && <InputGroup.Text>{unit}</InputGroup.Text>}
|
||||
</InputGroup>
|
||||
{!readOnly && (
|
||||
<div className="d-flex flex-column">
|
||||
<Button className="numberComponentButton" variant="outline-secondary">
|
||||
+
|
||||
</Button>
|
||||
<Button className="numberComponentButton" variant="outline-secondary">
|
||||
-
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { InputGroup, Form, Row, Col, Button, Collapse } from 'react-bootstrap';
|
||||
import { InputGroup, Form, Row, Col, Collapse, ToggleButton } from 'react-bootstrap';
|
||||
import { emit_update } from '../socket';
|
||||
import { DocStringComponent } from './DocStringComponent';
|
||||
import { Slider } from '@mui/material';
|
||||
@@ -144,10 +144,13 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
/>
|
||||
</Col>
|
||||
<Col xs="auto">
|
||||
<Button
|
||||
<ToggleButton
|
||||
onClick={() => setOpen(!open)}
|
||||
type="checkbox"
|
||||
checked={open}
|
||||
value=""
|
||||
className="btn"
|
||||
variant="white"
|
||||
variant="light"
|
||||
aria-controls="slider-settings"
|
||||
aria-expanded={open}>
|
||||
<svg
|
||||
@@ -159,7 +162,7 @@ export const SliderComponent = React.memo((props: SliderComponentProps) => {
|
||||
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z" />
|
||||
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</ToggleButton>
|
||||
</Col>
|
||||
</Row>
|
||||
<Collapse in={open}>
|
||||
|
||||
1134
poetry.lock
generated
1134
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,3 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
prefer-active-python = true
|
||||
|
||||
@@ -8,7 +8,7 @@ packages = [{ include = "pydase", from = "src" }]
|
||||
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
python = "^3.9"
|
||||
rpyc = "^5.3.1"
|
||||
loguru = "^0.7.0"
|
||||
fastapi = "^0.100.0"
|
||||
@@ -35,6 +35,7 @@ flake8-pep585 = "^0.1.7"
|
||||
flake8-pep604 = "^0.1.0"
|
||||
flake8-eradicate = "^1.4.0"
|
||||
matplotlib = "^3.7.2"
|
||||
pyright = "^1.1.323"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from pydase.data_service import DataService
|
||||
from pydase.server import Server
|
||||
from pydase.utils.logging import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
||||
__all__ = [
|
||||
"DataService",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Optional, Union
|
||||
from urllib.request import urlopen
|
||||
|
||||
import PIL.Image
|
||||
@@ -29,7 +29,7 @@ class Image(DataService):
|
||||
def format(self) -> str:
|
||||
return self._format
|
||||
|
||||
def load_from_path(self, path: Path | str) -> None:
|
||||
def load_from_path(self, path: Union[Path, str]) -> None:
|
||||
with PIL.Image.open(path) as image:
|
||||
self._load_from_PIL(image)
|
||||
|
||||
@@ -68,7 +68,7 @@ class Image(DataService):
|
||||
else:
|
||||
logger.error("Image format is 'None'. Skipping...")
|
||||
|
||||
def _get_image_format_from_bytes(self, value_: bytes) -> str | None:
|
||||
def _get_image_format_from_bytes(self, value_: bytes) -> Union[str, None]:
|
||||
image_data = base64.b64decode(value_)
|
||||
# Create a writable memory buffer for the image
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -39,11 +39,11 @@ class NumberSlider(DataService):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: float | int = 0,
|
||||
value: Union[float, int] = 0,
|
||||
min: float = 0.0,
|
||||
max: float = 100.0,
|
||||
step_size: float | int = 1.0,
|
||||
type: Literal["int"] | Literal["float"] = "float",
|
||||
step_size: Union[float, int] = 1.0,
|
||||
type: Union[Literal["int"], Literal["float"]] = "float",
|
||||
) -> None:
|
||||
if type not in {"float", "int"}:
|
||||
logger.error(f"Unknown type '{type}'. Using 'float'.")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from typing import Literal
|
||||
from typing import Literal, Union
|
||||
|
||||
from confz import BaseConfig, EnvSource
|
||||
|
||||
|
||||
class OperationMode(BaseConfig): # type: ignore
|
||||
environment: Literal["development"] | Literal["production"] = "production"
|
||||
environment: Union[Literal["development"], Literal["production"]] = "development"
|
||||
|
||||
CONFIG_SOURCES = EnvSource(allow=["ENVIRONMENT"])
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -206,8 +206,8 @@ class CallbackManager:
|
||||
|
||||
def __register_recursive_parameter_callback(
|
||||
self,
|
||||
obj: "AbstractDataService | DataServiceList",
|
||||
callback: Callable[[str | int, Any], None],
|
||||
obj: Union["AbstractDataService", DataServiceList],
|
||||
callback: Callable[[Union[str, int], Any], None],
|
||||
) -> None:
|
||||
"""
|
||||
Register callback to a DataService or DataServiceList instance and its nested
|
||||
@@ -222,7 +222,7 @@ class CallbackManager:
|
||||
if isinstance(obj, DataServiceList):
|
||||
# emits callback when item in list gets reassigned
|
||||
obj.add_callback(callback=callback)
|
||||
obj_list: DataServiceList | list[AbstractDataService] = obj
|
||||
obj_list: Union[DataServiceList, list[AbstractDataService]] = obj
|
||||
else:
|
||||
obj_list = [obj]
|
||||
|
||||
@@ -337,7 +337,7 @@ class CallbackManager:
|
||||
|
||||
# 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] = (
|
||||
callback: Callable[[str, Union[dict[str, Any], None]], None] = (
|
||||
lambda name, status: obj._callback_manager.emit_notification(
|
||||
parent_path=parent_path, name=name, value=status
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ from pydase.utils.helpers import (
|
||||
get_component_class_names,
|
||||
get_nested_value_from_DataService_by_path_and_key,
|
||||
get_object_attr_from_path,
|
||||
is_property_attribute,
|
||||
parse_list_attr_and_index,
|
||||
update_value_if_changed,
|
||||
)
|
||||
@@ -58,13 +59,15 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
self._load_values_from_json()
|
||||
|
||||
def __setattr__(self, __name: str, __value: Any) -> None:
|
||||
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)
|
||||
# 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)
|
||||
|
||||
if isinstance(current_value, u.Quantity):
|
||||
__value = u.convert_to_quantity(__value, str(current_value.u))
|
||||
if isinstance(current_value, u.Quantity):
|
||||
__value = u.convert_to_quantity(__value, str(current_value.u))
|
||||
|
||||
super().__setattr__(__name, __value)
|
||||
|
||||
@@ -84,6 +87,27 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
if not attr_name.startswith("_DataService__"):
|
||||
warn_if_instance_class_does_not_inherit_from_DataService(attr_value)
|
||||
|
||||
def __set_attribute_based_on_type( # noqa:CFQ002
|
||||
self,
|
||||
target_obj: Any,
|
||||
attr_name: str,
|
||||
attr: Any,
|
||||
value: Any,
|
||||
index: Optional[int],
|
||||
path_list: list[str],
|
||||
) -> None:
|
||||
if isinstance(attr, Enum):
|
||||
update_value_if_changed(target_obj, attr_name, attr.__class__[value])
|
||||
elif isinstance(attr, list) and index is not None:
|
||||
update_value_if_changed(attr, index, value)
|
||||
elif isinstance(attr, DataService) and isinstance(value, dict):
|
||||
for key, v in value.items():
|
||||
self.update_DataService_attribute([*path_list, attr_name], key, v)
|
||||
elif callable(attr):
|
||||
process_callable_attribute(attr, value["args"])
|
||||
else:
|
||||
update_value_if_changed(target_obj, attr_name, value)
|
||||
|
||||
def _rpyc_getattr(self, name: str) -> Any:
|
||||
if name.startswith("_"):
|
||||
# disallow special and private attributes
|
||||
@@ -338,24 +362,19 @@ class DataService(rpyc.Service, AbstractDataService):
|
||||
) -> None:
|
||||
# If attr_name corresponds to a list entry, extract the attr_name and the index
|
||||
attr_name, index = parse_list_attr_and_index(attr_name)
|
||||
|
||||
# Traverse the object according to the path parts
|
||||
target_obj = get_object_attr_from_path(self, path_list)
|
||||
|
||||
attr = get_object_attr_from_path(target_obj, [attr_name])
|
||||
# If the attribute is a property, change it using the setter without getting the
|
||||
# property value (would otherwise be bad for expensive getter methods)
|
||||
if is_property_attribute(target_obj, attr_name):
|
||||
setattr(target_obj, attr_name, value)
|
||||
return
|
||||
|
||||
attr = get_object_attr_from_path(target_obj, [attr_name])
|
||||
if attr is None:
|
||||
return
|
||||
|
||||
# Set the attribute at the terminal point of the path
|
||||
if isinstance(attr, Enum):
|
||||
update_value_if_changed(target_obj, attr_name, attr.__class__[value])
|
||||
elif isinstance(attr, list) and index is not None:
|
||||
update_value_if_changed(attr, index, value)
|
||||
elif isinstance(attr, DataService) and isinstance(value, dict):
|
||||
for key, v in value.items():
|
||||
self.update_DataService_attribute([*path_list, attr_name], key, v)
|
||||
elif callable(attr):
|
||||
return process_callable_attribute(attr, value["args"])
|
||||
else:
|
||||
update_value_if_changed(target_obj, attr_name, value)
|
||||
self.__set_attribute_based_on_type(
|
||||
target_obj, attr_name, attr, value, index, path_list
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import Any, Union
|
||||
|
||||
from pydase.utils.warnings import (
|
||||
warn_if_instance_class_does_not_inherit_from_DataService,
|
||||
@@ -31,7 +31,7 @@ class DataServiceList(list):
|
||||
def __init__(
|
||||
self,
|
||||
*args: list[Any],
|
||||
callback: list[Callable[[int, Any], None]] | None = None,
|
||||
callback: Union[list[Callable[[int, Any], None]], None] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.callbacks: list[Callable[[int, Any], None]] = []
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
from typing import TYPE_CHECKING, Any, TypedDict, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -82,7 +82,7 @@ class TaskManager:
|
||||
"""
|
||||
|
||||
self.task_status_change_callbacks: list[
|
||||
Callable[[str, dict[str, Any] | None], Any]
|
||||
Callable[[str, Union[dict[str, Any], None]], Any]
|
||||
] = []
|
||||
"""A list of callback functions to be invoked when the status of a task (start
|
||||
or stop) changes."""
|
||||
@@ -97,6 +97,28 @@ class TaskManager:
|
||||
|
||||
@wraps(method)
|
||||
def start_task(*args: Any, **kwargs: Any) -> None:
|
||||
def task_done_callback(task: asyncio.Task, name: str) -> None:
|
||||
"""Handles tasks that have finished.
|
||||
|
||||
Removes a task from the tasks dictionary, calls the defined
|
||||
callbacks, and logs and re-raises exceptions."""
|
||||
|
||||
# removing the finished task from the tasks i
|
||||
self.tasks.pop(name, None)
|
||||
|
||||
# emit the notification that the task was stopped
|
||||
for callback in self.task_status_change_callbacks:
|
||||
callback(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}"
|
||||
)
|
||||
raise exception
|
||||
|
||||
async def task(*args: Any, **kwargs: Any) -> None:
|
||||
try:
|
||||
await method(*args, **kwargs)
|
||||
@@ -126,11 +148,18 @@ class TaskManager:
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
# creating the task and adding the task_done_callback which checks
|
||||
# if an exception has occured during the task execution
|
||||
task_object = self._loop.create_task(task(*args, **kwargs))
|
||||
task_object.add_done_callback(
|
||||
lambda task: task_done_callback(task, name)
|
||||
)
|
||||
|
||||
# Store the task and its arguments in the '__tasks' dictionary. The
|
||||
# key is the name of the method, and the value is a dictionary
|
||||
# containing the task object and the updated keyword arguments.
|
||||
self.tasks[name] = {
|
||||
"task": self._loop.create_task(task(*args, **kwargs)),
|
||||
"task": task_object,
|
||||
"kwargs": kwargs_updated,
|
||||
}
|
||||
|
||||
@@ -142,14 +171,10 @@ class TaskManager:
|
||||
|
||||
def stop_task() -> None:
|
||||
# cancel the task
|
||||
task = self.tasks.pop(name, None)
|
||||
task = self.tasks.get(name, None)
|
||||
if task is not None:
|
||||
self._loop.call_soon_threadsafe(task["task"].cancel)
|
||||
|
||||
# emit the notification that the task was stopped
|
||||
for callback in self.task_status_change_callbacks:
|
||||
callback(name, None)
|
||||
|
||||
# create start and stop methods for each coroutine
|
||||
setattr(self.service, f"start_{name}", start_task)
|
||||
setattr(self.service, f"stop_{name}", stop_task)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.398bc7f8.css",
|
||||
"main.js": "/static/js/main.b69b2bbf.js",
|
||||
"main.js": "/static/js/main.c348625e.js",
|
||||
"index.html": "/index.html",
|
||||
"main.398bc7f8.css.map": "/static/css/main.398bc7f8.css.map",
|
||||
"main.b69b2bbf.js.map": "/static/js/main.b69b2bbf.js.map"
|
||||
"main.c348625e.js.map": "/static/js/main.c348625e.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.398bc7f8.css",
|
||||
"static/js/main.b69b2bbf.js"
|
||||
"static/js/main.c348625e.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.b69b2bbf.js"></script><link href="/static/css/main.398bc7f8.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.c348625e.js"></script><link href="/static/css/main.398bc7f8.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
src/pydase/frontend/static/js/main.c348625e.js.map
Normal file
1
src/pydase/frontend/static/js/main.c348625e.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from enum import Enum
|
||||
from types import FrameType
|
||||
from typing import Any, Optional, Protocol, TypedDict
|
||||
from typing import Any, Optional, Protocol, TypedDict, Union
|
||||
|
||||
import uvicorn
|
||||
from loguru import logger
|
||||
@@ -180,7 +180,7 @@ class Server:
|
||||
self._additional_servers = additional_servers
|
||||
self.should_exit = False
|
||||
self.servers: dict[str, asyncio.Future[Any]] = {}
|
||||
self.executor: ThreadPoolExecutor | None = None
|
||||
self.executor: Union[ThreadPoolExecutor, None] = None
|
||||
self._info: dict[str, Any] = {
|
||||
"name": self._service.get_service_name(),
|
||||
"version": __version__,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, TypedDict, Union
|
||||
|
||||
import socketio
|
||||
from fastapi import FastAPI
|
||||
@@ -8,7 +8,6 @@ from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
|
||||
from pydase import DataService
|
||||
from pydase.config import OperationMode
|
||||
from pydase.version import __version__
|
||||
|
||||
|
||||
@@ -48,8 +47,8 @@ class WebAPI:
|
||||
def __init__( # noqa: CFQ002
|
||||
self,
|
||||
service: DataService,
|
||||
frontend: str | Path | None = None,
|
||||
css: str | Path | None = None,
|
||||
frontend: Union[str, Path, None] = None,
|
||||
css: Union[str, Path, None] = None,
|
||||
enable_CORS: bool = True,
|
||||
info: dict[str, Any] = {},
|
||||
*args: Any,
|
||||
@@ -115,14 +114,13 @@ class WebAPI:
|
||||
def service_properties() -> dict[str, Any]:
|
||||
return self.service.serialize()
|
||||
|
||||
if OperationMode().environment == "production":
|
||||
app.mount(
|
||||
"/",
|
||||
StaticFiles(
|
||||
directory=Path(__file__).parent.parent / "frontend",
|
||||
html=True,
|
||||
),
|
||||
)
|
||||
app.mount(
|
||||
"/",
|
||||
StaticFiles(
|
||||
directory=Path(__file__).parent.parent / "frontend",
|
||||
html=True,
|
||||
),
|
||||
)
|
||||
|
||||
self.__fastapi_app = app
|
||||
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
from typing import TypedDict
|
||||
from typing import TypedDict, Union
|
||||
|
||||
import pint
|
||||
from pint import Quantity
|
||||
|
||||
units: pint.UnitRegistry = pint.UnitRegistry()
|
||||
units = pint.UnitRegistry()
|
||||
units.default_format = "~P" # pretty and short format
|
||||
|
||||
Quantity = pint.Quantity
|
||||
Unit = units.Unit
|
||||
|
||||
|
||||
class QuantityDict(TypedDict):
|
||||
magnitude: int | float
|
||||
magnitude: Union[int, float]
|
||||
unit: str
|
||||
|
||||
|
||||
def convert_to_quantity(
|
||||
value: QuantityDict | float | int | Quantity, unit: str = ""
|
||||
value: Union[QuantityDict, float, int, Quantity], unit: str = ""
|
||||
) -> Quantity:
|
||||
"""
|
||||
Convert a given value into a pint.Quantity object with the specified unit.
|
||||
@@ -47,10 +45,10 @@ def convert_to_quantity(
|
||||
will be unitless.
|
||||
"""
|
||||
|
||||
if isinstance(value, int | float):
|
||||
quantity = float(value) * Unit(unit)
|
||||
if isinstance(value, (int, float)):
|
||||
quantity = float(value) * units(unit)
|
||||
elif isinstance(value, dict):
|
||||
quantity = float(value["magnitude"]) * Unit(value["unit"])
|
||||
quantity = float(value["magnitude"]) * units(value["unit"])
|
||||
else:
|
||||
quantity = value
|
||||
return quantity # type: ignore
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import re
|
||||
from itertools import chain
|
||||
from typing import Any, Optional, cast
|
||||
from typing import Any, Optional, Union, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
STANDARD_TYPES = ("int", "float", "bool", "str", "Enum", "NoneType", "Quantity")
|
||||
|
||||
|
||||
def get_class_and_instance_attributes(obj: object) -> dict[str, Any]:
|
||||
def get_class_and_instance_attributes(obj: Any) -> dict[str, Any]:
|
||||
"""Dictionary containing all attributes (both instance and class level) of a
|
||||
given object.
|
||||
|
||||
@@ -126,7 +126,9 @@ def generate_paths_from_DataService_dict(
|
||||
return paths
|
||||
|
||||
|
||||
def extract_dict_or_list_entry(data: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||||
def extract_dict_or_list_entry(
|
||||
data: dict[str, Any], key: str
|
||||
) -> Union[dict[str, Any], None]:
|
||||
"""
|
||||
Extract a nested dictionary or list entry based on the provided key.
|
||||
|
||||
@@ -178,7 +180,7 @@ def extract_dict_or_list_entry(data: dict[str, Any], key: str) -> dict[str, Any]
|
||||
else:
|
||||
logger.error(f"Invalid index format in key: {key}")
|
||||
|
||||
current_data: dict[str, Any] | list[dict[str, Any]] | None = data.get(
|
||||
current_data: Union[dict[str, Any], list[dict[str, Any]], None] = data.get(
|
||||
attr_name, None
|
||||
)
|
||||
if not isinstance(current_data, dict):
|
||||
@@ -197,7 +199,7 @@ def extract_dict_or_list_entry(data: dict[str, Any], key: str) -> dict[str, Any]
|
||||
# When the attribute is a class instance, the attributes are nested in the
|
||||
# "value" key
|
||||
if current_data["type"] not in STANDARD_TYPES:
|
||||
current_data = cast(dict[str, Any], current_data.get("value", None))
|
||||
current_data = cast(dict[str, Any], current_data.get("value", None)) # type: ignore
|
||||
assert isinstance(current_data, dict)
|
||||
|
||||
return current_data
|
||||
@@ -251,7 +253,7 @@ def get_nested_value_from_DataService_by_path_and_key(
|
||||
|
||||
# Split the path into parts
|
||||
parts: list[str] = re.split(r"\.", path) # Split by '.'
|
||||
current_data: dict[str, Any] | None = data
|
||||
current_data: Union[dict[str, Any], None] = data
|
||||
|
||||
for part in parts:
|
||||
if current_data is None:
|
||||
@@ -264,7 +266,7 @@ def get_nested_value_from_DataService_by_path_and_key(
|
||||
|
||||
def convert_arguments_to_hinted_types(
|
||||
args: dict[str, Any], type_hints: dict[str, Any]
|
||||
) -> dict[str, Any] | str:
|
||||
) -> Union[dict[str, Any], str]:
|
||||
"""
|
||||
Convert the given arguments to their types hinted in the type_hints dictionary.
|
||||
|
||||
@@ -306,7 +308,7 @@ def convert_arguments_to_hinted_types(
|
||||
|
||||
|
||||
def update_value_if_changed(
|
||||
target: Any, attr_name_or_index: str | int, new_value: Any
|
||||
target: Any, attr_name_or_index: Union[str, int], new_value: Any
|
||||
) -> None:
|
||||
"""
|
||||
Updates the value of an attribute or a list element on a target object if the new
|
||||
@@ -394,3 +396,7 @@ def get_component_class_names() -> list[str]:
|
||||
import pydase.components
|
||||
|
||||
return pydase.components.__all__
|
||||
|
||||
|
||||
def is_property_attribute(target_obj: Any, attr_name: str) -> bool:
|
||||
return isinstance(getattr(type(target_obj), attr_name, None), property)
|
||||
|
||||
82
src/pydase/utils/logging.py
Normal file
82
src/pydase/utils/logging.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
import sys
|
||||
from types import FrameType
|
||||
from typing import Optional, Union
|
||||
|
||||
import loguru
|
||||
import rpyc
|
||||
from uvicorn.config import LOGGING_CONFIG
|
||||
|
||||
import pydase.config
|
||||
|
||||
ALLOWED_LOG_LEVELS = ["DEBUG", "INFO", "ERROR"]
|
||||
|
||||
|
||||
# from: https://github.com/Delgan/loguru section
|
||||
# "Entirely compatible with standard logging"
|
||||
class InterceptHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
# Ignore "asyncio.CancelledError" raised by uvicorn
|
||||
if record.name == "uvicorn.error" and "CancelledError" in record.msg:
|
||||
return
|
||||
|
||||
# Get corresponding Loguru level if it exists.
|
||||
level: Union[int, str]
|
||||
try:
|
||||
level = loguru.logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
# Find caller from where originated the logged message.
|
||||
frame: Optional[FrameType] = sys._getframe(6)
|
||||
depth = 6
|
||||
while frame and frame.f_code.co_filename == logging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
try:
|
||||
msg = record.getMessage()
|
||||
except TypeError:
|
||||
# A `TypeError` is raised when the `msg` string expects more arguments
|
||||
# than are provided by `args`. This can happen when intercepting log
|
||||
# messages with a certain format, like
|
||||
# > logger.debug("call: %s%r", method_name, *args) # in tiqi_rpc
|
||||
# where `*args` unpacks a sequence of values that should replace
|
||||
# placeholders in the string.
|
||||
msg = record.msg % (record.args[0], record.args[2:]) # type: ignore
|
||||
|
||||
loguru.logger.opt(depth=depth, exception=record.exc_info).log(level, msg)
|
||||
|
||||
|
||||
def setup_logging(level: Optional[str] = None) -> None:
|
||||
loguru.logger.debug("Configuring service logging.")
|
||||
|
||||
if pydase.config.OperationMode().environment == "development":
|
||||
log_level = "DEBUG"
|
||||
else:
|
||||
log_level = "INFO"
|
||||
|
||||
if level is not None and level in ALLOWED_LOG_LEVELS:
|
||||
log_level = level
|
||||
|
||||
loguru.logger.remove()
|
||||
loguru.logger.add(sys.stderr, level=log_level)
|
||||
|
||||
# set up the rpyc logger *before* adding the InterceptHandler to the logging module
|
||||
rpyc.setup_logger(quiet=True) # type: ignore
|
||||
|
||||
logging.basicConfig(handlers=[InterceptHandler()], level=0)
|
||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
|
||||
# overwriting the uvicorn logging config to use the loguru intercept handler
|
||||
LOGGING_CONFIG["handlers"] = {
|
||||
"default": {
|
||||
"()": InterceptHandler,
|
||||
"formatter": "default",
|
||||
},
|
||||
"access": {
|
||||
"()": InterceptHandler,
|
||||
"formatter": "access",
|
||||
},
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
__version__ = "0.1.0"
|
||||
from importlib.metadata import distribution
|
||||
|
||||
__version__ = distribution("pydase").version
|
||||
__major__, __minor__, __patch__ = [int(v) for v in __version__.split(".")]
|
||||
|
||||
0
tests/utils/__init__.py
Normal file
0
tests/utils/__init__.py
Normal file
93
tests/utils/test_helpers.py
Normal file
93
tests/utils/test_helpers.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import pytest
|
||||
|
||||
from pydase.utils.helpers import (
|
||||
extract_dict_or_list_entry,
|
||||
get_nested_value_from_DataService_by_path_and_key,
|
||||
is_property_attribute,
|
||||
)
|
||||
|
||||
# Sample data for the tests
|
||||
data_sample = {
|
||||
"attr1": {"type": "bool", "value": False, "readonly": False, "doc": None},
|
||||
"class_attr": {
|
||||
"type": "MyClass",
|
||||
"value": {"sub_attr": {"type": "float", "value": 20.5}},
|
||||
},
|
||||
"list_attr": {
|
||||
"type": "list",
|
||||
"value": [
|
||||
{"type": "int", "value": 0, "readonly": False, "doc": None},
|
||||
{"type": "float", "value": 1.0, "readonly": False, "doc": None},
|
||||
],
|
||||
"readonly": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Tests for extract_dict_or_list_entry
|
||||
def test_extract_dict_with_valid_list_index() -> None:
|
||||
result = extract_dict_or_list_entry(data_sample, "list_attr[1]")
|
||||
assert result == {"type": "float", "value": 1.0, "readonly": False, "doc": None}
|
||||
|
||||
|
||||
def test_extract_dict_without_list_index() -> None:
|
||||
result = extract_dict_or_list_entry(data_sample, "attr1")
|
||||
assert result == {"type": "bool", "value": False, "readonly": False, "doc": None}
|
||||
|
||||
|
||||
def test_extract_dict_with_invalid_key() -> None:
|
||||
result = extract_dict_or_list_entry(data_sample, "attr_not_exist")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_extract_dict_with_invalid_list_index() -> None:
|
||||
result = extract_dict_or_list_entry(data_sample, "list_attr[5]")
|
||||
assert result is None
|
||||
|
||||
|
||||
# Tests for get_nested_value_from_DataService_by_path_and_key
|
||||
def test_get_nested_value_with_default_key() -> None:
|
||||
result = get_nested_value_from_DataService_by_path_and_key(
|
||||
data_sample, "list_attr[0]"
|
||||
)
|
||||
assert result == 0
|
||||
|
||||
|
||||
def test_get_nested_value_with_custom_key() -> None:
|
||||
result = get_nested_value_from_DataService_by_path_and_key(
|
||||
data_sample, "class_attr.sub_attr", "type"
|
||||
)
|
||||
assert result == "float"
|
||||
|
||||
|
||||
def test_get_nested_value_with_invalid_path() -> None:
|
||||
result = get_nested_value_from_DataService_by_path_and_key(
|
||||
data_sample, "class_attr.nonexistent_attr"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attr_name, expected",
|
||||
[
|
||||
("regular_attribute", False),
|
||||
("my_property", True),
|
||||
("my_method", False),
|
||||
("non_existent_attr", False),
|
||||
],
|
||||
)
|
||||
def test_is_property_attribute(attr_name: str, expected: bool) -> None:
|
||||
# Test Suite
|
||||
class DummyClass:
|
||||
def __init__(self) -> None:
|
||||
self.regular_attribute = "I'm just an attribute"
|
||||
|
||||
@property
|
||||
def my_property(self) -> str:
|
||||
return "I'm a property"
|
||||
|
||||
def my_method(self) -> str:
|
||||
return "I'm a method"
|
||||
|
||||
dummy = DummyClass()
|
||||
assert is_property_attribute(dummy, attr_name) == expected
|
||||
Reference in New Issue
Block a user