18 Commits

Author SHA1 Message Date
Mose Müller
bc0c69f9e1 Merge pull request #220 from tiqi-group/release-v0.10.12
updates to version v0.10.12
2025-05-09 11:01:20 +02:00
Mose Müller
b2314f7e33 updates to version v0.10.12 2025-05-09 10:58:37 +02:00
Mose Müller
eb43e7b380 Merge pull request #219 from tiqi-group/feat/improve-input-cursor-handling
Feat: improve input cursor handling
2025-05-09 10:56:50 +02:00
Mose Müller
5dc28b0b55 npm run build 2025-05-09 10:54:55 +02:00
Mose Müller
c327215b5f feat: selection range in NumberComponent can be changed using Shift and arrows
When pressing shift, the arrow keys can be used to change the selection
range. This was done by using a cursor position reference instead of a
state and adapting the default behaviour of the arrow keys instead of
writing them from scratch.
2025-05-09 10:54:36 +02:00
Mose Müller
04a3b225f8 Merge pull request #218 from tiqi-group/docs/acknowledgements
readme: adds acknowledgements section
2025-05-08 10:17:04 +02:00
Mose Müller
86c4514e1a readme: adds acknowledgements section 2025-05-05 08:14:08 +02:00
Mose Müller
cac74e90db Merge pull request #217 from tiqi-group/release-v0.10.11
updates version to v0.10.11
2025-04-15 08:17:42 +02:00
Mose Müller
c24d63f4c0 updates version to v0.10.11 2025-04-15 08:17:21 +02:00
Mose Müller
b0dd5835a3 Merge pull request #216 from tiqi-group/config/changing_loading_behaviour
feat (config): changes web_port loading
2025-04-15 08:16:12 +02:00
Mose Müller
b0c8af0108 config: changes web_port loading
The web_port argument in the pydase.Server defaults to None now. If it
is None, the value from ServiceConfig().web_port will be used.
This fixes the issue where users might pass the web port dynamcially and
by passing None they want to use the default value.
2025-04-15 08:12:45 +02:00
Mose Müller
c0016673a8 fix: poetry lock 2025-04-11 14:50:11 +02:00
Mose Müller
eadc1df763 Merge pull request #215 from tiqi-group/docs/update_deps
Docs: update dependencies
2025-04-11 14:47:51 +02:00
Mose Müller
922fdf8fd0 mkdocs: replaces deprecated import key with inventories 2025-04-11 14:46:41 +02:00
Mose Müller
8b21c42ef7 updates python dependencies 2025-04-11 14:46:19 +02:00
Mose Müller
2399b3ca9f Merge pull request #214 from tiqi-group/192-starting-a-task-on-a-dataservice-exposed-as-a-property-causes-the-button-to-spin-indefinitely
fix: correctly handle observable properties
2025-03-28 09:44:11 +01:00
Mose Müller
db43f5dbbb tests: adds test reproducing the read-only dict bug 2025-03-28 09:00:59 +01:00
Mose Müller
f2c0a94904 fix: adds observable to an observable object accessed via a property
When an observable is stored returned by a property, this adds the
parent object as an observer to the observable returned by the property.
2025-03-28 09:00:59 +01:00
10 changed files with 677 additions and 565 deletions

View File

@@ -244,6 +244,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.
## 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
`pydase` is licensed under the [MIT License][License].

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { Form, InputGroup } from "react-bootstrap";
import { DocStringComponent } from "./DocStringComponent";
import "../App.css";
@@ -175,6 +175,33 @@ const handleNumericKey = (
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) => {
const {
fullAccessPath,
@@ -191,7 +218,8 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
} = props;
// 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
const [inputString, setInputString] = useState(value.toString());
const renderCount = useRenderCount();
@@ -200,26 +228,40 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
const { key, target } = event;
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
const { value } = inputTarget;
const valueLength = value.length;
const selectionEnd = inputTarget.selectionEnd ?? 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;
if (event.ctrlKey && key === "a") {
// Select everything when pressing Ctrl + a
inputTarget.setSelectionRange(0, value.length);
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 === "-") {
// Check if a number key or a decimal point key is pressed
({ value: newValue, selectionStart } = handleNumericKey(
@@ -314,7 +356,7 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
setInputString(newValue);
// Save the current cursor position before the component re-renders
setCursorPosition(selectionStart);
cursorPositionRef.current = selectionStart;
};
const handleBlur = () => {
@@ -367,8 +409,11 @@ export const NumberComponent = React.memo((props: NumberComponentProps) => {
useEffect(() => {
// Set the cursor position after the component re-renders
const inputElement = document.getElementsByName(id)[0] as HTMLInputElement;
if (inputElement && cursorPosition !== null) {
inputElement.setSelectionRange(cursorPosition, cursorPosition);
if (inputElement && cursorPositionRef.current !== null) {
inputElement.setSelectionRange(
cursorPositionRef.current,
cursorPositionRef.current,
);
}
});

View File

@@ -55,7 +55,7 @@ plugins:
handlers:
python:
paths: [src] # search packages in the src folder
import:
inventories:
- https://docs.python.org/3/objects.inv
- https://docs.pydantic.dev/latest/objects.inv
- https://confz.readthedocs.io/en/latest/objects.inv

1091
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.10"
version = "0.10.12"
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"
@@ -39,9 +39,9 @@ optional = true
[tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5.30"
mkdocs-include-markdown-plugin = "^3.9.1"
mkdocstrings = {extras = ["python"], version = "^0.25.2"}
mkdocstrings = {extras = ["python"], version = "^0.29.0"}
pymdown-extensions = "^10.1"
mkdocs-swagger-ui-tag = "^0.6.10"
mkdocs-swagger-ui-tag = "^0.7.0"
[build-system]
requires = ["poetry-core"]

View File

@@ -7,7 +7,7 @@
<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-DpoEqi_N.js"></script>
<script type="module" crossorigin src="/assets/index-BLJetjaQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJzFvk4W.css">
</head>

View File

@@ -55,6 +55,10 @@ class Observable(ObservableObject):
value = super().__getattribute__(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)
return value

View File

@@ -87,8 +87,10 @@ class Server:
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
[`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port].
web_port: The port number for the web server. If set to None, it will use the
port defined in
[`ServiceConfig().web_port`][pydase.config.ServiceConfig.web_port]. Defaults
to None.
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
@@ -140,7 +142,7 @@ class Server:
self,
service: DataService,
host: str = "0.0.0.0",
web_port: int = ServiceConfig().web_port,
web_port: int | None = None,
enable_web: bool = True,
filename: str | Path | None = None,
additional_servers: list[AdditionalServer] | None = None,
@@ -151,7 +153,10 @@ class Server:
additional_servers = []
self._service = service
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._kwargs = kwargs
self._additional_servers = additional_servers

View File

@@ -222,3 +222,22 @@ def test_nested_dict_property_changes(
# Changing the _voltage attribute should re-evaluate the voltage property, but avoid
# recursion
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