32 Commits

Author SHA1 Message Date
Mose Müller
c36cebf17c Merge pull request #213 from tiqi-group/release-v0.10.10
updates version to v0.10.10
2025-03-27 11:49:21 +01:00
Mose Müller
a96387b4d7 updates version to v0.10.10 2025-03-27 11:48:50 +01:00
Mose Müller
d1feff1a6a Merge pull request #211 from tiqi-group/feat/add_logging_config_helper
Feat: add logging config helper
2025-03-27 11:47:08 +01:00
Mose Müller
95df2f1650 docs: updates logging documentation 2025-03-27 11:43:28 +01:00
Mose Müller
0565c82448 renames function, can now take the name of the logger (defaults to root logger) 2025-03-27 11:43:15 +01:00
Mose Müller
755265bf53 Merge pull request #212 from tiqi-group/44-manually-saving-current-state-of-all-objects
Feat: autosaving feature
2025-03-25 14:06:30 +01:00
Mose Müller
4c7b386ab4 docs: updates state manager docstring (adds DataServiceCache link) 2025-03-25 14:05:21 +01:00
Mose Müller
92b2326dfc tests: adds autosave feature test 2025-03-25 14:01:31 +01:00
Mose Müller
9e18783a05 adds default values to StateManager 2025-03-25 13:46:39 +01:00
Mose Müller
9be4aac988 docs: updates service persistence documentation 2025-03-25 13:41:57 +01:00
Mose Müller
f3d659670f feat: adds autosave feature
The pydase service automatically saves the current state to a file now.
The interval between automatic save events can be configured using the
`autosave_interval` argument passed to the pydase.Server.
2025-03-25 13:41:25 +01:00
Mose Müller
23f051d6f1 fixes pyright issue 2025-02-28 17:08:42 +01:00
Mose Müller
c8979ab2e6 adds logging tests and type hints 2025-02-28 16:56:40 +01:00
Mose Müller
bd33252775 log: adds helper function configure_root_logger_with_pydase_formatter 2025-02-28 16:56:16 +01:00
Mose Müller
1fbcbc72bf Merge pull request #210 from tiqi-group/logging/updates_handlers
Feat: Updates logging handlers
2025-02-28 15:57:06 +01:00
Mose Müller
9a8628cfbd log: only write pydase.server to stdout, the rest to stderr 2025-02-28 15:53:14 +01:00
Mose Müller
3d13b20fda log: using filters to separate server and task logs from the rest 2025-02-28 15:43:24 +01:00
Mose Müller
f2183ec3e4 log: changing log level of trigger_method 2025-02-27 15:45:01 +01:00
Mose Müller
360aeb5574 log: pydase.server logs are written to stdout, the rest is written to stderr 2025-02-27 15:37:35 +01:00
Mose Müller
e85e93a1d9 Merge pull request #209 from tiqi-group/release-v0.10.9
updates version to v0.10.9
2025-02-20 17:34:00 +01:00
Mose Müller
ea5fd42919 updates version to v0.10.9 2025-02-20 17:33:33 +01:00
Mose Müller
247113f1db Merge pull request #208 from tiqi-group/feat/add_client_id_header
client: adds X-Client-Id header to pydase.Client
2025-02-20 17:30:31 +01:00
Mose Müller
c76b0b0b6e updates test 2025-02-20 17:28:53 +01:00
Mose Müller
2d39c56e3d updates docs 2025-02-20 17:28:07 +01:00
Mose Müller
60287fef95 client: client_id arg defaults to None 2025-02-20 17:27:55 +01:00
Mose Müller
c5e1a08c54 client: adds X-Client-Id header to pydase.Client 2025-02-20 17:17:19 +01:00
Mose Müller
9424d4c412 Merge pull request #207 from tiqi-group/fix/removes_backtick_from_index_html
chore: removes backtick typo from index.html
2025-01-20 14:01:15 +01:00
Mose Müller
0a4c13c617 frontend: removes backtick typo from index.html 2025-01-20 14:00:39 +01:00
Mose Müller
5d72604199 Merge pull request #206 from tiqi-group/fix/serving_modified_html
fix: serves modified index.html (X-Forwarded-Proto) when X-Forwarded-Prefix is not set
2025-01-20 13:56:42 +01:00
Mose Müller
3479c511fe fix: serves modified index.html (X-Forwarded-Proto) when X-Forwarded-Prefix is not set
When X-Forwarded-Prefix was not set, the X-Forwarded-Proto was also not
updated on the index.html file.
2025-01-20 13:55:04 +01:00
Mose Müller
9bf3b28390 Merge pull request #205 from tiqi-group/frontend/update_packages
frontend: updates packages
2025-01-20 13:11:49 +01:00
Mose Müller
0195f9d6f6 frontend: updates packages 2025-01-20 13:07:44 +01:00
24 changed files with 3882 additions and 2926 deletions

