16 Commits

Author SHA1 Message Date
Mose Müller
a4b4f179c6 Merge pull request #245 from tiqi-group/release-v0.10.17
updates to version v0.10.17
2025-06-19 10:43:45 +02:00
Mose Müller
c6beca3961 updates to version v0.10.17 2025-06-19 10:43:13 +02:00
Mose Müller
2fa8240e54 Merge pull request #244 from tiqi-group/feat/add_python_3_13_support
feat: official support for python 3.13
2025-06-19 10:42:11 +02:00
Mose Müller
369587a50c adds python 3.13 to checked versions in the github workflow 2025-06-19 10:39:40 +02:00
Mose Müller
25343f6909 Merge pull request #243 from tiqi-group/fix/test_image
fix: pathlib.Path signature annotation change in python 3.13
2025-06-19 10:37:53 +02:00
Mose Müller
c136c9f3de fix: pathlib.Path signature annotation change in python 3.13 2025-06-19 10:35:35 +02:00
Mose Müller
8897c2fe4c Merge pull request #242 from tiqi-group/refactor/web-server-initialisation
Refactor: web server initialisation
2025-06-19 10:26:12 +02:00
Mose Müller
80c5c4e99d fix: check if loop is running 2025-06-19 10:23:25 +02:00
Mose Müller
423441a74c initialise WebServer in pydase.Server constructor
The WebServer can be initialised in the pydase.Server constructor
without any problems. This would allow users to access the socketio
server before starting the pydase.Server.
2025-06-19 10:23:07 +02:00
Mose Müller
9ec60e3891 Merge pull request #241 from tiqi-group/fix/sio_server_warnings
fix: check if loop is running in SocketIOHandler
2025-06-19 10:00:53 +02:00
Mose Müller
8bde104322 fix: check if loop is running in SocketIOHandler
Before emitting sio events in the SocketIOHandler, I have to check if
the loop is actually still running. This caused issues with pytest as
pytest was tearing down asyncio tasks and stopping the loop, while the
sio handler was still trying to send those logs to the sio clients.
2025-06-19 09:59:08 +02:00
Mose Müller
9b57b6984e Merge pull request #240 from tiqi-group/fix/enable-web-argument
Fix: enable web argument handling
2025-06-19 09:53:29 +02:00
Mose Müller
e5b89f2581 always emit exceptions (also when enable_web is false)
replaces enable_web check with loop.is_running()
2025-06-19 09:50:09 +02:00
Mose Müller
ff1654e65c fix: enable_web argument toggles the frontend now
- always initialise the WebServer (also if enable_web is False).
Otherwise, the socketio server will not be initialised
- passing the enable_web argument to the WebServer which is then used to
decide whether to add the frontend routes
2025-06-19 09:43:34 +02:00
Mose Müller
cded80c8e5 Merge pull request #239 from tiqi-group/feat/post-startup-hook
feat: adds post_startup hook to pydase.Server
2025-06-19 09:23:34 +02:00
Mose Müller
87a33b6293 adds post_startup hook to pydase.Server 2025-06-19 09:18:44 +02:00
7 changed files with 59 additions and 36 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

View File

