34 Commits

Author SHA1 Message Date
Mose Müller
a158308686 removing python 3.8 from workflows 2023-09-19 18:16:14 +02:00
Mose Müller
7a55903b01 removing legacy type hints (<v3.9) 2023-09-19 18:16:14 +02:00
Mose Müller
dc432a1238 removing python 3.8 support 2023-09-19 18:16:14 +02:00
Mose Müller
c182e11527 updating pydase.units 2023-09-19 18:16:14 +02:00
Mose Müller
fe5b6c591d fix: flake8 errors 2023-09-19 18:16:14 +02:00
Mose Müller
f948605b58 feat: adding support for python 3.8, 3.9 2023-09-19 18:16:14 +02:00
Mose Müller
55ab705542 feat: adding publish workflow 2023-09-19 14:30:36 +02:00
Mose Müller
c970ae89d0 docs: updating image 2023-09-14 10:31:15 +02:00
Mose Müller
23ef229eb1 frontend: removes unused import 2023-09-14 10:29:23 +02:00
Mose Müller
cb94068faf Merge pull request #19 from tiqi-group/14-spin-box-buttons-not-working
Removes buttons from number components.
2023-09-14 10:26:56 +02:00
Mose Müller
2ce8ace227 docs: updating readme 2023-09-14 10:24:55 +02:00
Mose Müller
ee124ead89 removes buttons from number components 2023-09-14 10:17:25 +02:00
Mose Müller
bbee77e231 feat: adds simple functionality to buttons in number component 2023-09-14 10:12:51 +02:00
Mose Müller
27520864c4 frontend: 'instant update' defaults to false 2023-09-14 08:35:10 +02:00
Mose Müller
e743d89f2e Merge pull request #18 from tiqi-group/15-highlighted-digits-not-overwritten-when-typing-in-spin-boxes
feat: highlighted digits are overwritten in number components
2023-09-14 08:30:50 +02:00
Mose Müller
050a718e44 feat: highlighted digits are overwritten in number components 2023-09-14 08:28:38 +02:00
Mose Müller
d2b9dd832f Merge pull request #17 from tiqi-group/13-superfluous-parameter-access-when-using-service-persistence
13 superfluous parameter access when using service persistence
2023-09-13 18:06:29 +02:00
Mose Müller
d5cb1a1478 fix: ignoring flake8 error 2023-09-13 18:02:50 +02:00
Mose Müller
82f8e1f90c test: adding additional helpers test 2023-09-13 18:01:40 +02:00
Mose Müller
c12bb87b2b feat: removing superfluous property accesses 2023-09-13 17:56:23 +02:00
Mose Müller
6eafe07ac7 Merge pull request #12 from tiqi-group/1-exception-handling-in-tasks
adds task_done_callback to each task
2023-08-18 16:26:34 +02:00
Mose Müller
76fb674fcd adds task_done_callback to each task
The task_done_callback function handles exceptions and emitting
a notification when the task has finished.
2023-08-18 16:25:13 +02:00
Mose Müller
e86fd1ffbe fix: intercepting rpyc-handled errors 2023-08-18 16:20:12 +02:00
Mose Müller
0626500cdd Removing pydase/utils/logging.py from pytest coverage report
Changing the environment variable "ENVIRONMENT" does not change the
confz configuration. Additionally, the setup_logging file is removing
any loggers (line 61). With this, caplog with loguru doesn't work as we
are already using some sort of hack to get the loguru logs with pytest.
We can therefore not test if the setup_logging function works as
expected.
2023-08-17 17:42:47 +02:00
Mose Müller
fab526a679 Updates python environment 2023-08-17 17:11:41 +02:00
Mose Müller
ea621c7c4b updates github workflow (pyright test) 2023-08-17 17:09:20 +02:00
Mose Müller
60984b6e13 fix: pyright error in helpers.py 2023-08-17 17:08:05 +02:00
Mose Müller
08e0c59ad7 defines environment variables in vscode launch settings 2023-08-17 17:07:47 +02:00
Mose Müller
f132d71f60 feat: adds logging setup
- logging can now be configured using the setup_logging function and
  using the ENVIRONMENT env variable.
- using InterceptHandler to intercept standard logging towards the
  loguru sink
- forcing uvicorn to use the same handler
2023-08-17 17:05:44 +02:00
Mose Müller
fb6fac5f9a allways mount frontend files at port 8001
- files were previously only mounted when in production mode
2023-08-17 16:41:40 +02:00
Mose Müller
a2ce2e1116 update version.py to use metadata 2023-08-17 15:47:42 +02:00
Mose Müller
29ebc566bb tests: adds some utils.helpers tests 2023-08-17 11:16:34 +02:00
Mose Müller
b275446960 adapts slider frontend component (highlights pressed settigns button) 2023-08-17 11:15:39 +02:00
Mose Müller
24f35245bb Update issue templates 2023-08-17 10:14:45 +02:00
37 changed files with 1205 additions and 596 deletions

View File

@@ -1,4 +1,6 @@
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
if TYPE_CHECKING:
omit =
src/pydase/utils/logging.py

View File

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

View 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
View 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/

View File

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

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

View File

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

View File

@@ -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([]);

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,3 @@
[virtualenvs]
in-project = true
prefer-active-python = true

View File

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

View File

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

View File

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

View File

@@ -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'.")

View File

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

View File

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

View File

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

View File

@@ -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]] = []

View File

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

View File

@@ -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"
]
}

View File

@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site displaying a pydase UI."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>pydase App</title><script defer="defer" src="/static/js/main.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

View File

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

View File

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

View File

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

View File

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

View 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",
},
}

View File

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

View 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