mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-12-19 20:51:19 +01:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e29c890e | ||
|
|
6e407ba1d6 | ||
|
|
4fb5e56aa8 | ||
|
|
d55ba3a85f | ||
|
|
265d9a7ef5 | ||
|
|
4cd36b4a2b | ||
|
|
1b2ff38aff | ||
|
|
4b243985e8 | ||
|
|
8615bdeadc | ||
|
|
d24893a989 | ||
|
|
661603ef71 | ||
|
|
d6947b0f43 | ||
|
|
93d38651e8 | ||
|
|
72a3c199d9 | ||
|
|
7914e2fa7b | ||
|
|
0a4f898fde | ||
|
|
a9aa55fc99 | ||
|
|
fd5a230fa4 | ||
|
|
243b46aadb | ||
|
|
0f1ca84df5 | ||
|
|
6438a07305 | ||
|
|
80bfd209df | ||
|
|
e065b1fb22 | ||
|
|
977cee32b9 | ||
|
|
96f695020b | ||
|
|
33ce01865a | ||
|
|
f5374573cd | ||
|
|
43c6b5e817 | ||
|
|
37380c6d24 | ||
|
|
ae21656e83 | ||
|
|
a4b4f179c6 | ||
|
|
c6beca3961 | ||
|
|
2fa8240e54 | ||
|
|
369587a50c | ||
|
|
25343f6909 | ||
|
|
c136c9f3de | ||
|
|
8897c2fe4c | ||
|
|
80c5c4e99d | ||
|
|
423441a74c | ||
|
|
9ec60e3891 | ||
|
|
8bde104322 | ||
|
|
9b57b6984e | ||
|
|
e5b89f2581 | ||
|
|
ff1654e65c | ||
|
|
cded80c8e5 | ||
|
|
87a33b6293 | ||
|
|
6d621daaac | ||
|
|
8c1a50c106 | ||
|
|
a1545d341b | ||
|
|
28a1cc7cd3 | ||
|
|
c968708b85 | ||
|
|
fef8606d17 | ||
|
|
82286c8da0 | ||
|
|
533826a398 | ||
|
|
982875dee6 | ||
|
|
e54710cd4d | ||
|
|
f48f7aacfb | ||
|
|
e97aab4f36 | ||
|
|
015c66d5a6 | ||
|
|
9827d0747c | ||
|
|
38a12fb72e | ||
|
|
fb6ec16bf5 | ||
|
|
9ee498eb5c | ||
|
|
d015333123 | ||
|
|
c4e7fe66a8 | ||
|
|
5f1451a1c1 | ||
|
|
4c28cbaf7d | ||
|
|
a97b8eb2b4 | ||
|
|
f6b5c1b567 | ||
|
|
f92d525588 | ||
|
|
61b69d77cc | ||
|
|
8abe9357cf | ||
|
|
0dace2a9f0 | ||
|
|
9992ade0ed | ||
|
|
6c2cebada2 | ||
|
|
069a2b4696 | ||
|
|
38ed8d78de | ||
|
|
7ff6cab9b3 | ||
|
|
cbd93fb166 | ||
|
|
83c30439b6 | ||
|
|
e596c50915 | ||
|
|
9920350753 | ||
|
|
5dec01d800 | ||
|
|
18c66a8318 | ||
|
|
3d65240784 | ||
|
|
0d698e803d | ||
|
|
02a8791b74 | ||
|
|
35f658ce4d | ||
|
|
051e616280 | ||
|
|
98e9791d09 | ||
|
|
24ecbd1eb9 | ||
|
|
cd78d01b04 | ||
|
|
6be27217cf | ||
|
|
ae2c99b3ae | ||
|
|
c32b6a8694 | ||
|
|
57cfe45c76 | ||
|
|
7c18d86e9c | ||
|
|
c5eb5f80b4 | ||
|
|
18c64db826 | ||
|
|
27f8e1b1bc | ||
|
|
f8839f0e71 | ||
|
|
d31aff0b9b | ||
|
|
2c1db3fa45 | ||
|
|
b9cec19b02 | ||
|
|
6ba5193e9e | ||
|
|
bc0c69f9e1 | ||
|
|
b2314f7e33 | ||
|
|
eb43e7b380 | ||
|
|
5dc28b0b55 | ||
|
|
c327215b5f | ||
|
|
04a3b225f8 | ||
|
|
86c4514e1a | ||
|
|
cac74e90db | ||
|
|
c24d63f4c0 | ||
|
|
b0dd5835a3 | ||
|
|
b0c8af0108 | ||
|
|
c0016673a8 | ||
|
|
eadc1df763 | ||
|
|
922fdf8fd0 | ||
|
|
8b21c42ef7 | ||
|
|
2399b3ca9f | ||
|
|
db43f5dbbb | ||
|
|
f2c0a94904 | ||
|
|
c36cebf17c | ||
|
|
a96387b4d7 | ||
|
|
d1feff1a6a | ||
|
|
95df2f1650 | ||
|
|
0565c82448 | ||
|
|
755265bf53 | ||
|
|
4c7b386ab4 | ||
|
|
92b2326dfc | ||
|
|
9e18783a05 | ||
|
|
9be4aac988 | ||
|
|
f3d659670f | ||
|
|
23f051d6f1 | ||
|
|
c8979ab2e6 | ||
|
|
bd33252775 | ||
|
|
1fbcbc72bf | ||
|
|
9a8628cfbd | ||
|
|
3d13b20fda | ||
|
|
f2183ec3e4 | ||
|
|
360aeb5574 |
6
.github/workflows/python-package.yml
vendored
6
.github/workflows/python-package.yml
vendored
@@ -2,6 +2,8 @@
|
|||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
||||||
|
|
||||||
name: Python package
|
name: Python package
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -16,7 +18,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12"]
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -28,7 +30,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install poetry
|
python -m pip install poetry
|
||||||
poetry install --with dev
|
poetry install --with dev --all-extras
|
||||||
- name: Check with ruff
|
- name: Check with ruff
|
||||||
run: |
|
run: |
|
||||||
poetry run ruff check src
|
poetry run ruff check src
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -5,6 +5,7 @@
|
|||||||
[](https://pypi.org/project/pydase/)
|
[](https://pypi.org/project/pydase/)
|
||||||
[](https://pydase.readthedocs.io/en/stable/)
|
[](https://pydase.readthedocs.io/en/stable/)
|
||||||
[][License]
|
[][License]
|
||||||
|
[](https://doi.org/10.5281/zenodo.15703190)
|
||||||
|
|
||||||
`pydase` is a Python library that simplifies the creation of remote control interfaces for Python objects. It exposes the public attributes of a user-defined class via a [Socket.IO](https://python-socketio.readthedocs.io/en/stable/) web server, ensuring they are always in sync with the service state. You can interact with these attributes using an RPC client, a RESTful API, or a web browser. The web browser frontend is auto-generated, displaying components that correspond to each public attribute of the class for direct interaction.
|
`pydase` is a Python library that simplifies the creation of remote control interfaces for Python objects. It exposes the public attributes of a user-defined class via a [Socket.IO](https://python-socketio.readthedocs.io/en/stable/) web server, ensuring they are always in sync with the service state. You can interact with these attributes using an RPC client, a RESTful API, or a web browser. The web browser frontend is auto-generated, displaying components that correspond to each public attribute of the class for direct interaction.
|
||||||
`pydase` implements an [observer pattern][Observer Pattern] to provide the real-time updates, ensuring that changes to the class attributes are reflected across all clients.
|
`pydase` implements an [observer pattern][Observer Pattern] to provide the real-time updates, ensuring that changes to the class attributes are reflected across all clients.
|
||||||
@@ -226,45 +227,15 @@ For details, please see [here](https://pydase.readthedocs.io/en/stable/user-guid
|
|||||||
|
|
||||||
## Logging in pydase
|
## Logging in pydase
|
||||||
|
|
||||||
The `pydase` library organizes its loggers on a per-module basis, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library.
|
The `pydase` library provides structured, per-module logging with support for log level configuration, rich formatting, and optional client identification in logs.
|
||||||
|
|
||||||
### Changing the Log Level
|
To configure logging in your own service, you can use:
|
||||||
|
|
||||||
You have two primary ways to adjust the log levels in `pydase`:
|
```python
|
||||||
|
from pydase.utils.logging import configure_logging_with_pydase_formatter
|
||||||
|
```
|
||||||
|
|
||||||
1. directly targeting `pydase` loggers
|
For more information, see the [full guide](https://pydase.readthedocs.io/en/stable/user-guide/Logging/).
|
||||||
|
|
||||||
You can set the log level for any `pydase` logger directly in your code. This method is useful for fine-tuning logging levels for specific modules within `pydase`. For instance, if you want to change the log level of the main `pydase` logger or target a submodule like `pydase.data_service`, you can do so as follows:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# <your_script.py>
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# Set the log level for the main pydase logger
|
|
||||||
logging.getLogger("pydase").setLevel(logging.INFO)
|
|
||||||
|
|
||||||
# Optionally, target a specific submodule logger
|
|
||||||
# logging.getLogger("pydase.data_service").setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
# Your logger for the current script
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.info("My info message.")
|
|
||||||
```
|
|
||||||
|
|
||||||
This approach allows for specific control over different parts of the `pydase` library, depending on your logging needs.
|
|
||||||
|
|
||||||
2. using the `ENVIRONMENT` environment variable
|
|
||||||
|
|
||||||
For a more global setting that affects the entire `pydase` library, you can utilize the `ENVIRONMENT` environment variable. Setting this variable to "production" will configure all `pydase` loggers to only log messages of level "INFO" and above, filtering out more verbose logging. This is particularly useful for production environments where excessive logging can be overwhelming or unnecessary.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ENVIRONMENT="production" python -m <module_using_pydase>
|
|
||||||
```
|
|
||||||
|
|
||||||
In the absence of this setting, the default behavior is to log everything of level "DEBUG" and above, suitable for development environments where more detailed logs are beneficial.
|
|
||||||
|
|
||||||
**Note**: It is recommended to avoid calling the `pydase.utils.logging.setup_logging` function directly, as this may result in duplicated logging messages.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -274,6 +245,14 @@ The full documentation provides more detailed information about `pydase`, includ
|
|||||||
|
|
||||||
We welcome contributions! Please see [contributing.md](https://pydase.readthedocs.io/en/stable/about/contributing/) for details on how to contribute.
|
We welcome contributions! Please see [contributing.md](https://pydase.readthedocs.io/en/stable/about/contributing/) for details on how to contribute.
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
This work was funded by the [ETH Zurich-PSI Quantum Computing Hub](https://www.psi.ch/en/lnq/qchub).
|
||||||
|
|
||||||
|
The main idea behind `pydase` is based on a previous project called `tiqi-plugin`, which
|
||||||
|
was developed within the same research group. While the concept was inspired by that
|
||||||
|
project, `pydase` was implemented from the ground up with a new architecture and design.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
`pydase` is licensed under the [MIT License][License].
|
`pydase` is licensed under the [MIT License][License].
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
::: pydase.data_service
|
::: pydase.data_service
|
||||||
handler: python
|
handler: python
|
||||||
|
|
||||||
|
::: pydase.data_service.data_service_cache
|
||||||
|
handler: python
|
||||||
|
|
||||||
|
::: pydase.data_service.data_service_observer
|
||||||
|
handler: python
|
||||||
|
|
||||||
|
::: pydase.data_service.state_manager
|
||||||
|
handler: python
|
||||||
|
|
||||||
::: pydase.server.server
|
::: pydase.server.server
|
||||||
handler: python
|
handler: python
|
||||||
|
|
||||||
@@ -38,6 +47,9 @@
|
|||||||
options:
|
options:
|
||||||
filters: ["!render_in_frontend"]
|
filters: ["!render_in_frontend"]
|
||||||
|
|
||||||
|
::: pydase.utils.logging
|
||||||
|
handler: python
|
||||||
|
|
||||||
::: pydase.units
|
::: pydase.units
|
||||||
handler: python
|
handler: python
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
end="<!--getting-started-end-->"
|
end="<!--getting-started-end-->"
|
||||||
%}
|
%}
|
||||||
|
|
||||||
[RESTful API]: ./user-guide/interaction/README.md#restful-api
|
[RESTful API]: ./user-guide/interaction/RESTful-API.md
|
||||||
[Python RPC Client]: ./user-guide/interaction/README.md#python-rpc-client
|
[Python RPC Client]: ./user-guide/interaction/Python-Client.md
|
||||||
[Custom Components]: ./user-guide/Components.md#custom-components-pydasecomponents
|
[Custom Components]: ./user-guide/Components.md#custom-components-pydasecomponents
|
||||||
[Components]: ./user-guide/Components.md
|
[Components]: ./user-guide/Components.md
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
[Defining DataService]: ./getting-started.md#defining-a-dataservice
|
[Defining DataService]: ./getting-started.md#defining-a-dataservice
|
||||||
[Web Interface Access]: ./getting-started.md#accessing-the-web-interface
|
[Web Interface Access]: ./getting-started.md#accessing-the-web-interface
|
||||||
[Short RPC Client]: ./getting-started.md#connecting-to-the-service-via-python-rpc-client
|
[Short RPC Client]: ./getting-started.md#connecting-to-the-service-via-python-rpc-client
|
||||||
[Customizing Web Interface]: ./user-guide/interaction/README.md#customization-options
|
[Customizing Web Interface]: ./user-guide/interaction/Auto-generated-Frontend.md#customization-options
|
||||||
[Task Management]: ./user-guide/Tasks.md
|
[Task Management]: ./user-guide/Tasks.md
|
||||||
[Units]: ./user-guide/Understanding-Units.md
|
[Units]: ./user-guide/Understanding-Units.md
|
||||||
[Property Validation]: ./user-guide/Validating-Property-Setters.md
|
[Property Validation]: ./user-guide/Validating-Property-Setters.md
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ example of how to separate service code from configuration.
|
|||||||
|
|
||||||
- **`ENVIRONMENT`**:
|
- **`ENVIRONMENT`**:
|
||||||
Defines the operation mode (`"development"` or `"production"`), which influences
|
Defines the operation mode (`"development"` or `"production"`), which influences
|
||||||
behaviour such as logging (see [Logging in pydase](https://github.com/tiqi-group/pydase?tab=readme-ov-file#logging-in-pydase)).
|
behaviour such as logging (see [Logging in pydase](./Logging.md)).
|
||||||
|
|
||||||
- **`SERVICE_CONFIG_DIR`**:
|
- **`SERVICE_CONFIG_DIR`**:
|
||||||
Specifies the directory for configuration files (e.g., `web_settings.json`). Defaults
|
Specifies the directory for configuration files (e.g., `web_settings.json`). Defaults
|
||||||
@@ -46,8 +46,8 @@ example of how to separate service code from configuration.
|
|||||||
port. Default: `8001`.
|
port. Default: `8001`.
|
||||||
|
|
||||||
- **`GENERATE_WEB_SETTINGS`**:
|
- **`GENERATE_WEB_SETTINGS`**:
|
||||||
When `true`, generates or updates the `web_settings.json` file. Existing entries are
|
When `true`, generates or updates the `web_settings.json` file (see [Tailoring Frontend Component Layout](./interaction/Auto-generated-Frontend.md#tailoring-frontend-component-layout)).
|
||||||
preserved, and new entries are appended.
|
Existing entries are preserved, and new entries are appended.
|
||||||
|
|
||||||
### Configuring `pydase` via Keyword Arguments
|
### Configuring `pydase` via Keyword Arguments
|
||||||
|
|
||||||
@@ -70,32 +70,32 @@ server = Server(
|
|||||||
|
|
||||||
## Separating Service Code from Configuration
|
## Separating Service Code from Configuration
|
||||||
|
|
||||||
To decouple configuration from code, `pydase` utilizes `confz` for configuration
|
To decouple configuration from code, `pydase` utilizes `confz` for configuration
|
||||||
management. Below is an example that demonstrates how to configure a `pydase` service
|
management. Below is an example that demonstrates how to configure a `pydase` service
|
||||||
for a sensor readout application.
|
for a sensor readout application.
|
||||||
|
|
||||||
### Scenario: Configuring a Sensor Service
|
### Scenario: Configuring a Sensor Service
|
||||||
|
|
||||||
Imagine you have multiple sensors distributed across your lab. You need to configure
|
Imagine you have multiple sensors distributed across your lab. You need to configure
|
||||||
each service instance with:
|
each service instance with:
|
||||||
|
|
||||||
1. **Hostname**: The hostname or IP address of the sensor.
|
1. **Hostname**: The hostname or IP address of the sensor.
|
||||||
2. **Authentication Token**: A token or credentials to authenticate with the sensor.
|
2. **Authentication Token**: A token or credentials to authenticate with the sensor.
|
||||||
3. **Readout Interval**: A periodic interval to read sensor data and log it to a
|
3. **Readout Interval**: A periodic interval to read sensor data and log it to a
|
||||||
database.
|
database.
|
||||||
|
|
||||||
Given the repository structure:
|
Given the repository structure:
|
||||||
|
|
||||||
```bash title="Service Repository Structure"
|
```bash title="Service Repository Structure"
|
||||||
my_sensor
|
my_sensor
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── README.md
|
├── README.md
|
||||||
└── src
|
└── src
|
||||||
└── my_sensor
|
└── my_sensor
|
||||||
├── my_sensor.py
|
├── my_sensor.py
|
||||||
├── config.py
|
├── config.py
|
||||||
├── __init__.py
|
├── __init__.py
|
||||||
└── __main__.py
|
└── __main__.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Your service might look like this:
|
Your service might look like this:
|
||||||
@@ -119,7 +119,7 @@ class MySensorConfig(confz.BaseConfig):
|
|||||||
|
|
||||||
This class defines configurable parameters and loads values from a `config.yaml` file
|
This class defines configurable parameters and loads values from a `config.yaml` file
|
||||||
located in the service’s configuration directory (which is configurable through an
|
located in the service’s configuration directory (which is configurable through an
|
||||||
environment variable, see [above](#configuring-pydase-using-environment-variables)).
|
environment variable, see [above](#configuring-pydase-using-environment-variables)).
|
||||||
A sample YAML file might look like this:
|
A sample YAML file might look like this:
|
||||||
|
|
||||||
```yaml title="config.yaml"
|
```yaml title="config.yaml"
|
||||||
|
|||||||
@@ -1,44 +1,46 @@
|
|||||||
## Logging in pydase
|
# Logging in pydase
|
||||||
|
|
||||||
The `pydase` library organizes its loggers per module, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library. Logs can also include details about client identification based on headers sent by the client or proxy, providing additional context for debugging or auditing.
|
The `pydase` library organizes its loggers per module, mirroring the Python package hierarchy. This structured approach allows for granular control over logging levels and behaviour across different parts of the library. Logs can also include details about client identification based on headers sent by the client or proxy, providing additional context for debugging or auditing.
|
||||||
|
|
||||||
### Changing the Log Level
|
## Changing the pydase Log Level
|
||||||
|
|
||||||
You have two primary ways to adjust the log levels in `pydase`:
|
You have two primary ways to adjust the log levels in `pydase`:
|
||||||
|
|
||||||
1. **Directly targeting `pydase` loggers**
|
1. **Directly targeting `pydase` loggers**
|
||||||
|
|
||||||
You can set the log level for any `pydase` logger directly in your code. This method is useful for fine-tuning logging levels for specific modules within `pydase`. For instance, if you want to change the log level of the main `pydase` logger or target a submodule like `pydase.data_service`, you can do so as follows:
|
You can set the log level for any `pydase` logger directly in your code. This method is useful for fine-tuning logging levels for specific modules within `pydase`. For instance, if you want to change the log level of the main `pydase` logger or target a submodule like `pydase.data_service`, you can do so as follows:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# <your_script.py>
|
# <your_script.py>
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Set the log level for the main pydase logger
|
# Set the log level for the main pydase logger
|
||||||
logging.getLogger("pydase").setLevel(logging.INFO)
|
logging.getLogger("pydase").setLevel(logging.INFO)
|
||||||
|
|
||||||
# Optionally, target a specific submodule logger
|
# Optionally, target a specific submodule logger
|
||||||
# logging.getLogger("pydase.data_service").setLevel(logging.DEBUG)
|
# logging.getLogger("pydase.data_service").setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Your logger for the current script
|
# Your logger for the current script
|
||||||
logging.basicConfig(level=logging.INFO)
|
from pydase.utils.logging import configure_logging_with_pydase_formatter
|
||||||
logger = logging.getLogger(__name__)
|
configure_logging_with_pydase_formatter(level=logging.DEBUG)
|
||||||
logger.info("My info message.")
|
|
||||||
```
|
|
||||||
|
|
||||||
This approach allows for specific control over different parts of the `pydase` library, depending on your logging needs.
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.debug("My debug message.")
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach allows for specific control over different parts of the `pydase` library, depending on your logging needs.
|
||||||
|
|
||||||
2. **Using the `ENVIRONMENT` environment variable**
|
2. **Using the `ENVIRONMENT` environment variable**
|
||||||
|
|
||||||
For a more global setting that affects the entire `pydase` library, you can utilize the `ENVIRONMENT` environment variable. Setting this variable to `"production"` will configure all `pydase` loggers to only log messages of level `"INFO"` and above, filtering out more verbose logging. This is particularly useful for production environments where excessive logging can be overwhelming or unnecessary.
|
For a more global setting that affects the entire `pydase` library, you can utilize the `ENVIRONMENT` environment variable. Setting this variable to `"production"` will configure all `pydase` loggers to only log messages of level `"INFO"` and above, filtering out more verbose logging. This is particularly useful for production environments where excessive logging can be overwhelming or unnecessary.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ENVIRONMENT="production" python -m <module_using_pydase>
|
ENVIRONMENT="production" python -m <module_using_pydase>
|
||||||
```
|
```
|
||||||
|
|
||||||
In the absence of this setting, the default behavior is to log everything of level `"DEBUG"` and above, suitable for development environments where more detailed logs are beneficial.
|
In the absence of this setting, the default behavior is to log everything of level `"DEBUG"` and above, suitable for development environments where more detailed logs are beneficial.
|
||||||
|
|
||||||
### Client Identification in Logs
|
## Client Identification in pydase Logs
|
||||||
|
|
||||||
The logging system in `pydase` includes information about clients based on headers sent by the client or a proxy. The priority for identifying the client is fixed and as follows:
|
The logging system in `pydase` includes information about clients based on headers sent by the client or a proxy. The priority for identifying the client is fixed and as follows:
|
||||||
|
|
||||||
@@ -53,3 +55,37 @@ For example, a log entries might include the following details based on the avai
|
|||||||
|
|
||||||
2025-01-20 06:48:13.710 | INFO | pydase.server.web_server.api.v1.application:_get_value:36 - Client [user=Max Muster] is getting the value of 'property_attr'
|
2025-01-20 06:48:13.710 | INFO | pydase.server.web_server.api.v1.application:_get_value:36 - Client [user=Max Muster] is getting the value of 'property_attr'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Configuring Logging in Services
|
||||||
|
|
||||||
|
To configure logging in services built with `pydase`, use the helper function [`configure_logging_with_pydase_formatter`][pydase.utils.logging.configure_logging_with_pydase_formatter]. This function sets up a logger with the same formatting used internally by `pydase`, so your service logs match the style and structure of `pydase` logs.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
If your service follows a typical layout like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
└── src
|
||||||
|
└── my_service
|
||||||
|
├── __init__.py
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
you should call `configure_logging_with_pydase_formatter` inside `src/my_service/__init__.py`. This ensures the logger is configured as soon as your service is imported, and before any log messages are emitted.
|
||||||
|
|
||||||
|
```python title="src/my_service/__init__.py"
|
||||||
|
import sys
|
||||||
|
from pydase.utils.logging import configure_logging_with_pydase_formatter
|
||||||
|
|
||||||
|
configure_logging_with_pydase_formatter(
|
||||||
|
name="my_service", # Use the package/module name or None for the root logger
|
||||||
|
level=logging.DEBUG, # Set the desired logging level (defaults to INFO)
|
||||||
|
stream=sys.stderr # Optional: set the output stream (stderr by default)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- If you pass `name=None`, the root logger will be configured. This affects **all logs** that propagate to the root logger.
|
||||||
|
- Passing a specific `name` like `"my_service"` allows you to scope the configuration to your service only, which is safer in multi-library environments.
|
||||||
|
- You can use `sys.stdout` instead of `sys.stderr` if your logs are being captured or processed differently (e.g., in containers or logging systems).
|
||||||
|
|||||||
@@ -2,29 +2,47 @@
|
|||||||
|
|
||||||
`pydase` allows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.
|
`pydase` allows you to easily persist the state of your service by saving it to a file. This is especially useful when you want to maintain the service's state across different runs.
|
||||||
|
|
||||||
To save the state of your service, pass a `filename` keyword argument to the constructor of the `pydase.Server` class. If the file specified by `filename` does not exist, the state manager will create this file and store its state in it when the service is shut down. If the file already exists, the state manager will load the state from this file, setting the values of its attributes to the values stored in the file.
|
To enable persistence, pass a `filename` keyword argument to the constructor of the [`pydase.Server`][pydase.Server] class. The `filename` specifies the file where the state will be saved:
|
||||||
|
|
||||||
Here's an example:
|
- If the file **does not exist**, it will be created and populated with the current state when the service shuts down or saves.
|
||||||
|
- If the file **already exists**, the state manager will **load** the saved values into the service at startup.
|
||||||
|
|
||||||
|
Here’s an example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import pydase
|
import pydase
|
||||||
|
|
||||||
class Device(pydase.DataService):
|
class Device(pydase.DataService):
|
||||||
# ... defining the Device class ...
|
# ... define your service class ...
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
service = Device()
|
service = Device()
|
||||||
pydase.Server(service=service, filename="device_state.json").run()
|
pydase.Server(service=service, filename="device_state.json").run()
|
||||||
```
|
```
|
||||||
|
|
||||||
In this example, the state of the `Device` service will be saved to `device_state.json` when the service is shut down. If `device_state.json` exists when the server is started, the state manager will restore the state of the service from this file.
|
In this example, the service state will be automatically loaded from `device_state.json` at startup (if it exists), and saved to the same file periodically and upon shutdown.
|
||||||
|
|
||||||
|
## Automatic Periodic State Saving
|
||||||
|
|
||||||
|
When a `filename` is provided, `pydase` automatically enables **periodic autosaving** of the service state to that file. This ensures that the current state is regularly persisted, reducing the risk of data loss during unexpected shutdowns.
|
||||||
|
|
||||||
|
The autosave happens every 30 seconds by default. You can customize the interval using the `autosave_interval` argument (in seconds):
|
||||||
|
|
||||||
|
```python
|
||||||
|
pydase.Server(
|
||||||
|
service=service,
|
||||||
|
filename="device_state.json",
|
||||||
|
autosave_interval=10.0, # save every 10 seconds
|
||||||
|
).run()
|
||||||
|
```
|
||||||
|
|
||||||
|
To disable automatic saving, set `autosave_interval` to `None`.
|
||||||
|
|
||||||
## Controlling Property State Loading with `@load_state`
|
## Controlling Property State Loading with `@load_state`
|
||||||
|
|
||||||
By default, the state manager only restores values for public attributes of your service. If you have properties that you want to control the loading for, you can use the `@load_state` decorator on your property setters. This indicates to the state manager that the value of the property should be loaded from the state file.
|
By default, the state manager only restores values for public attributes of your service (i.e. *it does not restore property values*). If you have properties that you want to control the loading for, you can use the [`@load_state`][pydase.data_service.state_manager.load_state] decorator on your property setters. This indicates to the state manager that the value of the property should be loaded from the state file.
|
||||||
|
|
||||||
Here is how you can apply the `@load_state` decorator:
|
Example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import pydase
|
import pydase
|
||||||
@@ -43,7 +61,6 @@ class Device(pydase.DataService):
|
|||||||
self._name = value
|
self._name = value
|
||||||
```
|
```
|
||||||
|
|
||||||
With the `@load_state` decorator applied to the `name` property setter, the state manager will load and apply the `name` property's value from the file storing the state upon server startup, assuming it exists.
|
With the `@load_state` decorator applied to the `name` property setter, the state manager will load and apply the `name` property's value from the file upon server startup.
|
||||||
|
|
||||||
Note: If the service class structure has changed since the last time its state was saved, only the attributes and properties decorated with `@load_state` that have remained the same will be restored from the settings file.
|
|
||||||
|
|
||||||
|
**Note**: If the structure of your service class changes between saves, only properties decorated with `@load_state` and unchanged public attributes will be restored safely.
|
||||||
|
|||||||
48
docs/user-guide/advanced/SOCKS-Proxy.md
Normal file
48
docs/user-guide/advanced/SOCKS-Proxy.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Connecting Through a SOCKS5 Proxy
|
||||||
|
|
||||||
|
If your target service is only reachable via an SSH gateway or resides behind a
|
||||||
|
firewall, you can route your [`pydase.Client`][pydase.Client] connection through a local
|
||||||
|
SOCKS5 proxy. This is particularly useful in network environments where direct access to
|
||||||
|
the service is not possible.
|
||||||
|
|
||||||
|
## Setting Up a SOCKS5 Proxy
|
||||||
|
|
||||||
|
You can create a local [SOCKS5 proxy](https://en.wikipedia.org/wiki/SOCKS) using SSH's
|
||||||
|
`-D` option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -D 2222 user@gateway.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
This command sets up a SOCKS5 proxy on `localhost:2222`, securely forwarding traffic
|
||||||
|
over the SSH connection.
|
||||||
|
|
||||||
|
## Using the Proxy in Your Python Client
|
||||||
|
|
||||||
|
Once the proxy is running, configure the [`pydase.Client`][pydase.Client] to route
|
||||||
|
traffic through it using the `proxy_url` parameter:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pydase
|
||||||
|
|
||||||
|
client = pydase.Client(
|
||||||
|
url="ws://target-service:8001",
|
||||||
|
proxy_url="socks5://localhost:2222"
|
||||||
|
).proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
* You can also use this setup with `wss://` URLs for encrypted WebSocket connections.
|
||||||
|
|
||||||
|
## Installing Required Dependencies
|
||||||
|
|
||||||
|
To use this feature, you must install the optional `socks` dependency group, which
|
||||||
|
includes [`aiohttp_socks`](https://pypi.org/project/aiohttp-socks/):
|
||||||
|
|
||||||
|
- `poetry`
|
||||||
|
```bash
|
||||||
|
poetry add "pydase[socks]"
|
||||||
|
```
|
||||||
|
- `pip`
|
||||||
|
```bash
|
||||||
|
pip install "pydase[socks]"
|
||||||
|
```
|
||||||
@@ -89,7 +89,7 @@ Each key in the file corresponds to the full access path of public attributes, p
|
|||||||
- **Control Component Visibility**: Utilize the `"display"` key-value pair to control whether a component is rendered in the frontend. Set the value to `true` to make the component visible or `false` to hide it.
|
- **Control Component Visibility**: Utilize the `"display"` key-value pair to control whether a component is rendered in the frontend. Set the value to `true` to make the component visible or `false` to hide it.
|
||||||
- **Adjustable Component Order**: The `"displayOrder"` values determine the order of components. Alter these values to rearrange the components as desired. The value defaults to [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER).
|
- **Adjustable Component Order**: The `"displayOrder"` values determine the order of components. Alter these values to rearrange the components as desired. The value defaults to [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER).
|
||||||
|
|
||||||
The `web_settings.json` file will be stored in the directory specified by the `SERVICE_CONFIG_DIR` environment variable. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](../Configuration).
|
The `web_settings.json` file will be stored in the directory specified by the `SERVICE_CONFIG_DIR` environment variable. You can generate a `web_settings.json` file by setting the `GENERATE_WEB_SETTINGS` to `True`. For more information, see the [configuration section](../Configuration.md).
|
||||||
|
|
||||||
For example, styling the following service
|
For example, styling the following service
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Python RPC Client
|
# Python RPC Client
|
||||||
|
|
||||||
The [`pydase.Client`][pydase.Client] allows you to connect to a remote `pydase` service using socket.io, facilitating interaction with the service as though it were running locally.
|
The [`pydase.Client`][pydase.Client] allows you to connect to a remote `pydase` service using Socket.IO, facilitating interaction with the service as though it were running locally.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import pydase
|
|||||||
|
|
||||||
# Replace <ip_addr> and <service_port> with the appropriate values for your service
|
# Replace <ip_addr> and <service_port> with the appropriate values for your service
|
||||||
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy
|
client_proxy = pydase.Client(url="ws://<ip_addr>:<service_port>").proxy
|
||||||
|
|
||||||
# For SSL-encrypted services, use the wss protocol
|
# For SSL-encrypted services, use the wss protocol
|
||||||
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy
|
# client_proxy = pydase.Client(url="wss://your-domain.ch").proxy
|
||||||
|
|
||||||
@@ -22,6 +23,44 @@ The proxy acts as a local representation of the remote service, enabling intuiti
|
|||||||
|
|
||||||
The proxy class automatically synchronizes with the server's attributes and methods, keeping itself up-to-date with any changes. This dynamic synchronization essentially mirrors the server's API, making it feel like you're working with a local object.
|
The proxy class automatically synchronizes with the server's attributes and methods, keeping itself up-to-date with any changes. This dynamic synchronization essentially mirrors the server's API, making it feel like you're working with a local object.
|
||||||
|
|
||||||
|
## Automatic Proxy Updates
|
||||||
|
|
||||||
|
By default, the client listens for attribute and structure changes from the server and dynamically updates its internal proxy representation. This ensures that value changes or newly added attributes on the server appear in the client proxy without requiring reconnection or manual refresh.
|
||||||
|
|
||||||
|
This is useful, for example, when [integrating the client into another service](#integrating-the-client-into-another-service). However, if you want to avoid this behavior (e.g., to reduce network traffic or avoid frequent re-syncing), you can disable it. When passing `auto_update_proxy=False` to the client, the proxy will not track changes after the initial connection:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = pydase.Client(
|
||||||
|
url="ws://localhost:8001",
|
||||||
|
auto_update_proxy=False
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Direct API Access
|
||||||
|
|
||||||
|
In addition to using the `proxy` object, users may access the server API directly via the following methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = pydase.Client(url="ws://localhost:8001")
|
||||||
|
|
||||||
|
# Get the current value of an attribute
|
||||||
|
value = client.get_value("device.voltage")
|
||||||
|
|
||||||
|
# Update an attribute
|
||||||
|
client.update_value("device.voltage", 5.0)
|
||||||
|
|
||||||
|
# Call a method on the remote service
|
||||||
|
result = client.trigger_method("device.reset")
|
||||||
|
```
|
||||||
|
|
||||||
|
This bypasses the proxy and is useful for lower-level access to individual service endpoints.
|
||||||
|
|
||||||
|
## Accessing Services Behind Firewalls or SSH Gateways
|
||||||
|
|
||||||
|
If your service is only reachable through a private network or SSH gateway, you can route your connection through a local SOCKS5 proxy using the `proxy_url` parameter.
|
||||||
|
|
||||||
|
See [Connecting Through a SOCKS5 Proxy](../advanced/SOCKS-Proxy.md) for details.
|
||||||
|
|
||||||
## Context Manager Support
|
## Context Manager Support
|
||||||
|
|
||||||
You can also use the client within a context manager, which automatically handles connection management (i.e., opening and closing the connection):
|
You can also use the client within a context manager, which automatically handles connection management (i.e., opening and closing the connection):
|
||||||
@@ -51,8 +90,9 @@ class MyService(pydase.DataService):
|
|||||||
proxy = pydase.Client(
|
proxy = pydase.Client(
|
||||||
url="ws://<ip_addr>:<service_port>",
|
url="ws://<ip_addr>:<service_port>",
|
||||||
block_until_connected=False,
|
block_until_connected=False,
|
||||||
client_id="my_pydase_client_id",
|
client_id="my_pydase_client_id", # optional, defaults to system hostname
|
||||||
).proxy
|
).proxy
|
||||||
|
|
||||||
# For SSL-encrypted services, use the wss protocol
|
# For SSL-encrypted services, use the wss protocol
|
||||||
# proxy = pydase.Client(
|
# proxy = pydase.Client(
|
||||||
# url="wss://your-domain.ch",
|
# url="wss://your-domain.ch",
|
||||||
@@ -67,13 +107,14 @@ if __name__ == "__main__":
|
|||||||
```
|
```
|
||||||
|
|
||||||
In this example:
|
In this example:
|
||||||
|
|
||||||
- The `MyService` class has a `proxy` attribute that connects to a `pydase` service at `<ip_addr>:<service_port>`.
|
- The `MyService` class has a `proxy` attribute that connects to a `pydase` service at `<ip_addr>:<service_port>`.
|
||||||
- By setting `block_until_connected=False`, the service can start without waiting for the connection to succeed, which is particularly useful in distributed systems where services may initialize in any order.
|
- By setting `block_until_connected=False`, the service can start without waiting for the connection to succeed.
|
||||||
- By setting `client_id`, the server will provide more accurate logs of the connecting client. If set, this ID is sent as `X-Client-Id` header in the HTTP(s) request.
|
- The `client_id` is optional. If not specified, it defaults to the system hostname, which will be sent in the `X-Client-Id` HTTP header for logging or authentication on the server side.
|
||||||
|
|
||||||
## Custom `socketio.AsyncClient` Connection Parameters
|
## Custom `socketio.AsyncClient` Connection Parameters
|
||||||
|
|
||||||
You can also configure advanced connection options by passing additional arguments to the underlying [`AsyncClient`][socketio.AsyncClient] via `sio_client_kwargs`. This allows you to fine-tune reconnection behaviour, delays, and other settings:
|
You can configure advanced connection options by passing arguments to the underlying [`AsyncClient`][socketio.AsyncClient] via `sio_client_kwargs`. For example:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
client = pydase.Client(
|
client = pydase.Client(
|
||||||
@@ -1,81 +1,7 @@
|
|||||||
# Interacting with `pydase` Services
|
# Interacting with `pydase` Services
|
||||||
|
|
||||||
`pydase` offers multiple ways for users to interact with the services they create, providing flexibility and convenience for different use cases. This section outlines the primary interaction methods available, including an auto-generated frontend, a RESTful API, and a Python client based on Socket.IO.
|
`pydase` offers multiple ways for users to interact with the services they create.
|
||||||
|
|
||||||
{%
|
- [Auto-generated Frontend](./Auto-generated-Frontend.md)
|
||||||
include-markdown "./Auto-generated Frontend.md"
|
- [RESTful API](./RESTful-API.md)
|
||||||
heading-offset=1
|
- [Python Client](./Python-Client.md)
|
||||||
%}
|
|
||||||
|
|
||||||
{%
|
|
||||||
include-markdown "./RESTful API.md"
|
|
||||||
heading-offset=1
|
|
||||||
%}
|
|
||||||
|
|
||||||
{%
|
|
||||||
include-markdown "./Python Client.md"
|
|
||||||
heading-offset=1
|
|
||||||
%}
|
|
||||||
|
|
||||||
<!-- ## 2. **Socket.IO for Real-Time Updates** -->
|
|
||||||
<!-- For scenarios requiring real-time data updates, `pydase` includes a Socket.IO server. This feature is ideal for applications where live data tracking is crucial, such as monitoring systems or interactive dashboards. -->
|
|
||||||
<!---->
|
|
||||||
<!-- ### Key Features: -->
|
|
||||||
<!-- - **Live Data Streams**: Receive real-time updates for data changes. -->
|
|
||||||
<!-- - **Event-Driven Communication**: Utilize event-based messaging to push updates and handle client actions. -->
|
|
||||||
<!---->
|
|
||||||
<!-- ### Example Usage: -->
|
|
||||||
<!-- Clients can connect to the Socket.IO server to receive updates: -->
|
|
||||||
<!-- ```javascript -->
|
|
||||||
<!-- var socket = io.connect('http://<hostname>:<port>'); -->
|
|
||||||
<!-- socket.on('<event_name>', function(data) { -->
|
|
||||||
<!-- console.log(data); -->
|
|
||||||
<!-- }); -->
|
|
||||||
<!-- ``` -->
|
|
||||||
<!---->
|
|
||||||
<!-- **Use Cases:** -->
|
|
||||||
<!---->
|
|
||||||
<!-- - Real-time monitoring and alerts -->
|
|
||||||
<!-- - Live data visualization -->
|
|
||||||
<!-- - Collaborative applications -->
|
|
||||||
<!---->
|
|
||||||
<!-- ## 3. **Auto-Generated Frontend** -->
|
|
||||||
<!-- `pydase` automatically generates a web frontend based on the service definitions. This frontend is a convenient interface for interacting with the service, especially for users who prefer a graphical interface over command-line or code-based interactions. -->
|
|
||||||
<!---->
|
|
||||||
<!-- ### Key Features: -->
|
|
||||||
<!-- - **User-Friendly Interface**: Intuitive and easy to use, with real-time interaction capabilities. -->
|
|
||||||
<!-- - **Customizable**: Adjust the frontend's appearance and functionality to suit specific needs. -->
|
|
||||||
<!---->
|
|
||||||
<!-- ### Accessing the Frontend: -->
|
|
||||||
<!-- Once the service is running, access the frontend via a web browser: -->
|
|
||||||
<!-- ``` -->
|
|
||||||
<!-- http://<hostname>:<port> -->
|
|
||||||
<!-- ``` -->
|
|
||||||
<!---->
|
|
||||||
<!-- **Use Cases:** -->
|
|
||||||
<!---->
|
|
||||||
<!-- - End-user interfaces for data control and visualization -->
|
|
||||||
<!-- - Rapid prototyping and testing -->
|
|
||||||
<!-- - Demonstrations and training -->
|
|
||||||
<!---->
|
|
||||||
<!-- ## 4. **Python Client** -->
|
|
||||||
<!-- `pydase` also provides a Python client for programmatic interactions. This client is particularly useful for developers who want to integrate `pydase` services into other Python applications or automate interactions. -->
|
|
||||||
<!---->
|
|
||||||
<!-- ### Key Features: -->
|
|
||||||
<!-- - **Direct Interaction**: Call methods and access properties as if they were local. -->
|
|
||||||
<!-- - **Tab Completion**: Supports tab completion in interactive environments like Jupyter notebooks. -->
|
|
||||||
<!---->
|
|
||||||
<!-- ### Example Usage: -->
|
|
||||||
<!-- ```python -->
|
|
||||||
<!-- import pydase -->
|
|
||||||
<!---->
|
|
||||||
<!-- client = pydase.Client(hostname="<ip_addr>", port=8001) -->
|
|
||||||
<!-- service = client.proxy -->
|
|
||||||
<!-- service.some_method() -->
|
|
||||||
<!-- ``` -->
|
|
||||||
<!---->
|
|
||||||
<!-- **Use Cases:** -->
|
|
||||||
<!---->
|
|
||||||
<!-- - Integrating with other Python applications -->
|
|
||||||
<!-- - Automation and scripting -->
|
|
||||||
<!-- - Data analysis and manipulation -->
|
|
||||||
|
|||||||
1256
frontend/package-lock.json
generated
1256
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,6 @@
|
|||||||
"prettier": "3.3.2",
|
"prettier": "3.3.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^7.18.0",
|
"typescript-eslint": "^7.18.0",
|
||||||
"vite": "^5.4.12"
|
"vite": "^6.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const createDisplayNameFromAccessPath = (fullAccessPath: string): string => {
|
|||||||
|
|
||||||
function changeCallback(
|
function changeCallback(
|
||||||
value: SerializedObject,
|
value: SerializedObject,
|
||||||
callback: (ack: unknown) => void = () => {},
|
callback: (ack: undefined | SerializedObject) => void = () => {},
|
||||||
) {
|
) {
|
||||||
updateValue(value, callback);
|
updateValue(value, callback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Form, InputGroup } from "react-bootstrap";
|
import { Form, InputGroup } from "react-bootstrap";
|
||||||
import { DocStringComponent } from "./DocStringComponent";
|
import { DocStringComponent } from "./DocStringComponent";
|
||||||
import "../App.css";
|
import "../App.css";
|
||||||
@@ -38,7 +38,10 @@ interface NumberComponentProps {
|
|||||||
isInstantUpdate: boolean;
|
isInstantUpdate: boolean;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
addNotification: (message: string, levelname?: LevelName) => void;
|
addNotification: (message: string, levelname?: LevelName) => void;
|
||||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
changeCallback?: (
|
||||||
|
value: SerializedObject,
|
||||||
|
callback?: (ack: undefined | SerializedObject) => void,
|
||||||
|
) => void;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@@ -175,6 +178,33 @@ const handleNumericKey = (
|
|||||||
return { value: newValue, selectionStart: selectionStart + 1 };
|
return { value: newValue, selectionStart: selectionStart + 1 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the new cursor position after moving left by a specified step size.
|
||||||
|
*
|
||||||
|
* @param cursorPosition - The current position of the cursor.
|
||||||
|
* @param step - The number of positions to move left.
|
||||||
|
* @returns The new cursor position, clamped to a minimum of 0.
|
||||||
|
*/
|
||||||
|
const getCursorLeftPosition = (cursorPosition: number, step: number): number => {
|
||||||
|
return Math.max(0, cursorPosition - step);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the new cursor position after moving right by a specified step size.
|
||||||
|
*
|
||||||
|
* @param cursorPosition - The current position of the cursor.
|
||||||
|
* @param step - The number of positions to move right.
|
||||||
|
* @param maxPosition - The maximum allowed cursor position (e.g., value.length).
|
||||||
|
* @returns The new cursor position, clamped to a maximum of maxPosition.
|
||||||
|
*/
|
||||||
|
const getCursorRightPosition = (
|
||||||
|
cursorPosition: number,
|
||||||
|
step: number,
|
||||||
|
maxPosition: number,
|
||||||
|
): number => {
|
||||||
|
return Math.min(maxPosition, cursorPosition + step);
|
||||||
|
};
|
||||||
|
|
||||||
export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
||||||
const {
|
const {
|
||||||
fullAccessPath,
|
fullAccessPath,
|
||||||
@@ -190,8 +220,18 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
id,
|
id,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const handleChange = (newValue: SerializedObject) => {
|
||||||
|
changeCallback(newValue, (result: undefined | SerializedObject) => {
|
||||||
|
if (result === undefined) return;
|
||||||
|
if (result.type == "Exception") {
|
||||||
|
setInputString(value.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Create a state for the cursor position
|
// Create a state for the cursor position
|
||||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
const cursorPositionRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Create a state for the input string
|
// Create a state for the input string
|
||||||
const [inputString, setInputString] = useState(value.toString());
|
const [inputString, setInputString] = useState(value.toString());
|
||||||
const renderCount = useRenderCount();
|
const renderCount = useRenderCount();
|
||||||
@@ -200,26 +240,40 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
const { key, target } = event;
|
const { key, target } = event;
|
||||||
|
|
||||||
const inputTarget = target as HTMLInputElement;
|
const inputTarget = target as HTMLInputElement;
|
||||||
if (key === "F1" || key === "F5" || key === "F12" || key === "Tab") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// Get the current input value and cursor position
|
// Get the current input value and cursor position
|
||||||
const { value } = inputTarget;
|
const { value } = inputTarget;
|
||||||
|
const valueLength = value.length;
|
||||||
const selectionEnd = inputTarget.selectionEnd ?? 0;
|
const selectionEnd = inputTarget.selectionEnd ?? 0;
|
||||||
let selectionStart = inputTarget.selectionStart ?? 0;
|
let selectionStart = inputTarget.selectionStart ?? 0;
|
||||||
|
|
||||||
|
if (key === "F1" || key === "F5" || key === "F12" || key === "Tab") {
|
||||||
|
return;
|
||||||
|
} else if (key === "ArrowLeft" || key === "ArrowRight") {
|
||||||
|
const hasSelection = selectionEnd > selectionStart;
|
||||||
|
|
||||||
|
if (hasSelection && !event.shiftKey) {
|
||||||
|
// Collapse selection: ArrowLeft -> start, ArrowRight -> end
|
||||||
|
const collapseTo = key === "ArrowLeft" ? selectionStart : selectionEnd;
|
||||||
|
cursorPositionRef.current = collapseTo;
|
||||||
|
} else {
|
||||||
|
// No selection or shift key is pressed, just move cursor by one
|
||||||
|
const newSelectionStart =
|
||||||
|
key === "ArrowLeft"
|
||||||
|
? getCursorLeftPosition(selectionStart, 1)
|
||||||
|
: getCursorRightPosition(selectionEnd, 1, valueLength);
|
||||||
|
|
||||||
|
cursorPositionRef.current = newSelectionStart;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
let newValue: string = value;
|
let newValue: string = value;
|
||||||
if (event.ctrlKey && key === "a") {
|
if (event.ctrlKey && key === "a") {
|
||||||
// Select everything when pressing Ctrl + a
|
// Select everything when pressing Ctrl + a
|
||||||
inputTarget.setSelectionRange(0, value.length);
|
inputTarget.setSelectionRange(0, value.length);
|
||||||
return;
|
return;
|
||||||
} else if (key === "ArrowRight" || key === "ArrowLeft") {
|
|
||||||
// Move the cursor with the arrow keys and store its position
|
|
||||||
selectionStart = key === "ArrowRight" ? selectionStart + 1 : selectionStart - 1;
|
|
||||||
setCursorPosition(selectionStart);
|
|
||||||
return;
|
|
||||||
} else if ((key >= "0" && key <= "9") || key === "-") {
|
} else if ((key >= "0" && key <= "9") || key === "-") {
|
||||||
// Check if a number key or a decimal point key is pressed
|
// Check if a number key or a decimal point key is pressed
|
||||||
({ value: newValue, selectionStart } = handleNumericKey(
|
({ value: newValue, selectionStart } = handleNumericKey(
|
||||||
@@ -277,7 +331,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
changeCallback(serializedObject);
|
handleChange(serializedObject);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.debug(key);
|
console.debug(key);
|
||||||
@@ -308,13 +362,13 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
changeCallback(serializedObject);
|
handleChange(serializedObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputString(newValue);
|
setInputString(newValue);
|
||||||
|
|
||||||
// Save the current cursor position before the component re-renders
|
// Save the current cursor position before the component re-renders
|
||||||
setCursorPosition(selectionStart);
|
cursorPositionRef.current = selectionStart;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
@@ -342,7 +396,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
changeCallback(serializedObject);
|
handleChange(serializedObject);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -367,8 +421,11 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set the cursor position after the component re-renders
|
// Set the cursor position after the component re-renders
|
||||||
const inputElement = document.getElementsByName(id)[0] as HTMLInputElement;
|
const inputElement = document.getElementsByName(id)[0] as HTMLInputElement;
|
||||||
if (inputElement && cursorPosition !== null) {
|
if (inputElement && cursorPositionRef.current !== null) {
|
||||||
inputElement.setSelectionRange(cursorPosition, cursorPosition);
|
inputElement.setSelectionRange(
|
||||||
|
cursorPositionRef.current,
|
||||||
|
cursorPositionRef.current,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ interface SliderComponentProps {
|
|||||||
stepSize: NumberObject;
|
stepSize: NumberObject;
|
||||||
isInstantUpdate: boolean;
|
isInstantUpdate: boolean;
|
||||||
addNotification: (message: string, levelname?: LevelName) => void;
|
addNotification: (message: string, levelname?: LevelName) => void;
|
||||||
changeCallback?: (value: SerializedObject, callback?: (ack: unknown) => void) => void;
|
changeCallback?: (
|
||||||
|
value: SerializedObject,
|
||||||
|
callback?: (ack: undefined | SerializedObject) => void,
|
||||||
|
) => void;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { authority } from "../socket";
|
||||||
|
|
||||||
export default function useLocalStorage(key: string, defaultValue: unknown) {
|
export default function useLocalStorage(key: string, defaultValue: unknown) {
|
||||||
const [value, setValue] = useState(() => {
|
const [value, setValue] = useState(() => {
|
||||||
const storedValue = localStorage.getItem(key);
|
const storedValue = localStorage.getItem(`${authority}:${key}`);
|
||||||
if (storedValue) {
|
if (storedValue) {
|
||||||
return JSON.parse(storedValue);
|
return JSON.parse(storedValue);
|
||||||
}
|
}
|
||||||
@@ -11,7 +12,7 @@ export default function useLocalStorage(key: string, defaultValue: unknown) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value === undefined) return;
|
if (value === undefined) return;
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
localStorage.setItem(`${authority}:${key}`, JSON.stringify(value));
|
||||||
}, [value, key]);
|
}, [value, key]);
|
||||||
|
|
||||||
return [value, setValue];
|
return [value, setValue];
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const socket = io(URL, {
|
|||||||
|
|
||||||
export const updateValue = (
|
export const updateValue = (
|
||||||
serializedObject: SerializedObject,
|
serializedObject: SerializedObject,
|
||||||
callback?: (ack: unknown) => void,
|
callback?: (ack: undefined | SerializedObject) => void,
|
||||||
) => {
|
) => {
|
||||||
if (callback) {
|
if (callback) {
|
||||||
socket.emit(
|
socket.emit(
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ nav:
|
|||||||
- Getting Started: getting-started.md
|
- Getting Started: getting-started.md
|
||||||
- User Guide:
|
- User Guide:
|
||||||
- Components Guide: user-guide/Components.md
|
- Components Guide: user-guide/Components.md
|
||||||
- Interacting with pydase Services: user-guide/interaction/README.md
|
- Interaction:
|
||||||
|
- Overview: user-guide/interaction/README.md
|
||||||
|
- Auto-generated Frontend: user-guide/interaction/Auto-generated-Frontend.md
|
||||||
|
- RESTful API: user-guide/interaction/RESTful-API.md
|
||||||
|
- Python Client: user-guide/interaction/Python-Client.md
|
||||||
- Achieving Service Persistence: user-guide/Service_Persistence.md
|
- Achieving Service Persistence: user-guide/Service_Persistence.md
|
||||||
- Understanding Tasks: user-guide/Tasks.md
|
- Understanding Tasks: user-guide/Tasks.md
|
||||||
- Understanding Units: user-guide/Understanding-Units.md
|
- Understanding Units: user-guide/Understanding-Units.md
|
||||||
@@ -15,6 +19,7 @@ nav:
|
|||||||
- Logging in pydase: user-guide/Logging.md
|
- Logging in pydase: user-guide/Logging.md
|
||||||
- Advanced:
|
- Advanced:
|
||||||
- Deploying behind a Reverse Proxy: user-guide/advanced/Reverse-Proxy.md
|
- Deploying behind a Reverse Proxy: user-guide/advanced/Reverse-Proxy.md
|
||||||
|
- Connecting through a SOCKS Proxy: user-guide/advanced/SOCKS-Proxy.md
|
||||||
- Developer Guide:
|
- Developer Guide:
|
||||||
- Developer Guide: dev-guide/README.md
|
- Developer Guide: dev-guide/README.md
|
||||||
- API Reference: dev-guide/api.md
|
- API Reference: dev-guide/api.md
|
||||||
@@ -55,7 +60,7 @@ plugins:
|
|||||||
handlers:
|
handlers:
|
||||||
python:
|
python:
|
||||||
paths: [src] # search packages in the src folder
|
paths: [src] # search packages in the src folder
|
||||||
import:
|
inventories:
|
||||||
- https://docs.python.org/3/objects.inv
|
- https://docs.python.org/3/objects.inv
|
||||||
- https://docs.pydantic.dev/latest/objects.inv
|
- https://docs.pydantic.dev/latest/objects.inv
|
||||||
- https://confz.readthedocs.io/en/latest/objects.inv
|
- https://confz.readthedocs.io/en/latest/objects.inv
|
||||||
|
|||||||
3256
poetry.lock
generated
3256
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,56 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "pydase"
|
name = "pydase"
|
||||||
version = "0.10.9"
|
version = "0.10.19"
|
||||||
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."
|
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>"]
|
authors = [
|
||||||
|
{name = "Mose Müller",email = "mosemueller@gmail.com"}
|
||||||
|
]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [{ include = "pydase", from = "src" }]
|
requires-python = ">=3.10,<4.0"
|
||||||
|
dependencies = [
|
||||||
|
"toml (>=0.10.2,<0.11.0)",
|
||||||
|
"python-socketio (>=5.13.0,<6.0.0)",
|
||||||
|
"confz (>=2.1.0,<3.0.0)",
|
||||||
|
"pint (>=0.24.4,<0.25.0)",
|
||||||
|
"websocket-client (>=1.8.0,<2.0.0)",
|
||||||
|
"aiohttp (>=3.11.18,<4.0.0)",
|
||||||
|
"click (>=8.2.0,<9.0.0)",
|
||||||
|
"aiohttp-middlewares (>=2.4.0,<3.0.0)",
|
||||||
|
"anyio (>=4.9.0,<5.0.0)"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
socks = ["aiohttp-socks (>=0.10.1,<0.11.0)"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry]
|
||||||
python = "^3.10"
|
packages = [{include = "pydase", from = "src"}]
|
||||||
toml = "^0.10.2"
|
|
||||||
python-socketio = "^5.8.0"
|
|
||||||
confz = "^2.0.0"
|
|
||||||
pint = "^0.24"
|
|
||||||
websocket-client = "^1.7.0"
|
|
||||||
aiohttp = "^3.9.3"
|
|
||||||
click = "^8.1.7"
|
|
||||||
aiohttp-middlewares = "^2.3.0"
|
|
||||||
anyio = "^4.6.0"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev]
|
[tool.poetry.group.dev]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
types-toml = "^0.10.8.6"
|
types-toml = "^0.10.8.20240310"
|
||||||
pytest = "^7.4.0"
|
pytest = "^8.3.5"
|
||||||
pytest-cov = "^4.1.0"
|
pytest-cov = "^6.1.1"
|
||||||
mypy = "^1.4.1"
|
mypy = "^1.15.0"
|
||||||
matplotlib = "^3.7.2"
|
matplotlib = "^3.10.3"
|
||||||
pyright = "^1.1.323"
|
pyright = "^1.1.400"
|
||||||
pytest-mock = "^3.11.1"
|
pytest-mock = "^3.14.0"
|
||||||
ruff = "^0.5.0"
|
ruff = "^0.11.10"
|
||||||
pytest-asyncio = "^0.23.2"
|
pytest-asyncio = "^0.26.0"
|
||||||
|
|
||||||
[tool.poetry.group.docs]
|
[tool.poetry.group.docs]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[tool.poetry.group.docs.dependencies]
|
[tool.poetry.group.docs.dependencies]
|
||||||
mkdocs-material = "^9.5.30"
|
mkdocs-material = "^9.6.14"
|
||||||
mkdocs-include-markdown-plugin = "^3.9.1"
|
mkdocs-include-markdown-plugin = "^7.1.5"
|
||||||
mkdocstrings = {extras = ["python"], version = "^0.25.2"}
|
mkdocstrings = {extras = ["python"], version = "^0.29.1"}
|
||||||
pymdown-extensions = "^10.1"
|
pymdown-extensions = "^10.15"
|
||||||
mkdocs-swagger-ui-tag = "^0.6.10"
|
mkdocs-swagger-ui-tag = "^0.7.1"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
@@ -86,6 +92,7 @@ select = [
|
|||||||
ignore = [
|
ignore = [
|
||||||
"RUF006", # asyncio-dangling-task
|
"RUF006", # asyncio-dangling-task
|
||||||
"PERF203", # try-except-in-loop
|
"PERF203", # try-except-in-loop
|
||||||
|
"ASYNC110", # async-busy-wait
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.mccabe]
|
[tool.ruff.lint.mccabe]
|
||||||
@@ -104,3 +111,10 @@ disallow_incomplete_defs = true
|
|||||||
disallow_any_generics = true
|
disallow_any_generics = true
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
ignore_missing_imports = false
|
ignore_missing_imports = false
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
filterwarnings = [
|
||||||
|
# I don't controll the usage of the timeout
|
||||||
|
"ignore:parameter 'timeout' of type 'float' is deprecated, please use 'timeout=ClientWSTimeout"
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydase.utils.logging import setup_logging
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Client",
|
||||||
"DataService",
|
"DataService",
|
||||||
"Server",
|
"Server",
|
||||||
"Client",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from builtins import ModuleNotFoundError
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
import socketio # type: ignore
|
import socketio # type: ignore
|
||||||
|
|
||||||
from pydase.client.proxy_class import ProxyClass
|
from pydase.client.proxy_class import ProxyClass
|
||||||
from pydase.client.proxy_loader import ProxyLoader
|
from pydase.client.proxy_loader import (
|
||||||
|
ProxyLoader,
|
||||||
|
get_value,
|
||||||
|
trigger_method,
|
||||||
|
update_value,
|
||||||
|
)
|
||||||
from pydase.utils.serialization.deserializer import loads
|
from pydase.utils.serialization.deserializer import loads
|
||||||
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
|
from pydase.utils.serialization.types import SerializedDataService, SerializedObject
|
||||||
|
|
||||||
@@ -33,61 +41,74 @@ class NotifyDict(TypedDict):
|
|||||||
|
|
||||||
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop.run_forever()
|
try:
|
||||||
|
loop.run_forever()
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
"""
|
"""A client for connecting to a remote pydase service using Socket.IO. This client
|
||||||
A client for connecting to a remote pydase service using socket.io. This client
|
|
||||||
handles asynchronous communication with a service, manages events such as
|
handles asynchronous communication with a service, manages events such as
|
||||||
connection, disconnection, and updates, and ensures that the proxy object is
|
connection, disconnection, and updates, and ensures that the proxy object is
|
||||||
up-to-date with the server state.
|
up-to-date with the server state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url:
|
url: The URL of the pydase Socket.IO server. This should always contain the
|
||||||
The URL of the pydase Socket.IO server. This should always contain the
|
protocol (e.g., `ws` or `wss`) and the hostname, and can optionally include
|
||||||
protocol and the hostname.
|
a path prefix (e.g., `ws://localhost:8001/service`).
|
||||||
block_until_connected:
|
block_until_connected: If set to True, the constructor will block until the
|
||||||
If set to True, the constructor will block until the connection to the
|
connection to the service has been established. This is useful for ensuring
|
||||||
service has been established. This is useful for ensuring the client is
|
the client is ready to use immediately after instantiation. Default is True.
|
||||||
ready to use immediately after instantiation. Default is True.
|
sio_client_kwargs: Additional keyword arguments passed to the underlying
|
||||||
sio_client_kwargs:
|
|
||||||
Additional keyword arguments passed to the underlying
|
|
||||||
[`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the
|
[`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the
|
||||||
client's behaviour (e.g., reconnection attempts or reconnection delay).
|
client's behaviour (e.g., reconnection attempts or reconnection delay).
|
||||||
Default is an empty dictionary.
|
client_id: An optional client identifier. This ID is sent to the server as the
|
||||||
client_id: Client identification that will be shown in the server logs this
|
`X-Client-Id` HTTP header. It can be used for logging or authentication
|
||||||
client is connecting to. This ID is passed as a `X-Client-Id` header in the
|
purposes on the server side. If not provided, it defaults to the hostname
|
||||||
HTTP(s) request. Defaults to None.
|
of the machine running the client.
|
||||||
|
proxy_url: An optional proxy URL to route the connection through. This is useful
|
||||||
|
if the service is only reachable via an SSH tunnel or behind a firewall
|
||||||
|
(e.g., `socks5://localhost:2222`).
|
||||||
|
auto_update_proxy: If False, disables automatic updates from the server. Useful
|
||||||
|
for request-only clients where real-time synchronization is not needed.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
The following example demonstrates a `Client` instance that connects to another
|
Connect to a service directly:
|
||||||
pydase service, while customising some of the connection settings for the
|
|
||||||
underlying [`AsyncClient`][socketio.AsyncClient].
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
pydase.Client(url="ws://localhost:8001", sio_client_kwargs={
|
client = pydase.Client(url="ws://localhost:8001")
|
||||||
"reconnection_attempts": 2,
|
|
||||||
"reconnection_delay": 2,
|
|
||||||
"reconnection_delay_max": 8,
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
When connecting to a server over a secure connection (i.e., the server is using
|
Connect over a secure connection:
|
||||||
SSL/TLS encryption), make sure that the `wss` protocol is used instead of `ws`:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
pydase.Client(url="wss://my-service.example.com")
|
client = pydase.Client(url="wss://my-service.example.com")
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect using a SOCKS5 proxy (e.g., through an SSH tunnel):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -D 2222 user@gateway.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = pydase.Client(
|
||||||
|
url="ws://remote-server:8001",
|
||||||
|
proxy_url="socks5://localhost:2222"
|
||||||
|
)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
url: str,
|
url: str,
|
||||||
block_until_connected: bool = True,
|
block_until_connected: bool = True,
|
||||||
sio_client_kwargs: dict[str, Any] = {},
|
sio_client_kwargs: dict[str, Any] = {},
|
||||||
client_id: str | None = None,
|
client_id: str | None = None,
|
||||||
|
proxy_url: str | None = None,
|
||||||
|
auto_update_proxy: bool = True, # new argument
|
||||||
):
|
):
|
||||||
# Parse the URL to separate base URL and path prefix
|
# Parse the URL to separate base URL and path prefix
|
||||||
parsed_url = urllib.parse.urlparse(url)
|
parsed_url = urllib.parse.urlparse(url)
|
||||||
@@ -100,18 +121,15 @@ class Client:
|
|||||||
# Store the path prefix (e.g., "/service" in "ws://localhost:8081/service")
|
# Store the path prefix (e.g., "/service" in "ws://localhost:8081/service")
|
||||||
self._path_prefix = parsed_url.path.rstrip("/") # Remove trailing slash if any
|
self._path_prefix = parsed_url.path.rstrip("/") # Remove trailing slash if any
|
||||||
self._url = url
|
self._url = url
|
||||||
self._sio = socketio.AsyncClient(**sio_client_kwargs)
|
self._proxy_url = proxy_url
|
||||||
self._loop = asyncio.new_event_loop()
|
self._client_id = client_id or socket.gethostname()
|
||||||
self._client_id = client_id
|
self._sio_client_kwargs = sio_client_kwargs
|
||||||
self.proxy = ProxyClass(
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
sio_client=self._sio, loop=self._loop, reconnect=self.connect
|
self._thread: threading.Thread | None = None
|
||||||
)
|
self._auto_update_proxy = auto_update_proxy
|
||||||
|
self.proxy: ProxyClass
|
||||||
"""A proxy object representing the remote service, facilitating interaction as
|
"""A proxy object representing the remote service, facilitating interaction as
|
||||||
if it were local."""
|
if it were local."""
|
||||||
self._thread = threading.Thread(
|
|
||||||
target=asyncio_loop_thread, args=(self._loop,), daemon=True
|
|
||||||
)
|
|
||||||
self._thread.start()
|
|
||||||
self.connect(block_until_connected=block_until_connected)
|
self.connect(block_until_connected=block_until_connected)
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
@@ -126,17 +144,72 @@ class Client:
|
|||||||
self.disconnect()
|
self.disconnect()
|
||||||
|
|
||||||
def connect(self, block_until_connected: bool = True) -> None:
|
def connect(self, block_until_connected: bool = True) -> None:
|
||||||
|
if self._thread is None or self._loop is None:
|
||||||
|
self._loop = self._initialize_loop_and_thread()
|
||||||
|
self._initialize_socketio_client()
|
||||||
|
self.proxy = ProxyClass(
|
||||||
|
sio_client=self._sio,
|
||||||
|
loop=self._loop,
|
||||||
|
reconnect=self.connect,
|
||||||
|
)
|
||||||
|
|
||||||
connection_future = asyncio.run_coroutine_threadsafe(
|
connection_future = asyncio.run_coroutine_threadsafe(
|
||||||
self._connect(), self._loop
|
self._connect(), self._loop
|
||||||
)
|
)
|
||||||
if block_until_connected:
|
if block_until_connected:
|
||||||
connection_future.result()
|
connection_future.result()
|
||||||
|
|
||||||
def disconnect(self) -> None:
|
def _initialize_socketio_client(self) -> None:
|
||||||
connection_future = asyncio.run_coroutine_threadsafe(
|
if self._proxy_url is not None:
|
||||||
self._disconnect(), self._loop
|
try:
|
||||||
|
import aiohttp_socks.connector
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
raise ModuleNotFoundError(
|
||||||
|
"Missing dependency 'aiohttp_socks'. To use SOCKS5 proxy support, "
|
||||||
|
"install the optional 'socks' extra:\n\n"
|
||||||
|
' pip install "pydase[socks]"\n\n'
|
||||||
|
"This is required when specifying a `proxy_url` for "
|
||||||
|
"`pydase.Client`."
|
||||||
|
)
|
||||||
|
|
||||||
|
session = aiohttp.ClientSession(
|
||||||
|
connector=aiohttp_socks.connector.ProxyConnector.from_url(
|
||||||
|
url=self._proxy_url, loop=self._loop
|
||||||
|
),
|
||||||
|
loop=self._loop,
|
||||||
|
)
|
||||||
|
self._sio = socketio.AsyncClient(
|
||||||
|
http_session=session, **self._sio_client_kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._sio = socketio.AsyncClient(**self._sio_client_kwargs)
|
||||||
|
|
||||||
|
def _initialize_loop_and_thread(self) -> asyncio.AbstractEventLoop:
|
||||||
|
"""Initialize a new asyncio event loop, start it in a background thread,
|
||||||
|
and create the ProxyClass instance bound to that loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=asyncio_loop_thread,
|
||||||
|
args=(loop,),
|
||||||
|
daemon=True,
|
||||||
)
|
)
|
||||||
connection_future.result()
|
self._thread.start()
|
||||||
|
|
||||||
|
return loop
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
if self._loop is not None and self._thread is not None:
|
||||||
|
connection_future = asyncio.run_coroutine_threadsafe(
|
||||||
|
self._disconnect(), self._loop
|
||||||
|
)
|
||||||
|
connection_future.result()
|
||||||
|
|
||||||
|
# Stop the event loop and thread
|
||||||
|
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||||
|
self._thread.join()
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
async def _connect(self) -> None:
|
||||||
logger.debug("Connecting to server '%s' ...", self._url)
|
logger.debug("Connecting to server '%s' ...", self._url)
|
||||||
@@ -160,24 +233,25 @@ class Client:
|
|||||||
async def _setup_events(self) -> None:
|
async def _setup_events(self) -> None:
|
||||||
self._sio.on("connect", self._handle_connect)
|
self._sio.on("connect", self._handle_connect)
|
||||||
self._sio.on("disconnect", self._handle_disconnect)
|
self._sio.on("disconnect", self._handle_disconnect)
|
||||||
self._sio.on("notify", self._handle_update)
|
if self._auto_update_proxy:
|
||||||
|
self._sio.on("notify", self._handle_update)
|
||||||
|
|
||||||
async def _handle_connect(self) -> None:
|
async def _handle_connect(self) -> None:
|
||||||
logger.debug("Connected to '%s' ...", self._url)
|
logger.debug("Connected to '%s' ...", self._url)
|
||||||
serialized_object = cast(
|
if self._auto_update_proxy:
|
||||||
SerializedDataService, await self._sio.call("service_serialization")
|
serialized_object = cast(
|
||||||
)
|
"SerializedDataService", await self._sio.call("service_serialization")
|
||||||
ProxyLoader.update_data_service_proxy(
|
)
|
||||||
self.proxy, serialized_object=serialized_object
|
ProxyLoader.update_data_service_proxy(
|
||||||
)
|
self.proxy, serialized_object=serialized_object
|
||||||
serialized_object["type"] = "DeviceConnection"
|
)
|
||||||
if self.proxy._service_representation is not None:
|
serialized_object["type"] = "DeviceConnection"
|
||||||
# need to use object.__setattr__ to not trigger an observer notification
|
# need to use object.__setattr__ to not trigger an observer notification
|
||||||
object.__setattr__(self.proxy, "_service_representation", serialized_object)
|
object.__setattr__(self.proxy, "_service_representation", serialized_object)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
self.proxy._service_representation = serialized_object # type: ignore
|
self.proxy._service_representation = serialized_object # type: ignore
|
||||||
self.proxy._notify_changed("", self.proxy)
|
self.proxy._notify_changed("", self.proxy)
|
||||||
self.proxy._connected = True
|
self.proxy._connected = True
|
||||||
|
|
||||||
async def _handle_disconnect(self) -> None:
|
async def _handle_disconnect(self) -> None:
|
||||||
@@ -189,3 +263,77 @@ class Client:
|
|||||||
data["data"]["full_access_path"],
|
data["data"]["full_access_path"],
|
||||||
loads(data["data"]["value"]),
|
loads(data["data"]["value"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_value(self, access_path: str) -> Any:
|
||||||
|
"""Retrieve the current value of a remote attribute.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_path: The dot-separated path to the attribute in the remote service.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The deserialized value of the remote attribute, or None if the client is not
|
||||||
|
connected.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
value = client.get_value("my_device.temperature")
|
||||||
|
print(value)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._loop is not None:
|
||||||
|
return get_value(
|
||||||
|
sio_client=self._sio,
|
||||||
|
loop=self._loop,
|
||||||
|
access_path=access_path,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_value(self, access_path: str, new_value: Any) -> Any:
|
||||||
|
"""Set a new value for a remote attribute.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_path: The dot-separated path to the attribute in the remote service.
|
||||||
|
new_value: The new value to assign to the attribute.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
client.update_value("my_device.power", True)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._loop is not None:
|
||||||
|
update_value(
|
||||||
|
sio_client=self._sio,
|
||||||
|
loop=self._loop,
|
||||||
|
access_path=access_path,
|
||||||
|
value=new_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
def trigger_method(self, access_path: str, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
"""Trigger a remote method with optional arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_path: The dot-separated path to the method in the remote service.
|
||||||
|
*args: Positional arguments to pass to the method.
|
||||||
|
**kwargs: Keyword arguments to pass to the method.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The return value of the method call, if any.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
result = client.trigger_method("my_device.calibrate", timeout=5)
|
||||||
|
print(result)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._loop is not None:
|
||||||
|
return trigger_method(
|
||||||
|
sio_client=self._sio,
|
||||||
|
loop=self._loop,
|
||||||
|
access_path=access_path,
|
||||||
|
args=list(args),
|
||||||
|
kwargs=kwargs,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -65,22 +65,34 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
|
|||||||
self.reconnect = reconnect
|
self.reconnect = reconnect
|
||||||
|
|
||||||
def serialize(self) -> SerializedObject:
|
def serialize(self) -> SerializedObject:
|
||||||
if self._service_representation is None:
|
current_loop = asyncio.get_event_loop()
|
||||||
serialization_future = cast(
|
|
||||||
asyncio.Future[SerializedDataService],
|
if not self.connected or current_loop == self._loop:
|
||||||
|
logger.debug(
|
||||||
|
"Client not connected, or called from within client event loop - using "
|
||||||
|
"fallback serialization"
|
||||||
|
)
|
||||||
|
if self._service_representation is None:
|
||||||
|
serialized_service = pydase.components.DeviceConnection().serialize()
|
||||||
|
else:
|
||||||
|
serialized_service = self._service_representation
|
||||||
|
|
||||||
|
else:
|
||||||
|
future = cast(
|
||||||
|
"asyncio.Future[SerializedDataService]",
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._sio.call("service_serialization"), self._loop
|
self._sio.call("service_serialization"), self._loop
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
result = future.result()
|
||||||
# need to use object.__setattr__ to not trigger an observer notification
|
# need to use object.__setattr__ to not trigger an observer notification
|
||||||
object.__setattr__(
|
object.__setattr__(self, "_service_representation", result)
|
||||||
self, "_service_representation", serialization_future.result()
|
|
||||||
)
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
self._service_representation = serialization_future.result()
|
self._service_representation = result
|
||||||
|
serialized_service = result
|
||||||
|
|
||||||
device_connection_value = cast(
|
device_connection_value = cast(
|
||||||
dict[str, SerializedObject],
|
"dict[str, SerializedObject]",
|
||||||
pydase.components.DeviceConnection().serialize()["value"],
|
pydase.components.DeviceConnection().serialize()["value"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,10 +102,10 @@ class ProxyClass(ProxyClassMixin, pydase.components.DeviceConnection):
|
|||||||
|
|
||||||
value = {
|
value = {
|
||||||
**cast(
|
**cast(
|
||||||
dict[str, SerializedObject],
|
"dict[str, SerializedObject]",
|
||||||
# need to deepcopy to not overwrite the _service_representation dict
|
# need to deepcopy to not overwrite the _service_representation dict
|
||||||
# when adding a prefix with add_prefix_to_full_access_path
|
# when adding a prefix with add_prefix_to_full_access_path
|
||||||
deepcopy(self._service_representation["value"]),
|
deepcopy(serialized_service["value"]),
|
||||||
),
|
),
|
||||||
**device_connection_value,
|
**device_connection_value,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ def update_value(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_value(
|
||||||
|
sio_client: socketio.AsyncClient,
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
access_path: str,
|
||||||
|
) -> Any:
|
||||||
|
async def get_result() -> Any:
|
||||||
|
return await sio_client.call("get_value", access_path)
|
||||||
|
|
||||||
|
result = asyncio.run_coroutine_threadsafe(
|
||||||
|
get_result(),
|
||||||
|
loop=loop,
|
||||||
|
).result()
|
||||||
|
return ProxyLoader.loads_proxy(result, sio_client, loop)
|
||||||
|
|
||||||
|
|
||||||
class ProxyDict(dict[str, Any]):
|
class ProxyDict(dict[str, Any]):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -123,35 +138,35 @@ class ProxyList(list[Any]):
|
|||||||
|
|
||||||
update_value(self._sio, self._loop, full_access_path, value)
|
update_value(self._sio, self._loop, full_access_path, value)
|
||||||
|
|
||||||
def append(self, __object: Any) -> None:
|
def append(self, object_: Any, /) -> None:
|
||||||
full_access_path = f"{self._parent_path}.append"
|
full_access_path = f"{self._parent_path}.append"
|
||||||
|
|
||||||
trigger_method(self._sio, self._loop, full_access_path, [__object], {})
|
trigger_method(self._sio, self._loop, full_access_path, [object_], {})
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
full_access_path = f"{self._parent_path}.clear"
|
full_access_path = f"{self._parent_path}.clear"
|
||||||
|
|
||||||
trigger_method(self._sio, self._loop, full_access_path, [], {})
|
trigger_method(self._sio, self._loop, full_access_path, [], {})
|
||||||
|
|
||||||
def extend(self, __iterable: Iterable[Any]) -> None:
|
def extend(self, iterable: Iterable[Any], /) -> None:
|
||||||
full_access_path = f"{self._parent_path}.extend"
|
full_access_path = f"{self._parent_path}.extend"
|
||||||
|
|
||||||
trigger_method(self._sio, self._loop, full_access_path, [__iterable], {})
|
trigger_method(self._sio, self._loop, full_access_path, [iterable], {})
|
||||||
|
|
||||||
def insert(self, __index: SupportsIndex, __object: Any) -> None:
|
def insert(self, index: SupportsIndex, object_: Any, /) -> None:
|
||||||
full_access_path = f"{self._parent_path}.insert"
|
full_access_path = f"{self._parent_path}.insert"
|
||||||
|
|
||||||
trigger_method(self._sio, self._loop, full_access_path, [__index, __object], {})
|
trigger_method(self._sio, self._loop, full_access_path, [index, object_], {})
|
||||||
|
|
||||||
def pop(self, __index: SupportsIndex = -1) -> Any:
|
def pop(self, index: SupportsIndex = -1, /) -> Any:
|
||||||
full_access_path = f"{self._parent_path}.pop"
|
full_access_path = f"{self._parent_path}.pop"
|
||||||
|
|
||||||
return trigger_method(self._sio, self._loop, full_access_path, [__index], {})
|
return trigger_method(self._sio, self._loop, full_access_path, [index], {})
|
||||||
|
|
||||||
def remove(self, __value: Any) -> None:
|
def remove(self, value: Any, /) -> None:
|
||||||
full_access_path = f"{self._parent_path}.remove"
|
full_access_path = f"{self._parent_path}.remove"
|
||||||
|
|
||||||
trigger_method(self._sio, self._loop, full_access_path, [__value], {})
|
trigger_method(self._sio, self._loop, full_access_path, [value], {})
|
||||||
|
|
||||||
|
|
||||||
class ProxyClassMixin:
|
class ProxyClassMixin:
|
||||||
@@ -242,16 +257,11 @@ class ProxyClassMixin:
|
|||||||
self, attr_name: str, serialized_object: SerializedObject
|
self, attr_name: str, serialized_object: SerializedObject
|
||||||
) -> None:
|
) -> None:
|
||||||
def getter_proxy() -> Any:
|
def getter_proxy() -> Any:
|
||||||
async def get_result() -> Any:
|
return get_value(
|
||||||
return await self._sio.call(
|
sio_client=self._sio,
|
||||||
"get_value", serialized_object["full_access_path"]
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run_coroutine_threadsafe(
|
|
||||||
get_result(),
|
|
||||||
loop=self._loop,
|
loop=self._loop,
|
||||||
).result()
|
access_path=serialized_object["full_access_path"],
|
||||||
return ProxyLoader.loads_proxy(result, self._sio, self._loop)
|
)
|
||||||
|
|
||||||
dict.__setitem__(self._proxy_getters, attr_name, getter_proxy) # type: ignore
|
dict.__setitem__(self._proxy_getters, attr_name, getter_proxy) # type: ignore
|
||||||
|
|
||||||
@@ -266,7 +276,7 @@ class ProxyLoader:
|
|||||||
return ProxyList(
|
return ProxyList(
|
||||||
[
|
[
|
||||||
ProxyLoader.loads_proxy(item, sio_client, loop)
|
ProxyLoader.loads_proxy(item, sio_client, loop)
|
||||||
for item in cast(list[SerializedObject], serialized_object["value"])
|
for item in cast("list[SerializedObject]", serialized_object["value"])
|
||||||
],
|
],
|
||||||
parent_path=serialized_object["full_access_path"],
|
parent_path=serialized_object["full_access_path"],
|
||||||
sio_client=sio_client,
|
sio_client=sio_client,
|
||||||
@@ -283,7 +293,7 @@ class ProxyLoader:
|
|||||||
{
|
{
|
||||||
key: ProxyLoader.loads_proxy(value, sio_client, loop)
|
key: ProxyLoader.loads_proxy(value, sio_client, loop)
|
||||||
for key, value in cast(
|
for key, value in cast(
|
||||||
dict[str, SerializedObject], serialized_object["value"]
|
"dict[str, SerializedObject]", serialized_object["value"]
|
||||||
).items()
|
).items()
|
||||||
},
|
},
|
||||||
parent_path=serialized_object["full_access_path"],
|
parent_path=serialized_object["full_access_path"],
|
||||||
@@ -300,7 +310,7 @@ class ProxyLoader:
|
|||||||
proxy_class._proxy_setters.clear()
|
proxy_class._proxy_setters.clear()
|
||||||
proxy_class._proxy_methods.clear()
|
proxy_class._proxy_methods.clear()
|
||||||
for key, value in cast(
|
for key, value in cast(
|
||||||
dict[str, SerializedObject], serialized_object["value"]
|
"dict[str, SerializedObject]", serialized_object["value"]
|
||||||
).items():
|
).items():
|
||||||
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
type_handler: dict[str | None, None | Callable[..., Any]] = {
|
||||||
None: None,
|
None: None,
|
||||||
@@ -333,7 +343,7 @@ class ProxyLoader:
|
|||||||
) -> Any:
|
) -> Any:
|
||||||
# Custom types like Components or DataService classes
|
# Custom types like Components or DataService classes
|
||||||
component_class = cast(
|
component_class = cast(
|
||||||
type, Deserializer.get_service_base_class(serialized_object["type"])
|
"type", Deserializer.get_service_base_class(serialized_object["type"])
|
||||||
)
|
)
|
||||||
class_bases = (
|
class_bases = (
|
||||||
ProxyClassMixin,
|
ProxyClassMixin,
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ from pydase.components.image import Image
|
|||||||
from pydase.components.number_slider import NumberSlider
|
from pydase.components.number_slider import NumberSlider
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NumberSlider",
|
|
||||||
"Image",
|
|
||||||
"ColouredEnum",
|
"ColouredEnum",
|
||||||
"DeviceConnection",
|
"DeviceConnection",
|
||||||
|
"Image",
|
||||||
|
"NumberSlider",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from pydase.utils.helpers import (
|
|||||||
is_property_attribute,
|
is_property_attribute,
|
||||||
)
|
)
|
||||||
from pydase.utils.serialization.serializer import (
|
from pydase.utils.serialization.serializer import (
|
||||||
SerializedObject,
|
|
||||||
Serializer,
|
Serializer,
|
||||||
)
|
)
|
||||||
|
from pydase.utils.serialization.types import SerializedObject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,17 +27,17 @@ class DataService(AbstractDataService):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.__check_instance_classes()
|
self.__check_instance_classes()
|
||||||
|
|
||||||
def __setattr__(self, __name: str, __value: Any) -> None:
|
def __setattr__(self, name: str, value: Any, /) -> None:
|
||||||
# Check and warn for unexpected type changes in attributes
|
# Check and warn for unexpected type changes in attributes
|
||||||
self._warn_on_type_change(__name, __value)
|
self._warn_on_type_change(name, value)
|
||||||
|
|
||||||
# every class defined by the user should inherit from DataService if it is
|
# every class defined by the user should inherit from DataService if it is
|
||||||
# assigned to a public attribute
|
# assigned to a public attribute
|
||||||
if not __name.startswith("_") and not inspect.isfunction(__value):
|
if not name.startswith("_") and not inspect.isfunction(value):
|
||||||
self.__warn_if_not_observable(__value)
|
self.__warn_if_not_observable(value)
|
||||||
|
|
||||||
# Set the attribute
|
# Set the attribute
|
||||||
super().__setattr__(__name, __value)
|
super().__setattr__(name, value)
|
||||||
|
|
||||||
def _warn_on_type_change(self, attr_name: str, new_value: Any) -> None:
|
def _warn_on_type_change(self, attr_name: str, new_value: Any) -> None:
|
||||||
if is_property_attribute(self, attr_name):
|
if is_property_attribute(self, attr_name):
|
||||||
@@ -56,16 +56,14 @@ class DataService(AbstractDataService):
|
|||||||
|
|
||||||
def _is_unexpected_type_change(self, current_value: Any, new_value: Any) -> bool:
|
def _is_unexpected_type_change(self, current_value: Any, new_value: Any) -> bool:
|
||||||
return (
|
return (
|
||||||
isinstance(current_value, float)
|
isinstance(current_value, float) and not isinstance(new_value, float)
|
||||||
and not isinstance(new_value, float)
|
) or (
|
||||||
or (
|
isinstance(current_value, u.Quantity)
|
||||||
isinstance(current_value, u.Quantity)
|
and not isinstance(new_value, u.Quantity)
|
||||||
and not isinstance(new_value, u.Quantity)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __warn_if_not_observable(self, __value: Any) -> None:
|
def __warn_if_not_observable(self, value: Any, /) -> None:
|
||||||
value_class = __value if inspect.isclass(__value) else __value.__class__
|
value_class = value if inspect.isclass(value) else value.__class__
|
||||||
|
|
||||||
if not issubclass(
|
if not issubclass(
|
||||||
value_class,
|
value_class,
|
||||||
@@ -81,7 +79,7 @@ class DataService(AbstractDataService):
|
|||||||
| Observable
|
| Observable
|
||||||
| Callable
|
| Callable
|
||||||
),
|
),
|
||||||
) and not is_descriptor(__value):
|
) and not is_descriptor(value):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Class '%s' does not inherit from DataService. This may lead to"
|
"Class '%s' does not inherit from DataService. This may lead to"
|
||||||
" unexpected behaviour!",
|
" unexpected behaviour!",
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import logging
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from pydase.utils.serialization.serializer import (
|
from pydase.utils.serialization.serializer import (
|
||||||
SerializedObject,
|
|
||||||
get_nested_dict_by_path,
|
get_nested_dict_by_path,
|
||||||
set_nested_value_by_path,
|
set_nested_value_by_path,
|
||||||
)
|
)
|
||||||
|
from pydase.utils.serialization.types import SerializedObject
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pydase import DataService
|
from pydase import DataService
|
||||||
@@ -14,6 +14,22 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class DataServiceCache:
|
class DataServiceCache:
|
||||||
|
"""Maintains a serialized cache of the current state of a DataService instance.
|
||||||
|
|
||||||
|
This class is responsible for storing and updating a representation of the service's
|
||||||
|
public attributes and properties. It is primarily used by the StateManager and the
|
||||||
|
web server to serve consistent state to clients without accessing the DataService
|
||||||
|
attributes directly.
|
||||||
|
|
||||||
|
The cache is initialized once upon construction by serializing the full state of
|
||||||
|
the service. After that, it can be incrementally updated using attribute paths and
|
||||||
|
values as notified by the
|
||||||
|
[`DataServiceObserver`][pydase.data_service.data_service_observer.DataServiceObserver].
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service: The DataService instance whose state should be cached.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, service: "DataService") -> None:
|
def __init__(self, service: "DataService") -> None:
|
||||||
self._cache: SerializedObject
|
self._cache: SerializedObject
|
||||||
self.service = service
|
self.service = service
|
||||||
@@ -30,13 +46,13 @@ class DataServiceCache:
|
|||||||
|
|
||||||
def update_cache(self, full_access_path: str, value: Any) -> None:
|
def update_cache(self, full_access_path: str, value: Any) -> None:
|
||||||
set_nested_value_by_path(
|
set_nested_value_by_path(
|
||||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
cast("dict[str, SerializedObject]", self._cache["value"]),
|
||||||
full_access_path,
|
full_access_path,
|
||||||
value,
|
value,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
|
def get_value_dict_from_cache(self, full_access_path: str) -> SerializedObject:
|
||||||
return get_nested_dict_by_path(
|
return get_nested_dict_by_path(
|
||||||
cast(dict[str, SerializedObject], self._cache["value"]),
|
cast("dict[str, SerializedObject]", self._cache["value"]),
|
||||||
full_access_path,
|
full_access_path,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,13 +13,26 @@ from pydase.utils.helpers import (
|
|||||||
)
|
)
|
||||||
from pydase.utils.serialization.serializer import (
|
from pydase.utils.serialization.serializer import (
|
||||||
SerializationPathError,
|
SerializationPathError,
|
||||||
SerializedObject,
|
|
||||||
dump,
|
dump,
|
||||||
)
|
)
|
||||||
|
from pydase.utils.serialization.types import SerializedObject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nested_attribute(full_access_path: str, changing_attributes: list[str]) -> bool:
|
||||||
|
"""Return True if the full_access_path is a nested attribute of any
|
||||||
|
changing_attribute."""
|
||||||
|
|
||||||
|
return any(
|
||||||
|
(
|
||||||
|
full_access_path.startswith((f"{attr}.", f"{attr}["))
|
||||||
|
and full_access_path != attr
|
||||||
|
)
|
||||||
|
for attr in changing_attributes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DataServiceObserver(PropertyObserver):
|
class DataServiceObserver(PropertyObserver):
|
||||||
def __init__(self, state_manager: StateManager) -> None:
|
def __init__(self, state_manager: StateManager) -> None:
|
||||||
self.state_manager = state_manager
|
self.state_manager = state_manager
|
||||||
@@ -29,11 +42,7 @@ class DataServiceObserver(PropertyObserver):
|
|||||||
super().__init__(state_manager.service)
|
super().__init__(state_manager.service)
|
||||||
|
|
||||||
def on_change(self, full_access_path: str, value: Any) -> None:
|
def on_change(self, full_access_path: str, value: Any) -> None:
|
||||||
if any(
|
if _is_nested_attribute(full_access_path, self.changing_attributes):
|
||||||
full_access_path.startswith(changing_attribute)
|
|
||||||
and full_access_path != changing_attribute
|
|
||||||
for changing_attribute in self.changing_attributes
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
cached_value_dict: SerializedObject
|
cached_value_dict: SerializedObject
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -16,11 +17,11 @@ from pydase.utils.helpers import (
|
|||||||
from pydase.utils.serialization.deserializer import loads
|
from pydase.utils.serialization.deserializer import loads
|
||||||
from pydase.utils.serialization.serializer import (
|
from pydase.utils.serialization.serializer import (
|
||||||
SerializationPathError,
|
SerializationPathError,
|
||||||
SerializedObject,
|
|
||||||
generate_serialized_data_paths,
|
generate_serialized_data_paths,
|
||||||
get_nested_dict_by_path,
|
get_nested_dict_by_path,
|
||||||
serialized_dict_is_nested_object,
|
serialized_dict_is_nested_object,
|
||||||
)
|
)
|
||||||
|
from pydase.utils.serialization.types import SerializedObject
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pydase import DataService
|
from pydase import DataService
|
||||||
@@ -66,43 +67,41 @@ def has_load_state_decorator(prop: property) -> bool:
|
|||||||
class StateManager:
|
class StateManager:
|
||||||
"""
|
"""
|
||||||
Manages the state of a DataService instance, serving as both a cache and a
|
Manages the state of a DataService instance, serving as both a cache and a
|
||||||
persistence layer. It is designed to provide quick access to the latest known state
|
persistence layer. It provides fast access to the most recently known state of the
|
||||||
for newly connecting web clients without the need for expensive property accesses
|
service and ensures consistent state updates across connected clients and service
|
||||||
that may involve complex calculations or I/O operations.
|
restarts.
|
||||||
|
|
||||||
The StateManager listens for state change notifications from the DataService's
|
The StateManager is used by the web server to apply updates to service attributes
|
||||||
callback manager and updates its cache accordingly. This cache does not always
|
and to serve the current state to newly connected clients. Internally, it creates a
|
||||||
reflect the most current complex property states but rather retains the value from
|
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache]
|
||||||
the last known state, optimizing for performance and reducing the load on the
|
instance to track the state of public attributes and properties.
|
||||||
system.
|
|
||||||
|
|
||||||
While the StateManager ensures that the cached state is as up-to-date as possible,
|
The StateManager also handles state persistence: it can load a previously saved
|
||||||
it does not autonomously update complex properties of the DataService. Such
|
state from disk at startup and periodically autosave the current state to a file
|
||||||
properties must be updated programmatically, for instance, by invoking specific
|
during runtime.
|
||||||
tasks or methods that trigger the necessary operations to refresh their state.
|
|
||||||
|
|
||||||
The cached state maintained by the StateManager is particularly useful for web
|
|
||||||
clients that connect to the system and need immediate access to the current state of
|
|
||||||
the DataService. By avoiding direct and potentially costly property accesses, the
|
|
||||||
StateManager provides a snapshot of the DataService's state that is sufficiently
|
|
||||||
accurate for initial rendering and interaction.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service:
|
service: The DataService instance whose state is being managed.
|
||||||
The DataService instance whose state is being managed.
|
filename: The file name used for loading and storing the DataService's state.
|
||||||
filename:
|
If provided, the state is loaded from this file at startup and saved to it
|
||||||
The file name used for storing the DataService's state.
|
on shutdown or at regular intervals.
|
||||||
|
autosave_interval: Interval in seconds between automatic state save events.
|
||||||
|
If set to `None`, automatic saving is disabled.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
The StateManager's cache updates are triggered by notifications and do not
|
The StateManager does not autonomously poll hardware state. It relies on the
|
||||||
include autonomous updates of complex DataService properties, which must be
|
service to perform such updates. The cache maintained by
|
||||||
managed programmatically. The cache serves the purpose of providing immediate
|
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache]
|
||||||
state information to web clients, reflecting the state after the last property
|
reflects the last known state as notified by the `DataServiceObserver`, and is
|
||||||
update.
|
used by the web interface to provide fast and accurate state rendering for
|
||||||
|
connected clients.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, service: "DataService", filename: str | Path | None = None
|
self,
|
||||||
|
service: "DataService",
|
||||||
|
filename: str | Path | None = None,
|
||||||
|
autosave_interval: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.filename = getattr(service, "_filename", None)
|
self.filename = getattr(service, "_filename", None)
|
||||||
|
|
||||||
@@ -115,30 +114,51 @@ class StateManager:
|
|||||||
|
|
||||||
self.service = service
|
self.service = service
|
||||||
self.cache_manager = DataServiceCache(self.service)
|
self.cache_manager = DataServiceCache(self.service)
|
||||||
|
self.autosave_interval = autosave_interval
|
||||||
|
|
||||||
|
async def autosave(self) -> None:
|
||||||
|
"""Periodically saves the current service state to the configured file.
|
||||||
|
|
||||||
|
This coroutine is automatically started by the [`pydase.Server`][pydase.Server]
|
||||||
|
when a filename is provided. It runs in the background and writes the latest
|
||||||
|
known state of the service to disk every `autosave_interval` seconds.
|
||||||
|
|
||||||
|
If `autosave_interval` is set to `None`, autosaving is disabled and this
|
||||||
|
coroutine exits immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.autosave_interval is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if self.filename is not None:
|
||||||
|
self.save_state()
|
||||||
|
await asyncio.sleep(self.autosave_interval)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cache_value(self) -> dict[str, SerializedObject]:
|
def cache_value(self) -> dict[str, SerializedObject]:
|
||||||
"""Returns the "value" value of the DataService serialization."""
|
"""Returns the "value" value of the DataService serialization."""
|
||||||
return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
|
return cast("dict[str, SerializedObject]", self.cache_manager.cache["value"])
|
||||||
|
|
||||||
def save_state(self) -> None:
|
def save_state(self) -> None:
|
||||||
"""
|
"""Saves the DataService's current state to a JSON file defined by
|
||||||
Saves the DataService's current state to a JSON file defined by `self.filename`.
|
`self.filename`.
|
||||||
Logs an error if `self.filename` is not set.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.filename is not None:
|
if self.filename is not None:
|
||||||
with open(self.filename, "w") as f:
|
with open(self.filename, "w") as f:
|
||||||
json.dump(self.cache_value, f, indent=4)
|
json.dump(self.cache_value, f, indent=4)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"State manager was not initialised with a filename. Skipping "
|
"State manager was not initialised with a filename. Skipping "
|
||||||
"'save_state'..."
|
"'save_state'..."
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_state(self) -> None:
|
def load_state(self) -> None:
|
||||||
"""
|
"""Loads the DataService's state from a JSON file defined by `self.filename`.
|
||||||
Loads the DataService's state from a JSON file defined by `self.filename`.
|
|
||||||
Updates the service's attributes, respecting type and read-only constraints.
|
Updates the service's attributes, respecting type and read-only constraints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -183,7 +203,7 @@ class StateManager:
|
|||||||
with open(self.filename) as f:
|
with open(self.filename) as f:
|
||||||
# Load JSON data from file and update class attributes with these
|
# Load JSON data from file and update class attributes with these
|
||||||
# values
|
# values
|
||||||
return cast(dict[str, Any], json.load(f))
|
return cast("dict[str, Any]", json.load(f))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def set_service_attribute_value_by_path(
|
def set_service_attribute_value_by_path(
|
||||||
@@ -191,8 +211,7 @@ class StateManager:
|
|||||||
path: str,
|
path: str,
|
||||||
serialized_value: SerializedObject,
|
serialized_value: SerializedObject,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""Sets the value of an attribute in the service managed by the `StateManager`
|
||||||
Sets the value of an attribute in the service managed by the `StateManager`
|
|
||||||
given its path as a dot-separated string.
|
given its path as a dot-separated string.
|
||||||
|
|
||||||
This method updates the attribute specified by 'path' with 'value' only if the
|
This method updates the attribute specified by 'path' with 'value' only if the
|
||||||
|
|||||||
71
src/pydase/frontend/assets/index-CKS_bS2p.js
Normal file
71
src/pydase/frontend/assets/index-CKS_bS2p.js
Normal file
File diff suppressed because one or more lines are too long
5
src/pydase/frontend/assets/index-Cs09d5Pk.css
Normal file
5
src/pydase/frontend/assets/index-Cs09d5Pk.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,8 +7,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="description" content="Web site displaying a pydase UI." />
|
<meta name="description" content="Web site displaying a pydase UI." />
|
||||||
<script type="module" crossorigin src="/assets/index-DpoEqi_N.js"></script>
|
<script type="module" crossorigin src="/assets/index-CKS_bS2p.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DJzFvk4W.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cs09d5Pk.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ class Observable(ObservableObject):
|
|||||||
value = super().__getattribute__(name)
|
value = super().__getattribute__(name)
|
||||||
|
|
||||||
if is_property_attribute(self, name):
|
if is_property_attribute(self, name):
|
||||||
|
# fixes https://github.com/tiqi-group/pydase/issues/187 and
|
||||||
|
# https://github.com/tiqi-group/pydase/issues/192
|
||||||
|
if isinstance(value, ObservableObject):
|
||||||
|
value.add_observer(self, name)
|
||||||
self._notify_changed(name, value)
|
self._notify_changed(name, value)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import weakref
|
import weakref
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@@ -164,9 +165,9 @@ class _ObservableList(ObservableObject, list[Any]):
|
|||||||
|
|
||||||
self._notify_changed(f"[{key}]", value)
|
self._notify_changed(f"[{key}]", value)
|
||||||
|
|
||||||
def append(self, __object: Any) -> None:
|
def append(self, object_: Any, /) -> None:
|
||||||
self._notify_change_start("")
|
self._notify_change_start("")
|
||||||
super().append(self._initialise_new_objects(f"[{len(self)}]", __object))
|
super().append(self._initialise_new_objects(f"[{len(self)}]", object_))
|
||||||
self._notify_changed("", self)
|
self._notify_changed("", self)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
@@ -176,33 +177,33 @@ class _ObservableList(ObservableObject, list[Any]):
|
|||||||
|
|
||||||
self._notify_changed("", self)
|
self._notify_changed("", self)
|
||||||
|
|
||||||
def extend(self, __iterable: Iterable[Any]) -> None:
|
def extend(self, iterable: Iterable[Any], /) -> None:
|
||||||
self._remove_self_from_observables()
|
self._remove_self_from_observables()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
super().extend(__iterable)
|
super().extend(iterable)
|
||||||
finally:
|
finally:
|
||||||
for i, item in enumerate(self):
|
for i, item in enumerate(self):
|
||||||
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
||||||
|
|
||||||
self._notify_changed("", self)
|
self._notify_changed("", self)
|
||||||
|
|
||||||
def insert(self, __index: SupportsIndex, __object: Any) -> None:
|
def insert(self, index: SupportsIndex, object_: Any, /) -> None:
|
||||||
self._remove_self_from_observables()
|
self._remove_self_from_observables()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
super().insert(__index, __object)
|
super().insert(index, object_)
|
||||||
finally:
|
finally:
|
||||||
for i, item in enumerate(self):
|
for i, item in enumerate(self):
|
||||||
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
||||||
|
|
||||||
self._notify_changed("", self)
|
self._notify_changed("", self)
|
||||||
|
|
||||||
def pop(self, __index: SupportsIndex = -1) -> Any:
|
def pop(self, index: SupportsIndex = -1, /) -> Any:
|
||||||
self._remove_self_from_observables()
|
self._remove_self_from_observables()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
popped_item = super().pop(__index)
|
popped_item = super().pop(index)
|
||||||
finally:
|
finally:
|
||||||
for i, item in enumerate(self):
|
for i, item in enumerate(self):
|
||||||
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
||||||
@@ -210,11 +211,11 @@ class _ObservableList(ObservableObject, list[Any]):
|
|||||||
self._notify_changed("", self)
|
self._notify_changed("", self)
|
||||||
return popped_item
|
return popped_item
|
||||||
|
|
||||||
def remove(self, __value: Any) -> None:
|
def remove(self, value: Any, /) -> None:
|
||||||
self._remove_self_from_observables()
|
self._remove_self_from_observables()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
super().remove(__value)
|
super().remove(value)
|
||||||
finally:
|
finally:
|
||||||
for i, item in enumerate(self):
|
for i, item in enumerate(self):
|
||||||
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
super().__setitem__(i, self._initialise_new_objects(f"[{i}]", item))
|
||||||
@@ -252,7 +253,8 @@ class _ObservableDict(ObservableObject, dict[str, Any]):
|
|||||||
self.__setitem__(key, self._initialise_new_objects(f'["{key}"]', value))
|
self.__setitem__(key, self._initialise_new_objects(f'["{key}"]', value))
|
||||||
|
|
||||||
def __del__(self) -> None:
|
def __del__(self) -> None:
|
||||||
self._dict_mapping.pop(id(self._original_dict))
|
with contextlib.suppress(KeyError):
|
||||||
|
self._dict_mapping.pop(id(self._original_dict))
|
||||||
|
|
||||||
def __setitem__(self, key: str, value: Any) -> None:
|
def __setitem__(self, key: str, value: Any) -> None:
|
||||||
if not isinstance(key, str):
|
if not isinstance(key, str):
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ def reverse_dict(original_dict: dict[str, list[str]]) -> dict[str, list[str]]:
|
|||||||
|
|
||||||
def get_property_dependencies(prop: property, prefix: str = "") -> list[str]:
|
def get_property_dependencies(prop: property, prefix: str = "") -> list[str]:
|
||||||
source_code_string = inspect.getsource(prop.fget) # type: ignore[arg-type]
|
source_code_string = inspect.getsource(prop.fget) # type: ignore[arg-type]
|
||||||
pattern = r"self\.([^\s\{\}]+)"
|
pattern = r"self\.([^\s\{\}\(\)]+)"
|
||||||
matches = re.findall(pattern, source_code_string)
|
matches = re.findall(pattern, source_code_string)
|
||||||
return [prefix + match for match in matches if "(" not in match]
|
return [prefix + match for match in matches if "(" not in match]
|
||||||
|
|
||||||
|
|
||||||
class PropertyObserver(Observer):
|
class PropertyObserver(Observer):
|
||||||
def __init__(self, observable: Observable) -> None:
|
def __init__(self, observable: Observable) -> None:
|
||||||
|
self.property_deps_dict: dict[str, list[str]] = {}
|
||||||
super().__init__(observable)
|
super().__init__(observable)
|
||||||
self._update_property_deps_dict()
|
self._update_property_deps_dict()
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from pydase.data_service.data_service_observer import DataServiceObserver
|
|||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
from pydase.server.web_server import WebServer
|
from pydase.server.web_server import WebServer
|
||||||
from pydase.task.autostart import autostart_service_tasks
|
from pydase.task.autostart import autostart_service_tasks
|
||||||
from pydase.utils.helpers import current_event_loop_exists
|
|
||||||
|
|
||||||
HANDLED_SIGNALS = (
|
HANDLED_SIGNALS = (
|
||||||
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
|
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
|
||||||
@@ -84,21 +83,17 @@ class Server:
|
|||||||
The `Server` class provides a flexible server implementation for the `DataService`.
|
The `Server` class provides a flexible server implementation for the `DataService`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service:
|
service: The DataService instance that this server will manage.
|
||||||
The DataService instance that this server will manage.
|
host: The host address for the server. Defaults to `'0.0.0.0'`, which means all
|
||||||
host:
|
|
||||||
The host address for the server. Defaults to `'0.0.0.0'`, which means all
|
|
||||||
available network interfaces.
|
available network interfaces.
|
||||||
web_port:
|
web_port: The port number for the web server. If set to None, it will use the
|
||||||
The port number for the web server. Defaults to
|
port defined in
|
||||||
[`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port].
|
[`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port]. Defaults
|
||||||
enable_web:
|
to None.
|
||||||
Whether to enable the web server.
|
enable_web: Whether to enable the web server.
|
||||||
filename:
|
filename: Filename of the file managing the service state persistence.
|
||||||
Filename of the file managing the service state persistence.
|
additional_servers: A list of additional servers to run alongside the main
|
||||||
additional_servers:
|
server.
|
||||||
A list of additional servers to run alongside the main server.
|
|
||||||
|
|
||||||
Here's an example of how you might define an additional server:
|
Here's an example of how you might define an additional server:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -137,39 +132,63 @@ class Server:
|
|||||||
)
|
)
|
||||||
server.run()
|
server.run()
|
||||||
```
|
```
|
||||||
**kwargs:
|
autosave_interval: Interval in seconds between automatic state save events.
|
||||||
Additional keyword arguments.
|
If set to `None`, automatic saving is disabled. Defaults to 30 seconds.
|
||||||
|
**kwargs: Additional keyword arguments.
|
||||||
|
|
||||||
|
# Advanced
|
||||||
|
- [`post_startup`][pydase.Server.post_startup] hook:
|
||||||
|
|
||||||
|
This method is intended to be overridden in subclasses. It runs immediately
|
||||||
|
after all servers (web and additional) are initialized and before entering the
|
||||||
|
main event loop. You can use this hook to register custom logic after the
|
||||||
|
server is fully started.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__( # noqa: PLR0913
|
def __init__( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
service: DataService,
|
service: DataService,
|
||||||
host: str = "0.0.0.0",
|
host: str = "0.0.0.0",
|
||||||
web_port: int = ServiceConfig().web_port,
|
web_port: int | None = None,
|
||||||
enable_web: bool = True,
|
enable_web: bool = True,
|
||||||
filename: str | Path | None = None,
|
filename: str | Path | None = None,
|
||||||
additional_servers: list[AdditionalServer] | None = None,
|
additional_servers: list[AdditionalServer] | None = None,
|
||||||
|
autosave_interval: float = 30.0,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
if additional_servers is None:
|
if additional_servers is None:
|
||||||
additional_servers = []
|
additional_servers = []
|
||||||
self._service = service
|
self._service = service
|
||||||
self._host = host
|
self._host = host
|
||||||
self._web_port = web_port
|
if web_port is None:
|
||||||
|
self._web_port = ServiceConfig().web_port
|
||||||
|
else:
|
||||||
|
self._web_port = web_port
|
||||||
self._enable_web = enable_web
|
self._enable_web = enable_web
|
||||||
self._kwargs = kwargs
|
self._kwargs = kwargs
|
||||||
self._additional_servers = additional_servers
|
self._additional_servers = additional_servers
|
||||||
self.should_exit = False
|
self.should_exit = False
|
||||||
self.servers: dict[str, asyncio.Future[Any]] = {}
|
self.servers: dict[str, asyncio.Future[Any]] = {}
|
||||||
self._state_manager = StateManager(self._service, filename)
|
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
|
||||||
|
self._state_manager = StateManager(
|
||||||
|
service=self._service,
|
||||||
|
filename=filename,
|
||||||
|
autosave_interval=autosave_interval,
|
||||||
|
)
|
||||||
self._observer = DataServiceObserver(self._state_manager)
|
self._observer = DataServiceObserver(self._state_manager)
|
||||||
self._state_manager.load_state()
|
self._state_manager.load_state()
|
||||||
autostart_service_tasks(self._service)
|
autostart_service_tasks(self._service)
|
||||||
if not current_event_loop_exists():
|
|
||||||
self._loop = asyncio.new_event_loop()
|
self._web_server = WebServer(
|
||||||
asyncio.set_event_loop(self._loop)
|
data_service_observer=self._observer,
|
||||||
else:
|
host=self._host,
|
||||||
self._loop = asyncio.get_event_loop()
|
port=self._web_port,
|
||||||
|
enable_frontend=self._enable_web,
|
||||||
|
**self._kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -177,7 +196,10 @@ class Server:
|
|||||||
|
|
||||||
This method should be called to start the server after it's been instantiated.
|
This method should be called to start the server after it's been instantiated.
|
||||||
"""
|
"""
|
||||||
self._loop.run_until_complete(self.serve())
|
try:
|
||||||
|
self._loop.run_until_complete(self.serve())
|
||||||
|
finally:
|
||||||
|
self._loop.close()
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
process_id = os.getpid()
|
process_id = os.getpid()
|
||||||
@@ -185,6 +207,7 @@ class Server:
|
|||||||
logger.info("Started server process [%s]", process_id)
|
logger.info("Started server process [%s]", process_id)
|
||||||
|
|
||||||
await self.startup()
|
await self.startup()
|
||||||
|
await self.post_startup()
|
||||||
if self.should_exit:
|
if self.should_exit:
|
||||||
return
|
return
|
||||||
await self.main_loop()
|
await self.main_loop()
|
||||||
@@ -196,6 +219,10 @@ class Server:
|
|||||||
self._loop.set_exception_handler(self.custom_exception_handler)
|
self._loop.set_exception_handler(self.custom_exception_handler)
|
||||||
self.install_signal_handlers()
|
self.install_signal_handlers()
|
||||||
|
|
||||||
|
server_task = self._loop.create_task(self._web_server.serve())
|
||||||
|
server_task.add_done_callback(self._handle_server_shutdown)
|
||||||
|
self.servers["web"] = server_task
|
||||||
|
|
||||||
for server in self._additional_servers:
|
for server in self._additional_servers:
|
||||||
addin_server = server["server"](
|
addin_server = server["server"](
|
||||||
data_service_observer=self._observer,
|
data_service_observer=self._observer,
|
||||||
@@ -211,17 +238,8 @@ class Server:
|
|||||||
server_task = self._loop.create_task(addin_server.serve())
|
server_task = self._loop.create_task(addin_server.serve())
|
||||||
server_task.add_done_callback(self._handle_server_shutdown)
|
server_task.add_done_callback(self._handle_server_shutdown)
|
||||||
self.servers[server_name] = server_task
|
self.servers[server_name] = server_task
|
||||||
if self._enable_web:
|
|
||||||
self._web_server = WebServer(
|
|
||||||
data_service_observer=self._observer,
|
|
||||||
host=self._host,
|
|
||||||
port=self._web_port,
|
|
||||||
**self._kwargs,
|
|
||||||
)
|
|
||||||
server_task = self._loop.create_task(self._web_server.serve())
|
|
||||||
|
|
||||||
server_task.add_done_callback(self._handle_server_shutdown)
|
self._loop.create_task(self._state_manager.autosave())
|
||||||
self.servers["web"] = server_task
|
|
||||||
|
|
||||||
def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None:
|
def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None:
|
||||||
"""Handle server shutdown. If the service should exit, do nothing. Else, make
|
"""Handle server shutdown. If the service should exit, do nothing. Else, make
|
||||||
@@ -250,6 +268,9 @@ class Server:
|
|||||||
logger.debug("Cancelling tasks")
|
logger.debug("Cancelling tasks")
|
||||||
await self.__cancel_tasks()
|
await self.__cancel_tasks()
|
||||||
|
|
||||||
|
async def post_startup(self) -> None:
|
||||||
|
"""Override this in a subclass to register custom logic after startup."""
|
||||||
|
|
||||||
async def __cancel_servers(self) -> None:
|
async def __cancel_servers(self) -> None:
|
||||||
for server_name, task in self.servers.items():
|
for server_name, task in self.servers.items():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -299,7 +320,7 @@ class Server:
|
|||||||
# here we exclude most kinds of exceptions from triggering this kind of shutdown
|
# here we exclude most kinds of exceptions from triggering this kind of shutdown
|
||||||
exc = context.get("exception")
|
exc = context.get("exception")
|
||||||
if type(exc) not in [RuntimeError, KeyboardInterrupt, asyncio.CancelledError]:
|
if type(exc) not in [RuntimeError, KeyboardInterrupt, asyncio.CancelledError]:
|
||||||
if self._enable_web:
|
if loop.is_running():
|
||||||
|
|
||||||
async def emit_exception() -> None:
|
async def emit_exception() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from pydase.data_service.data_service_observer import DataServiceObserver
|
|||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
from pydase.server.web_server.api.v1 import endpoints
|
from pydase.server.web_server.api.v1 import endpoints
|
||||||
from pydase.utils.logging import SocketIOHandler
|
from pydase.utils.logging import SocketIOHandler
|
||||||
from pydase.utils.serialization.serializer import SerializedObject
|
from pydase.utils.serialization.types import SerializedObject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ def setup_sio_server(
|
|||||||
def sio_callback(
|
def sio_callback(
|
||||||
full_access_path: str, value: Any, cached_value_dict: SerializedObject
|
full_access_path: str, value: Any, cached_value_dict: SerializedObject
|
||||||
) -> None:
|
) -> None:
|
||||||
if cached_value_dict != {}:
|
if cached_value_dict != {} and loop.is_running():
|
||||||
|
|
||||||
async def notify() -> None:
|
async def notify() -> None:
|
||||||
try:
|
try:
|
||||||
@@ -202,7 +202,7 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
|
|||||||
@sio.event
|
@sio.event
|
||||||
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
|
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
|
||||||
async with sio.session(sid) as session:
|
async with sio.session(sid) as session:
|
||||||
logger.debug(
|
logger.info(
|
||||||
"Client [%s] is triggering the method '%s'",
|
"Client [%s] is triggering the method '%s'",
|
||||||
session["client_id"],
|
session["client_id"],
|
||||||
data["access_path"],
|
data["access_path"],
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class WebServer:
|
|||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
*,
|
*,
|
||||||
|
enable_frontend: bool = True,
|
||||||
css: str | Path | None = None,
|
css: str | Path | None = None,
|
||||||
favicon_path: str | Path | None = None,
|
favicon_path: str | Path | None = None,
|
||||||
enable_cors: bool = True,
|
enable_cors: bool = True,
|
||||||
@@ -97,19 +98,18 @@ class WebServer:
|
|||||||
self.enable_cors = enable_cors
|
self.enable_cors = enable_cors
|
||||||
self.frontend_src = frontend_src
|
self.frontend_src = frontend_src
|
||||||
self.favicon_path: Path | str = favicon_path # type: ignore
|
self.favicon_path: Path | str = favicon_path # type: ignore
|
||||||
|
self.enable_frontend = enable_frontend
|
||||||
|
|
||||||
if self.favicon_path is None:
|
if self.favicon_path is None:
|
||||||
self.favicon_path = self.frontend_src / "favicon.ico"
|
self.favicon_path = self.frontend_src / "favicon.ico"
|
||||||
|
|
||||||
self._service_config_dir = config_dir
|
self._service_config_dir = config_dir
|
||||||
self._generate_web_settings = generate_web_settings
|
self._generate_web_settings = generate_web_settings
|
||||||
self._loop: asyncio.AbstractEventLoop
|
self._loop = asyncio.get_event_loop()
|
||||||
|
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
|
||||||
self._initialise_configuration()
|
self._initialise_configuration()
|
||||||
|
|
||||||
async def serve(self) -> None:
|
async def serve(self) -> None:
|
||||||
self._loop = asyncio.get_running_loop()
|
|
||||||
self._sio = setup_sio_server(self.observer, self.enable_cors, self._loop)
|
|
||||||
|
|
||||||
async def index(
|
async def index(
|
||||||
request: aiohttp.web.Request,
|
request: aiohttp.web.Request,
|
||||||
) -> aiohttp.web.Response | aiohttp.web.FileResponse:
|
) -> aiohttp.web.Response | aiohttp.web.FileResponse:
|
||||||
@@ -162,15 +162,17 @@ class WebServer:
|
|||||||
|
|
||||||
# Define routes
|
# Define routes
|
||||||
self._sio.attach(app, socketio_path="/ws/socket.io")
|
self._sio.attach(app, socketio_path="/ws/socket.io")
|
||||||
app.router.add_static("/assets", self.frontend_src / "assets")
|
if self.enable_frontend:
|
||||||
app.router.add_get("/favicon.ico", self._favicon_route)
|
app.router.add_static("/assets", self.frontend_src / "assets")
|
||||||
app.router.add_get("/service-properties", self._service_properties_route)
|
app.router.add_get("/favicon.ico", self._favicon_route)
|
||||||
app.router.add_get("/web-settings", self._web_settings_route)
|
app.router.add_get("/service-properties", self._service_properties_route)
|
||||||
app.router.add_get("/custom.css", self._styles_route)
|
app.router.add_get("/web-settings", self._web_settings_route)
|
||||||
|
app.router.add_get("/custom.css", self._styles_route)
|
||||||
app.add_subapp("/api/", create_api_application(self.state_manager))
|
app.add_subapp("/api/", create_api_application(self.state_manager))
|
||||||
|
|
||||||
app.router.add_get(r"/", index)
|
if self.enable_frontend:
|
||||||
app.router.add_get(r"/{tail:.*}", index)
|
app.router.add_get(r"/", index)
|
||||||
|
app.router.add_get(r"/{tail:.*}", index)
|
||||||
|
|
||||||
await aiohttp.web._run_app(
|
await aiohttp.web._run_app(
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -219,7 +219,18 @@ def is_descriptor(obj: object) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def current_event_loop_exists() -> bool:
|
def current_event_loop_exists() -> bool:
|
||||||
"""Check if an event loop has been set."""
|
"""Check if a running and open asyncio event loop exists in the current thread.
|
||||||
|
|
||||||
|
This checks if an event loop is set via the current event loop policy and verifies
|
||||||
|
that the loop has not been closed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if an event loop exists and is not closed, False otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
return asyncio.get_event_loop_policy()._local._loop is not None # type: ignore
|
try:
|
||||||
|
return not asyncio.get_event_loop().is_closed()
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logging.config
|
|||||||
import sys
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from typing import ClassVar, Literal
|
from typing import ClassVar, Literal, TextIO
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import socketio # type: ignore[import-untyped]
|
import socketio # type: ignore[import-untyped]
|
||||||
@@ -29,22 +29,44 @@ LOGGING_CONFIG = {
|
|||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"only_pydase_server": {
|
||||||
|
"()": "pydase.utils.logging.NameFilter",
|
||||||
|
"match": "pydase.server",
|
||||||
|
},
|
||||||
|
"exclude_pydase_server": {
|
||||||
|
"()": "pydase.utils.logging.NameFilter",
|
||||||
|
"match": "pydase.server",
|
||||||
|
"invert": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"default": {
|
"stdout_handler": {
|
||||||
"formatter": "default",
|
"formatter": "default",
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"stream": "ext://sys.stdout",
|
"stream": "ext://sys.stdout",
|
||||||
|
"filters": ["only_pydase_server"],
|
||||||
|
},
|
||||||
|
"stderr_handler": {
|
||||||
|
"formatter": "default",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"stream": "ext://sys.stderr",
|
||||||
|
"filters": ["exclude_pydase_server"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"pydase": {"handlers": ["default"], "level": LOG_LEVEL, "propagate": False},
|
"pydase": {
|
||||||
|
"handlers": ["stdout_handler", "stderr_handler"],
|
||||||
|
"level": LOG_LEVEL,
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
"aiohttp_middlewares": {
|
"aiohttp_middlewares": {
|
||||||
"handlers": ["default"],
|
"handlers": ["stderr_handler"],
|
||||||
"level": logging.WARNING,
|
"level": logging.WARNING,
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
"aiohttp": {
|
"aiohttp": {
|
||||||
"handlers": ["default"],
|
"handlers": ["stderr_handler"],
|
||||||
"level": logging.INFO,
|
"level": logging.INFO,
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
@@ -52,6 +74,23 @@ LOGGING_CONFIG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NameFilter(logging.Filter):
|
||||||
|
"""
|
||||||
|
Logging filter that allows filtering logs based on the logger name.
|
||||||
|
Can either include or exclude a specific logger.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, match: str, invert: bool = False):
|
||||||
|
super().__init__()
|
||||||
|
self.match = match
|
||||||
|
self.invert = invert
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
if self.invert:
|
||||||
|
return not record.name.startswith(self.match)
|
||||||
|
return record.name.startswith(self.match)
|
||||||
|
|
||||||
|
|
||||||
class DefaultFormatter(logging.Formatter):
|
class DefaultFormatter(logging.Formatter):
|
||||||
"""
|
"""
|
||||||
A custom log formatter class that:
|
A custom log formatter class that:
|
||||||
@@ -126,15 +165,16 @@ class SocketIOHandler(logging.Handler):
|
|||||||
log_entry = self.format(record)
|
log_entry = self.format(record)
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.create_task(
|
if loop.is_running():
|
||||||
self._sio.emit(
|
loop.create_task(
|
||||||
"log",
|
self._sio.emit(
|
||||||
{
|
"log",
|
||||||
"levelname": record.levelname,
|
{
|
||||||
"message": log_entry,
|
"levelname": record.levelname,
|
||||||
},
|
"message": log_entry,
|
||||||
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging() -> None:
|
def setup_logging() -> None:
|
||||||
@@ -150,3 +190,51 @@ def setup_logging() -> None:
|
|||||||
logger.debug("Configuring pydase logging.")
|
logger.debug("Configuring pydase logging.")
|
||||||
|
|
||||||
logging.config.dictConfig(LOGGING_CONFIG)
|
logging.config.dictConfig(LOGGING_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging_with_pydase_formatter(
|
||||||
|
name: str | None = None, level: int = logging.INFO, stream: TextIO | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Configure a logger with the pydase `DefaultFormatter`.
|
||||||
|
|
||||||
|
This sets up a `StreamHandler` with the custom `DefaultFormatter`, which includes
|
||||||
|
timestamp, log level with color (if supported), logger name, function, and line
|
||||||
|
number. It can be used to configure the root logger or any named logger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The name of the logger to configure. If None, the root logger is used.
|
||||||
|
level: The logging level to set on the logger (e.g., logging.DEBUG,
|
||||||
|
logging.INFO). Defaults to logging.INFO.
|
||||||
|
stream: The output stream for the log messages (e.g., sys.stdout or sys.stderr).
|
||||||
|
If None, defaults to sys.stderr.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Configure logging in your service:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
from pydase.utils.logging import configure_logging_with_pydase_formatter
|
||||||
|
|
||||||
|
configure_logging_with_pydase_formatter(
|
||||||
|
name="my_service", # Use the package/module name or None for the root logger
|
||||||
|
level=logging.DEBUG, # Set the desired logging level (defaults to INFO)
|
||||||
|
stream=sys.stdout # Set the output stream (stderr by default)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This function adds a new handler each time it's called.
|
||||||
|
Use carefully to avoid duplicate logs.
|
||||||
|
- Colors are enabled if the stream supports TTY (e.g., in terminal).
|
||||||
|
""" # noqa: E501
|
||||||
|
|
||||||
|
logger = logging.getLogger(name=name)
|
||||||
|
handler = logging.StreamHandler(stream=stream)
|
||||||
|
formatter = DefaultFormatter(
|
||||||
|
fmt="%(asctime)s.%(msecs)03d | %(levelprefix)s | "
|
||||||
|
"%(name)s:%(funcName)s:%(lineno)d - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(level)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class Deserializer:
|
|||||||
def deserialize_list(cls, serialized_object: SerializedObject) -> Any:
|
def deserialize_list(cls, serialized_object: SerializedObject) -> Any:
|
||||||
return [
|
return [
|
||||||
cls.deserialize(item)
|
cls.deserialize(item)
|
||||||
for item in cast(list[SerializedObject], serialized_object["value"])
|
for item in cast("list[SerializedObject]", serialized_object["value"])
|
||||||
]
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -93,7 +93,7 @@ class Deserializer:
|
|||||||
return {
|
return {
|
||||||
key: cls.deserialize(value)
|
key: cls.deserialize(value)
|
||||||
for key, value in cast(
|
for key, value in cast(
|
||||||
dict[str, SerializedObject], serialized_object["value"]
|
"dict[str, SerializedObject]", serialized_object["value"]
|
||||||
).items()
|
).items()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ class Deserializer:
|
|||||||
|
|
||||||
# Process and add properties based on the serialized object
|
# Process and add properties based on the serialized object
|
||||||
for key, value in cast(
|
for key, value in cast(
|
||||||
dict[str, SerializedObject], serialized_object["value"]
|
"dict[str, SerializedObject]", serialized_object["value"]
|
||||||
).items():
|
).items():
|
||||||
if value["type"] != "method":
|
if value["type"] != "method":
|
||||||
class_attrs[key] = cls.create_attr_property(value)
|
class_attrs[key] = cls.create_attr_property(value)
|
||||||
|
|||||||
@@ -20,29 +20,29 @@ from pydase.utils.helpers import (
|
|||||||
parse_full_access_path,
|
parse_full_access_path,
|
||||||
parse_serialized_key,
|
parse_serialized_key,
|
||||||
)
|
)
|
||||||
from pydase.utils.serialization.types import (
|
|
||||||
DataServiceTypes,
|
|
||||||
SerializedBool,
|
|
||||||
SerializedDataService,
|
|
||||||
SerializedDatetime,
|
|
||||||
SerializedDict,
|
|
||||||
SerializedEnum,
|
|
||||||
SerializedException,
|
|
||||||
SerializedFloat,
|
|
||||||
SerializedInteger,
|
|
||||||
SerializedList,
|
|
||||||
SerializedMethod,
|
|
||||||
SerializedNoneType,
|
|
||||||
SerializedObject,
|
|
||||||
SerializedQuantity,
|
|
||||||
SerializedString,
|
|
||||||
SignatureDict,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from pydase.client.proxy_class import ProxyClass
|
from pydase.client.proxy_class import ProxyClass
|
||||||
|
from pydase.utils.serialization.types import (
|
||||||
|
DataServiceTypes,
|
||||||
|
SerializedBool,
|
||||||
|
SerializedDataService,
|
||||||
|
SerializedDatetime,
|
||||||
|
SerializedDict,
|
||||||
|
SerializedEnum,
|
||||||
|
SerializedException,
|
||||||
|
SerializedFloat,
|
||||||
|
SerializedInteger,
|
||||||
|
SerializedList,
|
||||||
|
SerializedMethod,
|
||||||
|
SerializedNoneType,
|
||||||
|
SerializedObject,
|
||||||
|
SerializedQuantity,
|
||||||
|
SerializedString,
|
||||||
|
SignatureDict,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ class Serializer:
|
|||||||
"doc": None,
|
"doc": None,
|
||||||
"readonly": True,
|
"readonly": True,
|
||||||
"type": "Exception",
|
"type": "Exception",
|
||||||
"value": obj.args[0],
|
"value": obj.args[0] if len(obj.args) > 0 else "",
|
||||||
"name": obj.__class__.__name__,
|
"name": obj.__class__.__name__,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ class Serializer:
|
|||||||
|
|
||||||
for k, v in sig.parameters.items():
|
for k, v in sig.parameters.items():
|
||||||
default_value = cast(
|
default_value = cast(
|
||||||
dict[str, Any], {} if v.default == inspect._empty else dump(v.default)
|
"dict[str, Any]", {} if v.default == inspect._empty else dump(v.default)
|
||||||
)
|
)
|
||||||
default_value.pop("full_access_path", None)
|
default_value.pop("full_access_path", None)
|
||||||
signature["parameters"][k] = {
|
signature["parameters"][k] = {
|
||||||
@@ -385,7 +385,7 @@ def set_nested_value_by_path(
|
|||||||
current_dict, path_part, allow_append=False
|
current_dict, path_part, allow_append=False
|
||||||
)
|
)
|
||||||
current_dict = cast(
|
current_dict = cast(
|
||||||
dict[Any, SerializedObject],
|
"dict[Any, SerializedObject]",
|
||||||
next_level_serialized_object["value"],
|
next_level_serialized_object["value"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ def get_nested_dict_by_path(
|
|||||||
current_dict, path_part, allow_append=False
|
current_dict, path_part, allow_append=False
|
||||||
)
|
)
|
||||||
current_dict = cast(
|
current_dict = cast(
|
||||||
dict[Any, SerializedObject],
|
"dict[Any, SerializedObject]",
|
||||||
next_level_serialized_object["value"],
|
next_level_serialized_object["value"],
|
||||||
)
|
)
|
||||||
return get_container_item_by_key(current_dict, path_parts[-1], allow_append=False)
|
return get_container_item_by_key(current_dict, path_parts[-1], allow_append=False)
|
||||||
@@ -456,7 +456,7 @@ def get_or_create_item_in_container(
|
|||||||
return container[key]
|
return container[key]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
if allow_add_key and key == len(container):
|
if allow_add_key and key == len(container):
|
||||||
cast(list[SerializedObject], container).append(
|
cast("list[SerializedObject]", container).append(
|
||||||
create_empty_serialized_object()
|
create_empty_serialized_object()
|
||||||
)
|
)
|
||||||
return container[key]
|
return container[key]
|
||||||
@@ -541,7 +541,7 @@ def get_data_paths_from_serialized_object( # noqa: C901
|
|||||||
|
|
||||||
elif serialized_dict_is_nested_object(serialized_obj):
|
elif serialized_dict_is_nested_object(serialized_obj):
|
||||||
for key, value in cast(
|
for key, value in cast(
|
||||||
dict[str, SerializedObject], serialized_obj["value"]
|
"dict[str, SerializedObject]", serialized_obj["value"]
|
||||||
).items():
|
).items():
|
||||||
# Serialized dictionaries need to have a different new_path than nested
|
# Serialized dictionaries need to have a different new_path than nested
|
||||||
# classes
|
# classes
|
||||||
@@ -628,13 +628,13 @@ def add_prefix_to_full_access_path(
|
|||||||
|
|
||||||
if isinstance(serialized_obj["value"], list):
|
if isinstance(serialized_obj["value"], list):
|
||||||
for value in serialized_obj["value"]:
|
for value in serialized_obj["value"]:
|
||||||
add_prefix_to_full_access_path(cast(SerializedObject, value), prefix)
|
add_prefix_to_full_access_path(cast("SerializedObject", value), prefix)
|
||||||
|
|
||||||
elif isinstance(serialized_obj["value"], dict):
|
elif isinstance(serialized_obj["value"], dict):
|
||||||
for value in cast(
|
for value in cast(
|
||||||
dict[str, SerializedObject], serialized_obj["value"]
|
"dict[str, SerializedObject]", serialized_obj["value"]
|
||||||
).values():
|
).values():
|
||||||
add_prefix_to_full_access_path(cast(SerializedObject, value), prefix)
|
add_prefix_to_full_access_path(cast("SerializedObject", value), prefix)
|
||||||
except (TypeError, KeyError, AttributeError):
|
except (TypeError, KeyError, AttributeError):
|
||||||
# passed dictionary is not a serialized object
|
# passed dictionary is not a serialized object
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import threading
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydase
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pydase
|
||||||
from pydase.client.proxy_loader import ProxyAttributeError
|
from pydase.client.proxy_loader import ProxyAttributeError
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ def pydase_client() -> Generator[pydase.Client, None, Any]:
|
|||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
server.handle_exit()
|
server.handle_exit()
|
||||||
thread.join()
|
thread.join()
|
||||||
|
|
||||||
@@ -166,10 +168,25 @@ def test_context_manager(pydase_client: pydase.Client) -> None:
|
|||||||
def test_client_id(
|
def test_client_id(
|
||||||
pydase_client: pydase.Client, caplog: pytest.LogCaptureFixture
|
pydase_client: pydase.Client, caplog: pytest.LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
|
import socket
|
||||||
|
|
||||||
pydase.Client(url="ws://localhost:9999")
|
pydase.Client(url="ws://localhost:9999")
|
||||||
|
|
||||||
assert "Client [sid=" in caplog.text
|
assert f"Client [id={socket.gethostname()}]" in caplog.text
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
pydase.Client(url="ws://localhost:9999", client_id="my_service")
|
pydase.Client(url="ws://localhost:9999", client_id="my_service")
|
||||||
assert "Client [id=my_service] connected" in caplog.text
|
assert "Client [id=my_service] connected" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_value(
|
||||||
|
pydase_client: pydase.Client, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
pydase_client.update_value("sub_service.name", "Other name")
|
||||||
|
|
||||||
|
assert pydase_client.get_value("sub_service.name") == "Other name"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
pydase_client.trigger_method("my_async_method", input_str="Hello World")
|
||||||
|
== "Hello World"
|
||||||
|
)
|
||||||
|
|||||||
22
tests/client/test_proxy_class.py
Normal file
22
tests/client/test_proxy_class.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pydase import components
|
||||||
|
from pydase.client.proxy_class import ProxyClass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_serialize_fallback_inside_event_loop() -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
mock_sio = AsyncMock()
|
||||||
|
proxy = ProxyClass(sio_client=mock_sio, loop=loop, reconnect=lambda: None)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
components.DeviceConnection, "serialize", return_value={"value": {}}
|
||||||
|
) as mock_fallback:
|
||||||
|
result = proxy.serialize()
|
||||||
|
|
||||||
|
mock_fallback.assert_has_calls(calls=[call(), call()])
|
||||||
|
assert isinstance(result, dict)
|
||||||
@@ -2,27 +2,26 @@ import threading
|
|||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydase
|
|
||||||
import pytest
|
import pytest
|
||||||
import socketio.exceptions
|
import socketio.exceptions
|
||||||
|
|
||||||
|
import pydase
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def pydase_restartable_server() -> (
|
def pydase_restartable_server() -> Generator[
|
||||||
Generator[
|
tuple[
|
||||||
tuple[
|
pydase.Server,
|
||||||
pydase.Server,
|
threading.Thread,
|
||||||
threading.Thread,
|
pydase.DataService,
|
||||||
pydase.DataService,
|
Callable[
|
||||||
Callable[
|
[pydase.Server, threading.Thread, pydase.DataService],
|
||||||
[pydase.Server, threading.Thread, pydase.DataService],
|
tuple[pydase.Server, threading.Thread],
|
||||||
tuple[pydase.Server, threading.Thread],
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
None,
|
],
|
||||||
Any,
|
None,
|
||||||
]
|
Any,
|
||||||
):
|
]:
|
||||||
class MyService(pydase.DataService):
|
class MyService(pydase.DataService):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -62,9 +61,6 @@ def pydase_restartable_server() -> (
|
|||||||
|
|
||||||
yield server, thread, service_instance, restart
|
yield server, thread, service_instance, restart
|
||||||
|
|
||||||
server.handle_exit()
|
|
||||||
thread.join()
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconnection(
|
def test_reconnection(
|
||||||
pydase_restartable_server: tuple[
|
pydase_restartable_server: tuple[
|
||||||
@@ -105,3 +101,6 @@ def test_reconnection(
|
|||||||
# the service proxies successfully reconnect and get the new service name
|
# the service proxies successfully reconnect and get the new service name
|
||||||
assert client.proxy.name == "New service name"
|
assert client.proxy.name == "New service name"
|
||||||
assert client_2.proxy.name == "New service name"
|
assert client_2.proxy.name == "New service name"
|
||||||
|
|
||||||
|
server.handle_exit()
|
||||||
|
thread.join()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydase.task.autostart import autostart_service_tasks
|
|||||||
from pytest import LogCaptureFixture
|
from pytest import LogCaptureFixture
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
@pytest.mark.asyncio(loop_scope="function")
|
||||||
async def test_reconnection(caplog: LogCaptureFixture) -> None:
|
async def test_reconnection(caplog: LogCaptureFixture) -> None:
|
||||||
class MyService(pydase.components.device_connection.DeviceConnection):
|
class MyService(pydase.components.device_connection.DeviceConnection):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from pytest import LogCaptureFixture
|
||||||
|
|
||||||
import pydase
|
import pydase
|
||||||
import pydase.components
|
import pydase.components
|
||||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
from pydase.utils.serialization.serializer import dump
|
from pydase.utils.serialization.serializer import dump
|
||||||
from pytest import LogCaptureFixture
|
|
||||||
|
if sys.version_info < (3, 13):
|
||||||
|
PATHLIB_PATH = "pathlib.Path"
|
||||||
|
else:
|
||||||
|
PATHLIB_PATH = "pathlib._local.Path"
|
||||||
|
|
||||||
|
|
||||||
def test_image_functions(caplog: LogCaptureFixture) -> None:
|
def test_image_functions(caplog: LogCaptureFixture) -> None:
|
||||||
@@ -106,7 +113,7 @@ def test_image_serialization() -> None:
|
|||||||
"signature": {
|
"signature": {
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"path": {
|
"path": {
|
||||||
"annotation": "pathlib.Path | str",
|
"annotation": f"{PATHLIB_PATH} | str",
|
||||||
"default": {},
|
"default": {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydase
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pydase
|
||||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
from pydase.utils.serialization.serializer import SerializationError, dump
|
from pydase.utils.serialization.serializer import SerializationError, dump
|
||||||
@@ -222,3 +223,61 @@ def test_nested_dict_property_changes(
|
|||||||
# Changing the _voltage attribute should re-evaluate the voltage property, but avoid
|
# Changing the _voltage attribute should re-evaluate the voltage property, but avoid
|
||||||
# recursion
|
# recursion
|
||||||
service.my_dict["key"].voltage = 1.2
|
service.my_dict["key"].voltage = 1.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_dict_property(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
class MyObservable(pydase.DataService):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._dict_attr = {"dotted.key": 1.0}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dict_attr(self) -> dict[str, Any]:
|
||||||
|
return self._dict_attr
|
||||||
|
|
||||||
|
service_instance = MyObservable()
|
||||||
|
state_manager = StateManager(service=service_instance)
|
||||||
|
DataServiceObserver(state_manager)
|
||||||
|
|
||||||
|
service_instance._dict_attr["dotted.key"] = 2.0
|
||||||
|
|
||||||
|
assert "'dict_attr[\"dotted.key\"]' changed to '2.0'" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependency_as_function_argument(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
class MyObservable(pydase.DataService):
|
||||||
|
some_int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def other_int(self) -> int:
|
||||||
|
return self.add_one(self.some_int)
|
||||||
|
|
||||||
|
def add_one(self, value: int) -> int:
|
||||||
|
return value + 1
|
||||||
|
|
||||||
|
service_instance = MyObservable()
|
||||||
|
state_manager = StateManager(service=service_instance)
|
||||||
|
DataServiceObserver(state_manager)
|
||||||
|
|
||||||
|
service_instance.some_int = 1337
|
||||||
|
|
||||||
|
assert "'other_int' changed to '1338'" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_property_starting_with_dependency_name(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
class MyObservable(pydase.DataService):
|
||||||
|
my_int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def my_int_2(self) -> int:
|
||||||
|
return self.my_int + 1
|
||||||
|
|
||||||
|
service_instance = MyObservable()
|
||||||
|
state_manager = StateManager(service=service_instance)
|
||||||
|
DataServiceObserver(state_manager)
|
||||||
|
|
||||||
|
service_instance.my_int = 1337
|
||||||
|
|
||||||
|
assert "'my_int_2' changed to '1338'" in caplog.text
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pydase
|
import pydase
|
||||||
import pydase.components
|
import pydase.components
|
||||||
import pydase.units as u
|
import pydase.units as u
|
||||||
|
import pytest
|
||||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||||
from pydase.data_service.state_manager import (
|
from pydase.data_service.state_manager import (
|
||||||
StateManager,
|
StateManager,
|
||||||
@@ -349,4 +352,24 @@ def test_property_load_state(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert service_instance.name == "Some other name"
|
assert service_instance.name == "Some other name"
|
||||||
assert service_instance.not_loadable_attr == "Not loadable"
|
assert service_instance.not_loadable_attr == "Not loadable"
|
||||||
assert not has_load_state_decorator(type(service_instance).property_without_setter)
|
assert not has_load_state_decorator(type(service_instance).property_without_setter) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
|
async def test_autosave(tmp_path: Path, caplog: LogCaptureFixture) -> None:
|
||||||
|
filename = tmp_path / "state.json"
|
||||||
|
|
||||||
|
service = Service()
|
||||||
|
manager = StateManager(service=service, filename=filename, autosave_interval=0.1)
|
||||||
|
DataServiceObserver(state_manager=manager)
|
||||||
|
|
||||||
|
task = asyncio.create_task(manager.autosave())
|
||||||
|
service.property_attr = 198.0
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
assert filename.exists(), "Autosave should write to the file"
|
||||||
|
async with await anyio.open_file(filename) as f:
|
||||||
|
data = json.loads(await f.read())
|
||||||
|
|
||||||
|
assert data["property_attr"]["value"] == service.property_attr
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import pydase
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pydase
|
||||||
from pydase.observer_pattern.observable.decorators import validate_set
|
from pydase.observer_pattern.observable.decorators import validate_set
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +18,10 @@ def linspace(start: float, stop: float, n: int):
|
|||||||
|
|
||||||
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop.run_forever()
|
try:
|
||||||
|
loop.run_forever()
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
def test_validate_set_precision(caplog: pytest.LogCaptureFixture) -> None:
|
def test_validate_set_precision(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
@@ -89,10 +93,10 @@ def test_validate_set_timeout(caplog: pytest.LogCaptureFixture) -> None:
|
|||||||
def value(self, value: float) -> None:
|
def value(self, value: float) -> None:
|
||||||
self.loop.create_task(self.set_value(value))
|
self.loop.create_task(self.set_value(value))
|
||||||
|
|
||||||
async def set_value(self, value) -> None:
|
async def set_value(self, value: float) -> None:
|
||||||
for i in linspace(self._value, value, 10):
|
for i in linspace(self._value, value, 10):
|
||||||
self._value = i
|
self._value = i
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
class Service(pydase.DataService):
|
class Service(pydase.DataService):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -104,7 +108,7 @@ def test_validate_set_timeout(caplog: pytest.LogCaptureFixture) -> None:
|
|||||||
return self._driver.value
|
return self._driver.value
|
||||||
|
|
||||||
@value_1.setter
|
@value_1.setter
|
||||||
@validate_set(timeout=0.5)
|
@validate_set(timeout=0.01)
|
||||||
def value_1(self, value: float) -> None:
|
def value_1(self, value: float) -> None:
|
||||||
self._driver.value = value
|
self._driver.value = value
|
||||||
|
|
||||||
@@ -113,7 +117,7 @@ def test_validate_set_timeout(caplog: pytest.LogCaptureFixture) -> None:
|
|||||||
return self._driver.value
|
return self._driver.value
|
||||||
|
|
||||||
@value_2.setter
|
@value_2.setter
|
||||||
@validate_set(timeout=1)
|
@validate_set(timeout=0.11)
|
||||||
def value_2(self, value: float) -> None:
|
def value_2(self, value: float) -> None:
|
||||||
self._driver.value = value
|
self._driver.value = value
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ from collections.abc import Generator
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import pydase
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pydase
|
||||||
from pydase.utils.serialization.deserializer import Deserializer
|
from pydase.utils.serialization.deserializer import Deserializer
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture(scope="module")
|
||||||
def pydase_server() -> Generator[None, None, None]:
|
def pydase_server() -> Generator[None, None, None]:
|
||||||
class SubService(pydase.DataService):
|
class SubService(pydase.DataService):
|
||||||
name = "SubService"
|
name = "SubService"
|
||||||
@@ -52,6 +53,9 @@ def pydase_server() -> Generator[None, None, None]:
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
server.handle_exit()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"access_path, expected",
|
"access_path, expected",
|
||||||
@@ -107,7 +111,7 @@ def pydase_server() -> Generator[None, None, None]:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_get_value(
|
async def test_get_value(
|
||||||
access_path: str,
|
access_path: str,
|
||||||
expected: dict[str, Any],
|
expected: dict[str, Any],
|
||||||
@@ -179,7 +183,7 @@ async def test_get_value(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_update_value(
|
async def test_update_value(
|
||||||
access_path: str,
|
access_path: str,
|
||||||
new_value: dict[str, Any],
|
new_value: dict[str, Any],
|
||||||
@@ -219,7 +223,7 @@ async def test_update_value(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_trigger_method(
|
async def test_trigger_method(
|
||||||
access_path: str,
|
access_path: str,
|
||||||
expected: Any,
|
expected: Any,
|
||||||
@@ -278,7 +282,7 @@ async def test_trigger_method(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_client_information_logging(
|
async def test_client_information_logging(
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
log_id: str,
|
log_id: str,
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import threading
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydase
|
|
||||||
import pytest
|
import pytest
|
||||||
import socketio
|
import socketio
|
||||||
|
|
||||||
|
import pydase
|
||||||
from pydase.utils.serialization.deserializer import Deserializer
|
from pydase.utils.serialization.deserializer import Deserializer
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture(scope="module")
|
||||||
def pydase_server() -> Generator[None, None, None]:
|
def pydase_server() -> Generator[None, None, None]:
|
||||||
class SubService(pydase.DataService):
|
class SubService(pydase.DataService):
|
||||||
name = "SubService"
|
name = "SubService"
|
||||||
@@ -51,6 +52,9 @@ def pydase_server() -> Generator[None, None, None]:
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
server.handle_exit()
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"access_path, expected",
|
"access_path, expected",
|
||||||
@@ -106,7 +110,7 @@ def pydase_server() -> Generator[None, None, None]:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_get_value(
|
async def test_get_value(
|
||||||
access_path: str,
|
access_path: str,
|
||||||
expected: dict[str, Any],
|
expected: dict[str, Any],
|
||||||
@@ -181,7 +185,7 @@ async def test_get_value(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_update_value(
|
async def test_update_value(
|
||||||
access_path: str,
|
access_path: str,
|
||||||
new_value: dict[str, Any],
|
new_value: dict[str, Any],
|
||||||
@@ -226,7 +230,7 @@ async def test_update_value(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_trigger_method(
|
async def test_trigger_method(
|
||||||
access_path: str,
|
access_path: str,
|
||||||
expected: Any,
|
expected: Any,
|
||||||
@@ -291,7 +295,7 @@ async def test_trigger_method(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_client_information_logging(
|
async def test_client_information_logging(
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
log_id: str,
|
log_id: str,
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import pydase
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytest import LogCaptureFixture
|
||||||
|
|
||||||
|
import pydase
|
||||||
from pydase.data_service.data_service_observer import DataServiceObserver
|
from pydase.data_service.data_service_observer import DataServiceObserver
|
||||||
from pydase.data_service.state_manager import StateManager
|
from pydase.data_service.state_manager import StateManager
|
||||||
from pydase.task.autostart import autostart_service_tasks
|
from pydase.task.autostart import autostart_service_tasks
|
||||||
from pydase.task.decorator import task
|
from pydase.task.decorator import task
|
||||||
from pydase.task.task_status import TaskStatus
|
from pydase.task.task_status import TaskStatus
|
||||||
from pytest import LogCaptureFixture
|
|
||||||
|
|
||||||
logger = logging.getLogger("pydase")
|
logger = logging.getLogger("pydase")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
@pytest.mark.asyncio()
|
||||||
async def test_start_and_stop_task(caplog: LogCaptureFixture) -> None:
|
async def test_start_and_stop_task(caplog: LogCaptureFixture) -> None:
|
||||||
class MyService(pydase.DataService):
|
class MyService(pydase.DataService):
|
||||||
@task()
|
@task()
|
||||||
@@ -28,11 +29,11 @@ async def test_start_and_stop_task(caplog: LogCaptureFixture) -> None:
|
|||||||
DataServiceObserver(state_manager)
|
DataServiceObserver(state_manager)
|
||||||
|
|
||||||
autostart_service_tasks(service_instance)
|
autostart_service_tasks(service_instance)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
|
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
|
||||||
|
|
||||||
service_instance.my_task.start()
|
service_instance.my_task.start()
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
assert service_instance.my_task.status == TaskStatus.RUNNING
|
assert service_instance.my_task.status == TaskStatus.RUNNING
|
||||||
|
|
||||||
assert "'my_task.status' changed to 'TaskStatus.RUNNING'" in caplog.text
|
assert "'my_task.status' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||||
@@ -40,12 +41,12 @@ async def test_start_and_stop_task(caplog: LogCaptureFixture) -> None:
|
|||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
service_instance.my_task.stop()
|
service_instance.my_task.stop()
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
|
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
|
||||||
assert "Task 'my_task' was cancelled" in caplog.text
|
assert "Task 'my_task' was cancelled" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
@pytest.mark.asyncio()
|
||||||
async def test_autostart_task(caplog: LogCaptureFixture) -> None:
|
async def test_autostart_task(caplog: LogCaptureFixture) -> None:
|
||||||
class MyService(pydase.DataService):
|
class MyService(pydase.DataService):
|
||||||
@task(autostart=True)
|
@task(autostart=True)
|
||||||
@@ -61,13 +62,16 @@ async def test_autostart_task(caplog: LogCaptureFixture) -> None:
|
|||||||
|
|
||||||
autostart_service_tasks(service_instance)
|
autostart_service_tasks(service_instance)
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
assert service_instance.my_task.status == TaskStatus.RUNNING
|
assert service_instance.my_task.status == TaskStatus.RUNNING
|
||||||
|
|
||||||
assert "'my_task.status' changed to 'TaskStatus.RUNNING'" in caplog.text
|
assert "'my_task.status' changed to 'TaskStatus.RUNNING'" in caplog.text
|
||||||
|
|
||||||
|
service_instance.my_task.stop()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
async def test_nested_list_autostart_task(
|
async def test_nested_list_autostart_task(
|
||||||
caplog: LogCaptureFixture,
|
caplog: LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -86,7 +90,7 @@ async def test_nested_list_autostart_task(
|
|||||||
DataServiceObserver(state_manager)
|
DataServiceObserver(state_manager)
|
||||||
autostart_service_tasks(service_instance)
|
autostart_service_tasks(service_instance)
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
assert service_instance.sub_services_list[0].my_task.status == TaskStatus.RUNNING
|
assert service_instance.sub_services_list[0].my_task.status == TaskStatus.RUNNING
|
||||||
assert service_instance.sub_services_list[1].my_task.status == TaskStatus.RUNNING
|
assert service_instance.sub_services_list[1].my_task.status == TaskStatus.RUNNING
|
||||||
|
|
||||||
@@ -99,8 +103,12 @@ async def test_nested_list_autostart_task(
|
|||||||
in caplog.text
|
in caplog.text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service_instance.sub_services_list[0].my_task.stop()
|
||||||
|
service_instance.sub_services_list[1].my_task.stop()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
async def test_nested_dict_autostart_task(
|
async def test_nested_dict_autostart_task(
|
||||||
caplog: LogCaptureFixture,
|
caplog: LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -120,7 +128,7 @@ async def test_nested_dict_autostart_task(
|
|||||||
|
|
||||||
autostart_service_tasks(service_instance)
|
autostart_service_tasks(service_instance)
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
service_instance.sub_services_dict["first"].my_task.status == TaskStatus.RUNNING
|
service_instance.sub_services_dict["first"].my_task.status == TaskStatus.RUNNING
|
||||||
@@ -139,8 +147,12 @@ async def test_nested_dict_autostart_task(
|
|||||||
in caplog.text
|
in caplog.text
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service_instance.sub_services_dict["first"].my_task.stop()
|
||||||
|
service_instance.sub_services_dict["second"].my_task.stop()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
async def test_manual_start_with_multiple_service_instances(
|
async def test_manual_start_with_multiple_service_instances(
|
||||||
caplog: LogCaptureFixture,
|
caplog: LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -161,7 +173,7 @@ async def test_manual_start_with_multiple_service_instances(
|
|||||||
|
|
||||||
autostart_service_tasks(service_instance)
|
autostart_service_tasks(service_instance)
|
||||||
|
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
service_instance.sub_services_list[0].my_task.status == TaskStatus.NOT_RUNNING
|
service_instance.sub_services_list[0].my_task.status == TaskStatus.NOT_RUNNING
|
||||||
@@ -291,7 +303,7 @@ async def test_manual_start_with_multiple_service_instances(
|
|||||||
assert "Task 'my_task' was cancelled" in caplog.text
|
assert "Task 'my_task' was cancelled" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
@pytest.mark.asyncio()
|
||||||
async def test_restart_on_exception(caplog: LogCaptureFixture) -> None:
|
async def test_restart_on_exception(caplog: LogCaptureFixture) -> None:
|
||||||
class MyService(pydase.DataService):
|
class MyService(pydase.DataService):
|
||||||
@task(restart_on_exception=True, restart_sec=0.1)
|
@task(restart_on_exception=True, restart_sec=0.1)
|
||||||
@@ -312,8 +324,11 @@ async def test_restart_on_exception(caplog: LogCaptureFixture) -> None:
|
|||||||
assert "Task 'my_task' encountered an exception" in caplog.text
|
assert "Task 'my_task' encountered an exception" in caplog.text
|
||||||
assert "Triggered task." in caplog.text
|
assert "Triggered task." in caplog.text
|
||||||
|
|
||||||
|
service_instance.my_task.stop()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
async def test_restart_sec(caplog: LogCaptureFixture) -> None:
|
async def test_restart_sec(caplog: LogCaptureFixture) -> None:
|
||||||
class MyService(pydase.DataService):
|
class MyService(pydase.DataService):
|
||||||
@task(restart_on_exception=True, restart_sec=0.1)
|
@task(restart_on_exception=True, restart_sec=0.1)
|
||||||
@@ -334,8 +349,11 @@ async def test_restart_sec(caplog: LogCaptureFixture) -> None:
|
|||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
assert "Triggered task." in caplog.text # Ensures the task restarted after 0.2s
|
assert "Triggered task." in caplog.text # Ensures the task restarted after 0.2s
|
||||||
|
|
||||||
|
service_instance.my_task.stop()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
async def test_exceeding_start_limit_interval_sec_and_burst(
|
async def test_exceeding_start_limit_interval_sec_and_burst(
|
||||||
caplog: LogCaptureFixture,
|
caplog: LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -359,7 +377,7 @@ async def test_exceeding_start_limit_interval_sec_and_burst(
|
|||||||
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
|
assert service_instance.my_task.status == TaskStatus.NOT_RUNNING
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
@pytest.mark.asyncio()
|
||||||
async def test_non_exceeding_start_limit_interval_sec_and_burst(
|
async def test_non_exceeding_start_limit_interval_sec_and_burst(
|
||||||
caplog: LogCaptureFixture,
|
caplog: LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -382,8 +400,11 @@ async def test_non_exceeding_start_limit_interval_sec_and_burst(
|
|||||||
assert "Task 'my_task' exceeded restart burst limit" not in caplog.text
|
assert "Task 'my_task' exceeded restart burst limit" not in caplog.text
|
||||||
assert service_instance.my_task.status == TaskStatus.RUNNING
|
assert service_instance.my_task.status == TaskStatus.RUNNING
|
||||||
|
|
||||||
|
service_instance.my_task.stop()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
async def test_exit_on_failure(
|
async def test_exit_on_failure(
|
||||||
monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture
|
monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -408,7 +429,7 @@ async def test_exit_on_failure(
|
|||||||
assert "Task 'my_task' encountered an exception" in caplog.text
|
assert "Task 'my_task' encountered an exception" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
@pytest.mark.asyncio()
|
||||||
async def test_exit_on_failure_exceeding_rate_limit(
|
async def test_exit_on_failure_exceeding_rate_limit(
|
||||||
monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture
|
monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -438,7 +459,7 @@ async def test_exit_on_failure_exceeding_rate_limit(
|
|||||||
assert "Task 'my_task' encountered an exception" in caplog.text
|
assert "Task 'my_task' encountered an exception" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="function")
|
@pytest.mark.asyncio()
|
||||||
async def test_gracefully_finishing_task(
|
async def test_gracefully_finishing_task(
|
||||||
monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture
|
monkeypatch: pytest.MonkeyPatch, caplog: LogCaptureFixture
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import toml
|
|||||||
|
|
||||||
def test_project_version() -> None:
|
def test_project_version() -> None:
|
||||||
pyproject = toml.load("pyproject.toml")
|
pyproject = toml.load("pyproject.toml")
|
||||||
pydase_pyroject_version = pyproject["tool"]["poetry"]["version"]
|
pydase_pyroject_version = pyproject["project"]["version"]
|
||||||
assert pydase.version.__version__ == pydase_pyroject_version
|
assert pydase.version.__version__ == pydase_pyroject_version
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import pydase
|
import pydase
|
||||||
import pydase.units as u
|
import pydase.units as u
|
||||||
import pytest
|
|
||||||
from pydase.components.coloured_enum import ColouredEnum
|
from pydase.components.coloured_enum import ColouredEnum
|
||||||
from pydase.task.task_status import TaskStatus
|
from pydase.task.task_status import TaskStatus
|
||||||
from pydase.utils.decorators import frontend
|
from pydase.utils.decorators import frontend
|
||||||
from pydase.utils.serialization.serializer import (
|
from pydase.utils.serialization.serializer import (
|
||||||
SerializationPathError,
|
SerializationPathError,
|
||||||
SerializedObject,
|
|
||||||
add_prefix_to_full_access_path,
|
add_prefix_to_full_access_path,
|
||||||
dump,
|
dump,
|
||||||
generate_serialized_data_paths,
|
generate_serialized_data_paths,
|
||||||
@@ -21,6 +21,7 @@ from pydase.utils.serialization.serializer import (
|
|||||||
serialized_dict_is_nested_object,
|
serialized_dict_is_nested_object,
|
||||||
set_nested_value_by_path,
|
set_nested_value_by_path,
|
||||||
)
|
)
|
||||||
|
from pydase.utils.serialization.types import SerializedObject
|
||||||
|
|
||||||
|
|
||||||
class MyEnum(enum.Enum):
|
class MyEnum(enum.Enum):
|
||||||
@@ -207,7 +208,7 @@ def test_ColouredEnum_serialize() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(scope="module")
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
async def test_method_serialization() -> None:
|
async def test_method_serialization() -> None:
|
||||||
class ClassWithMethod(pydase.DataService):
|
class ClassWithMethod(pydase.DataService):
|
||||||
def some_method(self) -> str:
|
def some_method(self) -> str:
|
||||||
@@ -252,7 +253,7 @@ def test_methods_with_type_hints() -> None:
|
|||||||
def method_with_type_hint(some_argument: int) -> None:
|
def method_with_type_hint(some_argument: int) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def method_with_union_type_hint(some_argument: int | float) -> None:
|
def method_with_union_type_hint(some_argument: int | float) -> None: # noqa: PYI041
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert dump(method_without_type_hint) == {
|
assert dump(method_without_type_hint) == {
|
||||||
@@ -1224,3 +1225,22 @@ def test_add_prefix_to_full_access_path(
|
|||||||
serialized_obj: SerializedObject, prefix: str, expected: SerializedObject
|
serialized_obj: SerializedObject, prefix: str, expected: SerializedObject
|
||||||
) -> None:
|
) -> None:
|
||||||
assert add_prefix_to_full_access_path(serialized_obj, prefix) == expected
|
assert add_prefix_to_full_access_path(serialized_obj, prefix) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_exception() -> None:
|
||||||
|
assert dump(Exception()) == {
|
||||||
|
"doc": None,
|
||||||
|
"full_access_path": "",
|
||||||
|
"name": "Exception",
|
||||||
|
"readonly": True,
|
||||||
|
"type": "Exception",
|
||||||
|
"value": "",
|
||||||
|
}
|
||||||
|
assert dump(Exception("Exception message")) == {
|
||||||
|
"doc": None,
|
||||||
|
"full_access_path": "",
|
||||||
|
"name": "Exception",
|
||||||
|
"readonly": True,
|
||||||
|
"type": "Exception",
|
||||||
|
"value": "Exception message",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pytest import LogCaptureFixture
|
import pytest
|
||||||
|
from pydase.utils.logging import configure_logging_with_pydase_formatter
|
||||||
|
|
||||||
|
|
||||||
def test_log_error(caplog: LogCaptureFixture):
|
def test_log_error(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
logger = logging.getLogger("pydase")
|
logger = logging.getLogger("pydase")
|
||||||
logger.setLevel(logging.ERROR)
|
logger.setLevel(logging.ERROR)
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ def test_log_error(caplog: LogCaptureFixture):
|
|||||||
assert any(record.levelname == "ERROR" for record in caplog.records)
|
assert any(record.levelname == "ERROR" for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
def test_log_warning(caplog: LogCaptureFixture):
|
def test_log_warning(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
logger = logging.getLogger("pydase")
|
logger = logging.getLogger("pydase")
|
||||||
logger.setLevel(logging.WARNING)
|
logger.setLevel(logging.WARNING)
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ def test_log_warning(caplog: LogCaptureFixture):
|
|||||||
assert any(record.levelname == "ERROR" for record in caplog.records)
|
assert any(record.levelname == "ERROR" for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
def test_log_debug(caplog: LogCaptureFixture):
|
def test_log_debug(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
logger = logging.getLogger("pydase")
|
logger = logging.getLogger("pydase")
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ def test_log_debug(caplog: LogCaptureFixture):
|
|||||||
assert "This is an error message" in caplog.text
|
assert "This is an error message" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_log_info(caplog: LogCaptureFixture):
|
def test_log_info(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
logger = logging.getLogger("pydase")
|
logger = logging.getLogger("pydase")
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
@@ -67,3 +68,21 @@ def test_log_info(caplog: LogCaptureFixture):
|
|||||||
assert "This is an info message" in caplog.text
|
assert "This is an info message" in caplog.text
|
||||||
assert "This is a warning message" in caplog.text
|
assert "This is a warning message" in caplog.text
|
||||||
assert "This is an error message" in caplog.text
|
assert "This is an error message" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_before_configuring_root_logger(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info("Hello world")
|
||||||
|
|
||||||
|
assert "Hello world" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_root_logger(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
configure_logging_with_pydase_formatter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info("Hello world")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"INFO tests.utils.test_logging:test_logging.py:83 Hello world"
|
||||||
|
in caplog.text
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user