@@ -1,6 +1,6 @@
[project]
name = "pydase"
version = "0.10.16"
version = "0.10.17"
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 = [
{name = "Mose Müller",email = "mosemueller@gmail.com"}

View File

@@ -135,6 +135,14 @@ class Server:
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.
# 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
@@ -174,6 +182,14 @@ class Server:
self._state_manager.load_state()
autostart_service_tasks(self._service)
self._web_server = WebServer(
data_service_observer=self._observer,
host=self._host,
port=self._web_port,
enable_frontend=self._enable_web,
**self._kwargs,
)
def run(self) -> None:
"""
Initializes the asyncio event loop and starts the server.
@@ -191,6 +207,7 @@ class Server:
logger.info("Started server process [%s]", process_id)
await self.startup()
await self.post_startup()
if self.should_exit:
return
await self.main_loop()
@@ -202,6 +219,10 @@ class Server:
self._loop.set_exception_handler(self.custom_exception_handler)
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:
addin_server = server["server"](
data_service_observer=self._observer,
@@ -217,17 +238,6 @@ class Server:
server_task = self._loop.create_task(addin_server.serve())
server_task.add_done_callback(self._handle_server_shutdown)
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.servers["web"] = server_task
self._loop.create_task(self._state_manager.autosave())
@@ -258,6 +268,9 @@ class Server:
logger.debug("Cancelling 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:
for server_name, task in self.servers.items():
task.cancel()
@@ -307,7 +320,7 @@ class Server:
# here we exclude most kinds of exceptions from triggering this kind of shutdown
exc = context.get("exception")
if type(exc) not in [RuntimeError, KeyboardInterrupt, asyncio.CancelledError]:
if self._enable_web:
if loop.is_running():
async def emit_exception() -> None:
try:

View File

@@ -115,7 +115,7 @@ def setup_sio_server(
def sio_callback(
full_access_path: str, value: Any, cached_value_dict: SerializedObject
) -> None:
if cached_value_dict != {}:
if cached_value_dict != {} and loop.is_running():
async def notify() -> None:
try:

View File

@@ -81,6 +81,7 @@ class WebServer:
host: str,
port: int,
*,
enable_frontend: bool = True,
css: str | Path | None = None,
favicon_path: str | Path | None = None,
enable_cors: bool = True,
@@ -97,19 +98,18 @@ class WebServer:
self.enable_cors = enable_cors
self.frontend_src = frontend_src
self.favicon_path: Path | str = favicon_path # type: ignore
self.enable_frontend = enable_frontend
if self.favicon_path is None:
self.favicon_path = self.frontend_src / "favicon.ico"
self._service_config_dir = config_dir
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()
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(
request: aiohttp.web.Request,
) -> aiohttp.web.Response | aiohttp.web.FileResponse:
@@ -162,15 +162,17 @@ class WebServer:
# Define routes
self._sio.attach(app, socketio_path="/ws/socket.io")
app.router.add_static("/assets", self.frontend_src / "assets")
app.router.add_get("/favicon.ico", self._favicon_route)
app.router.add_get("/service-properties", self._service_properties_route)
app.router.add_get("/web-settings", self._web_settings_route)
app.router.add_get("/custom.css", self._styles_route)
if self.enable_frontend:
app.router.add_static("/assets", self.frontend_src / "assets")
app.router.add_get("/favicon.ico", self._favicon_route)
app.router.add_get("/service-properties", self._service_properties_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.router.add_get(r"/", index)
app.router.add_get(r"/{tail:.*}", index)
if self.enable_frontend:
app.router.add_get(r"/", index)
app.router.add_get(r"/{tail:.*}", index)
await aiohttp.web._run_app(
app,

View File

@@ -165,15 +165,16 @@ class SocketIOHandler(logging.Handler):
log_entry = self.format(record)
loop = asyncio.get_event_loop()
loop.create_task(
self._sio.emit(
"log",
{
"levelname": record.levelname,
"message": log_entry,
},
if loop.is_running():
loop.create_task(
self._sio.emit(
"log",
{
"levelname": record.levelname,
"message": log_entry,
},
)
)
)
def setup_logging() -> None:

View File

@@ -1,10 +1,17 @@
import sys
from pytest import LogCaptureFixture
import pydase
import pydase.components
from pydase.data_service.data_service_observer import DataServiceObserver
from pydase.data_service.state_manager import StateManager
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:
@@ -106,7 +113,7 @@ def test_image_serialization() -> None:
"signature": {
"parameters": {
"path": {
"annotation": "pathlib.Path | str",
"annotation": f"{PATHLIB_PATH} | str",
"default": {},
}
},