View File

@@ -226,45 +226,15 @@ For details, please see [here](https://pydase.readthedocs.io/en/stable/user-guid
## 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
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.
For more information, see the [full guide](https://pydase.readthedocs.io/en/stable/user-guide/Logging/).
## Documentation

View File

@@ -1,6 +1,15 @@
::: pydase.data_service
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
handler: python
@@ -38,6 +47,9 @@
options:
filters: ["!render_in_frontend"]
::: pydase.utils.logging
handler: python
::: pydase.units
handler: python

View File

@@ -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.
### Changing the Log Level
## Changing the pydase Log Level
You have two primary ways to adjust the log levels in `pydase`:
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
# <your_script.py>
import logging
```python
# <your_script.py>
import logging
# Set the log level for the main pydase logger
logging.getLogger("pydase").setLevel(logging.INFO)
# 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)
# 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.")
```
# Your logger for the current script
from pydase.utils.logging import configure_logging_with_pydase_formatter
configure_logging_with_pydase_formatter(level=logging.DEBUG)
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**
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
ENVIRONMENT="production" python -m <module_using_pydase>
```
```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.
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:
@@ -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'
```
## 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).

View File

@@ -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.
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.
Heres an example:
```python
import pydase
class Device(pydase.DataService):
# ... defining the Device class ...
# ... define your service class ...
if __name__ == "__main__":
service = Device()
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`
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
import pydase
@@ -43,7 +61,6 @@ class Device(pydase.DataService):
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.
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.
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 structure of your service class changes between saves, only properties decorated with `@load_state` and unchanged public attributes will be restored safely.

View File

@@ -50,12 +50,14 @@ import pydase
class MyService(pydase.DataService):
proxy = pydase.Client(
url="ws://<ip_addr>:<service_port>",
block_until_connected=False
block_until_connected=False,
client_id="my_pydase_client_id",
).proxy
# For SSL-encrypted services, use the wss protocol
# proxy = pydase.Client(
# url="wss://your-domain.ch",
# block_until_connected=False
# block_until_connected=False,
# client_id="my_pydase_client_id",
# ).proxy
if __name__ == "__main__":
@@ -67,6 +69,7 @@ if __name__ == "__main__":
In this example:
- 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 `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.
## Custom `socketio.AsyncClient` Connection Parameters

View File

@@ -13,7 +13,7 @@
// this will be set by the python backend if the service is behind a proxy which strips a prefix. The frontend can use this to build the paths to the resources.
window.__FORWARDED_PREFIX__ = "";
window.__FORWARDED_PROTO__ = "";
</script>`
</script>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

File diff suppressed because it is too large Load Diff

View File

@@ -10,31 +10,31 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.14.1",
"@emotion/styled": "^11.14.0",
"@mui/material": "^5.16.14",
"bootstrap": "^5.3.3",
"deep-equal": "^2.2.3",
"react": "^18.3.1",
"react-bootstrap": "^2.10.0",
"react-bootstrap-icons": "^1.11.4",
"socket.io-client": "^4.7.1"
"react": "^19.0.0",
"react-bootstrap": "^2.10.7",
"react-bootstrap-icons": "^1.11.5",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@eslint/js": "^9.6.0",
"@eslint/js": "^9.18.0",
"@types/deep-equal": "^1.0.4",
"@types/eslint__js": "^8.42.3",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/node": "^20.17.14",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^8.57.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react": "^7.37.4",
"prettier": "3.3.2",
"typescript": "^5.5.3",
"typescript-eslint": "^7.15.0",
"vite": "^5.3.1"
"typescript": "^5.7.3",
"typescript-eslint": "^7.18.0",
"vite": "^5.4.12"
}
}

3059
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "pydase"
version = "0.10.8"
version = "0.10.10"
description = "A flexible and robust Python library for creating, managing, and interacting with data services, with built-in support for web and RPC servers, and customizable features for diverse use cases."
authors = ["Mose Mueller <mosmuell@ethz.ch>"]
readme = "README.md"

View File

@@ -56,6 +56,9 @@ class Client:
[`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the
client's behaviour (e.g., reconnection attempts or reconnection delay).
Default is an empty dictionary.
client_id: Client identification that will be shown in the server logs this
client is connecting to. This ID is passed as a `X-Client-Id` header in the
HTTP(s) request. Defaults to None.
Example:
The following example demonstrates a `Client` instance that connects to another
@@ -84,6 +87,7 @@ class Client:
url: str,
block_until_connected: bool = True,
sio_client_kwargs: dict[str, Any] = {},
client_id: str | None = None,
):
# Parse the URL to separate base URL and path prefix
parsed_url = urllib.parse.urlparse(url)
@@ -98,6 +102,7 @@ class Client:
self._url = url
self._sio = socketio.AsyncClient(**sio_client_kwargs)
self._loop = asyncio.new_event_loop()
self._client_id = client_id
self.proxy = ProxyClass(
sio_client=self._sio, loop=self._loop, reconnect=self.connect
)
@@ -136,8 +141,14 @@ class Client:
async def _connect(self) -> None:
logger.debug("Connecting to server '%s' ...", self._url)
await self._setup_events()
headers = {}
if self._client_id is not None:
headers["X-Client-Id"] = self._client_id
await self._sio.connect(
self._base_url,
url=self._base_url,
headers=headers,
socketio_path=f"{self._path_prefix}/ws/socket.io",
transports=["websocket"],
retry=True,

View File

@@ -14,6 +14,22 @@ logger = logging.getLogger(__name__)
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:
self._cache: SerializedObject
self.service = service

View File

@@ -1,3 +1,4 @@
import asyncio
import contextlib
import json
import logging
@@ -66,43 +67,41 @@ def has_load_state_decorator(prop: property) -> bool:
class StateManager:
"""
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
for newly connecting web clients without the need for expensive property accesses
that may involve complex calculations or I/O operations.
persistence layer. It provides fast access to the most recently known state of the
service and ensures consistent state updates across connected clients and service
restarts.
The StateManager listens for state change notifications from the DataService's
callback manager and updates its cache accordingly. This cache does not always
reflect the most current complex property states but rather retains the value from
the last known state, optimizing for performance and reducing the load on the
system.
The StateManager is used by the web server to apply updates to service attributes
and to serve the current state to newly connected clients. Internally, it creates a
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache]
instance to track the state of public attributes and properties.
While the StateManager ensures that the cached state is as up-to-date as possible,
it does not autonomously update complex properties of the DataService. Such
properties must be updated programmatically, for instance, by invoking specific
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.
The StateManager also handles state persistence: it can load a previously saved
state from disk at startup and periodically autosave the current state to a file
during runtime.
Args:
service:
The DataService instance whose state is being managed.
filename:
The file name used for storing the DataService's state.
service: The DataService instance whose state is being managed.
filename: The file name used for loading and storing the DataService's state.
If provided, the state is loaded from this file at startup and saved to it
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:
The StateManager's cache updates are triggered by notifications and do not
include autonomous updates of complex DataService properties, which must be
managed programmatically. The cache serves the purpose of providing immediate
state information to web clients, reflecting the state after the last property
update.
The StateManager does not autonomously poll hardware state. It relies on the
service to perform such updates. The cache maintained by
[`DataServiceCache`][pydase.data_service.data_service_cache.DataServiceCache]
reflects the last known state as notified by the `DataServiceObserver`, and is
used by the web interface to provide fast and accurate state rendering for
connected clients.
"""
def __init__(
self, service: "DataService", filename: str | Path | None = None
self,
service: "DataService",
filename: str | Path | None = None,
autosave_interval: float | None = None,
) -> None:
self.filename = getattr(service, "_filename", None)
@@ -115,6 +114,29 @@ class StateManager:
self.service = 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
def cache_value(self) -> dict[str, SerializedObject]:
@@ -122,23 +144,21 @@ class StateManager:
return cast(dict[str, SerializedObject], self.cache_manager.cache["value"])
def save_state(self) -> None:
"""
Saves the DataService's current state to a JSON file defined by `self.filename`.
Logs an error if `self.filename` is not set.
"""Saves the DataService's current state to a JSON file defined by
`self.filename`.
"""
if self.filename is not None:
with open(self.filename, "w") as f:
json.dump(self.cache_value, f, indent=4)
else:
logger.info(
logger.debug(
"State manager was not initialised with a filename. Skipping "
"'save_state'..."
)
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.
"""
@@ -191,8 +211,7 @@ class StateManager:
path: str,
serialized_value: SerializedObject,
) -> 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.
This method updates the attribute specified by 'path' with 'value' only if the

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,15 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site displaying a pydase UI." />
<script type="module" crossorigin src="/assets/index-B4RiTBEo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2aktF3W.css">
<script type="module" crossorigin src="/assets/index-DpoEqi_N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJzFvk4W.css">
</head>
<script>
// this will be set by the python backend if the service is behind a proxy which strips a prefix. The frontend can use this to build the paths to the resources.
window.__FORWARDED_PREFIX__ = "";
window.__FORWARDED_PROTO__ = "";
</script>`
</script>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -84,21 +84,15 @@ class Server:
The `Server` class provides a flexible server implementation for the `DataService`.
Args:
service:
The DataService instance that this server will manage.
host:
The host address for the server. Defaults to `'0.0.0.0'`, which means all
service: The DataService instance that this server will manage.
host: The host address for the server. Defaults to `'0.0.0.0'`, which means all
available network interfaces.
web_port:
The port number for the web server. Defaults to
web_port: The port number for the web server. Defaults to
[`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port].
enable_web:
Whether to enable the web server.
filename:
Filename of the file managing the service state persistence.
additional_servers:
A list of additional servers to run alongside the main server.
enable_web: Whether to enable the web server.
filename: Filename of the file managing the service state persistence.
additional_servers: A list of additional servers to run alongside the main
server.
Here's an example of how you might define an additional server:
```python
@@ -137,8 +131,9 @@ class Server:
)
server.run()
```
**kwargs:
Additional keyword arguments.
autosave_interval: Interval in seconds between automatic state save events.
If set to `None`, automatic saving is disabled. Defaults to 30 seconds.
**kwargs: Additional keyword arguments.
"""
def __init__( # noqa: PLR0913
@@ -149,6 +144,7 @@ class Server:
enable_web: bool = True,
filename: str | Path | None = None,
additional_servers: list[AdditionalServer] | None = None,
autosave_interval: float = 30.0,
**kwargs: Any,
) -> None:
if additional_servers is None:
@@ -161,7 +157,11 @@ class Server:
self._additional_servers = additional_servers
self.should_exit = False
self.servers: dict[str, asyncio.Future[Any]] = {}
self._state_manager = StateManager(self._service, filename)
self._state_manager = StateManager(
service=self._service,
filename=filename,
autosave_interval=autosave_interval,
)
self._observer = DataServiceObserver(self._state_manager)
self._state_manager.load_state()
autostart_service_tasks(self._service)
@@ -223,6 +223,8 @@ class Server:
server_task.add_done_callback(self._handle_server_shutdown)
self.servers["web"] = server_task
self._loop.create_task(self._state_manager.autosave())
def _handle_server_shutdown(self, task: asyncio.Task[Any]) -> None:
"""Handle server shutdown. If the service should exit, do nothing. Else, make
the service exit."""

View File

@@ -202,7 +202,7 @@ def setup_sio_events(sio: socketio.AsyncServer, state_manager: StateManager) ->
@sio.event
async def trigger_method(sid: str, data: TriggerMethodDict) -> Any:
async with sio.session(sid) as session:
logger.debug(
logger.info(
"Client [%s] is triggering the method '%s'",
session["client_id"],
data["access_path"],

View File

@@ -150,10 +150,7 @@ class WebServer:
f"{escaped_prefix}/favicon.ico",
)
return aiohttp.web.Response(
text=modified_html, content_type="text/html"
)
return aiohttp.web.FileResponse(self.frontend_src / "index.html")
return aiohttp.web.Response(text=modified_html, content_type="text/html")
app = aiohttp.web.Application()

View File

@@ -4,7 +4,7 @@ import logging.config
import sys
from collections.abc import Callable
from copy import copy
from typing import ClassVar, Literal
from typing import ClassVar, Literal, TextIO
import click
import socketio # type: ignore[import-untyped]
@@ -29,22 +29,44 @@ LOGGING_CONFIG = {
"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": {
"default": {
"stdout_handler": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"filters": ["only_pydase_server"],
},
"stderr_handler": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
"filters": ["exclude_pydase_server"],
},
},
"loggers": {
"pydase": {"handlers": ["default"], "level": LOG_LEVEL, "propagate": False},
"pydase": {
"handlers": ["stdout_handler", "stderr_handler"],
"level": LOG_LEVEL,
"propagate": False,
},
"aiohttp_middlewares": {
"handlers": ["default"],
"handlers": ["stderr_handler"],
"level": logging.WARNING,
"propagate": False,
},
"aiohttp": {
"handlers": ["default"],
"handlers": ["stderr_handler"],
"level": logging.INFO,
"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):
"""
A custom log formatter class that:
@@ -150,3 +189,51 @@ def setup_logging() -> None:
logger.debug("Configuring pydase logging.")
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)

