mirror of
https://github.com/tiqi-group/pydase.git
synced 2025-06-07 22:10:41 +02:00
Compare commits
29 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 |
@ -58,7 +58,7 @@ 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
|
||||||
@ -77,7 +77,7 @@ 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.
|
- By setting `block_until_connected=False`, the service can start without waiting for the connection to succeed.
|
||||||
- By setting `client_id`, the server will log a descriptive identifier for this client via the `X-Client-Id` HTTP header.
|
- 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
|
||||||
|
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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];
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pydase"
|
name = "pydase"
|
||||||
version = "0.10.13"
|
version = "0.10.16"
|
||||||
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 = [
|
authors = [
|
||||||
{name = "Mose Müller",email = "mosemueller@gmail.com"}
|
{name = "Mose Müller",email = "mosemueller@gmail.com"}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
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 aiohttp
|
||||||
import aiohttp_socks.connector
|
|
||||||
import socketio # type: ignore
|
import socketio # type: ignore
|
||||||
|
|
||||||
from pydase.client.proxy_class import ProxyClass
|
from pydase.client.proxy_class import ProxyClass
|
||||||
@ -59,7 +60,8 @@ class Client:
|
|||||||
client's behaviour (e.g., reconnection attempts or reconnection delay).
|
client's behaviour (e.g., reconnection attempts or reconnection delay).
|
||||||
client_id: An optional client identifier. This ID is sent to the server as the
|
client_id: An optional client identifier. This ID is sent to the server as the
|
||||||
`X-Client-Id` HTTP header. It can be used for logging or authentication
|
`X-Client-Id` HTTP header. It can be used for logging or authentication
|
||||||
purposes on the server side.
|
purposes on the server side. If not provided, it defaults to the hostname
|
||||||
|
of the machine running the client.
|
||||||
proxy_url: An optional proxy URL to route the connection through. This is useful
|
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
|
if the service is only reachable via an SSH tunnel or behind a firewall
|
||||||
(e.g., `socks5://localhost:2222`).
|
(e.g., `socks5://localhost:2222`).
|
||||||
@ -112,7 +114,7 @@ class Client:
|
|||||||
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._proxy_url = proxy_url
|
self._proxy_url = proxy_url
|
||||||
self._client_id = client_id
|
self._client_id = client_id or socket.gethostname()
|
||||||
self._sio_client_kwargs = sio_client_kwargs
|
self._sio_client_kwargs = sio_client_kwargs
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
@ -150,6 +152,17 @@ class Client:
|
|||||||
|
|
||||||
def _initialize_socketio_client(self) -> None:
|
def _initialize_socketio_client(self) -> None:
|
||||||
if self._proxy_url is not None:
|
if self._proxy_url is not None:
|
||||||
|
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(
|
session = aiohttp.ClientSession(
|
||||||
connector=aiohttp_socks.connector.ProxyConnector.from_url(
|
connector=aiohttp_socks.connector.ProxyConnector.from_url(
|
||||||
url=self._proxy_url, loop=self._loop
|
url=self._proxy_url, loop=self._loop
|
||||||
|
@ -20,6 +20,19 @@ 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
|
||||||
|
|
||||||
|
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
71
src/pydase/frontend/assets/index-XZbNXHJp.js
Normal file
71
src/pydase/frontend/assets/index-XZbNXHJp.js
Normal file
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-BLJetjaQ.js"></script>
|
<script type="module" crossorigin src="/assets/index-XZbNXHJp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DJzFvk4W.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cs09d5Pk.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -22,7 +22,7 @@ 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]
|
||||||
|
|
||||||
|
@ -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.
|
||||||
@ -162,6 +161,10 @@ class Server:
|
|||||||
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._loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
|
||||||
self._state_manager = StateManager(
|
self._state_manager = StateManager(
|
||||||
service=self._service,
|
service=self._service,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
@ -170,11 +173,6 @@ class Server:
|
|||||||
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()
|
|
||||||
asyncio.set_event_loop(self._loop)
|
|
||||||
else:
|
|
||||||
self._loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -231,6 +231,6 @@ def current_event_loop_exists() -> bool:
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return not asyncio.get_running_loop().is_closed()
|
return not asyncio.get_event_loop().is_closed()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return False
|
return False
|
||||||
|
@ -168,9 +168,11 @@ 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")
|
||||||
|
@ -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
|
||||||
@ -241,3 +242,42 @@ def test_read_only_dict_property(caplog: pytest.LogCaptureFixture) -> None:
|
|||||||
service_instance._dict_attr["dotted.key"] = 2.0
|
service_instance._dict_attr["dotted.key"] = 2.0
|
||||||
|
|
||||||
assert "'dict_attr[\"dotted.key\"]' changed to '2.0'" in caplog.text
|
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user