6 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
37 changed files with 767 additions and 1493 deletions

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

@@ -86,26 +86,26 @@ jobs:
'${{ 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/
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,37 +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
- name: Test with pyright
run: |
poetry run pyright src/pydase
- 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

View File

View File

@@ -1,21 +0,0 @@
# Read the Docs configuration file for MkDocs projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
mkdocs:
configuration: mkdocs.yml
# Optionally declare the Python requirements required to build your docs
python:
install:
- method: pip
path: .
- requirements: docs/requirements.txt

180
README.md
View File

@@ -1,8 +1,5 @@
# pydase (Python Data Service) <!-- omit from toc -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Documentation Status](https://readthedocs.org/projects/pydase/badge/?version=latest)](https://pydase.readthedocs.io/en/latest/?badge=latest)
`pydase` is a Python library for creating data service servers with integrated web and RPC servers. It's designed to handle the management of data structures, automated tasks, and callbacks, and provides built-in functionality for serving data over different protocols.
- [Features](#features)
@@ -12,16 +9,9 @@
- [Running the Server](#running-the-server)
- [Accessing the Web Interface](#accessing-the-web-interface)
- [Connecting to the Service using rpyc](#connecting-to-the-service-using-rpyc)
- [Understanding the Component System](#understanding-the-component-system)
- [Built-in Type and Enum Components](#built-in-type-and-enum-components)
- [Method Components](#method-components)
- [DataService Instances (Nested Classes)](#dataservice-instances-nested-classes)
- [Custom Components (`pydase.components`)](#custom-components-pydasecomponents)
- [Extending with New Components](#extending-with-new-components)
- [Understanding Service Persistence](#understanding-service-persistence)
- [Understanding Tasks in pydase](#understanding-tasks-in-pydase)
- [Understanding Units in pydase](#understanding-units-in-pydase)
- [Changing the Log Level](#changing-the-log-level)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
@@ -29,33 +19,30 @@
## Features
<!-- no toc -->
* [Simple data service definition through class-based interface](#defining-a-dataService)
* [Integrated web interface for interactive access and control of your data service](#accessing-the-web-interface)
* [Support for `rpyc` connections, allowing for programmatic control and interaction with your service](#connecting-to-the-service-using-rpyc)
* [Component system bridging Python backend with frontend visual representation](#understanding-the-component-system)
* [Saving and restoring the service state for service persistence](#understanding-service-persistence)
* [Automated task management with built-in start/stop controls and optional autostart](#understanding-tasks-in-pydase)
* [Support for units](#understanding-units-in-pydase)
<!-- * Event-based callback functionality for real-time updates
* Support for additional servers for specific use-cases -->
* Event-based callback functionality for real-time updates
* Support for additional servers for specific use-cases
## Installation
<!--installation-start-->
Install pydase using [`poetry`](https://python-poetry.org/):
```bash
poetry add pydase
poetry add git+https://github.com/tiqi-group/pydase.git
```
or `pip`:
```bash
pip install pydase
pip install git+https://github.com/tiqi-group/pydase.git
```
<!--installation-end-->
## Usage
<!--usage-start-->
Using `pydase` involves three main steps: defining a `DataService` subclass, running the server, and then connecting to the service either programmatically using `rpyc` or through the web interface.
### Defining a DataService
@@ -156,133 +143,6 @@ print(client.voltage) # prints 5.0
```
In this example, replace `<ip_addr>` with the IP address of the machine where the service is running. After establishing a connection, you can interact with the service attributes as if they were local attributes.
<!--usage-end-->
## Understanding the Component System
In `pydase`, components are fundamental building blocks that bridge the Python backend logic with frontend visual representation and interactions. This system can be understood based on the following categories:
### Built-in Type and Enum Components
`pydase` automatically maps standard Python data types to their corresponding frontend components:
- `str`: Translated into a `StringComponent` on the frontend.
- `int` and `float`: Manifested as the `NumberComponent`.
- `bool`: Rendered as a `ButtonComponent`.
- `list`: Each item displayed individually, named after the list attribute and its index.
- `enum.Enum`: Presented as an `EnumComponent`, facilitating dropdown selection.
### Method Components
Methods within the `DataService` class have frontend representations:
- Regular Methods: These are rendered as a `MethodComponent` in the frontend, allowing users to execute the method via an "execute" button.
- Asynchronous Methods: These are manifested as the `AsyncMethodComponent` with "start"/"stop" buttons to manage the execution of [tasks](#understanding-tasks-in-pydase).
### DataService Instances (Nested Classes)
Nested `DataService` instances offer an organized hierarchy for components, enabling richer applications. Each nested class might have its own attributes and methods, each mapped to a frontend component.
Here is an example:
```python
from pydase import DataService, Server
class Channel(DataService):
def __init__(self, channel_id: int) -> None:
self._channel_id = channel_id
self._current = 0.0
super().__init__()
@property
def current(self) -> float:
# run code to get current
result = self._current
return result
@current.setter
def current(self, value: float) -> None:
# run code to set current
self._current = value
class Device(DataService):
def __init__(self) -> None:
self.channels = [Channel(i) for i in range(2)]
super().__init__()
if __name__ == "__main__":
service = Device()
Server(service).run()
```
![Nested Classes App](docs/images/Nested_Class_App.png)
**Note** that defining classes within `DataService` classes is not supported (see [this issue](https://github.com/tiqi-group/pydase/issues/16)).
### Custom Components (`pydase.components`)
The custom components in `pydase` have two main parts:
- A **Python Component Class** in the backend, implementing the logic needed to set, update, and manage the component's state and data.
- A **Frontend React Component** that renders and manages user interaction in the browser.
Below are the components available in the `pydase.components` module, accompanied by their Python usage:
- `Image`: This component allows users to display and update images within the application.
```python
import matplotlib.pyplot as plt
import numpy as np
import pydase
from pydase.components.image import Image
class MyDataService(pydase.DataService):
my_image = Image()
if __name__ == "__main__":
service = MyDataService()
# loading from local path
service.my_image.load_from_path("/your/image/path/")
# loading from a URL
service.my_image.load_from_url("https://cataas.com/cat")
# loading a matplotlib figure
fig = plt.figure()
x = np.linspace(0, 2 * np.pi)
plt.plot(x, np.sin(x))
plt.grid()
service.my_image.load_from_matplotlib_figure(fig)
pydase.Server(service).run()
```
![Image Component](docs/images/Image_component.png)
- `NumberSlider`: An interactive slider component to adjust numerical values, including floats and integers, on the frontend while synchronizing the data with the backend in real-time.
```python
import pydase
from pydase.components import NumberSlider
class MyService(pydase.DataService):
slider = NumberSlider(value=3.5, min=0, max=10, step_size=0.1)
if __name__ == "__main__":
service = MyService()
pydase.Server(service).run()
```
![Slider Component](docs/images/Slider_component.png)
#### Extending with New Components
Users can also extend the library by creating custom components. This involves defining the behavior on the Python backend and the visual representation on the frontend. For those looking to introduce new components, the [guide on adding components](https://pydase.readthedocs.io/en/latest/dev-guide/Adding_Components/) provides detailed steps on achieving this.
## Understanding Service Persistence
@@ -419,36 +279,14 @@ 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
You can change the log level of loguru by either
1. (RECOMMENDED) setting the `ENVIRONMENT` environment variable to "production" or "development"
```bash
ENVIRONMENT="production" python -m <module_using_pydase>
```
The production environment will only log messages above "INFO", the development environment (default) logs everything above "DEBUG".
2. calling the `pydase.utils.logging.setup_logging` function with the desired log level
```python
# <your_script.py>
from pydase.utils.logging import setup_logging
setup_logging("INFO")
```
## Documentation
The full documentation provides more detailed information about `pydase`, including advanced usage examples, API references, and tips for troubleshooting common issues. See the [full documentation](https://pydase.readthedocs.io/en/latest/) for more information.
The full documentation provides more detailed information about `pydase`, including advanced usage examples, API references, and tips for troubleshooting common issues. See the [full documentation](URL_TO_YOUR_DOCUMENTATION) for more information.
## Contributing
We welcome contributions! Please see [contributing.md](https://pydase.readthedocs.io/en/latest/about/contributing/) for details on how to contribute.
We welcome contributions! Please see [CONTRIBUTING.md](URL_TO_YOUR_CONTRIBUTING_GUIDELINES) for details on how to contribute.
## License
`pydase` is licensed under the [MIT License](https://github.com/tiqi-group/pydase/blob/main/LICENSE).
`pydase` is licensed under the [MIT License](./LICENSE).

View File

@@ -1,10 +0,0 @@
License
=======
`pydase` is released under the *MIT license*:
```{.license}
{%
include "../../LICENSE"
comments=false
%}
```

View File

@@ -1,3 +0,0 @@
.language-license{
background-color: #eeffcc !important;
}

View File

@@ -1,299 +0,0 @@
# Adding Components to `pydase`
This guide provides a step-by-step process for adding new components to the `pydase` package. Components in `pydase` consist of both backend (Python) and frontend (React) parts. They work together to create interactive and dynamic data services.
## Overview
A component in `pydase` is a unique combination of a backend class (e.g., `Image`) and its corresponding frontend React component. The backend class stores the attributes needed for the component, and possibly methods for setting those in the backend, while the frontend part is responsible for rendering and interacting with the component.
## Adding a Backend Component to `pydase`
Backend components belong in the `src/pydase/components` directory.
### Step 1: Create a New Python File in the Components Directory
Navigate to the `src/pydase/components` directory and create a new Python file for your component. The name of the file should be descriptive of the component's functionality.
For example, for a `Image` component, create a file named `image.py`.
### Step 2: Define the Backend Class
Within the newly created file, define a Python class representing the component. This class should inherit from `DataService` and contains the attributes that the frontend needs to render the component. Every public attribute defined in this class will synchronise across the clients. It can also contain methods which can be used to interact with the component from the backend.
For the `Image` component, the class may look like this:
```python
# file: pydase/components/image.py
from pydase.data_service.data_service import DataService
class Image(DataService):
def __init__(
self,
image_representation: bytes = b"",
) -> None:
self.image_representation = image_representation
super().__init__()
# need to decode the bytes
def __setattr__(self, __name: str, __value: Any) -> None:
if __name == "value":
if isinstance(__value, bytes):
__value = __value.decode()
return super().__setattr__(__name, __value)
```
So, changing the `image_representation` will push the updated value to the browsers connected to the service.
### Step 3: Register the Backend Class
The component should be added to the `__init__.py` file to ensure `pydase` handles them properly:
```python
# file: pydase/components/__init__.py
from pydase.components.image import Image
from pydase.components.number_slider import NumberSlider
__all__ = [
"NumberSlider",
"Image", # add the new components here
]
```
### Step 4: Implement Necessary Methods (Optional)
If your component requires specific logic or methods, implement them within the class. Document any public methods or attributes to ensure that other developers understand their purpose and usage.
### Step 5: Write Tests for the Component (Recommended)
Consider writing unit tests for the component to verify its behavior. Place the tests in the appropriate directory within the `tests` folder.
For example, a test for the `Image` component could look like this:
```python
from pytest import CaptureFixture
from pydase.components.image import Image
from pydase.data_service.data_service import DataService
def test_Image(capsys: CaptureFixture) -> None:
class ServiceClass(DataService):
image = Image()
service = ServiceClass()
# ...
```
## Adding a Frontend Component to `pydase`
Frontend components in `pydase` live in the `frontend/src/components/` directory. Follow these steps to create and add a new frontend component:
### Step 1: Create a New React Component File in the Components Directory
Navigate to the `frontend/src/components/` directory and create a new React component file for your component. The name of the file should be descriptive of the component's functionality and reflect the naming conventions used in your project.
For example, for an `Image` component, create a file named `ImageComponent.tsx`.
### Step 2: Write the React Component Code
Write the React component code, following the structure and patterns used in existing components. Make sure to import necessary libraries and dependencies.
For example, for the `Image` component, a template could look like this:
```tsx
import { emit_update } from '../socket'; // use this when your component should update values in the backend
import { DocStringComponent } from './DocStringComponent';
import React, { useEffect, useRef, useState } from 'react';
import { Card, Collapse, Image } from 'react-bootstrap';
import { DocStringComponent } from './DocStringComponent';
import { ChevronDown, ChevronRight } from 'react-bootstrap-icons';
interface ImageComponentProps {
name: string;
parentPath: string;
readOnly: boolean;
docString: string;
addNotification: (string) => void;
// Define your component specific props here
value: string;
format: string;
}
export const ImageComponent = React.memo((props: ImageComponentProps) => {
const { name, parentPath, value, docString, format, addNotification } = props;
const renderCount = useRef(0);
const [open, setOpen] = useState(true); // add this if you want to expand/collapse your component
useEffect(() => {
renderCount.current++;
});
// This will trigger a notification if notifications are enabled.
useEffect(() => {
addNotification(`${parentPath}.${name} changed to ${value}.`);
}, [props.value]);
// Your component logic here
return (
<div className={'imageComponent'} id={parentPath.concat('.' + name)}>
{/* Add the Card and Collapse components here if you want to be able to expand and
collapse your component. */}
<Card>
<Card.Header
onClick={() => setOpen(!open)}
style={{ cursor: 'pointer' }} // Change cursor style on hover
>
{name} {open ? <ChevronDown /> : <ChevronRight />}
</Card.Header>
<Collapse in={open}>
<Card.Body>
{process.env.NODE_ENV === 'development' && (
<p>Render count: {renderCount.current}</p>
)}
<DocStringComponent docString={docString} />
{/* Your component TSX here */}
</Card.Body>
</Collapse>
</Card>
</div>
);
});
```
### Step 3: Emitting Updates to the Backend
Often, React components in the frontend will need to send updates to the backend, especially when user interactions result in a change of state or data. In `pydase`, we use `socketio` to seamlessly communicate these changes. Here's a detailed guide on how to emit update events from your frontend component:
1. **Setting Up Emission**: Ensure you've imported the required functions and methods for emission. The main function we'll use for this is `emit_update` from the `socket` module:
```tsx
import { emit_update } from '../socket';
```
2. **Understanding the Emission Parameters**:
When emitting an update, we send three main pieces of data:
- `parentPath`: This is the access path for the parent object of the attribute to be updated. This forms the basis to create the full access path for the attribute. For instance, for the attribute access path `attr1.list_attr[0].attr2`, `attr1.list_attr[0]` would be the `parentPath`.
- `name`: This represents the name of the attribute to be updated within the `DataService` instance. If the attribute is part of a nested structure, this would be the name of the attribute in the last nested object. So, for `attr1.list_attr[0].attr2`, `attr2` would be the name.
- `value`: This is the new value intended for the attribute. Ensure that the type of this value matches the type of the attribute in the backend.
3. **Implementing the Emission**:
To illustrate the emission process, let's consider the `ButtonComponent`. When the button state changes, we want to send this update to the backend:
```tsx
// ... (other imports)
export const ButtonComponent = React.memo((props: ButtonComponentProps) => {
// ...
const { name, parentPath, value } = props;
const setChecked = (checked: boolean) => {
emit_update(name, parentPath, checked);
};
return (
<ToggleButton
checked={value}
value={parentPath}
// ... other props
onChange={(e) => setChecked(e.currentTarget.checked)}>
<p>{name}</p>
</ToggleButton>
);
});
```
In this example, whenever the button's checked state changes (`onChange` event), we invoke the `setChecked` method, which in turn emits the new state to the backend using `emit_update`.
### Step 4: Add the New Component to the GenericComponent
The `GenericComponent` is responsible for rendering different types of components based on the attribute type. You can add the new `ImageComponent` to the `GenericComponent` by following these sub-steps:
#### 1. Import the New Component
At the beginning of the `GenericComponent` file, import the newly created `ImageComponent`:
```tsx
// file: frontend/src/components/GenericComponent.tsx
import { ImageComponent } from './ImageComponent';
```
#### 2. Update the AttributeType
Update the `AttributeType` type definition to include the new type for the `ImageComponent`.
For example, if the new attribute type is `'Image'` (which should correspond to the name of the backend component class), you can add it to the union:
```tsx
type AttributeType =
| 'str'
| 'bool'
| 'float'
| 'int'
| 'Quantity'
| 'list'
| 'method'
| 'DataService'
| 'Enum'
| 'NumberSlider'
| 'Image'; // Add the name of the backend component class here
```
#### 3. Add a Conditional Branch for the New Component
Inside the `GenericComponent` function, add a new conditional branch to render the `ImageComponent` when the attribute type is `'Image'`:
```tsx
} else if (attribute.type === 'Image') {
return (
<ImageComponent
name={name}
parentPath={parentPath}
readOnly={attribute.readonly}
docString={attribute.doc}
addNotification={addNotification}
// Add any other specific props for the ImageComponent here
value={attribute.value['value']['value'] as string}
format={attribute.value['format']['value'] as string}
/>
);
} else {
// other code
```
Make sure to update the props passed to the `ImageComponent` based on its specific requirements.
### Step 5: Adding Custom Notification Message (Optional)
In some cases, you may want to provide a custom notification message to the user when an attribute of a specific type is updated. This can be useful for enhancing user experience and providing contextual information about the changes.
For example, updating an `Image` component corresponds to setting a very long string. We don't want to display the whole string in the notification but just notify the user that the image was updated (and maybe also the format).
To create a custom notification message, you can update the message passed to the `addNotification` method in the `useEffect` hook in the component file file. For the `ImageComponent`, this could look like this:
```tsx
useEffect(() => {
addNotification(`${parentPath}.${name} changed.`);
}, [props.value]);
```
However, you might want to use the `addNotification` at different places. For an example, see the [MethodComponent](../../frontend/src/components/MethodComponent.tsx).
### Step 6: Write Tests for the Component (TODO)
Test the frontend component to ensure that it renders correctly and interacts seamlessly
with the backend. Consider writing unit tests using a testing library like Jest or React
Testing Library, and manually test the component in the browser.

View File

@@ -1,8 +0,0 @@
# Developer Guide
Extending `pydase`.
---
- [API Reference](api.md)
- [Adding Components](Adding_Components.md)

View File

View File

@@ -1,14 +0,0 @@
# Getting Started
## Installation
{%
include-markdown "../README.md"
start="<!--installation-start-->"
end="<!--installation-end-->"
%}
## Usage
{%
include-markdown "../README.md"
start="<!--usage-start-->"
end="<!--usage-end-->"
%}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1 +0,0 @@
{% include-markdown "../README.md" %}

View File

@@ -1,20 +0,0 @@
click==8.1.7 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
ghp-import==2.1.0 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
markdown==3.4.4 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
mergedeep==1.3.4 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-autorefs==0.5.0 ; python_version >= "3.10" and python_version < "4.0"
mkdocs-include-markdown-plugin==3.9.1 ; python_version >= "3.10" and python_version < "4.0"
mkdocs==1.5.3 ; python_version >= "3.10" and python_version < "4.0"
mkdocstrings==0.22.0 ; python_version >= "3.10" and python_version < "4.0"
packaging==23.1 ; python_version >= "3.10" and python_version < "4.0"
pathspec==0.11.2 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==3.10.0 ; python_version >= "3.10" and python_version < "4.0"
pymdown-extensions==10.3 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
pyyaml-env-tag==0.1 ; python_version >= "3.10" and python_version < "4.0"
pyyaml==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
watchdog==3.0.0 ; python_version >= "3.10" and python_version < "4.0"

View File

@@ -1,42 +0,0 @@
site_name: pydase Documentation
repo_url: https://github.com/tiqi-group/pydase
edit_uri: blob/docs/docs/
nav:
- Home: index.md
- Getting Started: getting-started.md
- Developer Guide:
- Developer Guide: dev-guide/README.md
- API Reference: dev-guide/api.md
- Adding Components: dev-guide/Adding_Components.md
- About:
- Release Notes: about/release-notes.md
- Contributing: about/contributing.md
- License: about/license.md
theme: readthedocs
extra_css:
- css/extra.css
markdown_extensions:
- smarty
- toc:
permalink: true
baselevel: 4
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.snippets
- pymdownx.superfences
# - pymdownx.highlight:
# - pymdownx.inlinehilite
plugins:
- include-markdown
- search
- mkdocstrings
watch:
- src/pydase

1333
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

@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydase"
version = "0.1.2"
version = "0.1.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"
@@ -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"
@@ -37,13 +37,6 @@ flake8-eradicate = "^1.4.0"
matplotlib = "^3.7.2"
pyright = "^1.1.323"
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5.2"
mkdocs-include-markdown-plugin = "^3.9.1"
mkdocstrings = "^0.22.0"
pymdown-extensions = "^10.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

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"] = "development"
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

@@ -83,9 +83,8 @@ class DataService(rpyc.Service, AbstractDataService):
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("_"):
# every class defined by the user should inherit from DataService
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
@@ -199,10 +198,22 @@ class DataService(rpyc.Service, AbstractDataService):
value, readonly status, and documentation if any in the resulting dictionary.
Attributes and methods starting with an underscore are ignored.
For nested DataService instances, the method serializes recursively.
For attributes, methods, and properties unique to the class (not inherited from
the base class), the method uses the format "<prefix>.<key>" for keys in the
dictionary. If no prefix is provided, the key format is simply "<key>".
For nested DataService instances, the method serializes recursively and appends
the key of the nested instance to the prefix in the format "<prefix>.<key>".
For attributes of type list, each item in the list is serialized individually.
If an item in the list is an instance of DataService, it is serialized
recursively.
recursively with its key in the format "<prefix>.<key>.<item_id>", where
"item_id" is the id of the item itself.
Args:
prefix (str, optional): The prefix for each key in the serialized
dictionary. This is mainly used when this method is called recursively to
maintain the structure of nested instances.
Returns:
dict: The serialized instance.
@@ -310,6 +321,16 @@ class DataService(rpyc.Service, AbstractDataService):
"readonly": True,
"value": running_task_info,
}
elif isinstance(getattr(self.__class__, key, None), property):
prop: property = getattr(self.__class__, key)
result[key] = {
"type": type(value).__name__,
"value": value
if not isinstance(value, u.Quantity)
else {"magnitude": value.m, "unit": str(value.u)},
"readonly": prop.fset is None,
"doc": get_attribute_doc(prop),
}
elif isinstance(value, Enum):
result[key] = {
"type": "Enum",
@@ -331,11 +352,6 @@ class DataService(rpyc.Service, AbstractDataService):
"doc": get_attribute_doc(value),
}
if isinstance(getattr(self.__class__, key, None), property):
prop: property = getattr(self.__class__, key)
result[key]["readonly"] = prop.fset is None
result[key]["doc"] = get_attribute_doc(prop)
return result
def update_DataService_attribute(

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."""

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
@@ -28,8 +28,9 @@ class AdditionalServerProtocol(Protocol):
This protocol sets the standard for how additional servers should be implemented
to ensure compatibility with the main Server class. The protocol requires that
any server implementing it should have an __init__ method for initialization and a
serve method for starting the server.
any server implementing it should have an __init__ method for initialization, a
serve method for starting the server, and an install_signal_handlers method for
setting up signal handlers.
Parameters:
-----------
@@ -61,6 +62,12 @@ class AdditionalServerProtocol(Protocol):
"""
...
def install_signal_handlers(self) -> None:
"""Sets up signal handlers for the server. This method is used to define how the
server should respond to various system signals, such as SIGINT and SIGTERM.
"""
...
class AdditionalServer(TypedDict):
"""
@@ -173,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__,
@@ -250,6 +257,13 @@ class Server:
info=self._info,
**server["kwargs"],
)
try:
addin_server.install_signal_handlers = lambda: None # type: ignore
except Exception:
logger.debug(
"Additional server does not have a method called "
"'install_signal_handlers'."
)
server_name = (
addin_server.__module__ + "." + addin_server.__class__.__name__

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

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

View File

@@ -1,7 +1,7 @@
import logging
import sys
from types import FrameType
from typing import Optional
from typing import Optional, Union
import loguru
import rpyc
@@ -21,7 +21,7 @@ class InterceptHandler(logging.Handler):
return
# Get corresponding Loguru level if it exists.
level: int | str
level: Union[int, str]
try:
level = loguru.logger.level(record.levelname).name
except ValueError:

View File

@@ -1,64 +0,0 @@
from enum import Enum
import pydase
def test_enum_serialize() -> None:
class EnumClass(Enum):
FOO = "foo"
BAR = "bar"
class EnumAttribute(pydase.DataService):
def __init__(self) -> None:
self.some_enum = EnumClass.FOO
super().__init__()
class EnumPropertyWithoutSetter(pydase.DataService):
def __init__(self) -> None:
self._some_enum = EnumClass.FOO
super().__init__()
@property
def some_enum(self) -> EnumClass:
return self._some_enum
class EnumPropertyWithSetter(pydase.DataService):
def __init__(self) -> None:
self._some_enum = EnumClass.FOO
super().__init__()
@property
def some_enum(self) -> EnumClass:
return self._some_enum
@some_enum.setter
def some_enum(self, value: EnumClass) -> None:
self._some_enum = value
assert EnumAttribute().serialize() == {
"some_enum": {
"type": "Enum",
"value": "FOO",
"enum": {"FOO": "foo", "BAR": "bar"},
"readonly": False,
"doc": None,
}
}
assert EnumPropertyWithoutSetter().serialize() == {
"some_enum": {
"type": "Enum",
"value": "FOO",
"enum": {"FOO": "foo", "BAR": "bar"},
"readonly": True,
"doc": None,
}
}
assert EnumPropertyWithSetter().serialize() == {
"some_enum": {
"type": "Enum",
"value": "FOO",
"enum": {"FOO": "foo", "BAR": "bar"},
"readonly": False,
"doc": None,
}
}

View File

@@ -2,7 +2,7 @@ from pytest import LogCaptureFixture
from pydase import DataService
from .. import caplog # noqa
from . import caplog # noqa
def test_setattr_warnings(caplog: LogCaptureFixture) -> None: # noqa
@@ -32,19 +32,3 @@ def test_private_attribute_warning(caplog: LogCaptureFixture) -> None: # noqa
" 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
)