View File

@@ -161,3 +161,15 @@ def test_context_manager(pydase_client: pydase.Client) -> None:
assert client.proxy.my_property == 1337.01
assert not client.proxy.connected
def test_client_id(
pydase_client: pydase.Client, caplog: pytest.LogCaptureFixture
) -> None:
pydase.Client(url="ws://localhost:9999")
assert "Client [sid=" in caplog.text
caplog.clear()
pydase.Client(url="ws://localhost:9999", client_id="my_service")
assert "Client [id=my_service] connected" in caplog.text

View File

@@ -1,10 +1,13 @@
import asyncio
import json
from pathlib import Path
from typing import Any
import anyio
import pydase
import pydase.components
import pydase.units as u
import pytest
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import (
StateManager,
@@ -349,4 +352,24 @@ def test_property_load_state(tmp_path: Path) -> None:
assert service_instance.name == "Some other name"
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

View File

@@ -1,9 +1,10 @@
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.setLevel(logging.ERROR)
@@ -20,7 +21,7 @@ def test_log_error(caplog: LogCaptureFixture):
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.setLevel(logging.WARNING)
@@ -37,7 +38,7 @@ def test_log_warning(caplog: LogCaptureFixture):
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.setLevel(logging.DEBUG)
@@ -53,7 +54,7 @@ def test_log_debug(caplog: LogCaptureFixture):
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.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 a warning 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
)