From 2432bfacce645a9d1952b5268665b644cb0803d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:39:39 +0000 Subject: [PATCH 1/9] Initial plan From 4186f810d22eaff17a7d7248dee2a1484cf986ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:52:42 +0000 Subject: [PATCH 2/9] Fix documentation code examples and add missing NativeWindowHandle methods Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/docs/docs/components/index.md | 2 +- pywry/docs/docs/guides/window-management.md | 33 ++--- pywry/pywry/widget_protocol.py | 130 ++++++++++++++++++++ pywry/tests/test_widget_protocol.py | 130 ++++++++++++++++++++ 4 files changed, 279 insertions(+), 16 deletions(-) diff --git a/pywry/docs/docs/components/index.md b/pywry/docs/docs/components/index.md index b4346ac..6c7b84b 100644 --- a/pywry/docs/docs/components/index.md +++ b/pywry/docs/docs/components/index.md @@ -101,7 +101,7 @@ All interactive components emit events via the `event` parameter: ```python Button(label="Save", event="file:save") -Select(label="Theme", event="settings:theme", ...) +Select(label="Theme", event="settings:theme", options=[...]) ``` Use namespaced events like `category:action` for organization: diff --git a/pywry/docs/docs/guides/window-management.md b/pywry/docs/docs/guides/window-management.md index 802f7c8..0939988 100644 --- a/pywry/docs/docs/guides/window-management.md +++ b/pywry/docs/docs/guides/window-management.md @@ -149,7 +149,7 @@ handle = app.show( ## The Window Handle -`app.show()` returns a handle object. In native mode this is a `WindowProxy` — a full-featured wrapper around the OS window. In browser/notebook mode it's an `InlineWidget` with a subset of the same interface. +`app.show()` returns a `NativeWindowHandle` in native mode — a wrapper that provides the same `emit()` / `on()` interface as notebook widgets, plus access to the full OS window API via its `.proxy` attribute. In browser/notebook mode it's an `InlineWidget` with the same event interface. ### Common Methods (all modes) @@ -158,10 +158,14 @@ handle = app.show( | `handle.emit(event, data)` | Send an event from Python to the window's JavaScript | | `handle.on(event, callback)` | Register a Python callback for events from the window | | `handle.label` | The window/widget label | +| `handle.alert(message, ...)` | Show a toast notification | +| `handle.set_toolbar_value(id, value, ...)` | Update a toolbar component's value | +| `handle.set_toolbar_values({id: value, ...})` | Update multiple toolbar components | +| `handle.request_toolbar_state()` | Request current toolbar state | ### Native-only Methods (WindowProxy) -The `WindowProxy` exposes the full set of OS window operations: +The `WindowProxy` (accessible via `handle.proxy`) exposes the full set of OS window operations: **Window state:** @@ -187,28 +191,27 @@ The `WindowProxy` exposes the full set of OS window operations: | `set_resizable(bool)` | Toggle resizing | | `set_theme(ThemeMode)` | Change theme | -**Read-only properties:** +**Read-only properties (via `handle.proxy`):** ```python -handle.title # Current title -handle.inner_size # Content area size (PhysicalSize) -handle.outer_size # Window frame size -handle.inner_position # Content position on screen -handle.is_fullscreen # bool -handle.is_maximized # bool -handle.is_focused # bool -handle.is_visible # bool -handle.current_monitor # Monitor info (name, size, position, scale) +handle.proxy.inner_size # Content area size (PhysicalSize) +handle.proxy.outer_size # Window frame size +handle.proxy.inner_position # Content position on screen +handle.proxy.is_fullscreen # bool +handle.proxy.is_maximized # bool +handle.proxy.is_focused # bool +handle.proxy.is_visible # bool +handle.proxy.current_monitor # Monitor info (name, size, position, scale) ``` **JavaScript execution:** ```python # Fire-and-forget -handle.eval("document.title = 'Hello'") +handle.eval_js("document.title = 'Hello'") -# With return value (blocks up to timeout) -result = handle.eval_with_result("document.querySelectorAll('li').length", timeout=5.0) +# With return value (blocks up to timeout, via proxy) +result = handle.proxy.eval_with_result("document.querySelectorAll('li').length", timeout=5.0) ``` ## Updating Content diff --git a/pywry/pywry/widget_protocol.py b/pywry/pywry/widget_protocol.py index 94471ae..a51da9b 100644 --- a/pywry/pywry/widget_protocol.py +++ b/pywry/pywry/widget_protocol.py @@ -9,6 +9,8 @@ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from .state_mixins import _UNSET, _Unset + if TYPE_CHECKING: from collections.abc import Callable @@ -640,6 +642,134 @@ def is_alive(self) -> bool: resources = self.resources return resources is not None and not resources.is_destroyed + # ───────────────────────────────────────────────────────────────────────── + # Notification helpers + # ───────────────────────────────────────────────────────────────────────── + + def alert( + self, + message: str, + alert_type: str = "info", + title: str | None = None, + duration: int | None = None, + callback_event: str | None = None, + position: str = "top-right", + ) -> None: + """Show a toast notification in this window. + + Parameters + ---------- + message : str + The message to display. + alert_type : str + Alert type: 'info', 'success', 'warning', 'error', or 'confirm'. + title : str, optional + Optional title for the toast. + duration : int, optional + Auto-dismiss duration in ms. Defaults based on type. + callback_event : str, optional + Event name to emit when confirm dialog is answered. + position : str + Toast position: 'top-right', 'top-left', 'bottom-right', 'bottom-left'. + + Examples + -------- + >>> handle.alert("File saved successfully") + >>> handle.alert("Export complete", alert_type="success", title="Done") + >>> handle.alert("Are you sure?", alert_type="confirm", callback_event="app:confirm") + """ + payload: dict[str, Any] = { + "message": message, + "type": alert_type, + "position": position, + } + if title is not None: + payload["title"] = title + if duration is not None: + payload["duration"] = duration + if callback_event is not None: + payload["callback_event"] = callback_event + self.emit("pywry:alert", payload) + + # ───────────────────────────────────────────────────────────────────────── + # Toolbar state helpers + # ───────────────────────────────────────────────────────────────────────── + + def request_toolbar_state(self, toolbar_id: str | None = None) -> None: + """Request the current state of all toolbar components. + + The response will be emitted as a 'toolbar:state-response' event + containing component IDs and their current values. + + Parameters + ---------- + toolbar_id : str, optional + If provided, only request state for the specified toolbar. + + Examples + -------- + >>> handle.request_toolbar_state() + """ + payload: dict[str, Any] = {} + if toolbar_id: + payload["toolbarId"] = toolbar_id + self.emit("toolbar:request-state", payload) + + def set_toolbar_value( + self, + component_id: str, + value: Any = _UNSET, + toolbar_id: str | None = None, + **attrs: Any, + ) -> None: + """Set a toolbar component's value and/or attributes. + + Parameters + ---------- + component_id : str + The component_id of the toolbar item to update. + value : Any, optional + The new value for the component. Pass explicitly to set it. + toolbar_id : str, optional + The toolbar ID (if applicable). + **attrs : Any + Additional attributes to set on the component: + label, disabled, variant, options, style, placeholder, min, max, step. + + Examples + -------- + >>> handle.set_toolbar_value("theme-select", "light") + >>> handle.set_toolbar_value("submit-btn", disabled=True, label="Loading...") + """ + payload: dict[str, Any] = {"componentId": component_id} + if not isinstance(value, _Unset): + payload["value"] = value + if toolbar_id: + payload["toolbarId"] = toolbar_id + payload.update(attrs) + self.emit("toolbar:set-value", payload) + + def set_toolbar_values( + self, values: dict[str, Any], toolbar_id: str | None = None + ) -> None: + """Set multiple toolbar component values at once. + + Parameters + ---------- + values : dict[str, Any] + Mapping of component_id to new value. + toolbar_id : str, optional + The toolbar ID (if applicable). + + Examples + -------- + >>> handle.set_toolbar_values({"theme-select": "light", "zoom-input": 100}) + """ + payload: dict[str, Any] = {"values": values} + if toolbar_id: + payload["toolbarId"] = toolbar_id + self.emit("toolbar:set-values", payload) + def __str__(self) -> str: """Return the window label.""" return self._label diff --git a/pywry/tests/test_widget_protocol.py b/pywry/tests/test_widget_protocol.py index e3cfc4d..f2822aa 100644 --- a/pywry/tests/test_widget_protocol.py +++ b/pywry/tests/test_widget_protocol.py @@ -585,3 +585,133 @@ def test_inject_css_with_complex_styles(self, native_handle): result = native_handle.inject_css(css, "complex-styles") assert result is True mock_inject.assert_called_once() + + +# ============================================================================= +# NativeWindowHandle Notification and Toolbar State Tests +# ============================================================================= + + +class TestNativeWindowHandleAlertAndToolbar: + """Tests for NativeWindowHandle alert and toolbar state methods.""" + + def test_alert_emits_pywry_alert_event(self, native_handle, mock_app): + """Test alert() emits pywry:alert with correct payload.""" + native_handle.alert("File saved successfully") + mock_app.emit.assert_called_once_with( + "pywry:alert", + {"message": "File saved successfully", "type": "info", "position": "top-right"}, + "test-window", + ) + + def test_alert_with_type_and_title(self, native_handle, mock_app): + """Test alert() with success type and title.""" + native_handle.alert("Export complete", alert_type="success", title="Done") + mock_app.emit.assert_called_once_with( + "pywry:alert", + { + "message": "Export complete", + "type": "success", + "position": "top-right", + "title": "Done", + }, + "test-window", + ) + + def test_alert_with_duration(self, native_handle, mock_app): + """Test alert() with custom duration.""" + native_handle.alert("Warning", alert_type="warning", duration=8000) + call_args = mock_app.emit.call_args + assert call_args[0][1]["duration"] == 8000 + assert call_args[0][1]["type"] == "warning" + + def test_alert_with_callback_event(self, native_handle, mock_app): + """Test alert() confirm type with callback event.""" + native_handle.alert( + "Are you sure?", + alert_type="confirm", + callback_event="app:confirm", + ) + call_args = mock_app.emit.call_args + assert call_args[0][1]["type"] == "confirm" + assert call_args[0][1]["callback_event"] == "app:confirm" + + def test_alert_with_position(self, native_handle, mock_app): + """Test alert() with custom position.""" + native_handle.alert("Notice", position="bottom-left") + call_args = mock_app.emit.call_args + assert call_args[0][1]["position"] == "bottom-left" + + def test_alert_optional_fields_not_included_when_none(self, native_handle, mock_app): + """Test alert() omits None optional fields from payload.""" + native_handle.alert("Simple message") + payload = mock_app.emit.call_args[0][1] + assert "title" not in payload + assert "duration" not in payload + assert "callback_event" not in payload + + def test_request_toolbar_state_emits_event(self, native_handle, mock_app): + """Test request_toolbar_state() emits toolbar:request-state.""" + native_handle.request_toolbar_state() + mock_app.emit.assert_called_once_with( + "toolbar:request-state", {}, "test-window" + ) + + def test_request_toolbar_state_with_toolbar_id(self, native_handle, mock_app): + """Test request_toolbar_state() passes toolbar_id.""" + native_handle.request_toolbar_state(toolbar_id="toolbar-abc123") + mock_app.emit.assert_called_once_with( + "toolbar:request-state", + {"toolbarId": "toolbar-abc123"}, + "test-window", + ) + + def test_set_toolbar_value_with_value(self, native_handle, mock_app): + """Test set_toolbar_value() emits toolbar:set-value with value.""" + native_handle.set_toolbar_value("theme-select", "light") + mock_app.emit.assert_called_once_with( + "toolbar:set-value", + {"componentId": "theme-select", "value": "light"}, + "test-window", + ) + + def test_set_toolbar_value_without_value(self, native_handle, mock_app): + """Test set_toolbar_value() without value only sets other attrs.""" + native_handle.set_toolbar_value("submit-btn", disabled=True, label="Loading...") + call_args = mock_app.emit.call_args + assert call_args[0][0] == "toolbar:set-value" + payload = call_args[0][1] + assert payload["componentId"] == "submit-btn" + assert payload["disabled"] is True + assert payload["label"] == "Loading..." + assert "value" not in payload + + def test_set_toolbar_value_with_toolbar_id(self, native_handle, mock_app): + """Test set_toolbar_value() passes toolbar_id.""" + native_handle.set_toolbar_value("slider", 50, toolbar_id="toolbar-abc") + payload = mock_app.emit.call_args[0][1] + assert payload["toolbarId"] == "toolbar-abc" + + def test_set_toolbar_value_none_value_not_included(self, native_handle, mock_app): + """Test set_toolbar_value() with no value doesn't include it in payload.""" + native_handle.set_toolbar_value("zoom-input", label="Zoom") + payload = mock_app.emit.call_args[0][1] + assert "value" not in payload + assert payload["label"] == "Zoom" + + def test_set_toolbar_values_emits_event(self, native_handle, mock_app): + """Test set_toolbar_values() emits toolbar:set-values.""" + values = {"theme-select": "light", "zoom-input": 100} + native_handle.set_toolbar_values(values) + mock_app.emit.assert_called_once_with( + "toolbar:set-values", + {"values": values}, + "test-window", + ) + + def test_set_toolbar_values_with_toolbar_id(self, native_handle, mock_app): + """Test set_toolbar_values() passes toolbar_id.""" + native_handle.set_toolbar_values({"slider": 50}, toolbar_id="toolbar-abc") + payload = mock_app.emit.call_args[0][1] + assert payload["toolbarId"] == "toolbar-abc" + assert payload["values"] == {"slider": 50} From bb24be977e5dfb995b33ee0114727c2feca5a0fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:50:23 +0000 Subject: [PATCH 3/9] Fix ruff formatting and flaky test_set_title polling logic Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/pywry/widget_protocol.py | 8 ++++---- pywry/tests/test_widget_protocol.py | 4 +--- pywry/tests/test_window_proxy.py | 11 +++++++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/pywry/pywry/widget_protocol.py b/pywry/pywry/widget_protocol.py index a51da9b..b036459 100644 --- a/pywry/pywry/widget_protocol.py +++ b/pywry/pywry/widget_protocol.py @@ -676,7 +676,9 @@ def alert( -------- >>> handle.alert("File saved successfully") >>> handle.alert("Export complete", alert_type="success", title="Done") - >>> handle.alert("Are you sure?", alert_type="confirm", callback_event="app:confirm") + >>> handle.alert( + ... "Are you sure?", alert_type="confirm", callback_event="app:confirm" + ... ) """ payload: dict[str, Any] = { "message": message, @@ -749,9 +751,7 @@ def set_toolbar_value( payload.update(attrs) self.emit("toolbar:set-value", payload) - def set_toolbar_values( - self, values: dict[str, Any], toolbar_id: str | None = None - ) -> None: + def set_toolbar_values(self, values: dict[str, Any], toolbar_id: str | None = None) -> None: """Set multiple toolbar component values at once. Parameters diff --git a/pywry/tests/test_widget_protocol.py b/pywry/tests/test_widget_protocol.py index f2822aa..842c432 100644 --- a/pywry/tests/test_widget_protocol.py +++ b/pywry/tests/test_widget_protocol.py @@ -653,9 +653,7 @@ def test_alert_optional_fields_not_included_when_none(self, native_handle, mock_ def test_request_toolbar_state_emits_event(self, native_handle, mock_app): """Test request_toolbar_state() emits toolbar:request-state.""" native_handle.request_toolbar_state() - mock_app.emit.assert_called_once_with( - "toolbar:request-state", {}, "test-window" - ) + mock_app.emit.assert_called_once_with("toolbar:request-state", {}, "test-window") def test_request_toolbar_state_with_toolbar_id(self, native_handle, mock_app): """Test request_toolbar_state() passes toolbar_id.""" diff --git a/pywry/tests/test_window_proxy.py b/pywry/tests/test_window_proxy.py index 473c180..dfaae53 100644 --- a/pywry/tests/test_window_proxy.py +++ b/pywry/tests/test_window_proxy.py @@ -170,15 +170,22 @@ class TestWindowProxyActions: def test_set_title(self) -> None: """set_title actually changes the window title.""" + import contextlib + app = PyWry(theme=ThemeMode.DARK) proxy = show_and_wait_ready(app, "

Title

", title="Original Title") # Change title proxy.set_title("New Title") - time.sleep(0.1) # Allow IPC to complete - # Verify title changed + # Poll until title propagates through IPC (fire-and-forget is async) + deadline = time.time() + 3.0 new_title = proxy.title + while "New Title" not in new_title and time.time() < deadline: + time.sleep(0.1) + with contextlib.suppress(IPCTimeoutError): + new_title = proxy.title + assert "New Title" in new_title app.close() From 19319546be71876b9f85b7d6ae13ed3623507feb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:52:26 +0000 Subject: [PATCH 4/9] Move contextlib import to module level and tighten polling loop Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/tests/test_window_proxy.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pywry/tests/test_window_proxy.py b/pywry/tests/test_window_proxy.py index dfaae53..dc4f065 100644 --- a/pywry/tests/test_window_proxy.py +++ b/pywry/tests/test_window_proxy.py @@ -8,6 +8,7 @@ from __future__ import annotations +import contextlib import os import sys import time @@ -170,8 +171,6 @@ class TestWindowProxyActions: def test_set_title(self) -> None: """set_title actually changes the window title.""" - import contextlib - app = PyWry(theme=ThemeMode.DARK) proxy = show_and_wait_ready(app, "

Title

", title="Original Title") @@ -182,9 +181,10 @@ def test_set_title(self) -> None: deadline = time.time() + 3.0 new_title = proxy.title while "New Title" not in new_title and time.time() < deadline: - time.sleep(0.1) with contextlib.suppress(IPCTimeoutError): new_title = proxy.title + if "New Title" not in new_title: + time.sleep(0.1) assert "New Title" in new_title app.close() From f31355b7865bbca5a1f087e4ef763532d2d4eac5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:24:57 +0000 Subject: [PATCH 5/9] Fix flaky Windows E2E test: robust cleanup in retry_on_subprocess_failure Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/tests/test_alerts.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/pywry/tests/test_alerts.py b/pywry/tests/test_alerts.py index c51f56a..e505199 100644 --- a/pywry/tests/test_alerts.py +++ b/pywry/tests/test_alerts.py @@ -13,6 +13,7 @@ from __future__ import annotations +import sys import time from collections.abc import Callable @@ -22,7 +23,12 @@ import pytest # Import shared test utilities from tests.conftest -from tests.conftest import show_and_wait_ready, wait_for_result +from tests.conftest import ( + _clear_registries, + _stop_runtime_sync, + show_and_wait_ready, + wait_for_result, +) F = TypeVar("F", bound=Callable[..., Any]) @@ -40,8 +46,6 @@ def retry_on_subprocess_failure(max_attempts: int = 3, delay: float = 1.0) -> Ca def decorator(func: F) -> F: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: - from pywry import runtime - last_error: Exception | None = None for attempt in range(max_attempts): try: @@ -49,10 +53,18 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: except (TimeoutError, AssertionError) as e: last_error = e if attempt < max_attempts - 1: - # Clean up and wait before retry - runtime.stop() - # Progressive backoff for CI stability - time.sleep(delay * (attempt + 1)) + # Stop runtime and poll until the subprocess is fully + # terminated (matches _stop_runtime_sync in conftest). + _stop_runtime_sync() + # Clear stale callback/lifecycle state before the next + # attempt so leftover registrations don't interfere. + _clear_registries() + # Progressive backoff. On Windows, the OS needs extra + # time after process termination before the WebView2 + # "Chrome_WidgetWin_0" window class registration is + # released and a new subprocess can re-register it. + extra = 5.0 if sys.platform == "win32" else 0.0 + time.sleep(delay * (attempt + 1) + extra) raise last_error # type: ignore[misc] return wrapper # type: ignore[return-value] From fbfce263c8435eecedd37f0097464ac7d53db0e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:27:04 +0000 Subject: [PATCH 6/9] Guard against last_error being None in retry decorator Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/tests/test_alerts.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pywry/tests/test_alerts.py b/pywry/tests/test_alerts.py index e505199..1344109 100644 --- a/pywry/tests/test_alerts.py +++ b/pywry/tests/test_alerts.py @@ -65,7 +65,9 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # released and a new subprocess can re-register it. extra = 5.0 if sys.platform == "win32" else 0.0 time.sleep(delay * (attempt + 1) + extra) - raise last_error # type: ignore[misc] + if last_error is None: + raise RuntimeError("retry_on_subprocess_failure: no attempts were made") + raise last_error return wrapper # type: ignore[return-value] From 883b320c4cb2ae696e2db496a38815431389915f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:30:22 +0000 Subject: [PATCH 7/9] Document OAuth2 auth:* events in event reference Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/docs/docs/reference/events.md | 83 ++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/pywry/docs/docs/reference/events.md b/pywry/docs/docs/reference/events.md index 1f9e3c8..b0fc52a 100644 --- a/pywry/docs/docs/reference/events.md +++ b/pywry/docs/docs/reference/events.md @@ -11,7 +11,7 @@ All events follow the `namespace:event-name` pattern: | namespace | Starts with letter, alphanumeric | `app`, `plotly`, `grid`, `myapp` | | event-name | Starts with letter, alphanumeric + hyphens | `click`, `row-select`, `update-data` | -**Reserved namespaces:** `pywry:*`, `plotly:*`, `grid:*`, `toolbar:*` +**Reserved namespaces:** `pywry:*`, `plotly:*`, `grid:*`, `toolbar:*`, `auth:*` --- @@ -187,6 +187,53 @@ All events follow the `namespace:event-name` pattern: --- +## Auth Events (auth:*) + +The `auth:*` namespace is used by the built-in OAuth2 authentication system. +Events flow in both directions: the frontend can request login/logout, and the +backend notifies the frontend when auth state changes (e.g. after a token +refresh or successful logout). + +!!! note "Availability" + Auth events are only active when `PYWRY_DEPLOY__AUTH_ENABLED=true` and a + valid `PYWRY_OAUTH2__*` configuration is present. In native mode the full + flow is handled by `app.login()` / `app.logout()` — these events apply to + the frontend integration via `window.pywry.auth`. + +### Auth Requests (JS → Python) + +| Event | Payload | Description | +|-------|---------|-------------| +| `auth:login-request` | `{}` | Frontend requests a login flow (calls `window.pywry.auth.login()`). In native mode the backend opens the provider's authorization URL; in deploy mode it redirects to `/auth/login`. | +| `auth:logout-request` | `{}` | Frontend requests logout (calls `window.pywry.auth.logout()`). The backend revokes tokens, destroys the session, and emits `auth:logout` back. | + +### Auth Notifications (Python → JS) + +| Event | Payload | Description | +|-------|---------|-------------| +| `auth:state-changed` | `{authenticated, user_id?, roles?, token_type?}` | Auth state changed (login succeeded or session expired). When `authenticated` is `false`, `window.__PYWRY_AUTH__` is cleared. | +| `auth:token-refresh` | `{token_type, expires_in?}` | Access token was refreshed in the background. Updates the current session without requiring re-login. | +| `auth:logout` | `{}` | Server-side logout completed. Clears `window.__PYWRY_AUTH__` and notifies registered `onAuthStateChange` handlers. | + +**`auth:state-changed` payload detail:** + +```python +{ + "authenticated": True, + "user_id": "user@example.com", # sub / id / email from userinfo + "roles": ["viewer", "editor"], # from session roles list + "token_type": "Bearer" # OAuth2 token type +} +``` + +When `authenticated` is `false` only the key itself is present: + +```python +{"authenticated": False} +``` + +--- + ## Component Event Payloads Every toolbar component emits its custom event with these payloads: @@ -271,6 +318,40 @@ window.__PYWRY_TOOLBAR__.getValue("component-id") // Get value window.__PYWRY_TOOLBAR__.setValue("component-id", value) // Set value ``` +### Auth Globals (window.pywry.auth) + +When `auth_enabled=True` the `auth-helpers.js` script is injected and the +`window.pywry.auth` namespace becomes available. + +```javascript +// Check authentication state +window.pywry.auth.isAuthenticated() // boolean + +// Get the full auth state +window.pywry.auth.getState() +// Returns: { authenticated, user_id, roles, token_type } + +// Trigger OAuth2 login flow (emits auth:login-request to Python) +window.pywry.auth.login() + +// Trigger logout (emits auth:logout-request to Python) +window.pywry.auth.logout() + +// React to auth state changes (from auth:state-changed / auth:logout events) +window.pywry.auth.onAuthStateChange(function(state) { + if (state.authenticated) { + console.log("Logged in as", state.user_id, "with roles", state.roles); + } else { + console.log("Logged out"); + } +}); +``` + +**`window.__PYWRY_AUTH__`** is injected server-side for authenticated requests and +contains `{ user_id, roles, token_type }`. Use `window.pywry.auth.getState()` +rather than reading it directly — the helper normalizes the value and handles +the unauthenticated case. + ### Tauri Access (Native Mode Only) In native desktop mode, Tauri APIs are available: From 1408ead423d735923342782e7930867701b158b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:37:04 +0000 Subject: [PATCH 8/9] Add multi-widget guide, auth reference docs, and fix why-pywry.md typo Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/docs/docs/getting-started/why-pywry.md | 1 + pywry/docs/docs/guides/multi-widget.md | 162 ++++++ pywry/docs/docs/guides/oauth2.md | 481 ++++++++++++++++++ .../docs/reference/auth-callback-server.md | 14 + .../docs/docs/reference/auth-deploy-routes.md | 23 + pywry/docs/docs/reference/auth-flow.md | 36 ++ pywry/docs/docs/reference/auth-pkce.md | 14 + pywry/docs/docs/reference/auth-providers.md | 67 +++ pywry/docs/docs/reference/auth-session.md | 14 + pywry/docs/docs/reference/auth-token-store.md | 56 ++ pywry/docs/docs/reference/auth.md | 45 ++ pywry/docs/mkdocs.yml | 11 + 12 files changed, 924 insertions(+) create mode 100644 pywry/docs/docs/guides/multi-widget.md create mode 100644 pywry/docs/docs/guides/oauth2.md create mode 100644 pywry/docs/docs/reference/auth-callback-server.md create mode 100644 pywry/docs/docs/reference/auth-deploy-routes.md create mode 100644 pywry/docs/docs/reference/auth-flow.md create mode 100644 pywry/docs/docs/reference/auth-pkce.md create mode 100644 pywry/docs/docs/reference/auth-providers.md create mode 100644 pywry/docs/docs/reference/auth-session.md create mode 100644 pywry/docs/docs/reference/auth-token-store.md create mode 100644 pywry/docs/docs/reference/auth.md diff --git a/pywry/docs/docs/getting-started/why-pywry.md b/pywry/docs/docs/getting-started/why-pywry.md index 5530ef0..408e1c7 100644 --- a/pywry/docs/docs/getting-started/why-pywry.md +++ b/pywry/docs/docs/getting-started/why-pywry.md @@ -70,6 +70,7 @@ Native windows open in under a second. There's no server to spin up, no browser PyWry isn't just for prototyping and single-user applications: - **Deploy Mode** with Redis backend for horizontal scaling and RBAC +- **OAuth2** authentication system for both native and deploy modes - **Token authentication** and CSRF protection out of the box - **CSP headers** and security presets for production environments - **TOML-based configuration** with layered precedence (defaults → project → user → env vars) diff --git a/pywry/docs/docs/guides/multi-widget.md b/pywry/docs/docs/guides/multi-widget.md new file mode 100644 index 0000000..43c1e8c --- /dev/null +++ b/pywry/docs/docs/guides/multi-widget.md @@ -0,0 +1,162 @@ +# Multi-Widget Pages + +`show_plotly()` and `show_dataframe()` each render a single widget. To combine multiple widgets — charts, grids, forms, tickers — in one window, use `build_html()` to generate each piece and compose them with `Div`. + +## The Pattern + +1. **Generate HTML snippets** — `build_html()` on any component returns a self-contained HTML string. +2. **Compose with `Div`** — Nest `Div` objects to build your page tree. Style with CSS using `--pywry-*` theme variables. +3. **Show** — Pass combined HTML as `HtmlContent` to `app.show()` with `include_plotly` / `include_aggrid` flags as needed. + +Every toolbar component (`Button`, `Select`, `Toggle`, `TextInput`, `Checkbox`, `RadioGroup`, `TabGroup`, `SliderInput`, `RangeInput`, `NumberInput`, `DateInput`, `SearchInput`, `SecretInput`, `TextArea`, `MultiSelect`, `Marquee`, `TickerItem`), plus `Toolbar`, `Modal`, and `Div` all expose `build_html()`. See the [Toolbar System](toolbars.md) and [Modals](modals.md) guides for their APIs. + +--- + +## Widget Snippets + +### Plotly + +```python +import json +from pywry.templates import build_plotly_init_script + +fig_dict = json.loads(fig.to_json()) # must be a plain dict, not a Figure +chart_html = build_plotly_init_script(figure=fig_dict, chart_id="my-chart") +``` + +Requires `include_plotly=True` on `app.show()`. Target later with `widget.emit("plotly:update-figure", {"figure": new_dict, "chartId": "my-chart"})`. + +### AG Grid + +```python +from pywry.grid import build_grid_config, build_grid_html + +config = build_grid_config(df, grid_id="my-grid", row_selection=True) +grid_html = build_grid_html(config) +``` + +Requires `include_aggrid=True` on `app.show()`. Target later with `widget.emit("grid:update-data", {"data": rows, "gridId": "my-grid"})`. + +Use unique `chart_id` / `grid_id` values when placing multiple charts or grids on the same page. + +--- + +## Composing with `Div` + +`Div` is the layout primitive. Use `content` for raw HTML (widget snippets, headings, text) and `children` for nested component objects. Both render in order: `content` first, then `children`. + +```python +from pywry import Div, Button, Toggle + +dashboard = Div( + class_name="dashboard", + children=[ + Div(class_name="kpi-row", children=[ + Div(class_name="kpi-card", content='Revenue$318K'), + Div(class_name="kpi-card", content='Users1,247'), + ]), + Div(class_name="content-row", children=[ + Div(class_name="chart-panel", content=chart_html), + Div(class_name="grid-panel", content=grid_html), + ]), + Div(class_name="controls", children=[ + Toggle(label="Live:", event="app:live", value=True), + Button(label="Export", event="app:export", variant="secondary"), + ]), + ], +) + +page_html = dashboard.build_html() +``` + +- `class_name` is added alongside the automatic `pywry-div` class — target it in CSS +- Nested `Div`s pass parent context via `data-parent-id` automatically + +### Scripts + +`Div` and `Toolbar` accept a `script` field (inline JS or file path). `build_html()` resolves it and emits a ` + +panel = Div(content="

Chart

", script="static/chart_init.js") # reads file +``` + +`collect_scripts()` is also available if you need raw script strings without HTML wrapping. + +--- + +## Showing the Page + +```python +from pywry import PyWry, HtmlContent + +app = PyWry(title="Dashboard", width=1200, height=780) + +content = HtmlContent(html=dashboard.build_html(), inline_css=css) + +widget = app.show( + content, + include_plotly=True, + include_aggrid=True, + toolbars=[toolbar], # optional positioned toolbar bars + modals=[modal], # optional modals + callbacks={ + "plotly:click": on_chart_click, + "grid:row-selected": on_row_selected, + "app:export": on_export, + }, +) +``` + +- Components embedded directly in your HTML emit events via `window.pywry.emit()` without needing `toolbars=` +- Toolbars passed via `toolbars=` are auto-positioned (top, bottom, left, right) +- Modals passed via `modals=` are auto-injected with open/close wiring + +### Sizing Tips + +- Plotly: set `width: 100% !important; height: 100% !important` on `.pywry-plotly` and give its parent a flex layout +- AG Grid: needs a parent with defined height — `flex: 1` inside a flex column works +- Use `min-height: 0` on flex children that need to shrink below content size + +--- + +## Cross-Widget Events + +With everything in one page, wire interactions through callbacks — no JavaScript needed: + +```python +# Grid selection → update chart +def on_row_selected(data, _event_type, _label): + rows = data.get("rows", []) + widget.emit("plotly:update-figure", {"figure": filtered_fig, "chartId": "my-chart"}) + +# Chart click → update detail panel +def on_chart_click(data, _event_type, _label): + point = data.get("points", [{}])[0] + widget.emit("pywry:set-content", {"id": "detail-panel", "html": f"{point['x']}"}) + +# Export CSV +def on_export(_data, _event_type, _label): + widget.emit("pywry:download", {"content": df.to_csv(index=False), "filename": "data.csv", "mimeType": "text/csv"}) +``` + +See the [Event System guide](events.md) for the full list of system events (`pywry:set-content`, `pywry:download`, `plotly:update-figure`, `grid:update-data`, etc.). + +--- + +## Complete Example + +See [`examples/pywry_demo_multi_widget.py`](https://github.com/deeleeramone/PyWry/blob/main/pywry/examples/pywry_demo_multi_widget.py) for a full working dashboard with KPI cards, Plotly chart, AG Grid, toolbar, cross-widget filtering, and CSV export. + +--- + +## Related Guides + +- [Toolbar System](toolbars.md) — all toolbar component types and their APIs +- [Modals](modals.md) — modal overlay components +- [Event System](events.md) — event registration and dispatch +- [Theming & CSS](theming.md) — `--pywry-*` variables and theme switching +- [HtmlContent](html-content.md) — CSS files, script files, inline CSS, JSON data +- [Content Assembly](content-assembly.md) — what PyWry injects into the document diff --git a/pywry/docs/docs/guides/oauth2.md b/pywry/docs/docs/guides/oauth2.md new file mode 100644 index 0000000..c69d2ad --- /dev/null +++ b/pywry/docs/docs/guides/oauth2.md @@ -0,0 +1,481 @@ +# OAuth2 Authentication + +PyWry includes a full OAuth2 authentication system for both **native mode** (desktop apps) and **deploy mode** (multi-user web servers). It supports Google, GitHub, Microsoft, and any OpenID Connect provider out of the box. + +This guide uses two perspectives throughout: + +- **Developer** — the person writing the PyWry application +- **User** — the person running the app and logging in + +--- + +## How it works (overview) + +=== "Native mode" + + The developer calls `app.login()`. PyWry starts an ephemeral HTTP server on a + random `localhost` port, opens the provider's login page in the user's system + browser, waits for the OAuth2 redirect to land on that server, exchanges the + authorization code for tokens, and hands the result back to the developer's code. + + The user never interacts with PyWry directly — they log in at the provider's + own website (Google, GitHub, etc.) and are then redirected back automatically. + +=== "Deploy mode" + + The developer mounts a FastAPI router (`/auth/*`) on the server. The user + navigates to `/auth/login`, gets redirected to the provider, authenticates, + and lands back at `/auth/callback` where PyWry creates a server session and + sets a cookie. The user is then redirected to the application root (`/`). + +--- + +## Developer: one-time provider setup + +Before writing any code, register an OAuth2 application with your chosen provider. +Each provider's developer console will give you a **client ID** and **client secret**. +You must also register the **redirect URI** that PyWry will use. + +| Mode | Redirect URI to register | +|:---|:---| +| Native | `http://127.0.0.1` (any port, or use a wildcard if the provider supports it) | +| Deploy | `https://your-domain.com/auth/callback` | + +!!! warning "Keep your client secret private" + Never commit `client_secret` to version control. Use environment variables or + a secrets manager. + +Provider registration links: + +- **Google**: [console.cloud.google.com](https://console.cloud.google.com/) → APIs & Services → Credentials +- **GitHub**: [github.com/settings/developers](https://github.com/settings/developers) → OAuth Apps +- **Microsoft**: [portal.azure.com](https://portal.azure.com/) → Azure Active Directory → App registrations + +--- + +## Developer: configure the provider + +Set credentials via environment variables (recommended) or construct the provider +in code. PyWry reads `PYWRY_OAUTH2__*` variables automatically when +`PYWRY_OAUTH2__CLIENT_ID` is present. + +=== "Environment variables" + + ```bash + # Choose one of: google, github, microsoft, oidc, custom + export PYWRY_OAUTH2__PROVIDER=github + export PYWRY_OAUTH2__CLIENT_ID=your-client-id + export PYWRY_OAUTH2__CLIENT_SECRET=your-client-secret + ``` + + Other available variables: + + | Variable | Default | Description | + |:---|:---|:---| + | `PYWRY_OAUTH2__SCOPES` | `openid email profile` | Space-separated scopes | + | `PYWRY_OAUTH2__USE_PKCE` | `true` | Enable PKCE (recommended) | + | `PYWRY_OAUTH2__TOKEN_STORE_BACKEND` | `memory` | `memory`, `keyring`, or `redis` | + | `PYWRY_OAUTH2__AUTH_TIMEOUT_SECONDS` | `120` | Seconds to wait for user callback | + | `PYWRY_OAUTH2__REFRESH_BUFFER_SECONDS` | `60` | Seconds before expiry to refresh | + | `PYWRY_OAUTH2__ISSUER_URL` | *(empty)* | OIDC discovery URL (oidc provider) | + | `PYWRY_OAUTH2__TENANT_ID` | `common` | Azure AD tenant (microsoft provider) | + | `PYWRY_OAUTH2__AUTHORIZE_URL` | *(empty)* | Required for `custom` provider | + | `PYWRY_OAUTH2__TOKEN_URL` | *(empty)* | Required for `custom` provider | + +=== "Google" + + ```python + from pywry.auth import GoogleProvider + + provider = GoogleProvider( + client_id="…", + client_secret="…", + # Default scopes: openid email profile + # Always adds access_type=offline and prompt=consent + # so a refresh token is returned. + ) + ``` + +=== "GitHub" + + ```python + from pywry.auth import GitHubProvider + + provider = GitHubProvider( + client_id="…", + client_secret="…", + # Default scopes: read:user user:email + # client_secret is required for token revocation. + ) + ``` + + GitHub is not a standard OIDC provider. Userinfo comes from + `https://api.github.com/user`. Token revocation uses + `DELETE /applications/{client_id}/token` with HTTP Basic auth. + +=== "Microsoft / Azure AD" + + ```python + from pywry.auth import MicrosoftProvider + + provider = MicrosoftProvider( + client_id="…", + client_secret="…", + tenant_id="common", # or your specific tenant GUID + # Default scopes: openid email profile offline_access + ) + ``` + + Use `tenant_id="common"` for multi-tenant apps. Microsoft does not implement + RFC 7009, so `revoke_token()` always returns `False`. + +=== "Generic OIDC" + + ```python + from pywry.auth import GenericOIDCProvider + + provider = GenericOIDCProvider( + client_id="…", + client_secret="…", + issuer_url="https://auth.example.com", + # Fetches /.well-known/openid-configuration on first use. + # Explicit URLs take precedence over discovered ones. + scopes=["openid", "profile"], + ) + ``` + +=== "From settings" + + When settings are loaded from config or env vars: + + ```python + from pywry.auth import create_provider_from_settings + from pywry.config import get_settings + + provider = create_provider_from_settings(get_settings().oauth2) + # Returns GoogleProvider, GitHubProvider, MicrosoftProvider, + # or GenericOIDCProvider based on the 'provider' field. + # Raises AuthenticationError for unknown types or missing URLs. + ``` + +--- + +## Native mode + +### Developer: call `app.login()` + +`PyWry.login()` is the single entry point for native mode. It constructs the +provider, token store, session manager, and flow manager from settings, runs the +OAuth2 flow, and returns when the user has finished authenticating (or fails). + +```python +from pywry import PyWry +from pywry.exceptions import AuthFlowTimeout, AuthFlowCancelled, AuthenticationError + +app = PyWry() + +try: + result = app.login() # blocks — see "User experience" below +except AuthFlowTimeout: + print("User took too long to authenticate") +except AuthFlowCancelled: + print("User closed the login window") +except AuthenticationError as e: + print(f"Authentication failed: {e}") +``` + +After a successful login: + +```python +if result.success: + user_id = result.user_info.get("sub") or result.user_info.get("login") + email = result.user_info.get("email") + token = result.tokens.access_token # valid access token + expires = result.tokens.expires_at # float timestamp, or None + +app.is_authenticated # True after a successful login() +app.logout() # revokes token at provider, clears store +``` + +`result.user_info` is the raw dict returned by the provider's userinfo endpoint. +The keys vary by provider: + +| Provider | Common keys | +|:---|:---| +| Google | `sub`, `email`, `name`, `picture` | +| GitHub | `id`, `login`, `email`, `name`, `avatar_url` | +| Microsoft | `sub`, `email`, `name` | + +### Developer: configure token persistence + +By default, tokens are stored in memory and lost when the process exits. For +desktop apps that should remember the user across restarts, use the keyring backend: + +```python +result = app.login( + # Pass a custom token store via the flow manager if needed, + # or set via environment variable: + # PYWRY_OAUTH2__TOKEN_STORE_BACKEND=keyring +) +``` + +```bash +export PYWRY_OAUTH2__TOKEN_STORE_BACKEND=keyring +pip install pywry[auth] # installs the keyring package +``` + +See [Token Storage](#token-storage) below for all backends. + +### Developer: keep the access token fresh + +`SessionManager` runs a background `threading.Timer` that refreshes the token +before it expires, so `app.login()` does not need to be called again after the +initial authentication: + +```python +# Constructed automatically inside app.login() using settings: +# PYWRY_OAUTH2__REFRESH_BUFFER_SECONDS=60 + +# To access the token at any point after login: +token = await app._session_manager.get_access_token() +# Automatically refreshes if the token is within 60s of expiry. +``` + +When the refresh token itself expires (i.e. the user has been away too long), +re-authentication is needed. Wire `on_reauth_required` to prompt the user: + +```python +from pywry.auth import SessionManager + +mgr = SessionManager( + provider=provider, + token_store=store, + session_key="user@example.com", + refresh_buffer_seconds=60, + on_reauth_required=lambda: app.login(), # re-authenticate automatically +) +``` + +### User experience (native mode) + +1. The developer's app calls `app.login()`. +2. The user's **system browser opens** to the provider's login page (e.g. + accounts.google.com, github.com/login). PyWry does not host or render this page. +3. The user enters their credentials at the provider's site and approves the + requested scopes. +4. The provider redirects the browser to `http://127.0.0.1:{port}/callback`. + PyWry's ephemeral callback server responds with a plain HTML page: + > ✅ **Authentication Complete** — You can close this window. +5. Control returns to the developer's code with `AuthFlowResult`. + +The user never sees PyWry UI during login. If the provider is slow or the user +takes too long, `AuthFlowTimeout` is raised after `auth_timeout_seconds` (default +120 s). + +--- + +## Deploy mode + +### Developer: register the OAuth2 app + +Register `https://your-domain.com/auth/callback` as the redirect URI with the +provider (not a `localhost` URL). + +### Developer: configure environment variables + +```bash +# OAuth2 provider +PYWRY_OAUTH2__PROVIDER=google +PYWRY_OAUTH2__CLIENT_ID=… +PYWRY_OAUTH2__CLIENT_SECRET=… + +# Auth middleware +PYWRY_DEPLOY__AUTH_ENABLED=true +PYWRY_DEPLOY__AUTH_SESSION_COOKIE=pywry_session +PYWRY_DEPLOY__DEFAULT_ROLES=viewer +PYWRY_DEPLOY__ADMIN_USERS=admin@example.com + +# Session / state backend +PYWRY_DEPLOY__STATE_BACKEND=redis +PYWRY_DEPLOY__REDIS_URL=redis://localhost:6379/0 +``` + +Any user whose ID or email matches `ADMIN_USERS` gets `"admin"` added to their +session roles automatically when they log in. + +### Developer: mount the auth router + +```python +from pywry.auth import create_provider_from_settings, get_token_store +from pywry.auth.deploy_routes import create_auth_router +from pywry.state import get_session_store +from pywry.state.auth import AuthConfig + +provider = create_provider_from_settings(settings.oauth2) + +auth_router = create_auth_router( + provider=provider, + session_store=get_session_store(), + token_store=get_token_store("redis", redis_url="redis://localhost:6379/0"), + deploy_settings=settings.deploy, + auth_config=AuthConfig( + enabled=True, + token_secret="your-secret-key", + session_ttl=86400, # 24 hours + ), + use_pkce=True, +) + +fastapi_app.include_router(auth_router) +``` + +This exposes six endpoints: + +| Route | Method | Description | +|:---|:---|:---| +| `/auth/login` | `GET` | Redirects the user to the provider's login page | +| `/auth/callback` | `GET` | Receives the redirect, creates a session, sets a cookie | +| `/auth/refresh` | `POST` | Refreshes the access token (called by frontend JS) | +| `/auth/logout` | `POST` | Revokes the token, deletes the session, clears the cookie | +| `/auth/userinfo` | `GET` | Returns `user_id`, `roles`, and `user_info` for the session | +| `/auth/status` | `GET` | Returns `{authenticated, user_id, roles, expires_at}` | + +### Developer: protect routes + +Check the session on each request using the auth middleware. The session is +populated by the `pywry_session` cookie set at `/auth/callback`: + +```python +from fastapi import Request + +@fastapi_app.get("/dashboard") +async def dashboard(request: Request): + session = getattr(request.state, "session", None) + if not session: + return RedirectResponse("/auth/login") + return {"user": session.user_id, "roles": session.roles} +``` + +### Developer: prune stale CSRF nonces + +`/auth/login` stores a one-time state nonce server-side. Nonces older than +`max_age` seconds can be pruned periodically (e.g. from a background task): + +```python +from pywry.auth.deploy_routes import cleanup_expired_states + +removed = cleanup_expired_states(max_age=600.0) # default 10 min +``` + +### User experience (deploy mode) + +1. The user navigates to `/auth/login` (e.g. by clicking a "Sign in" button in the + frontend). +2. The server generates a PKCE challenge and a CSRF state nonce, then + **redirects the browser** to the provider's login page. +3. The user logs in at the provider's site and approves the scopes. +4. The provider redirects the browser to `/auth/callback?code=…&state=…`. +5. PyWry validates the state nonce, exchanges the code for tokens, creates a + session, and sets an `HttpOnly Secure SameSite=Lax` cookie. +6. The browser is **redirected to `/`** — the user lands in the application, + already authenticated. + +To check authentication status from frontend JavaScript: + +```javascript +const res = await fetch('/auth/status'); +const { authenticated, user_id, roles, expires_at } = await res.json(); +``` + +--- + +## Token Storage + +All backends implement the same async interface (`save`, `load`, `delete`, `exists`, +`list_keys`). Select one with `get_token_store()`: + +```python +from pywry.auth import get_token_store + +store = get_token_store("memory") # default +store = get_token_store("keyring", service_name="my-app") # pip install pywry[auth] +store = get_token_store("redis", redis_url="redis://localhost:6379/0") # pip install redis +``` + +`get_token_store` is `@lru_cache(maxsize=1)` — the same `backend` argument always +returns the same instance. + +| Backend | Persistence | Best for | +|:---|:---|:---| +| `memory` | Process lifetime | Development, single-process apps | +| `keyring` | OS credential store | Desktop apps that remember the user | +| `redis` | Redis TTL | Multi-worker deploy mode | + +For `redis`, the TTL is `expires_in + 300` seconds (5-minute buffer for refresh). +Keys are namespaced as `{prefix}:oauth:tokens:{key}`. + +--- + +## PKCE + +PKCE (Proof Key for Code Exchange, RFC 7636) is enabled by default and recommended +for all clients, especially desktop apps where the OAuth2 redirect goes to +`localhost`. It prevents authorization code interception attacks. + +`AuthFlowManager` and `create_auth_router` both handle PKCE automatically. +`PKCEChallenge` is only needed if building a custom flow: + +```python +from pywry.auth import PKCEChallenge + +pkce = PKCEChallenge.generate(length=64) +pkce.verifier # sent during token exchange (code_verifier) +pkce.challenge # sent during authorize (base64url SHA-256 of verifier) +pkce.method # always "S256" +``` + +--- + +## Error Handling + +```python +from pywry.exceptions import ( + AuthenticationError, # general auth failure or bad configuration + AuthFlowTimeout, # user did not complete login within auth_timeout_seconds + AuthFlowCancelled, # flow.cancel() was called (e.g. user closed the window) + TokenExpiredError, # token is expired and no refresh token is available + TokenRefreshError, # provider rejected the refresh token + TokenError, # token exchange with provider failed +) +``` + +All exceptions carry contextual fields (`provider`, `flow_id`, `timeout`) where +applicable. + +--- + +## Integration with State & RBAC + +OAuth2 authentication integrates with the PyWry session and RBAC system: + +1. **OAuth2 tokens** are stored in `TokenStore`, keyed by the user's ID from + `user_info` (`sub`, `id`, `login`, or `email` — first non-empty). +2. **Sessions** are created in `SessionStore` with `auth_config.session_ttl` as TTL. +3. **Roles** default to `deploy_settings.default_roles` (`viewer` by default). +4. **Admin promotion**: if the user's ID or email appears in + `deploy_settings.admin_users`, `"admin"` is appended to their roles. +5. **`user_info`** from the provider is stored in `session.metadata["user_info"]` + and returned verbatim from `/auth/userinfo`. + +For details on sessions, RBAC, and the session store, see +[State, Redis & RBAC](state-and-auth.md). + +--- + +## Next Steps + +- **[State, Redis & RBAC](state-and-auth.md)** — Session store, roles, and permissions +- **[Deploy Mode](deploy-mode.md)** — Running PyWry as a production web server +- **[Configuration](configuration.md)** — Full settings reference including `OAuth2Settings` +- **[API Reference: Auth](../reference/auth.md)** — Full API docs for the auth package + diff --git a/pywry/docs/docs/reference/auth-callback-server.md b/pywry/docs/docs/reference/auth-callback-server.md new file mode 100644 index 0000000..1da9dcc --- /dev/null +++ b/pywry/docs/docs/reference/auth-callback-server.md @@ -0,0 +1,14 @@ +# pywry.auth.callback_server + +Local HTTP callback server for OAuth2 redirect handling in native mode. + +--- + +## OAuthCallbackServer + +::: pywry.auth.callback_server.OAuthCallbackServer + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false diff --git a/pywry/docs/docs/reference/auth-deploy-routes.md b/pywry/docs/docs/reference/auth-deploy-routes.md new file mode 100644 index 0000000..d8760ae --- /dev/null +++ b/pywry/docs/docs/reference/auth-deploy-routes.md @@ -0,0 +1,23 @@ +# pywry.auth.deploy_routes + +FastAPI routes for OAuth2 authentication in deploy mode. + +--- + +## create_auth_router + +::: pywry.auth.deploy_routes.create_auth_router + options: + show_root_heading: true + heading_level: 2 + show_source: false + +--- + +## cleanup_expired_states + +::: pywry.auth.deploy_routes.cleanup_expired_states + options: + show_root_heading: true + heading_level: 2 + show_source: false diff --git a/pywry/docs/docs/reference/auth-flow.md b/pywry/docs/docs/reference/auth-flow.md new file mode 100644 index 0000000..c995cc8 --- /dev/null +++ b/pywry/docs/docs/reference/auth-flow.md @@ -0,0 +1,36 @@ +# pywry.auth.flow + +OAuth2 authentication flow orchestrator. + +--- + +## AuthFlowManager + +::: pywry.auth.flow.AuthFlowManager + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## AuthFlowResult + +::: pywry.auth.flow.AuthFlowResult + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## AuthFlowState + +::: pywry.auth.flow.AuthFlowState + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false diff --git a/pywry/docs/docs/reference/auth-pkce.md b/pywry/docs/docs/reference/auth-pkce.md new file mode 100644 index 0000000..f1bfcbb --- /dev/null +++ b/pywry/docs/docs/reference/auth-pkce.md @@ -0,0 +1,14 @@ +# pywry.auth.pkce + +Proof Key for Code Exchange (PKCE) utilities for OAuth2 authorization code flow. + +--- + +## PKCEChallenge + +::: pywry.auth.pkce.PKCEChallenge + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false diff --git a/pywry/docs/docs/reference/auth-providers.md b/pywry/docs/docs/reference/auth-providers.md new file mode 100644 index 0000000..5da67ac --- /dev/null +++ b/pywry/docs/docs/reference/auth-providers.md @@ -0,0 +1,67 @@ +# pywry.auth.providers + +OAuth2 provider abstractions and concrete implementations. + +--- + +## OAuthProvider (ABC) + +::: pywry.auth.providers.OAuthProvider + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## GenericOIDCProvider + +::: pywry.auth.providers.GenericOIDCProvider + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## GoogleProvider + +::: pywry.auth.providers.GoogleProvider + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## GitHubProvider + +::: pywry.auth.providers.GitHubProvider + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## MicrosoftProvider + +::: pywry.auth.providers.MicrosoftProvider + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## Factory + +::: pywry.auth.providers.create_provider_from_settings + options: + show_root_heading: true + heading_level: 2 diff --git a/pywry/docs/docs/reference/auth-session.md b/pywry/docs/docs/reference/auth-session.md new file mode 100644 index 0000000..2cb2e73 --- /dev/null +++ b/pywry/docs/docs/reference/auth-session.md @@ -0,0 +1,14 @@ +# pywry.auth.session + +OAuth2 session manager with automatic token refresh. + +--- + +## SessionManager + +::: pywry.auth.session.SessionManager + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false diff --git a/pywry/docs/docs/reference/auth-token-store.md b/pywry/docs/docs/reference/auth-token-store.md new file mode 100644 index 0000000..3d7f074 --- /dev/null +++ b/pywry/docs/docs/reference/auth-token-store.md @@ -0,0 +1,56 @@ +# pywry.auth.token_store + +Pluggable token storage backends for OAuth2 tokens. + +--- + +## TokenStore (ABC) + +::: pywry.auth.token_store.TokenStore + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## MemoryTokenStore + +::: pywry.auth.token_store.MemoryTokenStore + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## KeyringTokenStore + +::: pywry.auth.token_store.KeyringTokenStore + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## RedisTokenStore + +::: pywry.auth.token_store.RedisTokenStore + options: + show_root_heading: true + heading_level: 2 + members_order: source + show_source: false + +--- + +## Factory + +::: pywry.auth.token_store.get_token_store + options: + show_root_heading: true + heading_level: 2 diff --git a/pywry/docs/docs/reference/auth.md b/pywry/docs/docs/reference/auth.md new file mode 100644 index 0000000..09f0cf3 --- /dev/null +++ b/pywry/docs/docs/reference/auth.md @@ -0,0 +1,45 @@ +# pywry.auth + +OAuth2 authentication system — providers, token storage, session management, and flow orchestration. + +--- + +## Package Exports + +::: pywry.auth + options: + show_root_heading: false + heading_level: 2 + members: + - AuthFlowManager + - OAuthProvider + - GenericOIDCProvider + - GoogleProvider + - GitHubProvider + - MicrosoftProvider + - SessionManager + - TokenStore + - MemoryTokenStore + - RedisTokenStore + - PKCEChallenge + - create_provider_from_settings + - get_token_store + show_if_no_docstring: false + show_source: false + show_docstring_description: true + show_docstring_parameters: false + show_docstring_returns: false + +--- + +## Submodules + +| Module | Description | +|:---|:---| +| [Providers](auth-providers.md) | `OAuthProvider` ABC and concrete implementations (Google, GitHub, Microsoft, OIDC) | +| [Token Store](auth-token-store.md) | Pluggable token storage backends (memory, keyring, Redis) | +| [Flow](auth-flow.md) | `AuthFlowManager` — orchestrates the full OAuth2 authorization code flow | +| [Session](auth-session.md) | `SessionManager` — token lifecycle with automatic background refresh | +| [Deploy Routes](auth-deploy-routes.md) | FastAPI router for server-side OAuth2 in deploy mode | +| [Callback Server](auth-callback-server.md) | Ephemeral localhost server for capturing OAuth2 redirects | +| [PKCE](auth-pkce.md) | RFC 7636 PKCE code challenge generation | diff --git a/pywry/docs/mkdocs.yml b/pywry/docs/mkdocs.yml index 9af8609..11b0814 100644 --- a/pywry/docs/mkdocs.yml +++ b/pywry/docs/mkdocs.yml @@ -167,6 +167,7 @@ nav: - JavaScript Bridge: guides/javascript-bridge.md - Configuration: guides/configuration.md - State, Redis & RBAC: guides/state-and-auth.md + - OAuth2 Authentication: guides/oauth2.md - Hot Reload: guides/hot-reload.md - UI: - Toolbar System: guides/toolbars.md @@ -176,6 +177,7 @@ nav: - Integrations: - Plotly Charts: guides/plotly.md - AgGrid Tables: guides/aggrid.md + - Multi-Widget Pages: guides/multi-widget.md - Hosting: - Browser Mode: guides/browser-mode.md - Deploy Mode: guides/deploy-mode.md @@ -205,6 +207,15 @@ nav: - Mixins: reference/state-mixins.md - Auth & RBAC: reference/state-auth.md - Redis: reference/state-redis.md + - OAuth2: + - Overview: reference/auth.md + - Providers: reference/auth-providers.md + - Token Store: reference/auth-token-store.md + - Flow Manager: reference/auth-flow.md + - Session Manager: reference/auth-session.md + - Deploy Routes: reference/auth-deploy-routes.md + - Callback Server: reference/auth-callback-server.md + - PKCE: reference/auth-pkce.md - UI: - reference/components/index.md - CSS: reference/css.md From 85b0ba4e656b2ae901e87c46a751b61dd4b7a545 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:49:56 +0000 Subject: [PATCH 9/9] Add retry_on_subprocess_failure to test_set_always_on_top for flaky CI fix Co-authored-by: deeleeramone <85772166+deeleeramone@users.noreply.github.com> --- pywry/.pylintrc | 2 +- pywry/AGENTS.md | 80 +- pywry/README.md | 7529 +-------------------- pywry/docs/docs/guides/state-and-auth.md | 2 + pywry/docs/docs/stylesheets/extra.css | 2 +- pywry/examples/pywry_demo_multi_widget.py | 471 ++ pywry/examples/pywry_demo_oauth2.py | 340 + pywry/pyproject.toml | 6 + pywry/pytest.ini | 1 - pywry/pywry/__main__.py | 10 +- pywry/pywry/app.py | 125 + pywry/pywry/auth/__init__.py | 44 + pywry/pywry/auth/callback_server.py | 219 + pywry/pywry/auth/deploy_routes.py | 590 ++ pywry/pywry/auth/flow.py | 381 ++ pywry/pywry/auth/pkce.py | 52 + pywry/pywry/auth/providers.py | 811 +++ pywry/pywry/auth/session.py | 258 + pywry/pywry/auth/token_store.py | 349 + pywry/pywry/cli.py | 81 +- pywry/pywry/commands/__init__.py | 15 + pywry/pywry/config.py | 276 +- pywry/pywry/exceptions.py | 96 + pywry/pywry/frontend/src/auth-helpers.js | 135 + pywry/pywry/inline.py | 92 +- pywry/pywry/mcp/skills/authentication.md | 139 + pywry/pywry/runtime.py | 8 + pywry/pywry/state/auth.py | 14 +- pywry/pywry/state/sync_helpers.py | 50 + pywry/pywry/state/types.py | 83 + pywry/pywry/toolbar.py | 129 +- pywry/pywry/widget_protocol.py | 130 - pywry/pywry/window_manager/lifecycle.py | 13 + pywry/tests/conftest.py | 69 + pywry/tests/test_alerts.py | 32 +- pywry/tests/test_auth_callback_server.py | 205 + pywry/tests/test_auth_deploy_routes.py | 423 ++ pywry/tests/test_auth_flow_integration.py | 279 + pywry/tests/test_auth_providers.py | 582 ++ pywry/tests/test_auth_rbac_integration.py | 2 +- pywry/tests/test_auth_session.py | 301 + pywry/tests/test_auth_token_store.py | 174 + pywry/tests/test_inline_e2e.py | 7 + pywry/tests/test_inline_ssl.py | 8 + pywry/tests/test_toolbar.py | 4 +- pywry/tests/test_widget_protocol.py | 128 - pywry/tests/test_window_proxy.py | 49 +- 47 files changed, 6906 insertions(+), 7890 deletions(-) create mode 100644 pywry/examples/pywry_demo_multi_widget.py create mode 100644 pywry/examples/pywry_demo_oauth2.py create mode 100644 pywry/pywry/auth/__init__.py create mode 100644 pywry/pywry/auth/callback_server.py create mode 100644 pywry/pywry/auth/deploy_routes.py create mode 100644 pywry/pywry/auth/flow.py create mode 100644 pywry/pywry/auth/pkce.py create mode 100644 pywry/pywry/auth/providers.py create mode 100644 pywry/pywry/auth/session.py create mode 100644 pywry/pywry/auth/token_store.py create mode 100644 pywry/pywry/frontend/src/auth-helpers.js create mode 100644 pywry/pywry/mcp/skills/authentication.md create mode 100644 pywry/tests/test_auth_callback_server.py create mode 100644 pywry/tests/test_auth_deploy_routes.py create mode 100644 pywry/tests/test_auth_flow_integration.py create mode 100644 pywry/tests/test_auth_providers.py create mode 100644 pywry/tests/test_auth_session.py create mode 100644 pywry/tests/test_auth_token_store.py diff --git a/pywry/.pylintrc b/pywry/.pylintrc index 02ad9f3..53e38a1 100644 --- a/pywry/.pylintrc +++ b/pywry/.pylintrc @@ -27,7 +27,7 @@ disable= [FORMAT] max-line-length=100 -max-module-lines=1000 +max-module-lines=2500 expected-line-ending-format= [BASIC] diff --git a/pywry/AGENTS.md b/pywry/AGENTS.md index 506596b..5de4100 100644 --- a/pywry/AGENTS.md +++ b/pywry/AGENTS.md @@ -144,8 +144,18 @@ pywry/ │ ├── _factory.py # Factory functions for store instantiation │ ├── memory.py # In-memory state backends (default) │ ├── redis.py # Redis-backed state backends -│ ├── types.py # Type definitions (StateBackend, WidgetData, etc.) -│ └── auth.py # Authentication and RBAC utilities +│ ├── types.py # Type definitions (StateBackend, WidgetData, OAuthTokenSet, etc.) +│ ├── auth.py # Authentication and RBAC utilities +│ └── sync_helpers.py # Sync↔async bridging (run_async, wait_for_event) +├── auth/ # OAuth2 authentication system +│ ├── __init__.py # Public exports +│ ├── pkce.py # PKCE challenge generation (RFC 7636) +│ ├── providers.py # OAuthProvider ABC + Google, GitHub, Microsoft, OIDC implementations +│ ├── token_store.py # TokenStore ABC + Memory, Keyring, Redis backends +│ ├── callback_server.py # Ephemeral localhost server for native auth redirects +│ ├── deploy_routes.py # FastAPI /auth/* routes for deploy mode +│ ├── flow.py # AuthFlowManager orchestrator +│ └── session.py # SessionManager with automatic token refresh ├── utils/ # Utility helpers └── window_manager/ # Window mode implementations ├── controller.py @@ -1134,6 +1144,72 @@ PYWRY_DEPLOY__DEFAULT_ROLE=viewer --- +## Authentication & OAuth2 + +PyWry includes a full OAuth2 authentication system that works in both native window mode and deploy mode. + +### Quick Start (Native Mode) + +```python +from pywry import PyWry + +app = PyWry() + +# Login with Google (configure via environment variables) +# PYWRY_OAUTH2__PROVIDER=google +# PYWRY_OAUTH2__CLIENT_ID=your-client-id +# PYWRY_OAUTH2__CLIENT_SECRET=your-secret +result = app.login() + +if result.success: + print(f"Logged in! Tokens: {result.tokens.token_type}") + app.show("

Welcome!

") + app.block() +``` + +### Quick Start (Deploy Mode) + +```bash +PYWRY_DEPLOY__AUTH_ENABLED=true +PYWRY_DEPLOY__STATE_BACKEND=redis +PYWRY_OAUTH2__PROVIDER=github +PYWRY_OAUTH2__CLIENT_ID=your-client-id +PYWRY_OAUTH2__CLIENT_SECRET=your-secret +``` + +Deploy mode automatically mounts `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/status` routes. + +### Architecture + +| Component | File | Purpose | +|-----------|------|---------| +| `OAuthProvider` | `auth/providers.py` | ABC for OAuth2 providers (Google, GitHub, Microsoft, OIDC, custom) | +| `PKCEChallenge` | `auth/pkce.py` | PKCE code challenge generation (RFC 7636) | +| `TokenStore` | `auth/token_store.py` | ABC for token persistence (Memory, Keyring, Redis) | +| `OAuthCallbackServer` | `auth/callback_server.py` | Ephemeral localhost HTTP server for native redirect capture | +| `AuthFlowManager` | `auth/flow.py` | Orchestrates the complete OAuth2 flow | +| `SessionManager` | `auth/session.py` | Token lifecycle with automatic background refresh | +| `deploy_routes` | `auth/deploy_routes.py` | FastAPI `/auth/*` routes for production deployments | + +### OAuth2Settings + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `provider` | `str` | `"custom"` | `google`, `github`, `microsoft`, `oidc`, or `custom` | +| `client_id` | `str` | `""` | OAuth2 client ID | +| `client_secret` | `str` | `""` | Client secret (empty for PKCE public clients) | +| `scopes` | `str` | `"openid email profile"` | Space-separated scopes | +| `use_pkce` | `bool` | `True` | Enable PKCE for public clients | +| `token_store_backend` | `str` | `"memory"` | `memory`, `keyring`, or `redis` | +| `auth_timeout_seconds` | `float` | `120.0` | Max wait for OAuth callback | +| `refresh_buffer_seconds` | `int` | `60` | Pre-expiry refresh margin | + +### Frontend Integration + +When authenticated, `window.__PYWRY_AUTH__` contains `{ user_id, roles, token_type }`. Use `window.pywry.auth.isAuthenticated()`, `.getState()`, `.login()`, `.logout()`, `.onAuthStateChange(cb)`. + +--- + ## Key Classes Reference ### Core Classes diff --git a/pywry/README.md b/pywry/README.md index 20a27be..15ef952 100644 --- a/pywry/README.md +++ b/pywry/README.md @@ -1,9 +1,21 @@ +
+ ![PyWry](./pywry/frontend/assets/PyWry.png) -PyWry is a blazingly fast rendering library for generating and managing native desktop windows, iFrames, and Jupyter widgets - with full bidirectional Python ↔ JavaScript communication. Get started in minutes, not hours. +**Blazingly fast rendering library for native desktop windows, Jupyter widgets, and browser tabs.** + +Full bidirectional Python ↔ JavaScript communication. Get started in minutes, not hours. + +[![PyPI](https://img.shields.io/pypi/v/pywry?color=blue)](https://pypi.org/project/pywry/) +[![Python](https://img.shields.io/pypi/pyversions/pywry)](https://pypi.org/project/pywry/) +[![License](https://img.shields.io/github/license/deeleeramone/PyWry)](LICENSE) +[![Docs](https://img.shields.io/badge/docs-live-brightgreen)](https://deeleeramone.github.io/PyWry/) + +
-Unlike other similar libraries, PyWry is **not** a web dashboard framework. It is a **rendering engine** that targets three output paths from one API: +--- +PyWry is **not** a web dashboard framework. It is a **rendering engine** that targets three output paths from one unified API: | Mode | Where It Runs | Backend | |------|---------------|---------| @@ -11,45 +23,38 @@ Unlike other similar libraries, PyWry is **not** a web dashboard framework. It i | `NOTEBOOK` | Jupyter / VS Code / Colab | anywidget or IFrame + FastAPI + WebSocket | | `BROWSER` | System browser tab | FastAPI server + WebSocket + Redis | -It uses declarative Pydantic components that automatically wrap content in a nested structure that can be targeted with CSS selectors: - -HEADER → LEFT | TOP → CONTENT + INSIDE → BOTTOM | RIGHT → FOOTER - -Built on [PyTauri](https://pypi.org/project/pytauri/) (which uses Rust's [Tauri](https://tauri.app/) framework), it leverages the OS webview instead of bundling a browser engine — a few MBs versus Electron's 150MB+ overhead. +Built on [PyTauri](https://pypi.org/project/pytauri/) (Rust's [Tauri](https://tauri.app/) framework), it uses the OS webview instead of bundling a browser engine — a few MBs versus Electron's 150MB+ overhead. -Its unified API lets you build fast and use anywhere. Batteries included. - -
Features at a Glance | Feature | What It Does | |---------|--------------| | **Native Windows** | Lightweight OS webview windows (not Electron) | -| **Jupyter Widgets** | Works in notebooks with anywidget | +| **Jupyter Widgets** | Works in notebooks via anywidget with traitlet sync | | **Browser Mode** | Deploy to web with FastAPI + WebSocket | -| **Toolbar System** | 18 declarative Pydantic components with 7 layout positions — automatic nested flexbox structure | -| **Two-Way Events** | Python ↔ JavaScript communication with pre-wired Plotly/AgGrid events and utility events for DOM manipulation | -| **Marquee Ticker** | Scrolling text/content with dynamic updates | -| **AgGrid Tables** | Best-in-class Pandas → AgGrid conversion, with pre-wired grid events, context menus, and practical default gridOptions | -| **Plotly Charts** | Plotly rendering with pre-wired plot events for Dash-like functionality when combined with Toolbar components | -| **Toast Notifications** | Built-in alert system with positioning | -| **Theming & CSS** | Light/dark modes, 60+ CSS variables, component ID targeting, and dynamic styling via events (`pywry:set-style`, `pywry:inject-css`) | -| **Secrets Handling** | Secure password inputs — values stored server-side, never rendered in HTML, configurable getter and setter methods | -| **Security** | Scoped token auth enabled by default, CSP headers, internal API protection, production presets available | -| **Configuration** | TOML files, env vars, security presets | -| **Hot Reload** | Live CSS/JS updates during development | -| **Deploy Mode** | Redis backend for horizontal scaling | +| **Toolbar System** | 18 declarative Pydantic components with 7 layout positions | +| **Two-Way Events** | Python ↔ JavaScript with pre-wired Plotly/AgGrid events | +| **Modals** | Overlay dialogs with toolbar components inside | +| **AgGrid Tables** | Pandas → AgGrid conversion with pre-wired grid events | +| **Plotly Charts** | Plotly rendering with custom modebar buttons and plot events | +| **Toast Notifications** | Built-in alert system with configurable positioning | +| **Theming & CSS** | Light/dark modes, 60+ CSS variables, hot reload | +| **Secrets Handling** | Server-side password storage, never rendered in HTML | +| **Security** | Token auth, CSP headers, production presets | +| **Configuration** | Layered TOML files, env vars, security presets | +| **Hot Reload** | Live CSS injection and JS updates during development | +| **Deploy Mode** | Redis state backend for horizontal scaling | +| **MCP Server** | AI agent integration via Model Context Protocol |
## Installation -Install in a virtual environment with a version of Python between 3.10 and 3.14. - -### Linux +Requires Python 3.10–3.14. Install in a virtual environment. -Linux requires WebKitGTK and GTK3 development libraries: +
+Linux Prerequisites ```bash # Ubuntu/Debian @@ -59,32 +64,25 @@ sudo apt-get install libwebkit2gtk-4.1-dev libgtk-3-dev libglib2.0-dev \ libxcb-shape0 libgl1 libegl1 ``` -### Basic Installation +
```bash pip install pywry ``` -### Optional Extras - | Extra | Command | Description | |-------|---------|-------------| -| **notebook** | `pip install 'pywry[notebook]'` | anywidget for Jupyter integration (recommended) | +| **notebook** | `pip install 'pywry[notebook]'` | anywidget for Jupyter integration | | **mcp** | `pip install 'pywry[mcp]'` | Model Context Protocol server for AI agents | | **all** | `pip install 'pywry[all]'` | All optional dependencies | -| **dev** | `pip install 'pywry[dev]'` | Development and testing tools | - -For AI agent integration with MCP: -```bash -pip install 'pywry[mcp]' -``` +> See [Installation Guide](https://deeleeramone.github.io/PyWry/getting-started/installation/) for full details. --- ## Quick Start -### Hello World! +### Hello World ```python from pywry import PyWry @@ -94,35 +92,31 @@ app = PyWry() app.show("Hello World!") app.block() # block the main thread until the window closes - -app.show("Hello again, World!") ``` -### Button Updates Content +### Interactive Toolbar ```python from pywry import PyWry, Toolbar, Button -# Create instance app = PyWry() def on_click(data, event_type, label): - """Update the heading text and inject custom CSS.""" app.emit("pywry:set-content", {"selector": "h1", "text": "Toolbar Works!"}, label) -# Display HTML with an interactive toolbar toolbar = Toolbar( position="top", items=[Button(label="Update Text", event="app:click")] ) + handle = app.show( - "

Hello, World!

", + "

Hello, World!

", toolbars=[toolbar], callbacks={"app:click": on_click}, ) ``` -### DataFrame -> AgGrid +### DataFrame → AgGrid ```python from pywry import PyWry @@ -133,20 +127,14 @@ app = PyWry() df = pd.DataFrame({"name": ["Alice", "Bob", "Carol"], "age": [30, 25, 35]}) def on_select(data, event_type, label): - """Print selected row names.""" names = ", ".join(row["name"] for row in data["rows"]) app.emit("pywry:alert", {"message": f"Selected: {names}" if names else "None selected"}, label) -handle = app.show_dataframe( - df, - callbacks={"grid:row-selected": on_select}, -) +handle = app.show_dataframe(df, callbacks={"grid:row-selected": on_select}) ``` ### Plotly Chart -Install Plotly into the environment first (`pip install plotly`). - ```python from pywry import PyWry, Toolbar, Button import plotly.express as px @@ -155,7409 +143,76 @@ app = PyWry(theme="light") fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species") -def on_click(data, event_type, label): - """Update chart title when a point is clicked.""" - point = data["points"][0] - app.emit( - "plotly:update-layout", - { - "layout": { - "title": f"Clicked: ({point['x']:.2f}, {point['y']:.2f})" - }, - }, - label - ) - -def on_reset(data, event_type, label): - """Reset the chart zoom.""" - app.emit("plotly:reset-zoom", {}, label) - handle = app.show_plotly( fig, toolbars=[Toolbar(position="top", items=[Button(label="Reset Zoom", event="app:reset")])], - callbacks={ - "plotly:click": on_click, - "app:reset": on_reset, - }, + callbacks={"app:reset": lambda d, e, l: app.emit("plotly:reset-zoom", {}, l)}, ) ``` ---- - -## Table of Contents - -| Section | Description | -|---------|-------------| -| [Features](#features) | Overview of PyWry capabilities | -| [Installation](#installation) | How to install PyWry | -| [Quick Start](#quick-start) | Minimal working example | - -**Core Documentation** - -| Section | Description | -|---------|-------------| -| [Rendering Paths](#rendering-paths) | Native Window, Notebook, IFrame, Browser modes | -| ↳ [Native Window](#rendering-paths) ・ [Notebook Widget](#rendering-paths) ・ [Inline IFrame](#rendering-paths) ・ [Browser Mode](#rendering-paths) | | -| [Core API](#core-api) | PyWry class, imports, display & event methods | -| ↳ [Imports](#core-api) ・ [PyWry Class](#core-api) ・ [Display Methods](#core-api) ・ [Widget Types](#widget-types) ・ [Event Methods](#core-api) | | -| [HtmlContent Model](#htmlcontent-model) | Advanced content configuration | -| [WindowConfig Model](#windowconfig-model) | Window property configuration | -| [Configuration System](#configuration-system) | TOML files, environment variables, presets | -| ↳ [pywry.toml](#configuration-system) ・ [pyproject.toml](#configuration-system) ・ [Environment Variables](#configuration-system) ・ [Security Presets](#configuration-system) | | -| [Hot Reload](#hot-reload) | Live CSS/JS updates during development | - -**Event & Toolbar Systems** - -| Section | Description | -|---------|-------------| -| [Event System](#event-system) | Bidirectional Python ↔ JS communication | -| ↳ [Event Naming](#event-system) ・ [Handler Signature](#event-system) ・ [Toast Notifications](#toast-notifications-pywryalert) ・ [Utility Events](#utility-events-python-to-js) | | -| [Pre-Registered Events](#pre-registered-events-built-in) | Built-in system, Plotly, and AgGrid events | -| [Toolbar System](#toolbar-system) | All 18 toolbar components with examples | -| ↳ [Positions & Layout](#toolbar-system) ・ [Component Reference](#toolbar-system) ・ [State Management](#toolbar-system) | | -| [CSS Selectors and Theming](#css-selectors-and-theming) | Styling with CSS variables and classes | -| ↳ [Theme Classes](#css-selectors-and-theming) ・ [Layout Classes](#css-selectors-and-theming) ・ [Toast Classes](#toast-notification-classes) ・ [CSS Variables](#css-selectors-and-theming) | | - -**Advanced Topics** - -| Section | Description | -|---------|-------------| -| [JavaScript Bridge](#javascript-bridge) | `window.pywry` API reference | -| ↳ [Available Methods](#javascript-bridge) ・ [Injected Globals](#javascript-bridge) ・ [Plotly/AgGrid APIs](#javascript-bridge) ・ [Toolbar API](#javascript-bridge) ・ [Toast Notifications](#javascript-bridge) | | -| [Direct Tauri API Access](#direct-tauri-api-access) | Native filesystem, dialogs, clipboard | -| ↳ [`__TAURI__` Global](#direct-tauri-api-access) ・ [PyTauri IPC](#direct-tauri-api-access) ・ [Tauri Events](#direct-tauri-api-access) | | -| [Managing Multiple Windows/Widgets](#managing-multiple-windowswidgets) | Window lifecycle, widget references | -| ↳ [Window Modes](#managing-multiple-windowswidgets) ・ [Native Windows](#managing-multiple-windowswidgets) ・ [Widget Methods](#managing-multiple-windowswidgets) | | -| [Browser Mode & Server Configuration](#browser-mode--server-configuration) | Headless/web deployment, security settings | -| ↳ [Server Config](#server-configuration) ・ [HTTPS](#https-configuration) ・ [WebSocket Security](#websocket--api-security) | | -| [Deploy Mode & Scaling](#deploy-mode--scaling) | Redis backend, horizontal scaling, multi-worker | -| ↳ [State Backends](#deploy-mode--scaling) ・ [Redis Configuration](#deploy-mode--scaling) ・ [Authentication](#deploy-mode--scaling) | | -| [CLI Commands](#cli-commands) | Command-line tools | -| [Debugging](#debugging) | DevTools, logging, troubleshooting | -| [Building from Source](#building-from-source) | Development setup | - -**Integrations** - -| Section | Description | -|---------|-------------| -| [Plotly Integration](#plotly-integration) | Charts with custom modebar buttons | -| [AgGrid Integration](#aggrid-integration) | DataFrames with column definitions | -| [MCP Server (AI Agents)](#mcp-server-ai-agents) | Model Context Protocol for AI agent integration | +> See [Quick Start Guide](https://deeleeramone.github.io/PyWry/getting-started/quickstart/) and [Examples](https://deeleeramone.github.io/PyWry/examples/) for more. --- -## Rendering Paths +## Components -
-Click to expand +PyWry includes 18 declarative toolbar components, all Pydantic models with 7 layout positions (`header`, `footer`, `top`, `bottom`, `left`, `right`, `inside`): -**In this section:** [Native Window](#native-window) · [Notebook Widget](#notebook-widget-anywidget) · [Inline IFrame](#inline-iframe) · [Browser Mode](#browser-mode) +| Component | Description | +|-----------|-------------| +| **Button** | Clickable button — primary, secondary, neutral, ghost, outline, danger, warning, icon | +| **Select** | Dropdown select with `Option` items | +| **MultiSelect** | Multi-select dropdown with checkboxes | +| **TextInput** | Text input with debounce support | +| **SecretInput** | Secure password input — values stored server-side, never in HTML | +| **TextArea** | Multi-line text area | +| **SearchInput** | Search input with debounce | +| **NumberInput** | Numeric input with min/max/step | +| **DateInput** | Date picker | +| **SliderInput** | Slider with optional value display | +| **RangeInput** | Dual-handle range slider | +| **Toggle** | Boolean toggle switch | +| **Checkbox** | Boolean checkbox | +| **RadioGroup** | Radio button group | +| **TabGroup** | Tab navigation | +| **Div** | Container element for content/HTML | +| **Marquee** | Scrolling ticker — scroll, alternate, slide, static | +| **Modal** | Overlay dialog supporting all toolbar components | + +> See [Components Documentation](https://deeleeramone.github.io/PyWry/components/) for live previews, attributes, and usage examples. + +--- + +## Documentation + +Full documentation is available at **[deeleeramone.github.io/PyWry](https://deeleeramone.github.io/PyWry/)**. + +| Section | Topics | +|---------|--------| +| [Getting Started](https://deeleeramone.github.io/PyWry/getting-started/) | Installation, Quick Start, Rendering Paths | +| [Concepts](https://deeleeramone.github.io/PyWry/getting-started/) | `app.show()`, HtmlContent, Events, Configuration, State & RBAC, Hot Reload | +| [UI](https://deeleeramone.github.io/PyWry/getting-started/) | Toolbar System, Modals, Toasts & Alerts, Theming & CSS | +| [Integrations](https://deeleeramone.github.io/PyWry/getting-started/) | Plotly Charts, AgGrid Tables | +| [Hosting](https://deeleeramone.github.io/PyWry/getting-started/) | Browser Mode, Deploy Mode | +| [Components](https://deeleeramone.github.io/PyWry/components/) | Live previews for all 18 toolbar components + Modal | +| [API Reference](https://deeleeramone.github.io/PyWry/reference/) | Auto-generated API docs for every class and function | +| [MCP Server](https://deeleeramone.github.io/PyWry/mcp/) | AI agent integration via Model Context Protocol | --- -PyWry automatically selects the appropriate rendering path based on your environment: - -| Environment | Rendering Path | Module | Return Type | -|-------------|----------------|--------|-------------| -| Desktop (script/terminal) | Native Window | `pywry.app.PyWry` | `NativeWindowHandle` | -| Jupyter/VS Code with anywidget | Notebook Widget | `pywry.widget` | `PyWryWidget` | -| Jupyter/VS Code without anywidget | Inline IFrame | `pywry.inline` | `InlineWidget` | -| Headless / Server / SSH | Browser Mode | `pywry.window_manager.modes.browser` | `InlineWidget` | - -
-Rendering Path Diagram - -``` -┌───────────────────────────────────────────────────────────────────────┐ -│ PyWry Rendering Paths │ -└───────────────────────────────────────────────────────────────────────┘ - - ┌──────────────────┐ - │ PyWry.show() │ - │ show_plotly() │ - │ show_dataframe() │ - └────────┬─────────┘ - │ - ┌─────────────┴─────────────┐ - ▼ ▼ - ┌────────────────────┐ ┌────────────────────┐ - │ Desktop/Terminal │ │ Notebook/Browser │ - │ (GUI Available) │ │ Environment │ - └─────────┬──────────┘ └──────────┬─────────┘ - │ │ - ▼ ┌─────┴─────┐ - ┌──────────────────┐ ▼ ▼ - │ NATIVE WINDOW │ ┌───────────┐ ┌───────────┐ - │ │ │ Notebook? │ │ Headless/ │ - │ PyTauri + Rust │ │ │ │ Server │ - │ WebView2/WebKit │ └─────┬─────┘ └─────┬─────┘ - │ │ │ │ - │ ┌────────────┐ │ ┌─────┴─────┐ ▼ - │ │ OS WebView │ │ ▼ ▼ ┌─────────┐ - │ │ │ │ ┌───────┐ ┌─────┐ │ BROWSER │ - │ │ HTML/JS/CSS│ │ │ any- │ │Falls│ │ MODE │ - │ └────────────┘ │ │widget │ │back │ │ │ - │ │ │ avail?│ │ │ │ FastAPI │ - │ Returns: Native │ └───┬───┘ └──┬──┘ │ Server │ - │ WindowHandle │ │ │ │ │ - └──────────────────┘ ▼ │ │Opens in │ - │ ┌─────────┐ │ │ Browser │ - │ │NOTEBOOK │ │ └────┬────┘ - │ │ WIDGET │ │ │ - │ │ │ ▼ ▼ - │ │anywidget│ ┌─────────┐ Returns: - │ │ comms │ │ INLINE │ widget_id - │ │ │ │ IFRAME │ (str) - │ │ Returns:│ │ │ - │ │PyWry- │ │ FastAPI │ - │ │ Widget │ │ Server │ - │ └─────────┘ │ │ - │ │ │ Returns:│ - │ │ │ Inline- │ - │ │ │ Widget │ - │ │ └─────────┘ - │ │ │ - ▼ ▼ ▼ - ┌─────────────────────────────────────────────────┐ - │ Bidirectional Events │ - │ Python ◄────────────► JavaScript │ - │ │ - │ • widget.emit("event:name", data) (Python→JS) │ - │ • window.pywry.emit("event:name") (JS→Python) │ - │ • callbacks={"event:name": handler} │ - └─────────────────────────────────────────────────┘ -``` - -**Decision Flow:** - -1. **Desktop/Terminal with GUI** → Native Window (PyTauri + WebView) -2. **Jupyter/VS Code + anywidget installed** → Notebook Widget (anywidget comms) -3. **Jupyter/VS Code without anywidget** → Inline IFrame (FastAPI server) -4. **Headless/SSH/Server** → Browser Mode (FastAPI + system browser) - -
- -### Native Window - -Uses PyTauri/Tauri to create native OS windows with WebView2 (Windows), WebKit (macOS/Linux). - -```python -from pywry import PyWry, WindowMode, ThemeMode - -app = PyWry( - mode=WindowMode.SINGLE_WINDOW, # or NEW_WINDOW, MULTI_WINDOW - theme=ThemeMode.DARK, - title="My App", - width=1280, - height=720, -) - -# Display content - returns NativeWindowHandle (implements BaseWidget protocol) -handle = app.show("

Hello

") - -# Update content using built-in utility event -# NativeWindowHandle has emit() method and .label property -handle.emit("pywry:set-content", {"id": "greeting", "text": "Hello from Python!"}) -``` - -> **Note:** For low-level access to the PyTauri runtime, use `from pywry import runtime` and call `runtime.emit_event(handle.label, ...)` directly. The `runtime` module is not re-exported in `__all__` but is importable from the pywry package. - -**Window Modes:** - -| Mode | Behavior | -|------|----------| -| `NEW_WINDOW` | Creates new window for each `show()` call | -| `SINGLE_WINDOW` | Reuses one window, replaces content | -| `MULTI_WINDOW` | Multiple labeled windows, update by label | -| `NOTEBOOK` | Inline rendering in Jupyter notebooks (auto-detected) | -| `BROWSER` | Opens in system browser, uses FastAPI server (headless/SSH) | - -### Notebook Widget (anywidget) - -When `anywidget` is installed, PyWry uses it for tighter Jupyter integration with bidirectional communication without a local server. - -```python -from pywry.inline import show_plotly, show_dataframe - -# Returns PyWryPlotlyWidget or PyWryAgGridWidget -widget = show_plotly(fig, callbacks={"plotly:click": my_handler}) - -# Update theme using built-in utility event -widget.emit("pywry:update-theme", {"theme": "plotly_white"}) -``` - -### Inline IFrame - -Fallback when `anywidget` is not available. Uses FastAPI server running in background thread. - -```python -from pywry.inline import show_plotly, show_dataframe - -# Returns InlineWidget (IFrame-based) -widget = show_plotly(fig, callbacks={"plotly:click": my_handler}) - -# Update theme using built-in utility event -widget.emit("pywry:update-theme", {"theme": "plotly_white"}) -``` - -### Browser Mode - -For headless environments (servers, SSH sessions, containers), use `BROWSER` mode to open content in the system's default browser: - -```python -from pywry import PyWry, WindowMode - -app = PyWry(mode=WindowMode.BROWSER) +## MCP Server -# Opens in default browser, returns InlineWidget -widget = app.show("

Hello from Browser

") +PyWry includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server for AI agent integration — 25 tools, 8 skills, and 20+ resources. -# Block until browser tab is closed -app.block() +```bash +pip install 'pywry[mcp]' +pywry mcp --transport stdio ``` -Browser mode starts a FastAPI server and opens the widget URL in the browser. Use `app.block()` to keep the server running after your script completes. - -
- ---- - -## Core API - -
-Click to expand - -**In this section:** [Imports](#imports) · [PyWry Class](#pywry-class) · [Display Methods](#display-methods) · [Widget Types](#widget-types) · [Event Methods](#event-methods) · [Other Methods](#other-methods) · [Window Management](#window-management) +> See [MCP Documentation](https://deeleeramone.github.io/PyWry/mcp/) for setup with Claude Desktop, tool reference, and examples. --- -### Imports - -```python -# Main class -from pywry import PyWry - -# Enums -from pywry import WindowMode, ThemeMode - -# Models -from pywry import HtmlContent, WindowConfig - -# Toolbar components -from pywry import ( - Toolbar, Button, Select, MultiSelect, TextInput, TextArea, SearchInput, - SecretInput, NumberInput, DateInput, SliderInput, RangeInput, Toggle, - Checkbox, RadioGroup, TabGroup, Div, Marquee, TickerItem, Option, ToolbarItem -) - -# Plotly configuration (for customizing modebar, icons, buttons) -from pywry import PlotlyConfig, PlotlyIconName, ModeBarButton, ModeBarConfig, SvgIcon, StandardButton - -# Grid models (for AgGrid customization) -from pywry.grid import ColDef, ColGroupDef, DefaultColDef, RowSelection, GridOptions, GridConfig, GridData, build_grid_config, to_js_grid_config - -# State mixins (for extending custom widgets) -from pywry import GridStateMixin, PlotlyStateMixin, ToolbarStateMixin - -# Inline functions (for notebooks) -from pywry.inline import show_plotly, show_dataframe, block, stop_server - -# Notebook detection -from pywry import NotebookEnvironment, detect_notebook_environment, is_anywidget_available, should_use_inline_rendering - -# Widget classes (PyWryWidget for notebooks) -from pywry import PyWryWidget, PyWryPlotlyWidget, PyWryAgGridWidget - -# Widget protocol (for type checking and custom implementations) -from pywry.widget_protocol import BaseWidget, NativeWindowHandle, is_base_widget - -# Window manager -from pywry import BrowserMode, get_lifecycle - -# Settings (exported from pywry) -from pywry import PyWrySettings, SecuritySettings, WindowSettings, ThemeSettings, HotReloadSettings, TimeoutSettings, AssetSettings, LogSettings - -# Settings (require full path import) -from pywry.config import ServerSettings, DeploySettings - -# Asset loading -from pywry import AssetLoader, get_asset_loader - -# Callback registry -from pywry import CallbackFunc, WidgetType, get_registry - -# State management (for deploy mode / horizontal scaling) -from pywry.state import ( - get_widget_store, - get_event_bus, - get_connection_router, - get_session_store, - is_deploy_mode, - get_worker_id, - get_state_backend, - WidgetData, - EventMessage, - ConnectionInfo, - UserSession, - StateBackend, -) -``` - -### PyWry Class - -```python -PyWry( - mode: WindowMode = WindowMode.NEW_WINDOW, - theme: ThemeMode = ThemeMode.DARK, - title: str = "PyWry", - width: int = 800, - height: int = 600, - settings: PyWrySettings | None = None, - hot_reload: bool = False, -) -``` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `mode` | `WindowMode` | `NEW_WINDOW` | Window management mode | -| `theme` | `ThemeMode` | `DARK` | Theme mode (DARK, LIGHT, SYSTEM) | -| `title` | `str` | `"PyWry"` | Default window title | -| `width` | `int` | `800` | Default window width | -| `height` | `int` | `600` | Default window height | -| `settings` | `PyWrySettings` | `None` | Configuration settings | -| `hot_reload` | `bool` | `False` | Enable hot reload | - -### Display Methods - -**`show(content, ...) -> NativeWindowHandle | BaseWidget`** - -```python -handle = app.show( - content, # str or HtmlContent - title=None, # Window title override - width=None, # Window width override (int for pixels, str for CSS like "60%") - height=None, # Window height override - callbacks=None, # Dict of event handlers {"event:name": handler} - include_plotly=False, # Include Plotly.js - include_aggrid=False, # Include AgGrid - aggrid_theme="alpine", # quartz, alpine, balham, material - label=None, # Window label (auto-generated if None) - watch=None, # Enable file watching for hot reload - toolbars=None, # List of Toolbar objects -) -``` - -**`show_plotly(figure, ...) -> NativeWindowHandle | BaseWidget`** - -```python -handle = app.show_plotly( - figure, # Plotly Figure or dict - title=None, - width=None, - height=None, - callbacks=None, - label=None, - inline_css=None, - on_click=None, # Click callback (notebook mode) - on_hover=None, # Hover callback (notebook mode) - on_select=None, # Selection callback (notebook mode) - toolbars=None, - config=None, # PlotlyConfig or dict -) -``` - -**`show_dataframe(data, ...) -> NativeWindowHandle | BaseWidget`** - -```python -handle = app.show_dataframe( - data, # pandas DataFrame or dict - title=None, - width=None, - height=None, - callbacks=None, - label=None, - column_defs=None, # List of ColDef objects - aggrid_theme="alpine", # quartz, alpine, balham, material - grid_options=None, # GridOptions dict - toolbars=None, - inline_css=None, - on_cell_click=None, # Cell click callback (notebook mode) - on_row_selected=None, # Row selection callback (notebook mode) - server_side=False, # Use server-side mode for large datasets (>10K rows) -) -``` - -### Event Methods - -**Callback Signature:** - -```python -def my_handler(data: dict, event_type: str, label: str) -> None: - """ - data: Event payload from JavaScript - event_type: The event name (e.g., "app:click") - label: Window label that triggered the event - """ - pass -``` - -**Sending Events to JavaScript:** - -> **Which `emit()` to use?** -> - **Native mode** (`NEW_WINDOW`, `SINGLE_WINDOW`, `MULTI_WINDOW`): `show_*()` returns a `NativeWindowHandle` → use `handle.emit(event, data)` or `app.emit(event, data, handle.label)` -> - **Notebook/Browser mode** (`NOTEBOOK`, `BROWSER`): `show_*()` returns a widget → use `widget.emit(event, data)` -> -> The `callbacks={}` parameter in `show_*()` works identically in all modes. - -```python -from pywry import PyWry - -app = PyWry() -handle = app.show("

Hello

") - -# For native mode: use handle.emit() or app.emit() with handle.label -handle.emit("pywry:set-content", {"id": "title", "text": "Updated!"}) -# Or: app.emit("pywry:set-content", {"id": "title", "text": "Updated!"}, handle.label) - -# For notebook mode: show_*() returns a widget with .emit() -# widget.emit("pywry:set-content", {"id": "title", "text": "Updated!"}) -``` - -### Other Methods - -| Method | Description | -|--------|-------------| -| `emit(event_type, data, label=None)` | Send event to JavaScript in window(s) | -| `alert(message, alert_type, ...)` | Show toast notification | -| `on(event_type, handler, ...)` | Register event handler | -| `on_grid(event_type, handler, ...)` | Register grid-specific event handler | -| `on_chart(event_type, handler, ...)` | Register chart-specific event handler | -| `on_toolbar(event_type, handler, ...)` | Register toolbar-specific event handler | -| `on_html(event_type, handler, ...)` | Register HTML element event handler | -| `on_window(event_type, handler, ...)` | Register window lifecycle event handler | -| `eval_js(script, label=None)` | Execute JavaScript in window(s) | -| `update_content(html, label=None)` | Update window HTML content | -| `refresh(label=None)` | Refresh window content | -| `refresh_css(label=None)` | Hot-reload CSS without page refresh | -| `enable_hot_reload()` | Enable hot reload | -| `disable_hot_reload()` | Disable hot reload | -| `get_lifecycle()` | Get WindowLifecycle manager | - -### Widget Types - -All `show_*()` methods return a widget object that implements the `BaseWidget` protocol. The specific type depends on the rendering environment: - -| Type | Environment | Description | -|------|-------------|-------------| -| [NativeWindowHandle](#nativewindowhandle) | Desktop/Terminal | Handle for native OS windows | -| [WindowProxy](#windowproxy) | via `handle.proxy` | Full WebviewWindow API access | -| [PyWryWidget](#pywrywidget) | Jupyter with anywidget | anywidget-based notebook widget | -| [InlineWidget](#inlinewidget) | Jupyter fallback / Browser | FastAPI server + IFrame widget | - -All widget types share a common API defined by the `BaseWidget` protocol: - -| Method | Description | -|--------|-------------| -| `emit(event_type, data)` | Send event from Python → JavaScript | -| `on(event_type, callback)` | Register callback for JS → Python events | -| `update(html)` | Update widget HTML content | -| `display()` | Display widget (notebooks only) | -| `label` | Property: unique widget/window identifier | - -#### NativeWindowHandle - -Handle for native desktop windows. Returned by `show_*()` methods when running in desktop/terminal mode. Wraps native window resources and provides the same API as notebook widgets. - -
-Usage Example - -```python -from pywry import PyWry - -app = PyWry() -handle = app.show("

Hello

", title="My Window") - -# BaseWidget protocol methods -handle.emit("update", {"value": 42}) # Send event to JS -handle.on("click", my_handler) # Register callback -handle.update("

New content

") # Update HTML -print(handle.label) # Window label identifier - -# Window control methods -handle.close() # Close/destroy window -handle.hide() # Hide (keep alive) -handle.show_window() # Show hidden window -handle.eval_js("console.log('Hi')") # Execute JavaScript - -# Window state methods -handle.maximize() # Maximize window -handle.minimize() # Minimize window -handle.center() # Center on screen -handle.set_title("New Title") # Change title -handle.set_size(1024, 768) # Resize window - -# Advanced: WindowProxy access -handle.proxy.set_always_on_top(True) # Full WebviewWindow API -handle.proxy.open_devtools() # Open developer tools -handle.proxy.set_zoom(1.5) # Set zoom level - -# Metadata access -print(handle.resources.created_at) # Window creation time -print(handle.resources.config.title) # Window configuration -``` - -
- -**Properties:** - -| Property | Type | Description | -|----------|------|-------------| -| `label` | `str` | Window label identifier | -| `resources` | `WindowResources` | Window metadata (config, creation time, watched files) | -| `proxy` | `WindowProxy` | Full WebviewWindow API access | - -**Window Control Methods:** - -| Method | Description | -|--------|-------------| -| `close()` | Close and destroy the window | -| `hide()` | Hide window without destroying | -| `show_window()` | Show a hidden window | -| `eval_js(script)` | Execute JavaScript in window | -| `maximize()` | Maximize window | -| `minimize()` | Minimize window | -| `center()` | Center window on screen | -| `set_title(title)` | Set window title | -| `set_size(width, height)` | Set window dimensions | - -#### WindowProxy - -Full WebviewWindow API access for native windows. Accessed via `handle.proxy` property on `NativeWindowHandle`. Provides direct IPC to the pytauri subprocess for complete window control. - -```python -proxy = handle.proxy # Get WindowProxy from NativeWindowHandle - -# State queries -print(proxy.is_maximized) # bool -print(proxy.is_fullscreen) # bool -print(proxy.title) # str -print(proxy.scale_factor) # float - -# Window actions -proxy.maximize() -proxy.center() -proxy.set_always_on_top(True) - -# Appearance -proxy.set_background_color((30, 30, 30, 255)) # RGBA tuple -proxy.set_theme(Theme.DARK) -proxy.set_decorations(False) - -# Webview operations -proxy.navigate("https://example.com") -proxy.set_zoom(1.5) -proxy.open_devtools() -``` - -
-State Properties (Read-Only) - -| Property | Type | Description | -|----------|------|-------------| -| `label` | `str` | Window label | -| `title` | `str` | Window title | -| `url` | `str` | Current URL | -| `theme` | `Theme` | Current theme | -| `scale_factor` | `float` | Display scale factor | -| `inner_position` | `PhysicalPosition` | Inner position (x, y) | -| `outer_position` | `PhysicalPosition` | Outer position (x, y) | -| `inner_size` | `PhysicalSize` | Inner dimensions (width, height) | -| `outer_size` | `PhysicalSize` | Outer dimensions (width, height) | -| `cursor_position` | `PhysicalPosition` | Cursor position relative to window | -| `current_monitor` | `Monitor \| None` | Current monitor info | -| `primary_monitor` | `Monitor \| None` | Primary monitor info | -| `available_monitors` | `list[Monitor]` | All available monitors | - -
- -
-Boolean State Properties - -| Property | Description | -|----------|-------------| -| `is_fullscreen` | Window is in fullscreen mode | -| `is_minimized` | Window is minimized | -| `is_maximized` | Window is maximized | -| `is_focused` | Window has focus | -| `is_decorated` | Window has decorations (title bar, borders) | -| `is_resizable` | Window can be resized | -| `is_enabled` | Window is enabled | -| `is_visible` | Window is visible | -| `is_closable` | Window can be closed | -| `is_maximizable` | Window can be maximized | -| `is_minimizable` | Window can be minimized | -| `is_always_on_top` | Window stays above others | -| `is_devtools_open` | DevTools is open | - -
- -
-Window Actions (No Parameters) - -| Method | Description | -|--------|-------------| -| `show()` | Show the window | -| `hide()` | Hide the window | -| `close()` | Close the window | -| `destroy()` | Destroy the window | -| `maximize()` | Maximize the window | -| `unmaximize()` | Restore from maximized | -| `minimize()` | Minimize the window | -| `unminimize()` | Restore from minimized | -| `center()` | Center window on screen | -| `set_focus()` | Set focus to window | -| `reload()` | Reload the webview | -| `print_page()` | Print the page | -| `open_devtools()` | Open DevTools | -| `close_devtools()` | Close DevTools | -| `clear_all_browsing_data()` | Clear all browsing data | -| `start_dragging()` | Start window dragging | - -
- -
-Window Actions (With Parameters) - -| Method | Parameters | Description | Platform | -|--------|------------|-------------|----------| -| `request_user_attention(type)` | `UserAttentionType \| None` | Flash/bounce window | All | -| `set_title(title)` | `str` | Set window title | All | -| `set_size(size)` | `SizeType` | Set window size | All | -| `set_min_size(size)` | `SizeType \| None` | Set minimum size | All | -| `set_max_size(size)` | `SizeType \| None` | Set maximum size | All | -| `set_position(pos)` | `PositionType` | Set window position | All | -| `set_fullscreen(enable)` | `bool` | Toggle fullscreen | All | -| `set_decorations(enable)` | `bool` | Toggle decorations | All | -| `set_always_on_top(enable)` | `bool` | Toggle always-on-top | All | -| `set_resizable(enable)` | `bool` | Toggle resizable | All | -| `set_enabled(enable)` | `bool` | Toggle enabled | Windows | -| `set_closable(enable)` | `bool` | Toggle closable | macOS | -| `set_maximizable(enable)` | `bool` | Toggle maximizable | macOS | -| `set_minimizable(enable)` | `bool` | Toggle minimizable | macOS | -| `set_visible_on_all_workspaces(enable)` | `bool` | Toggle multi-workspace visibility | macOS, Linux | -| `set_skip_taskbar(skip)` | `bool` | Toggle taskbar visibility | Windows, Linux | -| `set_cursor_icon(icon)` | `CursorIcon` | Set cursor icon | All | -| `set_cursor_position(pos)` | `PositionType` | Set cursor position | All | -| `set_cursor_visible(visible)` | `bool` | Toggle cursor visibility | All | -| `set_cursor_grab(grab)` | `bool` | Toggle cursor grab | All | -| `set_icon(icon)` | `bytes \| None` | Set window icon (PNG bytes) | Windows, Linux | -| `set_shadow(enable)` | `bool` | Toggle window shadow | Windows, macOS | -| `set_title_bar_style(style)` | `TitleBarStyle` | Set title bar style | macOS | -| `set_theme(theme)` | `Theme \| None` | Set window theme | All | - -
- -
-Webview Operations - -| Method | Parameters | Description | -|--------|------------|-------------| -| `eval(script)` | `str` | Execute JavaScript (fire-and-forget) | -| `eval_with_result(script, timeout)` | `str`, `float` | Execute JavaScript and return result | -| `navigate(url)` | `str` | Navigate to URL | -| `set_zoom(scale)` | `float` | Set zoom level | -| `set_background_color(color)` | `Color` | Set background color (r, g, b, a) | - -
- -
-Visual Effects & Progress (Platform-Specific) - -| Method | Parameters | Description | Platform | -|--------|------------|-------------|----------| -| `set_effects(effects)` | `Effects` | Set window visual effects | Windows, macOS | -| `set_progress_bar(state)` | `ProgressBarState` | Set progress indicator | Windows (taskbar), macOS (dock) | -| `set_badge_count(count)` | `int \| None` | Set badge count | macOS (dock), Linux (some DEs) | -| `set_overlay_icon(icon)` | `bytes \| None` | Set overlay icon on taskbar | Windows only | - -**EffectState Values (for `Effects.effects` list):** - -| Value | Platform | Description | -|-------|----------|-------------| -| `BLUR` | Windows | Standard blur effect | -| `ACRYLIC` | Windows | Acrylic blur (Windows 10+) | -| `MICA` | Windows | Mica material (Windows 11+) | -| `MICA_DARK` | Windows | Mica dark variant | -| `MICA_LIGHT` | Windows | Mica light variant | -| `TABBED` | Windows | Tabbed Mica variant | -| `TABBED_DARK` | Windows | Tabbed dark variant | -| `TABBED_LIGHT` | Windows | Tabbed light variant | -| `UNDER_WINDOW_BACKGROUND` | macOS | Behind window vibrancy | -| `CONTENT_BACKGROUND` | macOS | Content area vibrancy | -| `SIDEBAR` | macOS | Sidebar vibrancy | -| `HEADER_VIEW` | macOS | Header vibrancy | -| `SHEET` | macOS | Sheet vibrancy | -| `WINDOW_BACKGROUND` | macOS | Window background vibrancy | -| `HUD_WINDOW` | macOS | HUD overlay vibrancy | -| `FULLSCREEN_UI` | macOS | Fullscreen UI vibrancy | -| `TOOLTIP` | macOS | Tooltip vibrancy | -| `MENU` | macOS | Menu vibrancy | -| `POPOVER` | macOS | Popover vibrancy | -| `SELECTION` | macOS | Selection vibrancy | - -**ProgressBarState Fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `progress` | `float \| None` | Progress value (0.0 - 1.0), or None for indeterminate | -| `status` | `ProgressBarStatus` | Status indicator: `NONE`, `NORMAL`, `INDETERMINATE`, `PAUSED`, `ERROR` | - -
- -
-Cookie Management - -| Method | Parameters | Description | -|--------|------------|-------------| -| `cookies()` | — | Get all cookies | -| `set_cookie(cookie)` | `Cookie` | Set a cookie | -| `delete_cookie(name)` | `str` | Delete cookie by name | - -
- -#### PyWryWidget - -> **Requires:** `pip install 'pywry[notebook]'` (installs anywidget) - -anywidget-based notebook widget. Returned by `show_*()` methods when running in Jupyter with anywidget installed. Provides real-time bidirectional communication via traitlet sync. **Best performance for notebooks.** - -If anywidget is not installed, PyWry automatically falls back to [InlineWidget](#inlinewidget). - -
-Usage Example - -```python -from pywry import PyWry - -app = PyWry() -widget = app.show("

Hello

") - -# BaseWidget protocol methods -widget.emit("update", {"value": 42}) -widget.on("click", my_handler) -widget.update("

New content

") -widget.display() # Show in notebook cell - -# Access widget properties -print(widget.label) # Widget ID -print(widget.content) # Current HTML content -print(widget.theme) # 'dark' or 'light' -``` - -
- -**Traitlet Properties (synced with frontend):** - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `content` | `str` | `""` | HTML content to render | -| `theme` | `str` | `"dark"` | Color theme | -| `width` | `str` | `"100%"` | Widget width (CSS value) | -| `height` | `str` | `"500px"` | Widget height (CSS value) | - -**Methods:** - -| Method | Description | -|--------|-------------| -| `emit(event_type, data)` | Send event to JavaScript | -| `on(event_type, callback)` | Register event callback | -| `update(html)` | Update HTML content | -| `set_content(content)` | Alias for `update()` | -| `display()` | Display widget in notebook cell | -| `from_html(content, callbacks, ...)` | Class method to create widget from HTML | - -**Factory Method:** - -```python -# Create widget directly with callbacks -widget = PyWryWidget.from_html( - content="

Hello

", - callbacks={"button:click": my_handler}, - theme="dark", - width="100%", - height="500px", - toolbars=[...], # Optional toolbar configs -) -``` - -#### InlineWidget - -FastAPI + IFrame widget. Returned when anywidget is not available or in browser mode. Uses FastAPI server with WebSocket communication. - -
-Usage Example +## License -```python -from pywry import PyWry - -app = PyWry() -widget = app.show("

Hello

") - -# BaseWidget protocol methods -widget.emit("update", {"value": 42}) -widget.on("click", my_handler) -widget.update("

New content

") -widget.display() # Show IFrame in notebook - -# Widget properties -print(widget.label) # Widget ID -print(widget.widget_id) # Same as label -print(widget.url) # http://localhost:8765/widget/{id} - -# Open in browser -widget.open_in_browser() - -# Toast notifications -widget.alert("Success!", alert_type="success") -``` - -
- -**Properties:** - -| Property | Type | Description | -|----------|------|-------------| -| `widget_id` | `str` | Unique widget identifier | -| `label` | `str` | Alias for `widget_id` (BaseWidget protocol) | -| `url` | `str` | Full URL to access widget | -| `output` | `Output` | IPython Output widget for callback messages | - -**Methods:** - -| Method | Description | -|--------|-------------| -| `emit(event_type, data)` | Send event to JavaScript | -| `send(event_type, data)` | Alias for `emit()` | -| `on(event_type, callback)` | Register event callback | -| `update(html)` | Update HTML content | -| `update_html(html)` | Alias for `update()` | -| `display()` | Display IFrame and output widget in notebook | -| `open_in_browser()` | Open widget URL in system browser | -| `alert(message, ...)` | Show toast notification | - -
-Toast Notifications - -```python -# Basic alerts -widget.alert("Operation complete", alert_type="success") -widget.alert("Something went wrong", alert_type="error") -widget.alert("Please confirm", alert_type="warning") - -# With options -widget.alert( - message="File saved successfully", - alert_type="success", - title="Save Complete", - duration=5000, # Auto-dismiss after 5 seconds - position="bottom-right", # top-right, top-left, bottom-right, bottom-left -) - -# Confirmation dialog -widget.alert( - message="Delete this item?", - alert_type="confirm", - callback_event="confirm:delete", # Emits event with user response -) -``` - -
- -
-Constructor Parameters - -```python -InlineWidget( - html: str, # HTML content to render - callbacks: dict | None = None, # Event callbacks - width: str = "100%", # Widget width - height: int = 500, # Widget height in pixels - port: int | None = None, # Server port (default from settings) - widget_id: str | None = None, # Custom widget ID - headers: dict | None = None, # Custom HTTP headers - auth: Any | None = None, # Authentication config - browser_only: bool = False, # Skip IPython requirement - token: str | None = None, # Widget access token -) -``` - -
- -Used as fallback when anywidget unavailable, or for browser mode deployment. - -### Window Management - -> **See also:** [Managing Multiple Windows/Widgets](#managing-multiple-windowswidgets) for comprehensive coverage of both native windows and notebook widgets, including return types, widget properties, and lifecycle management. - -PyWry provides fine-grained control over window visibility and lifecycle through [NativeWindowHandle](#nativewindowhandle) methods and `PyWry` app methods. Windows can be shown, hidden, and closed independently. - -**App-Level Methods (operate on labels):** - -| Method | Description | -|--------|-------------| -| `show_window(label)` | Show a hidden window (brings to front) | -| `hide_window(label)` | Hide a window (keeps it alive, not destroyed) | -| `close(label)` | Close/destroy a specific window permanently | -| `close()` | Close/destroy all windows | -| `get_labels()` | Get list of currently visible window labels | -| `block(label=None)` | Block until specific window or all windows close | - -**Handle-Level Methods (via NativeWindowHandle):** - -You can also control windows directly through the [NativeWindowHandle](#nativewindowhandle) returned by `show_*()`: - -```python -handle = app.show("

Hello

") -handle.hide() # Same as app.hide_window(handle.label) -handle.show_window() # Same as app.show_window(handle.label) -handle.close() # Same as app.close(handle.label) -``` - -See [NativeWindowHandle](#nativewindowhandle) for the full API including `maximize()`, `minimize()`, `center()`, `set_title()`, and `proxy` access. - -**Window Lifecycle:** - -``` -Created → Visible → Hidden → Visible → Closed - ↑ │ ↑ - └─────────┘ │ - (show_window) (close) -``` - -**X Button Behavior by Mode:** - -| Mode | X Button | Behavior | -|------|----------|----------| -| `SINGLE_WINDOW` | Always hides | Window reused on next `show()` | -| `NEW_WINDOW` | Always closes | Window destroyed, cannot recover | -| `MULTI_WINDOW` | Configurable | Uses `on_window_close` setting | - -For `MULTI_WINDOW`, use `on_window_close` to control whether clicking X hides or destroys windows: - -```python -from pywry import PyWry, PyWrySettings, WindowMode - -# X button hides windows (default) - can recover with show_window() -app = PyWry(mode=WindowMode.MULTI_WINDOW) - -# X button destroys windows - cannot recover -settings = PyWrySettings() -settings.window.on_window_close = "close" -app = PyWry(mode=WindowMode.MULTI_WINDOW, settings=settings) -``` - -**Example: Managing Multiple Windows** - -```python -from pywry import PyWry, WindowMode - -app = PyWry(mode=WindowMode.MULTI_WINDOW) - -# Create windows with labels -app.show("

Dashboard

", label="dashboard") -app.show("

Settings

", label="settings") -app.show("

Help

", label="help") - -# Check visible windows -print(app.get_labels()) # ['dashboard', 'settings', 'help'] - -# Hide the settings window -app.hide_window("settings") -print(app.get_labels()) # ['dashboard', 'help'] - -# Bring it back -app.show_window("settings") -print(app.get_labels()) # ['dashboard', 'settings', 'help'] - -# Close the help window permanently -app.close("help") -print(app.get_labels()) # ['dashboard', 'settings'] - -# Block until specific window closes -app.block("dashboard") # Waits only for dashboard to close - -print(app.get_labels()) -# Or block until all windows close -app.block() # Waits for all remaining windows -``` - -**SINGLE_WINDOW Mode Behavior:** - -In `SINGLE_WINDOW` mode, calling `show()` multiple times reuses the same window without destroying it. If the window was hidden, it will be shown again with the new content: - -```python -from pywry import PyWry, WindowMode - -app = PyWry(mode=WindowMode.SINGLE_WINDOW) - -# First show - creates window -app.show("

Page 1

") - -# User hides window (clicks X)... - -# Second show - reuses window, shows it again -app.show("

Page 2

") # No "destroying" - just updates content - -app.block() -``` - -
- ---- - -## HtmlContent Model - -
-Click to expand - -For advanced content configuration, use the `HtmlContent` model: - -```python -from pywry import PyWry, HtmlContent - -app = PyWry() - -content = HtmlContent( - html="
", - json_data={"key": "value"}, - init_script="console.log('ready');", - css_files=["styles/main.css"], - script_files=["js/app.js"], - inline_css="body { margin: 0; }", - watch=True, -) - -app.show(content) -``` - -**Fields:** - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `html` | `str` | Required | HTML content | -| `json_data` | `dict` | `None` | Injected as `window.json_data` | -| `init_script` | `str` | `None` | Custom initialization JavaScript | -| `css_files` | `list[Path \| str]` | `None` | CSS files to include | -| `script_files` | `list[Path \| str]` | `None` | JS files to include | -| `inline_css` | `str` | `None` | Inline CSS styles | -| `watch` | `bool` | `False` | Enable hot reload for these files | - -
- ---- - -## WindowConfig Model - -
-Click to expand - -The `WindowConfig` model controls window properties: - -```python -from pywry import WindowConfig, ThemeMode - -config = WindowConfig( - title="My Window", - width=1280, - height=720, - theme=ThemeMode.DARK, -) -``` - -**Fields:** - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `title` | `str` | `"PyWry"` | Window title | -| `width` | `int` | `1280` | Window width (min: 200) | -| `height` | `int` | `720` | Window height (min: 150) | -| `min_width` | `int` | `400` | Minimum width (min: 100) | -| `min_height` | `int` | `300` | Minimum height (min: 100) | -| `theme` | `ThemeMode` | `DARK` | Theme mode | -| `center` | `bool` | `True` | Center window on screen | -| `resizable` | `bool` | `True` | Allow window resizing | -| `decorations` | `bool` | `True` | Show window decorations | -| `always_on_top` | `bool` | `False` | Keep window above others | -| `devtools` | `bool` | `False` | Open developer tools | -| `allow_network` | `bool` | `True` | Allow network requests | -| `enable_plotly` | `bool` | `False` | Include Plotly.js library | -| `enable_aggrid` | `bool` | `False` | Include AgGrid library | -| `plotly_theme` | `str` | `"plotly_dark"` | Plotly theme | -| `aggrid_theme` | `str` | `"alpine"` | AgGrid theme | - -
- ---- - -## Configuration System - -
-Click to expand - -**In this section:** [pywry.toml](#configuration-file-pywrytoml) · [pyproject.toml](#pyprojecttoml) · [Environment Variables](#environment-variables) · [Configuration Sections](#configuration-sections) · [Programmatic Config](#programmatic-configuration) · [Security Presets](#security-presets) - ---- - -PyWry uses a layered configuration system. Settings are merged in this order (highest priority last): - -1. **Built-in defaults** -2. **pyproject.toml**: `[tool.pywry]` section -3. **Project config**: `./pywry.toml` -4. **User config**: `~/.config/pywry/config.toml` (Linux/macOS) or `%APPDATA%\pywry\config.toml` (Windows) -5. **Environment variables**: `PYWRY_*` prefix - -User config overrides project-level settings, allowing personal preferences across all projects. - -### Configuration File (pywry.toml) - -```toml -[theme] -# Optional: Path to custom CSS file for styling overrides -# css_file = "/path/to/custom.css" - -[window] -title = "My Application" -width = 1280 -height = 720 -center = true -resizable = true -devtools = false -on_window_close = "hide" # "hide" or "close" (MULTI_WINDOW only) - -[timeout] -startup = 10.0 -response = 5.0 -create_window = 5.0 -set_content = 5.0 -shutdown = 2.0 - -[hot_reload] -enabled = true -debounce_ms = 100 -css_reload = "inject" -preserve_scroll = true -watch_directories = ["./src", "./styles"] - -[csp] -default_src = "'self' 'unsafe-inline' 'unsafe-eval' data: blob:" -connect_src = "'self' http://*:* https://*:* ws://*:* wss://*:*" -script_src = "'self' 'unsafe-inline' 'unsafe-eval'" -style_src = "'self' 'unsafe-inline'" -img_src = "'self' http://*:* https://*:* data: blob:" -font_src = "'self' data:" - -[asset] -plotly_version = "3.3.1" -aggrid_version = "35.0.0" - -[log] -level = "WARNING" -format = "%(name)s - %(levelname)s - %(message)s" - -[deploy] -state_backend = "redis" # "memory" or "redis" -redis_url = "redis://localhost:6379/0" -redis_prefix = "pywry:" -widget_ttl = 86400 # 24 hours in seconds -connection_ttl = 300 # 5 minutes -auto_cleanup = true -enable_auth = false -# auth_secret = "your-secret-key" # Required if enable_auth = true -# rbac_enabled = false -# default_role = "viewer" -``` - -### pyproject.toml - -Add configuration to your existing `pyproject.toml`: - -```toml -[tool.pywry] -[tool.pywry.window] -title = "My App" -width = 1280 - -[tool.pywry.log] -level = "DEBUG" -``` - -### Environment Variables - -Override any setting with environment variables using the pattern `PYWRY_{SECTION}__{KEY}`: - -```bash -export PYWRY_WINDOW__TITLE="Production App" -export PYWRY_WINDOW__WIDTH=1920 -export PYWRY_WINDOW__ON_WINDOW_CLOSE="close" # "hide" or "close" (MULTI_WINDOW only) -export PYWRY_THEME__CSS_FILE="/path/to/custom.css" -export PYWRY_HOT_RELOAD__ENABLED=true -export PYWRY_LOG__LEVEL=DEBUG -``` - -### Configuration Sections - -| Section | Env Prefix | Description | -|---------|------------|-------------| -| `csp` | `PYWRY_CSP__` | Content Security Policy directives | -| `theme` | `PYWRY_THEME__` | Custom CSS file path | -| `timeout` | `PYWRY_TIMEOUT__` | Timeout values in seconds | -| `asset` | `PYWRY_ASSET__` | Library versions and asset paths | -| `log` | `PYWRY_LOG__` | Log level and format | -| `window` | `PYWRY_WINDOW__` | Default window properties | -| `hot_reload` | `PYWRY_HOT_RELOAD__` | Hot reload behavior | -| `server` | `PYWRY_SERVER__` | Inline server settings (host, port, CORS, security) | -| `deploy` | `PYWRY_DEPLOY__` | Deploy mode settings (Redis, scaling, auth) | - -#### Server Security Settings - -| Setting | Env Variable | Default | Description | -|---------|--------------|---------|-------------| -| `websocket_allowed_origins` | `PYWRY_SERVER__WEBSOCKET_ALLOWED_ORIGINS` | `[]` | Allowed WebSocket origins (empty = any) | -| `websocket_require_token` | `PYWRY_SERVER__WEBSOCKET_REQUIRE_TOKEN` | `true` | Require per-widget token | -| `internal_api_header` | `PYWRY_SERVER__INTERNAL_API_HEADER` | `X-PyWry-Token` | Internal API auth header | -| `internal_api_token` | `PYWRY_SERVER__INTERNAL_API_TOKEN` | auto-generated | Internal API token | -| `strict_widget_auth` | `PYWRY_SERVER__STRICT_WIDGET_AUTH` | `false` | Strict widget endpoint auth | - -#### Deploy Mode Settings - -| Setting | Env Variable | Default | Description | -|---------|--------------|---------|-------------| -| `state_backend` | `PYWRY_DEPLOY__STATE_BACKEND` | `memory` | State storage backend (`memory` or `redis`) | -| `redis_url` | `PYWRY_DEPLOY__REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL | -| `redis_prefix` | `PYWRY_DEPLOY__REDIS_PREFIX` | `pywry` | Key prefix for Redis keys | -| `widget_ttl` | `PYWRY_DEPLOY__WIDGET_TTL` | `86400` | Widget data TTL (seconds) | -| `auth_enabled` | `PYWRY_DEPLOY__AUTH_ENABLED` | `false` | Enable authentication | - -See the [Deploy Mode & Scaling](#deploy-mode--scaling) section for complete documentation. - -### Programmatic Configuration - -Pass settings directly to PyWry: - -```python -from pywry import PyWry, PyWrySettings, WindowSettings - -settings = PyWrySettings( - window=WindowSettings( - title="My App", - width=1920, - height=1080, - ) -) - -pywry = PyWry(settings=settings) -``` - -### Security Presets - -PyWry provides CSP (Content Security Policy) factory methods: - -```python -from pywry import SecuritySettings - -# Permissive - allows unsafe-inline/eval (default, good for development) -permissive = SecuritySettings.permissive() - -# Strict - removes unsafe-eval, restricts to self and specific CDNs -strict = SecuritySettings.strict() - -# Localhost - allows only localhost connections -localhost = SecuritySettings.localhost() - -# Localhost with specific ports -localhost_ports = SecuritySettings.localhost(ports=[8000, 8080]) -``` - -
- ---- - -## Hot Reload - -
-Click to expand - -**In this section:** [Enable Hot Reload](#enable-hot-reload) · [Behavior](#behavior) · [Watch Files](#watch-files) · [Manual CSS Reload](#manual-css-reload) · [Configuration](#configuration) - ---- - -Hot reload enables live updates during development without restarting. - -### Enable Hot Reload - -```python -# Via constructor -pywry = PyWry(hot_reload=True) - -# Or enable/disable at runtime -pywry.enable_hot_reload() -pywry.disable_hot_reload() -``` - -### Behavior - -| File Type | Behavior | -|-----------|----------| -| **CSS** | Injected without page reload | -| **JS/HTML** | Full page refresh with scroll preservation | - -### Watch Files - -Use the `watch` parameter in `HtmlContent` or `show()`: - -```python -from pywry import PyWry, HtmlContent - -pywry = PyWry(hot_reload=True) - -content = HtmlContent( - html="
", - css_files=["styles/main.css", "styles/theme.css"], - script_files=["js/app.js"], - watch=True, -) - -pywry.show(content) -# Editing main.css will inject new styles instantly -``` - -Or pass `watch=True` directly to `show()`: - -```python -pywry.show("

Hello

", watch=True) -``` - -### Manual CSS Reload - -```python -# Reload CSS for all windows -pywry.refresh_css() - -# Reload CSS for specific window -pywry.refresh_css(label="main-window") -``` - -### Configuration - -```toml -[hot_reload] -enabled = true -debounce_ms = 100 # Wait before reloading (milliseconds) -css_reload = "inject" # "inject" or "refresh" -preserve_scroll = true # Keep scroll position on JS refresh -watch_directories = ["./src", "./styles"] -``` - -
- ---- - -## Event System - - - -
-Click to expand - -**In this section:** [What is an Event?](#what-is-an-event) · [Event Naming](#event-naming-format) · [Reserved Namespaces](#reserved-namespaces) · [Handler Signature](#handler-signature) · [Registering Handlers](#registering-handlers) · [Wildcard Handlers](#wildcard-handlers) - ---- - -PyWry provides bidirectional communication between Python and JavaScript through a **namespace-based event system**. This allows your Python code to respond to user interactions in the browser (clicks, selections, form inputs) and to send updates back to the browser UI. - -### What is an Event? - -An **event** is a message with a name and optional data. Events flow in two directions: - -1. **JS → Python**: User does something in the browser (clicks a chart, selects a row) → JavaScript sends an event → Python callback is triggered -2. **Python → JS**: Your Python code wants to update the UI → Python sends an event → JavaScript handler updates the display - -### Event Naming Format - -All events follow the pattern: `namespace:event-name` - -| Part | Rules | Examples | -|------|-------|----------| -| **namespace** | Starts with letter, alphanumeric only | `app`, `plotly`, `grid`, `myapp` | -| **event-name** | Starts with letter, alphanumeric + hyphens + underscores | `click`, `row-select`, `update_data` | - -**Valid examples:** `app:save`, `plotly:click`, `grid:row-select`, `myapp:refresh` - -**Invalid examples:** `save` (no namespace), `:click` (empty namespace), `123:event` (starts with number) - -> **Note:** JavaScript validation is stricter (lowercase only) than Python validation. For maximum compatibility, use **lowercase with hyphens** (e.g., `app:my-action`). - -#### Compound Event IDs (Advanced) - -Events can optionally include a third component for targeting specific widgets: `namespace:event-name:component-id` - -```python -# Handle clicks from ANY chart -app.on("plotly:click", handle_all_charts) - -# Handle clicks from a SPECIFIC chart -app.on("plotly:click:sales-chart", handle_sales_chart) -``` - -### Reserved Namespaces - -These namespaces are used by PyWry internally. **Do not use them for custom events:** - -| Namespace | Purpose | -|-----------|---------| -| `pywry:*` | System events (initialization, results) | -| `plotly:*` | Plotly chart events | -| `grid:*` | AgGrid table events | - -> **Tip:** Use a namespace that makes sense for your app (e.g., `app:`, `data:`, `view:`, `myapp:`). - -### Handler Signature - -All event handlers receive three arguments: - -```python -def handler(data: dict, event_type: str, label: str) -> None: - """ - Parameters - ---------- - data : dict - Event payload from JavaScript (e.g., clicked point, selected rows) - event_type : str - The event that was triggered (e.g., "plotly:click", "app:export") - label : str - Window/widget identifier (e.g., "main", "chart-1") - """ - pass -``` - -PyWry inspects your function signature and supports shorter versions for convenience: - -```python -from pywry import PyWry, Toolbar, Button - -app = PyWry() - -# Full signature (recommended) - access all context -def on_action1(data, event_type, label): - app.emit("pywry:set-content", {"id": "log", "text": f"[{label}] {event_type}: {data}"}, label) - -# Two parameters - when you don't need the widget label -def on_action2(data, event_type): - print(f"{event_type} received") - -# One parameter - simplest, just the data -def on_action3(data): - print(str(data)) - -handle = app.show( - '
Click a button...
', - toolbars=[Toolbar(position="top", items=[ - Button(label="Action 1", event="app:action1"), - Button(label="Action 2", event="app:action2"), - Button(label="Action 3", event="app:action3"), - ])], - callbacks={ - "app:action1": on_action1, - "app:action2": on_action2, - "app:action3": on_action3, - } -) -``` - -### Registering Handlers - -**All modes support the `callbacks={}` parameter in `show()` methods.** This is the most portable approach. - -| Mode | `callbacks={}` | `app.on()` | `widget.on()` | Returns | -|------|----------------|------------|---------------|---------| -| Native Window | ✅ | ✅ | ✅ | `NativeWindowHandle` | -| Notebook (anywidget) | ✅ | ❌ | ✅ | `PyWryWidget` | -| Browser Mode | ✅ | ❌ | ✅ | `InlineWidget` | - -#### Option 1: `callbacks={}` in show() — Works Everywhere - -```python -from pywry import PyWry, Toolbar, Button -import plotly.express as px - -app = PyWry() - -# Create a Plotly figure -fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species") - -def on_click(data, event_type, label): - """When user clicks a point, print the coordinates.""" - point = data["points"][0] - print(f"Clicked: ({point['x']:.2f}, {point['y']:.2f})") - -def on_export(data, event_type, label): - """When export is clicked, save CSV.""" - csv_content = px.data.iris().to_csv(index=False) - print(f"Exporting {len(csv_content)} bytes...") - -# Works in ALL modes (native returns NativeWindowHandle, notebook returns widget) -handle = app.show_plotly( - fig, - toolbars=[Toolbar(position="top", items=[Button(label="Export CSV", event="app:export")])], - callbacks={ - "plotly:click": on_click, - "app:export": on_export, - } -) -``` - -#### Option 2: `app.on()` — Native Windows Only - -For native desktop windows, you can register handlers separately using `app.on()`. **Important:** You must use the same `label` when registering handlers and showing content: - -```python -from pywry import PyWry, Toolbar, Button -import plotly.express as px - -app = PyWry() - -fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species") - -def on_click(data, event_type, label): - """When user clicks a point, update the chart title.""" - point = data["points"][0] - app.emit("plotly:update-layout", { - "layout": {"title": f"Selected: ({point['x']:.2f}, {point['y']:.2f})"} - }, label) - -def on_zoom_reset(data, event_type, label): - """Reset chart zoom when button is clicked.""" - app.emit("plotly:reset-zoom", {}, label) - -# Register handlers with explicit label BEFORE calling show() -app.on("plotly:click", on_click, label="my-chart") -app.on("app:zoom-reset", on_zoom_reset, label="my-chart") - -# Use the SAME label when showing content -handle = app.show_plotly( - fig, - title="Click points to select them", - toolbars=[Toolbar(position="top", items=[Button(label="Reset Zoom", event="app:zoom-reset")])], - label="my-chart", # Must match the label used in app.on() -) -``` - -> **Note:** If you omit the `label` parameter, `show()` generates a random label and `app.on()` registers to `"main"`, causing a mismatch. For simpler code, prefer `callbacks={}` in `show()` (Option 1). -``` - -#### Option 3: `widget.on()` — All Modes - -All widgets (native and notebook) support chaining handlers after `show()`. This is useful for adding handlers dynamically: - -```python -from pywry import PyWry, Toolbar, Button -import plotly.express as px - -app = PyWry() - -fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species") - -def on_click(data, event_type, label): - """When user clicks a point, update the chart title.""" - point = data["points"][0] - app.emit("plotly:update-layout", { - "layout": {"title": f"Last clicked: ({point['x']:.2f}, {point['y']:.2f})"} - }, label) - -def on_theme_toggle(data, event_type, label): - """Toggle to dark theme.""" - app.emit("pywry:update-theme", {"theme": "plotly_dark"}, label) - -# Using callbacks={} is recommended for most cases -handle = app.show_plotly( - fig, - title="Click points to annotate", - toolbars=[Toolbar(position="top", items=[Button(label="Dark Mode", event="app:theme")])], - callbacks={ - "plotly:click": on_click, - "app:theme": on_theme_toggle, - } -) - -# handle.on() can add additional handlers after show() -# handle.on("plotly:hover", on_hover) -``` - -### Wildcard Handlers - -Wildcard handlers receive ALL events — useful for debugging during development: - -```python -from pywry import PyWry, Toolbar, Button - -app = PyWry() - -def log_event(data, event_type, label): - """Log the event to console.""" - print(f"[{event_type}] {data}") - -# Show content with toolbar buttons -handle = app.show( - '
Click a button above...
', - toolbars=[ - Toolbar(position="top", items=[ - Button(label="Action 1", event="app:action1"), - Button(label="Action 2", event="app:action2"), - ]) - ], - # Use callbacks for each event - no race condition - callbacks={ - "app:action1": log_event, - "app:action2": log_event, - } -) -``` - -> **Note:** For true wildcard logging in native mode, use `app.on("*", handler)` but be aware that events firing before `handle` is assigned (like `window:ready`) will need a guard check. - ---- - -## Pre-Registered Events (Built-in) - -PyWry automatically hooks into Plotly and AgGrid event systems. These **pre-registered events** are emitted automatically when users interact with charts and grids — **no JavaScript required**. - -**In this section:** [What "Pre-Registered" Means](#what-pre-registered-means) · [Understanding IDs](#understanding-ids-label-vs-chartidgrididcomponentid) · [System Events](#system-events-pywry) · [Toast Notifications](#toast-notifications-pywryalert) · [Plotly Events](#plotly-events-plotly) · [AgGrid Events](#aggrid-events-grid) · [Toolbar Events](#toolbar-events-toolbar) - -### What "Pre-Registered" Means - -When you create a Plotly chart or AgGrid table, PyWry injects JavaScript that: -1. Listens for native library events (e.g., Plotly's `plotly_click`) -2. Transforms the raw event data into a standardized payload -3. Emits a PyWry event (e.g., `plotly:click`) that triggers your Python callback - -**You just register a Python handler; PyWry handles the JavaScript wiring.** - -### Understanding IDs: label vs. chartId/gridId/componentId - -PyWry uses a hierarchy of identifiers: - -| ID Type | Scope | Purpose | Example | -|---------|-------|---------|---------| -| `label` | Window/Widget | Identifies a window (native) or widget (notebook/browser) | `"pywry-abc123"`, `"w-def456"` | -| `chartId` | Component | Identifies a specific Plotly chart within a window | `"sales-chart"` | -| `gridId` | Component | Identifies a specific AgGrid table within a window | `"users-grid"` | -| `componentId` | Toolbar Item | Identifies a specific toolbar control | `"theme-select"`, `"export-btn"` | - -**Event Payloads Include IDs:** -- **Plotly events** (`plotly:click`, `plotly:hover`, etc.) include `chartId` and `widget_type: "chart"` in the payload. -- **AgGrid events** (`grid:row-selected`, `grid:cell-click`, `grid:cell-edit`, etc.) include `gridId` and `widget_type: "grid"` in the payload. -- **Toolbar components** always include `componentId` in their payloads. -- **Python → JS events** support targeting via `chartId`/`gridId` when using widget methods like `widget.update_figure(fig, chart_id="my-chart")`. - -### System Events (`pywry:*`) - -These are internal events for window/widget lifecycle and utility operations. - -#### Lifecycle Events (JS → Python) - -| Event | Payload | Description | -|-------|---------|-------------| -| `pywry:ready` | `{}` | Window/widget has finished initializing | -| `pywry:result` | `any` | Data sent via `window.pywry.result(data)` | -| `pywry:content-request` | `{ widget_type, window_label, reason }` | Window requests content (initial load or reload) | -| `pywry:disconnect` | `{}` | Widget disconnected (inline/browser mode only) | - -#### Utility Events (Python → JS) - -These events trigger built-in browser behaviors. They are handled automatically by PyWry's JavaScript bridge — **no custom JavaScript required**: - -| Event | Payload | Description | -|-------|---------|-------------| -| `pywry:update-theme` | `{ theme: str }` | Update theme dynamically (e.g., `"plotly_dark"`, `"plotly_white"`) | -| `pywry:inject-css` | `{ css: str, id?: str }` | Inject CSS dynamically; optional `id` for replacing existing styles | -| `pywry:remove-css` | `{ id: str }` | Remove a previously injected CSS style element by ID | -| `pywry:set-style` | `{ id?: str, selector?: str, styles: {} }` | Update inline styles on element(s) by id or CSS selector | -| `pywry:set-content` | `{ id?: str, selector?: str, html?: str, text?: str }` | Update innerHTML or textContent on element(s) | -| `pywry:update-html` | `{ html: str }` | Replace entire widget/window HTML content | -| `pywry:download` | `{ content: str, filename: str, mimeType?: str }` | Trigger a file download | -| `pywry:download-csv` | `{ csv: str, filename: str }` | Trigger a CSV file download (Jupyter widget mode) | -| `pywry:navigate` | `{ url: str }` | Navigate to a URL (SPA-style navigation) | -| `pywry:alert` | `{ message, type?, title?, duration?, position? }` | Show a toast notification (info, success, warning, error, confirm) | -| `pywry:refresh` | `{}` | Request fresh content from Python (triggers content re-send) | - -**Example: DOM Manipulation Without Custom JavaScript** - -```python -from pywry import PyWry -import time - -app = PyWry() - -# Show HTML with elements we'll manipulate -html = """ -
-

Dashboard

- - Loading... - -
- Row 1: Pending -
-
- Row 2: Pending -
-

Waiting for data...

-
-""" - -handle = app.show(html, title="DOM Manipulation Demo") - -# Update the status badge style -handle.emit("pywry:set-style", { - "id": "status-badge", - "styles": {"backgroundColor": "green", "color": "white"} -}) - -time.sleep(1) -# Update all rows with a highlight (by CSS selector) -handle.emit("pywry:set-style", { - "selector": ".data-row", - "styles": {"borderColor": "#3b82f6", "borderWidth": "2px"} # Blue border -}) - -time.sleep(1) -# Update content by ID (with HTML) -handle.emit("pywry:set-content", { - "id": "values-display", - "html": "Updated! 42 items loaded" -}) -time.sleep(1) -# Update all status-text elements (plain text, safer) -handle.emit("pywry:set-content", { - "selector": ".status-text", - "text": "Complete" -}) - -# Update the badge text -handle.emit("pywry:set-content", { - "id": "status-badge", - "text": "Ready" -}) -time.sleep(1) -# Inject custom CSS dynamically -handle.emit("pywry:inject-css", { - "css": "#title { color: #2563eb; }", - "id": "my-styles" -}) -time.sleep(2) - -handle.close() -``` - -> **Note:** For notebook mode, use `widget.emit("pywry:...", {...})` on the returned widget instead. -> `pywry:set-style` and `pywry:set-content` support either `id` (for a single element by ID) or `selector` (for multiple elements via CSS selector). If both are provided, `id` takes precedence. - -### Toast Notifications (`pywry:alert`) - -
-Alert System - -PyWry provides a unified toast notification system that works consistently across all rendering paths (native window, notebook, and browser). Toast notifications are non-blocking (except `confirm` type) and support multiple types with automatic dismiss behavior. - -#### Alert Types - -| Type | Icon | Default Behavior | Use Case | -|------|------|------------------|----------| -| `info` | ℹ️ | Auto-dismiss 5s | Status updates, general information | -| `success` | ✅ | Auto-dismiss 3s | Completed actions, confirmations | -| `warning` | ⚠️ | Persist until clicked | Important notices requiring attention | -| `error` | ⛔ | Persist until clicked | Errors requiring acknowledgment | -| `confirm` | ❓ | Blocks UI until response | User confirmation needed before action | - -#### Example - -```python -from pywry import PyWry, Toolbar, Button - -app = PyWry() - -# Create a toolbar with buttons for each alert type -toolbar = Toolbar( - position="top", - items=[ - Button(label="ℹ️ Info", event="alert:info"), - Button(label="✅ Success", event="alert:success"), - Button(label="⚠️ Warning", event="alert:warning"), - Button(label="⛔ Error", event="alert:error"), - Button(label="❓ Confirm", event="alert:confirm"), - ] -) - -def show_info(data, event_type, label): - app.alert("Data refreshed successfully", alert_type="info", label=label) - -def show_success(data, event_type, label): - app.alert("Export complete!", alert_type="success", title="Done", label=label) - -def show_warning(data, event_type, label): - app.alert("No items selected.", alert_type="warning", title="Selection Required", label=label) - -def show_error(data, event_type, label): - app.alert("Failed to connect to server.", alert_type="error", title="Connection Error", label=label) - -def show_confirm(data, event_type, label): - app.alert( - "Are you sure you want to delete these items?", - alert_type="confirm", - title="Confirm Delete", - callback_event="alert:confirm-response", - label=label - ) - -def handle_confirm_response(data, event_type, label): - if data.get("confirmed"): - app.alert("Items deleted successfully", alert_type="success", label=label) - else: - app.alert("Deletion cancelled", alert_type="info", label=label) - -handle = app.show( - "

Alert Demo

Click the toolbar buttons to see different alert types.

", - title="Toast Notifications", - toolbars=[toolbar], - callbacks={ - "alert:info": show_info, - "alert:success": show_success, - "alert:warning": show_warning, - "alert:error": show_error, - "alert:confirm": show_confirm, - "alert:confirm-response": handle_confirm_response, - } -) -``` - -> **Note:** The `callback_event` parameter specifies which event to emit when the user clicks Confirm or Cancel. Register a handler for that event in your `callbacks={}` dict to process the response. - -#### Using emit() Directly - -```python -# Same as app.alert() but with explicit event emission -handle.emit("pywry:alert", { - "message": "Processing complete", - "type": "success", - "title": "Done", - "duration": 4000, # Override auto-dismiss time (ms) - "position": "bottom-right" # top-right, top-left, bottom-right, bottom-left -}) -``` - -#### Toast Positions - -Toasts can be positioned in any corner of the widget container: - -| Position | Description | -|----------|-------------| -| `top-right` | Top-right corner (default) | -| `top-left` | Top-left corner | -| `bottom-right` | Bottom-right corner | -| `bottom-left` | Bottom-left corner | - -```python -# Position toast in different corners -handle.emit("pywry:alert", {"message": "Top right", "position": "top-right"}) -handle.emit("pywry:alert", {"message": "Bottom left", "position": "bottom-left"}) -``` - -#### Multiple Toasts and Stacking - -Multiple toasts stack vertically in their position container. New toasts appear at the top of the stack. When a toast is dismissed, remaining toasts stay in position without animation to prevent jarring visual effects. - -```python -# Show multiple toasts - they stack -app.alert("First message", alert_type="info", label=handle.label) -app.alert("Second message", alert_type="success", label=handle.label) -app.alert("Third message", alert_type="warning", label=handle.label) -``` - -#### Keyboard Shortcuts - -| Key | Action | -|-----|--------| -| `Escape` | Dismiss all visible toasts in the widget | - -#### JavaScript API (Advanced) - -The toast system exposes a global `PYWRY_TOAST` object for custom JavaScript integrations: - -```javascript -// Show a toast programmatically -window.PYWRY_TOAST.show({ - message: "Hello from JS", - type: "info", - title: "Custom Title", - duration: 5000, - position: "top-right", - container: document.querySelector('.pywry-widget') -}); - -// Show a confirm dialog -window.PYWRY_TOAST.confirm({ - message: "Are you sure?", - title: "Confirm", - position: "top-right", - container: document.querySelector('.pywry-widget'), - onConfirm: function() { console.log("Confirmed"); }, - onCancel: function() { console.log("Cancelled"); } -}); - -// Dismiss a specific toast by ID -window.PYWRY_TOAST.dismiss(toastId); - -// Dismiss all toasts in a widget -window.PYWRY_TOAST.dismissAllInWidget(container); -``` - -
- -### Plotly Events (`plotly:*`) - -#### Events from JavaScript → Python (User Interactions) - -These events fire automatically when users interact with Plotly charts: - -| Event | Trigger | Payload | -|-------|---------|---------| -| `plotly:click` | User clicks a data point | `{ chartId, widget_type, points: [...], point_indices: [...], curve_number, event }` | -| `plotly:hover` | User hovers over a point | `{ chartId, widget_type, points: [...], point_indices: [...], curve_number }` | -| `plotly:selected` | User selects with box/lasso | `{ chartId, widget_type, points: [...], point_indices: [...], range, lassoPoints }` | -| `plotly:relayout` | User zooms, pans, or resizes | `{ chartId, widget_type, relayout_data: {...} }` | -| `plotly:state-response` | Response to state request | `{ chartId, layout: {...}, data: [...] }` | - -> **Note:** All Plotly events include `chartId` and `widget_type: "chart"` in the payload for identifying which chart triggered the event. - -**`plotly:click` payload structure:** -```python -{ - "chartId": "chart_abc123", # Unique chart identifier - "widget_type": "chart", # Always "chart" for Plotly events - "points": [ - { - "curveNumber": 0, # Which trace (0-indexed) - "pointNumber": 5, # Which point in the trace (0-indexed) - "pointIndex": 5, # Same as pointNumber (Plotly alias) - "x": 2.5, # X value - "y": 10.3, # Y value - "z": None, # Z value (for 3D charts) - "text": "label", # Point text label (if any) - "customdata": {...}, # Custom data attached to point - "data": {...}, # Full trace data object - "trace_name": "Series A" # Name of the trace - } - ], - "point_indices": [5], # List of all pointNumber values - "curve_number": 0, # curveNumber of first point (convenience) - "event": {...} # Original browser event -} -``` - -**Example:** - -```python -from pywry import PyWry -import plotly.express as px - -app = PyWry() - -fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species") - -def on_chart_click(data, event_type, label): - """Display clicked point coordinates in the chart title.""" - point = data["points"][0] - app.emit("plotly:update-layout", { - "layout": {"title": f"Clicked: ({point['x']:.2f}, {point['y']:.2f})"} - }, label) - -handle = app.show_plotly(fig, title="Click any point", callbacks={"plotly:click": on_chart_click}) -``` - -#### Events from Python → JavaScript (Update Chart) - -Use these to update the chart programmatically. These methods support optional `chart_id` targeting: - -| Event | Method | Payload | -|-------|--------|---------| -| `plotly:update-figure` | `widget.update_figure(fig, chart_id=...)` | `{ figure: {...}, chartId?, config?: {...}, animate?: bool }` | -| `plotly:update-layout` | `widget.update_layout({...})` | `{ layout: {...} }` | -| `plotly:update-traces` | `widget.update_traces({...}, indices)` | `{ update: {...}, indices: [int, ...] or null }` | -| `plotly:reset-zoom` | `widget.reset_zoom()` | `{}` | -| `plotly:request-state` | `widget.request_plotly_state(chart_id=...)` | `{ chartId? }` | -| `plotly:export-data` | `app.emit("plotly:export-data", {chartId?}, label)` | `{ chartId? }` | - -> **Note:** `plotly:export-data` triggers `plotly:export-response` (JS → Python) with payload `{ data: [{ traceIndex, name, x, y, type }, ...] }` containing extracted trace data. - -### AgGrid Events (`grid:*`) - -#### Events from JavaScript → Python (User Interactions) - -These events fire automatically when users interact with AgGrid tables: - -| Event | Trigger | Payload | -|-------|---------|---------| -| `grid:row-selected` | Row selection changes | `{ gridId, widget_type, rows: [...] }` | -| `grid:cell-click` | User clicks a cell | `{ gridId, widget_type, rowIndex, colId, value, data }` | -| `grid:cell-edit` | User edits a cell | `{ gridId, widget_type, rowIndex, rowId, colId, oldValue, newValue, data }` | -| `grid:filter-changed` | Filter applied | `{ gridId, widget_type, filterModel }` | -| `grid:data-truncated` | Dataset truncated (client-side) | `{ gridId, widget_type, displayedRows, truncatedRows, message }` | -| `grid:mode` | Grid mode info (server-side) | `{ gridId, widget_type, mode, serverSide, totalRows, blockSize, message }` | -| `grid:request-page` | Server-side requests data block | `{ gridId, widget_type, startRow, endRow, sortModel, filterModel }` | -| `grid:state-response` | Response to state request | `{ gridId, columnState, filterModel, sortModel, context? }` | - -> **Note:** All AgGrid events include `gridId` and `widget_type: "grid"` in the payload for identifying which grid triggered the event. - -**`grid:row-selected` payload structure:** -```python -{ - "gridId": "grid_def456", # Unique grid identifier - "widget_type": "grid", # Always "grid" for AgGrid events - "rows": [ - {"name": "Alice", "age": 30, "city": "NYC"}, - {"name": "Bob", "age": 25, "city": "LA"} - ] -} -``` - -**Example:** - -```python -from pywry import PyWry -import pandas as pd - -app = PyWry() - -df = pd.DataFrame({"name": ["Alice", "Bob", "Carol"], "age": [30, 25, 35], "city": ["NYC", "LA", "SF"]}) - -def on_row_select(data, event_type, label): - """Print selected row names.""" - rows = data["rows"] - names = ", ".join(row["name"] for row in rows) - print(f"Selected: {names}" if names else "No selection") - -handle = app.show_dataframe( - df, - callbacks={"grid:row-selected": on_row_select} -) -``` - -#### Events from Python → JavaScript (Update Grid) - -Use these to update the grid programmatically. These methods support optional `grid_id` targeting: - -| Event | Method | Payload | -|-------|--------|---------| -| `grid:page-response` | (server-side callback) | `{ gridId, rows: [...], totalRows, isLastPage, requestId }` | -| `grid:update-data` | `widget.update_data(rows, grid_id=...)` | `{ data: [...], gridId?, strategy? }` | -| `grid:update-columns` | `widget.update_columns(col_defs, grid_id=...)` | `{ columnDefs: [...], gridId? }` | -| `grid:update-cell` | `widget.update_cell(row_id, col, value, grid_id=...)` | `{ rowId, colId, value, gridId? }` | -| `grid:update-grid` | `widget.update_grid(options, grid_id=...)` | `{ data?, columnDefs?, restoreState?, gridId? }` | -| `grid:request-state` | `widget.request_grid_state(grid_id=...)` | `{ gridId?, context? }` | -| `grid:restore-state` | `widget.restore_state(state, grid_id=...)` | `{ state: {...}, gridId? }` | -| `grid:reset-state` | `widget.reset_state(grid_id=...)` | `{ gridId?, hard?: bool }` | -| `grid:update-theme` | `widget.update_theme(theme, grid_id=...)` | `{ theme, gridId? }` | -| `grid:show-notification` | (internal) | `{ message, duration?, gridId? }` | - -### Toolbar Events (`toolbar:*`) - -Toolbar events are used for state management (querying and setting component values). - -#### System Toolbar Events (Automatic) - -These events are emitted automatically when users interact with toolbar chrome: - -| Event | Direction | Payload | Description | -|-------|-----------|---------|-------------| -| `toolbar:collapse` | JS → Python | `{ componentId, collapsed: true }` | User collapsed a toolbar | -| `toolbar:expand` | JS → Python | `{ componentId, collapsed: false }` | User expanded a toolbar | -| `toolbar:resize` | JS → Python | `{ componentId, position, width, height }` | User resized a toolbar | - -#### State Management Events - -| Event | Direction | Payload | Description | -|-------|-----------|---------|-------------| -| `toolbar:state-response` | JS → Python | `{ toolbars, components, timestamp, context? }` | Response to state request | -| `toolbar:request-state` | Python → JS | `{ toolbarId?, componentId?, context? }` | Request current state | -| `toolbar:set-value` | Python → JS | `{ componentId, value?, label?, disabled?, ...attrs }` | Set component value and/or attributes | -| `toolbar:set-values` | Python → JS | `{ values: { id: value, ... }, toolbarId? }` | Set multiple component values | - -#### Marquee Events - -| Event | Direction | Payload | Description | -|-------|-----------|---------|-------------| -| `toolbar:marquee-set-content` | Python → JS | `{ id, text?, html?, speed?, paused?, separator? }` | Update marquee content or settings | -| `toolbar:marquee-set-item` | Python → JS | `{ ticker, text?, html?, styles?, class_add?, class_remove? }` | Update individual ticker item by `data-ticker` | - -> **Note:** Toolbar *components* (Button, Select, etc.) emit their own custom events that you define via the `event` parameter. All component events automatically include `componentId` in their payload. See the Toolbar System section. - ---- - -## Custom Events - -Custom events are events **you define** for your application. Unlike pre-registered events, custom events require you to either: - -1. Use toolbar components (which emit events automatically), or -2. Write JavaScript that calls `window.pywry.emit()` - -**In this section:** [Event Direction Overview](#event-direction-overview) · [JS → Python](#js--python-receiving-events-from-javascript) · [Python → JS](#python--js-sending-events-to-javascript) · [Two-Way Communication](#complete-two-way-communication-example) - -### Event Direction Overview - -| Direction | How to Send | How to Receive | Use Case | -|-----------|-------------|----------------|----------| -| **JS → Python** | `window.pywry.emit(event, data)` | `callbacks={}`, `app.on()`, `widget.on()` | User interactions | -| **Python → JS** | `app.emit(event, data, label=...)` or `widget.emit(event, data)` | `window.pywry.on(event, handler)` | Update UI | - -### JS → Python: Receiving Events from JavaScript - -#### Toolbar Component Events (Easiest) - -Toolbar components automatically emit events — you just specify the event name: - -```python -from pywry import PyWry, Toolbar, Button, Select, Option -import pandas as pd - -app = PyWry() - -# Sample data to export -data = pd.DataFrame({"name": ["Alice", "Bob"], "score": [95, 87]}) - -toolbar = Toolbar( - position="top", - items=[ - Button(label="Export CSV", event="app:export-csv"), - Button(label="Refresh", event="app:refresh"), - Select( - label="Theme:", - event="app:theme-change", - options=[Option(label="Light", value="light"), Option(label="Dark", value="dark")], - selected="dark" - ), - ] -) - -def on_export(data_evt, event_type, label): - """Trigger a CSV download when export button is clicked.""" - app.emit("pywry:download", { - "content": data.to_csv(index=False), - "filename": "export.csv", - "mimeType": "text/csv" - }, label) - -def on_theme_change(data_evt, event_type, label): - """Apply the selected theme to the chart.""" - theme = "plotly_dark" if data_evt["value"] == "dark" else "plotly_white" - app.emit("pywry:update-theme", {"theme": theme}, label) - -handle = app.show( - "

Dashboard

Select a theme or export data.

", - toolbars=[toolbar], - callbacks={ - "app:export-csv": on_export, - "app:theme-change": on_theme_change, - } -) -``` - -**Toolbar component payloads:** - -All toolbar components include `componentId` in their event payload for identification. Component IDs are auto-generated in the format `{type}-{uuid8}` (e.g., `button-a1b2c3d4`, `select-f099cfba`). - -| Component | Payload | -|-----------|---------| -| `Button` | `{ componentId, ...data }` (merges `Button(data={...})` with componentId) | -| `Select` | `{ value: str, componentId }` | -| `MultiSelect` | `{ values: [str, ...], componentId }` | -| `TextInput` | `{ value: str, componentId }` | -| `NumberInput` | `{ value: number, componentId }` | -| `DateInput` | `{ value: "YYYY-MM-DD", componentId }` | -| `SliderInput` | `{ value: number, componentId }` | -| `RangeInput` | `{ start: number, end: number, componentId }` | -| `Toggle` | `{ value: bool, componentId }` | -| `Checkbox` | `{ value: bool, componentId }` | -| `RadioGroup` | `{ value: str, componentId }` | -| `TabGroup` | `{ value: str, componentId }` | - -**Using componentId to identify which button was clicked:** - -```python -from pywry import PyWry, Toolbar, Button -import pandas as pd - -app = PyWry() - -# Sample data -data = pd.DataFrame({"name": ["Alice", "Bob"], "score": [95, 87]}) - -def on_action(evt_data, event_type, label): - """Handle export button clicks - different buttons trigger different formats.""" - fmt = evt_data.get("format", "csv") - if fmt == "csv": - content = data.to_csv(index=False) - mime = "text/csv" - else: - content = data.to_json(orient="records") - mime = "application/json" - - # Trigger download in the browser - app.emit("pywry:download", { - "content": content, - "filename": f"data.{fmt}", - "mimeType": mime - }, label) - -toolbar = Toolbar( - position="top", - items=[ - Button(label="Export CSV", event="app:export", data={"format": "csv"}), - Button(label="Export JSON", event="app:export", data={"format": "json"}), - ] -) - -handle = app.show( - "

Data Export

Click a button to download.

", - toolbars=[toolbar], - callbacks={"app:export": on_action} -) -``` - -#### Custom JavaScript Events - -Emit events from your own JavaScript code: - -```python -from pywry import PyWry - -app = PyWry() - -def on_action(data, event_type, label): - """Respond to button click by updating the UI.""" - received = data.get("value", 0) - doubled = received * 2 - app.emit("pywry:set-content", { - "id": "result", - "text": f"Received {received}, doubled = {doubled}" - }, label) - -handle = app.show(""" - -
Click the button...
-""", callbacks={"app:my-action": on_action}) -``` - -### Python → JS: Sending Events to JavaScript - -To update the browser UI from Python, use `app.emit()` for native mode or `widget.emit()` for notebooks. - -**Using Built-in Utility Events (recommended):** - -```python -from pywry import PyWry - -app = PyWry() -handle = app.show("
Hello
") - -# Update content without any custom JavaScript -handle.emit("pywry:set-content", {"id": "msg", "text": "Updated!"}) - -# Notebook mode: widget.emit() on the returned widget -# widget.emit("pywry:set-content", {"id": "msg", "text": "Updated!"}) -``` - -**Using Custom Events (requires JavaScript handler):** - -```python -from pywry import PyWry - -app = PyWry() -handle = app.show(""" -
Hello
- -""") - -# Now your custom event has a handler -handle.emit("app:update-message", {"text": "Updated!"}) -``` - -### Complete Two-Way Communication Example - -```python -from pywry import PyWry - -app = PyWry() - -def handle_request(data, event_type, label): - """Handle request from JavaScript and send response back.""" - result = {"items": [1, 2, 3], "total": 3} - # Use app.emit() to send response back to JavaScript - app.emit("app:response", result, label) - -handle = app.show(""" - -
Click the button to fetch data...
- -""", callbacks={"app:request-data": handle_request}) -``` - -
- ---- - -## Toolbar System - -
-Click to expand - -**In this section:** [Quick Start](#quick-start) · [Imports](#imports-1) · [Positions & Layout](#toolbar-positions--layout) · [Common Properties](#common-properties) · [Component Reference](#component-reference) · [Toolbar Container](#toolbar-container) · [Examples](#examples) · [State Management](#state-management) - ---- - -PyWry provides a flexible toolbar system for adding interactive controls to any window. The toolbar system uses Pydantic models for type-safe configuration with auto-generated component IDs for state tracking. - -### Quick Start - -```python -from pywry import PyWry, Toolbar, Button, Select, Option - -app = PyWry() - -def on_save(data, event_type, label): - """Flash a success message when save is clicked.""" - app.emit("pywry:set-style", { - "id": "status", - "styles": {"backgroundColor": "#22c55e", "color": "#fff"} - }, label) - app.emit("pywry:set-content", {"id": "status", "text": "Saved!"}, label) - -def on_view_change(data, event_type, label): - """Update heading to show current view mode.""" - view = data["value"] - app.emit("pywry:set-content", {"id": "heading", "text": f"Current View: {view}"}, label) - -toolbar = Toolbar( - position="top", - items=[ - Button(label="Save", event="app:save"), - Select( - label="View:", - event="view:change", - options=["Table", "Chart", "Map"], - selected="Table", - ), - ], -) - -handle = app.show( - '

Current View: Table

Ready
', - toolbars=[toolbar], - callbacks={"app:save": on_save, "view:change": on_view_change}, -) -``` - -### Imports - -```python -from pywry import ( - Toolbar, # Container for toolbar items - Button, # Clickable button - Select, # Single-select dropdown - MultiSelect, # Multi-select dropdown with checkboxes - TextInput, # Text input with debounce - TextArea, # Multi-line text area with resize - SearchInput, # Search input with magnifying glass icon - SecretInput, # Password/secret input with visibility toggle - NumberInput, # Numeric input with min/max/step - DateInput, # Date picker (YYYY-MM-DD) - SliderInput, # Single-value slider - RangeInput, # Dual-handle range slider - Toggle, # Boolean switch (on/off) - Checkbox, # Boolean checkbox - RadioGroup, # Radio button group - TabGroup, # Tab-style selection - Div, # Container for custom HTML/nested items - Marquee, # Scrolling text/content ticker - TickerItem, # Helper for updatable items within Marquee - Option, # Option for Select/MultiSelect/RadioGroup/TabGroup -) -``` - -### Toolbar Positions & Layout - -PyWry supports **7 toolbar positions** that combine to create a flexible layout system: - -| Position | Description | -|----------|-------------| -| `"header"` | Full-width bar at the very top (outermost) | -| `"footer"` | Full-width bar at the very bottom (outermost) | -| `"left"` | Vertical bar on the left, extends between header/footer | -| `"right"` | Vertical bar on the right, extends between header/footer | -| `"top"` | Horizontal bar above content, inside left/right sidebars | -| `"bottom"` | Horizontal bar below content, inside left/right sidebars | -| `"inside"` | Floating overlay in the top-right corner of content | - -
-Layout Diagram — How positions nest together - -When you use multiple toolbars, they are layered from outside in: - -``` -┌─────────────────────────────────────────────────────────┐ -│ HEADER │ ← Full width, outermost -├───────┬─────────────────────────────────────────┬───────┤ -│ │ TOP │ │ -│ ├─────────────────────────────────────────┤ │ -│ LEFT │ │ RIGHT │ ← Extend full height -│ │ CONTENT │ │ between header/footer -│ │ ┌─────────────┐ │ │ -│ │ │ INSIDE │ (overlay) │ │ -│ │ └─────────────┘ │ │ -│ ├─────────────────────────────────────────┤ │ -│ │ BOTTOM │ │ -├───────┴─────────────────────────────────────────┴───────┤ -│ FOOTER │ ← Full width, outermost -└─────────────────────────────────────────────────────────┘ -``` - -**Nesting order (outside → inside):** -1. `header` / `footer` — Span full width at very top/bottom -2. `left` / `right` — Extend full height between header and footer -3. `top` / `bottom` — Inside left/right columns, above/below content -4. `inside` — Floating overlay on top of content -5. Content — Your actual HTML/chart/grid - -
- -
-Multi-Toolbar Example - -```python -from pywry import PyWry, Toolbar, Button, Select, Toggle - -app = PyWry() - -# Header: App-wide navigation -header = Toolbar( - position="header", - items=[ - Button(label="Home", event="nav:home"), - Button(label="Settings", event="nav:settings", style="margin-left: auto;"), - ], -) - -# Left sidebar: View controls -sidebar = Toolbar( - position="left", - items=[ - Button(label="📊", event="view:chart", variant="icon"), - Button(label="📋", event="view:table", variant="icon"), - Button(label="🗺️", event="view:map", variant="icon"), - ], -) - -# Top: Context-specific controls -top_bar = Toolbar( - position="top", - items=[ - Select(label="Period:", event="filter:period", options=["1D", "1W", "1M", "1Y"]), - Toggle(label="Live:", event="data:live", value=True), - ], -) - -# Inside: Quick actions overlay -overlay = Toolbar( - position="inside", - items=[ - Button(label="⟳", event="data:refresh", variant="icon"), - ], -) - -# Footer: Status bar -footer = Toolbar( - position="footer", - items=[ - Button(label="Last updated: 12:34:56", event="status:info", variant="ghost", disabled=True), - ], -) - -app.show( - "

Dashboard

", - toolbars=[header, sidebar, top_bar, overlay, footer], - callbacks={...}, -) -``` - -
- -### Common Properties - -All toolbar items share these properties: - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `event` | `str` | `"toolbar:input"` | Event name in `namespace:event-name` format | -| `component_id` | `str` | auto-generated | Unique ID (format: `{type}-{uuid8}`, e.g., `button-a1b2c3d4`) | -| `label` | `str` | `""` | Label text displayed next to the control | -| `description` | `str` | `""` | Tooltip text shown on hover | -| `disabled` | `bool` | `False` | Whether the control is disabled | -| `style` | `str` | `""` | Inline CSS styles | - ---- - -### Component Reference - -PyWry provides **18 toolbar components** for building interactive UIs. Expand the details below for each component's full documentation. - -| Input Components | Selection Components | Container/Display | -|------------------|---------------------|-------------------| -| Button | Select | Div | -| TextInput | MultiSelect | Marquee | -| TextArea | RadioGroup | TickerItem | -| SearchInput | TabGroup | Option | -| SecretInput | Toggle | | -| NumberInput | Checkbox | | -| DateInput | | | -| SliderInput | | | -| RangeInput | | | - -
-Button — Clickable button with optional data payload - -```python -Button( - label="Export", - event="app:export", - data={"format": "csv"}, # Optional payload merged into event data - variant="primary", # Style: primary|secondary|neutral|ghost|outline|danger|warning|icon - size=None, # Size: None|xs|sm|lg|xl -) -``` - -**Emits:** `{ componentId, ...data }` — The `data` dict merged with `componentId`. - -**Variants:** -- `"primary"` — Theme-aware (light bg in dark mode, accent in light mode) -- `"secondary"` — Subtle background, theme-aware -- `"neutral"` — Always blue accent (for primary actions) -- `"ghost"` — Transparent background -- `"outline"` — Bordered, transparent fill -- `"danger"` — Red accent for destructive actions -- `"warning"` — Orange accent for caution -- `"icon"` — Square aspect ratio for icon-only buttons - -
- -
-Select — Single-select dropdown - -```python -Select( - label="Theme:", - event="theme:change", - options=[ - Option(label="Dark", value="dark"), - Option(label="Light", value="light"), - ], - selected="dark", -) - -# Shorthand: strings auto-convert to Option(label=s, value=s) -Select(event="view:change", options=["Table", "Chart", "Map"], selected="Table") -``` - -**Emits:** `{ value: str, componentId: str }` - -
- -
-MultiSelect — Multi-select dropdown with checkboxes - -```python -MultiSelect( - label="Columns:", - event="columns:filter", - options=["Name", "Age", "City", "Country"], - selected=["Name", "Age"], # Initially selected values -) -``` - -Features a search box and "All" / "None" quick-select buttons. Selected items appear at the top. - -**Emits:** `{ values: [str, ...], componentId: str }` - -
- -
-TextInput — Text input with debounce - -```python -TextInput( - label="Search:", - event="search:query", - value="", # Initial value - placeholder="Type...", # Placeholder text - debounce=300, # Delay in ms before emitting (default: 300) -) -``` - -**Emits:** `{ value: str, componentId: str }` after debounce delay. - -
- -
-TextArea — Multi-line text area with resize - -```python -TextArea( - label="Notes:", - event="notes:update", - value="", # Initial text content - placeholder="Enter notes...", - debounce=300, # Delay in ms before emitting (default: 300) - rows=3, # Initial visible text rows (default: 3) - cols=40, # Initial visible columns (default: 40) - resize="vertical", # both|horizontal|vertical|none (default: both) - min_height="50px", # Minimum height CSS value - max_height="500px", # Maximum height CSS value -) -``` - -The textarea is resizable by default. Use `resize` to control behavior. - -**Emits:** `{ value: str, componentId: str }` after debounce delay. - -
- -
-SearchInput — Search input with magnifying glass icon - -```python -SearchInput( - label="Filter:", - event="filter:search", - value="", # Current search text - placeholder="Search...", # Default placeholder - debounce=300, # Delay in ms before emitting (default: 300) - spellcheck=False, # Browser spell checking (default: False) - autocomplete="off", # Browser autocomplete (default: "off") -) -``` - -Includes a theme-aware magnifying glass icon on the left. Browser behaviors (spellcheck, autocomplete, autocorrect, autocapitalize) are disabled by default for cleaner search/filter UX. - -**Emits:** `{ value: str, componentId: str }` after debounce delay. - -
- -
-SecretInput — Password/secret input with visibility toggle - -**In this section:** [Security Model](#security-model) · [How It Works](#how-it-works-full-chain) · [Default Behavior](#default-behavior-no-handler) · [Pre-populated Value](#pre-populated-value-from-database) · [Custom Handler](#custom-handler-external-vault-database-etc) · [Events Emitted](#events-emitted) · [Utility Functions](#utility-functions) - -```python -SecretInput( - label="API Key:", - event="settings:api_key", - value="my-secret", # Stored as SecretStr (NEVER rendered in HTML) - placeholder="Enter key...", - show_toggle=True, # Show visibility toggle button (default: True) - show_copy=True, # Show copy to clipboard button (default: True) - value_exists=None, # Override has_value detection (for external vaults) - handler=my_handler, # Optional custom handler for external storage -) -``` - -#### Security Model - -**The secret value is NEVER rendered in HTML.** When a value exists, the input displays a fixed mask (`••••••••••••`). The show/copy buttons emit events that request the secret from the Python backend — secrets are only transmitted on explicit user action and never embedded in the DOM. - -Values are base64-encoded in transit for obfuscation (not encryption — use HTTPS for security). - -#### How It Works (Full Chain) - -
-Event Flow Diagram - -**1. Initialization — Setting a value:** - -When you create a `SecretInput` with a `value`, it's stored as a Pydantic `SecretStr` and registered in an internal `_SECRET_REGISTRY` keyed by `component_id`. The HTML only contains a mask. - -```python -# Value stored internally, mask shown in UI -SecretInput(label="API Key:", event="key:change", value="sk-abc123") -``` - -**2. User clicks Show (👁) button:** - -``` -Frontend Backend -──────── ─────── - │ │ - ├─── emit("{event}:reveal", {componentId}) ────►│ - │ │ Looks up secret - │ │ from registry/handler - │ │ - │◄── emit("{event}:reveal-response") ───────────┤ - │ {value: "base64...", encoded: true} │ - │ │ - └─── Decode & display in input │ -``` - -**3. User clicks Copy (📋) button:** - -Same flow as reveal, but copies to clipboard instead of displaying. - -**4. User edits the value:** - -Clicking the Edit (✏) button opens a textarea. On confirm (blur or Ctrl+Enter), the new value is base64-encoded and emitted: - -```python -# Backend receives: {value: "c2stbmV3a2V5MTIz", encoded: True, componentId: "secret-a1b2c3d4"} -``` - -
- -#### Default Behavior (No Handler) - -Without a custom `handler`, SecretInput uses an internal in-memory registry: - -```python -from pywry.toolbar import register_secret, get_secret, clear_secret - -# Automatic on render: -register_secret("secret-a1b2c3d4", SecretStr("my-value")) - -# On reveal/copy: -value = get_secret("secret-a1b2c3d4") # Returns "my-value" -``` - -The reveal/copy events are automatically handled by PyWry's callback system. - -#### Pre-populated Value from Database - -To display a SecretInput with a value that exists externally (database, vault, env var), use `value_exists=True` to show the mask without providing the actual secret: - -```python -SecretInput( - label="Database Password:", - event="db:password", - value_exists=True, # Shows mask, handler provides actual value on reveal - handler=db_password_handler, -) -``` - -When `value_exists=True`: -- The mask (••••••••••••) is displayed -- No secret is stored in Python memory -- Your `handler` must provide the value on reveal/copy - -#### Custom Handler (External Vault, Database, etc.) - -The `handler` is called for **both** get and set operations: - -```python -def handler( - value: str | None, # None = get, string = set - *, - component_id: str, # Unique ID like "secret-a1b2c3d4" - event: str, # Event name like "settings:api_key" - label: str | None, # Label text if provided - **metadata, # Additional context -) -> str | None: - """Return secret on get, store and return on set.""" -``` - -
-Database Handler Example - -```python -from pywry import PyWry, Toolbar, SecretInput -import database # Your database module - -app = PyWry() - -def api_key_handler( - value: str | None, - *, - component_id: str, - event: str, - label: str | None = None, - **metadata, -) -> str | None: - """Fetch from or store to database.""" - user_id = get_current_user_id() - - if value is None: - # GET: User clicked show/copy — fetch from database - row = database.query( - "SELECT api_key FROM user_settings WHERE user_id = ?", - user_id - ) - return row["api_key"] if row else None - else: - # SET: User edited the value — store to database - database.execute( - "INSERT OR REPLACE INTO user_settings (user_id, api_key) VALUES (?, ?)", - user_id, value - ) - return value - -# Check if user already has a key set -has_existing_key = database.query( - "SELECT 1 FROM user_settings WHERE user_id = ?", user_id -) is not None - -toolbar = Toolbar( - position="top", - items=[ - SecretInput( - label="API Key:", - event="settings:api_key", - value_exists=has_existing_key, # Show mask if key exists - handler=api_key_handler, - ), - ], -) - -app.show("

Settings

", toolbars=[toolbar]) -``` - -
- -
-Environment Variable Example - -```python -import os - -def env_handler(value: str | None, *, component_id: str, **_) -> str | None: - """Read from environment, warn on write attempts.""" - if value is None: - return os.environ.get("MY_API_KEY") - else: - print("Warning: Cannot write to environment variables at runtime") - return None - -SecretInput( - label="API Key (from env):", - event="env:api_key", - value_exists="MY_API_KEY" in os.environ, - handler=env_handler, - disabled=True, # Read-only since we can't write to env -) -``` - -
- -#### Events Emitted - -| Event | Direction | Payload | Description | -|-------|-----------|---------|-------------| -| `{event}` | JS → Python | `{ value, componentId, encoded: true }` | User edited value (base64) | -| `{event}:reveal` | JS → Python | `{ componentId }` | User clicked show button | -| `{event}:reveal-response` | Python → JS | `{ componentId, value, encoded: true }` | Backend response with secret | -| `{event}:copy` | JS → Python | `{ componentId }` | User clicked copy button | -| `{event}:copy-response` | Python → JS | `{ componentId, value, encoded: true }` | Backend response for clipboard | - -#### Utility Functions - -```python -from pywry.toolbar import ( - register_secret, # Store secret: register_secret(component_id, SecretStr("...")) - get_secret, # Retrieve secret: get_secret(component_id) -> str | None - clear_secret, # Remove secret: clear_secret(component_id) - encode_secret, # Base64 encode: encode_secret("value") -> "dmFsdWU=" - decode_secret, # Base64 decode: decode_secret("dmFsdWU=") -> "value" - set_secret_handler, # Set custom handler for specific event - get_secret_handler, # Get custom handler for event -) -``` - -
- -
-NumberInput — Numeric input with constraints - -```python -NumberInput( - label="Limit:", - event="filter:limit", - value=10, - min=1, - max=100, - step=1, -) -``` - -Includes up/down spinner buttons. - -**Emits:** `{ value: number, componentId: str }` - -
- -
-DateInput — Date picker - -```python -DateInput( - label="Start Date:", - event="filter:date", - value="2025-01-01", # YYYY-MM-DD format - min="2020-01-01", # Optional minimum - max="2030-12-31", # Optional maximum -) -``` - -**Emits:** `{ value: "YYYY-MM-DD", componentId: str }` - -
- -
-SliderInput — Single-value slider - -```python -SliderInput( - label="Zoom:", - event="zoom:level", - value=50, - min=0, - max=100, - step=5, - show_value=True, # Display current value (default: True) - debounce=50, # Delay in ms (default: 50) -) -``` - -**Emits:** `{ value: number, componentId: str }` - -
- -
-RangeInput — Dual-handle range slider - -```python -RangeInput( - label="Price Range:", - event="filter:price", - start=100, # Initial start value - end=500, # Initial end value - min=0, - max=1000, - step=10, - show_value=True, # Display start/end values - debounce=50, -) -``` - -Two handles on a single track for selecting a value range. - -**Emits:** `{ start: number, end: number, componentId: str }` - -
- -
-Toggle — Boolean switch - -```python -Toggle( - label="Dark Mode:", - event="theme:toggle", - value=True, # Initial state (default: False) -) -``` - -A sliding on/off switch. - -**Emits:** `{ value: bool, componentId: str }` - -
- -
-Checkbox — Boolean checkbox - -```python -Checkbox( - label="Enable notifications", - event="settings:notify", - value=True, # Initial checked state -) -``` - -A standard checkbox with label. - -**Emits:** `{ value: bool, componentId: str }` - -
- -
-RadioGroup — Radio button group - -```python -RadioGroup( - label="View:", - event="view:change", - options=["List", "Grid", "Cards"], - selected="List", - direction="horizontal", # horizontal|vertical (default: horizontal) -) -``` - -Mutually exclusive radio buttons. - -**Emits:** `{ value: str, componentId: str }` - -
- -
-TabGroup — Tab-style selection - -```python -TabGroup( - label="View:", - event="view:change", - options=[ - Option(label="Table", value="table"), - Option(label="Chart", value="chart"), - Option(label="Map", value="map"), - ], - selected="table", - size="md", # sm|md|lg (default: md) -) -``` - -Similar to RadioGroup but styled as tabs. Ideal for view switching. - -**Emits:** `{ value: str, componentId: str }` - -
- -
-Div — Container for custom HTML and nested items - -```python -Div( - content="

Controls

", # Custom HTML - class_name="my-controls", # CSS class (added to pywry-div) - children=[ # Nested toolbar items - Button(label="Action", event="app:action"), - Div(content="Nested"), - ], - script="console.log('Div loaded');", # JS file path or inline script -) -``` - -Container for grouping items or injecting custom HTML. Supports unlimited nesting. - -**Emits:** No automatic events (children emit their own events). - -
- -
-Marquee — Scrolling text/content ticker - -**In this section:** [Content Types](#content-types) · [Behavior & Direction](#behavior--direction-options) · [Dynamic Updates](#dynamic-updates-python--js) · [Events](#events) - -```python -Marquee( - text="Breaking News: Stock prices are up 5% today!", - event="ticker:click", # Emitted when clicked (if clickable=True) - speed=15, # Seconds per scroll cycle (default: 15, lower = faster) - direction="left", # left|right|up|down (default: left) - behavior="scroll", # scroll|alternate|slide (default: scroll) - pause_on_hover=True, # Pause animation on hover (default: True) - gap=50, # Gap in pixels between repeated content (default: 50) - clickable=False, # Emit event when clicked (default: False) - separator=" • ", # Optional separator between repeated content - children=[...], # Nested toolbar items (alternative to text) -) -``` - -Uses pure CSS animations for smooth, performant scrolling. Content is automatically duplicated internally to create seamless looping without any JavaScript animation. - -#### Content Types - -| Type | Parameter | Use Case | -|------|-----------|----------| -| **Plain Text** | `text="..."` | Simple scrolling text, auto-escaped | -| **HTML Content** | `text="..."` | Rich text (detected by `<` and `>` chars) | -| **Nested Components** | `children=[...]` | Toolbar items (Button, Div, etc.) | - -#### Behavior & Direction Options - -| Behavior | Description | -|----------|-------------| -| `"scroll"` | Continuous seamless loop (default) | -| `"alternate"` | Bounces back and forth | -| `"slide"` | Scrolls once and stops | - -| Direction | Description | -|-----------|-------------| -| `"left"` | Content moves right → left (default) | -| `"right"` | Content moves left → right | -| `"up"` | Content moves bottom → top | -| `"down"` | Content moves top → bottom | - -
-How Seamless Scrolling Works - -Marquee automatically duplicates content for seamless looping: - -``` -┌────────────────────────────────────────────────────────────────┐ -│ Marquee Container │ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ [Content A] [sep] [Content A] [sep] [Content A] [sep] ... │ │ -│ │ ▲ ▲ │ │ -│ │ Copy 1 Copy 2 (duplicate for seamless loop) │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ ◄────────────────────────── │ -│ Animation scrolls left │ -└────────────────────────────────────────────────────────────────┘ -``` - -When you update content via `toolbar:marquee-set-content`, **both copies are updated** automatically to maintain the seamless effect. - -
- -#### Dynamic Updates (Python → JS) - -```python -# Update text content -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "text": "New scrolling text!" -}) - -# Change speed and pause -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "speed": 10, # seconds per cycle - "paused": False # or True to pause -}) -``` - -
-All Update Options - -```python -# Update with plain text (auto-escaped) -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "text": "New scrolling text!" -}) - -# Update with HTML content -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "html": "Breaking: Market update" -}) - -# Change animation speed -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "speed": 10 # Faster: 10 seconds per cycle -}) - -# Pause/resume animation -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "paused": True # or False to resume -}) - -# Update separator -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "separator": " ★ " -}) - -# Combine multiple updates -widget.emit("toolbar:marquee-set-content", { - "id": marquee.component_id, - "text": "Alert: System maintenance", - "speed": 8, - "separator": " ⚠️ " -}) - -# Alternative: Use Python helper method -event, data = marquee.update_payload(text="Breaking news!", speed=10) -widget.emit(event, data) # event = "toolbar:marquee-set-content" -``` - -
- -#### Events - -| Event | Direction | Payload | -|-------|-----------|---------| -| `{event}` | JS → Python | `{ value, componentId }` — when clicked | -| `toolbar:marquee-set-content` | Python → JS | `{ id, text?, html?, speed?, paused?, separator? }` | -| `toolbar:marquee-set-item` | Python → JS | `{ ticker, text?, html?, styles?, class_add?, class_remove? }` | - -
-CSS Classes & Custom Properties - -| Class | Description | -|-------|-------------| -| `.pywry-marquee` | Base marquee container | -| `.pywry-marquee-left` / `-right` / `-up` / `-down` | Direction modifier | -| `.pywry-marquee-scroll` / `-alternate` / `-slide` | Behavior modifier | -| `.pywry-marquee-horizontal` / `-vertical` | Axis modifier | -| `.pywry-marquee-pause` | Added when `pause_on_hover=True` | -| `.pywry-marquee-clickable` | Added when `clickable=True` | -| `.pywry-marquee-track` | Inner scrolling track | -| `.pywry-marquee-content` | Content wrapper (duplicated) | -| `.pywry-marquee-separator` | Separator between copies | - -```css -/* Control via CSS or inline style */ ---pywry-marquee-speed: 15s; /* Animation duration */ ---pywry-marquee-gap: 50px; /* Gap between content copies */ -``` - -
- -
-Complete Example: News Ticker - -```python -from pywry import PyWry, Toolbar, Marquee - -app = PyWry() - -# Create marquee with initial content -news_ticker = Marquee( - text="Loading latest news...", - speed=20, - pause_on_hover=True, - component_id="news-ticker", # Explicit ID for targeting -) - -toolbar = Toolbar(position="header", items=[news_ticker]) - -widget = app.show("

Dashboard

", toolbars=[toolbar]) - -# Later, update from Python (e.g., after API call) -def update_news(headlines: list[str]): - widget.emit("toolbar:marquee-set-content", { - "id": "news-ticker", - "text": " • ".join(headlines), - "speed": 25 # Slow down for more content - }) - -update_news(["Market up 2%", "Tech earnings beat", "Fed holds rates"]) -``` - -
- -
- -
-TickerItem — Helper for updatable items within Marquee - -TickerItem creates individually-updatable spans within a Marquee. Each item has a `data-ticker` attribute that allows targeting specific items for dynamic updates without replacing the entire content. - -**In this section:** [Basic Usage](#basic-usage-1) · [Parameters](#tickeritem-parameters) · [Dynamic Updates](#dynamic-updates) · [Update Payload Options](#update-payload-options) - -#### Basic Usage - -```python -from pywry import Marquee, TickerItem - -items = [ - TickerItem(ticker="AAPL", text="AAPL $185.50", class_name="stock-up"), - TickerItem(ticker="GOOGL", text="GOOGL $142.20"), - TickerItem(ticker="MSFT", text="MSFT $415.80"), -] - -marquee = Marquee( - text=" • ".join(item.build_html() for item in items), - speed=20, -) -``` - -**Generated HTML:** `AAPL $185.50` - -
-How It Works - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ Marquee Container │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ [AAPL $185] • [GOOGL $142] • [MSFT $415] │ [AAPL $185] • [...] │ │ -│ │ ▲ ▲ ▲ │ ▲ │ │ -│ │ data-ticker data-ticker data-ticker │ (duplicate copy) │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - widget.emit("toolbar:marquee-set-item", {ticker: "AAPL", text: "$186"}) - │ - ▼ - Updates ALL elements with data-ticker="AAPL" - (both copies for seamless scrolling) -``` - -
- -#### TickerItem Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `ticker` | `str` | **Required.** Unique ID for targeting updates | -| `text` | `str` | Plain text content (auto-escaped) | -| `html` | `str` | HTML content (alternative to text) | -| `class_name` | `str` | Additional CSS classes | -| `style` | `str` | Inline CSS styles | - -#### Dynamic Updates - -```python -# Update individual item -widget.emit("toolbar:marquee-set-item", { - "ticker": "AAPL", - "text": "AAPL $186.25 ▲", - "styles": {"color": "#22c55e"} -}) - -# Or use helper method -event, data = items[0].update_payload( - text="AAPL $186.25 ▲", - styles={"color": "#22c55e"}, - class_add="stock-up", - class_remove="stock-down" -) -widget.emit(event, data) -``` - -#### Update Payload Options - -| Field | Type | Description | -|-------|------|-------------| -| `ticker` | `str` | **Required.** Target elements with `data-ticker="{ticker}"` | -| `selector` | `str` | Alternative: CSS selector to match elements | -| `text` | `str` | New plain text content | -| `html` | `str` | New HTML content (overrides text) | -| `styles` | `dict` | Inline styles to apply (camelCase keys) | -| `class_add` | `str \| list` | CSS class(es) to add | -| `class_remove` | `str \| list` | CSS class(es) to remove | - -
-Complete Example: Real-Time Stock Ticker - -```python -from pywry import PyWry, Toolbar, Marquee, TickerItem -import random - -app = PyWry() - -# Define stocks with initial prices -stocks = { - "AAPL": {"price": 185.50, "item": None}, - "GOOGL": {"price": 142.20, "item": None}, - "MSFT": {"price": 415.80, "item": None}, - "AMZN": {"price": 178.25, "item": None}, -} - -# Create TickerItems -for symbol, data in stocks.items(): - data["item"] = TickerItem( - ticker=symbol, - text=f"{symbol} ${data['price']:.2f}", - ) - -# Build marquee -stock_ticker = Marquee( - text=" • ".join(data["item"].build_html() for data in stocks.values()), - speed=25, - pause_on_hover=True, -) - -toolbar = Toolbar(position="header", items=[stock_ticker]) - -def simulate_price_update(data, event_type, label): - """Simulate random price changes.""" - for symbol, stock in stocks.items(): - change = random.uniform(-2, 2) - new_price = stock["price"] + change - stock["price"] = new_price - - arrow = "▲" if change >= 0 else "▼" - color = "#22c55e" if change >= 0 else "#ef4444" - - widget.emit("toolbar:marquee-set-item", { - "ticker": symbol, - "text": f"{symbol} ${new_price:.2f} {arrow}", - "styles": {"color": color}, - }) - -widget = app.show( - '', - toolbars=[toolbar], - callbacks={"stock:update": simulate_price_update}, -) -``` - -
- -**Note:** TickerItem is NOT a ToolbarItem — it's a content helper. Updates target ALL matching `data-ticker` elements (both duplicated copies). - -
- -
-Option — Choice for Select/MultiSelect/RadioGroup/TabGroup - -```python -Option( - label="Display Text", # Text shown in UI - value="internal_value", # Value sent in event (defaults to label) -) - -# Shorthand: strings auto-convert -options=["A", "B", "C"] # → [Option(label="A", value="A"), ...] -``` - -
- ---- - -### Toolbar Container - -```python -Toolbar( - position="top", # top|bottom|left|right|inside - items=[...], # List of toolbar items - component_id="my-toolbar", # Optional custom ID (auto-generated if omitted) - class_name="my-toolbar-class", # Custom CSS class - style="gap: 12px;", # Inline CSS for the content wrapper - collapsible=False, # Enable collapse/expand toggle button - resizable=False, # Enable drag-to-resize edge handle - script="console.log('loaded');", # JS file path or inline script -) -``` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `position` | `str` | `"top"` | Toolbar placement | -| `items` | `list` | `[]` | List of toolbar items | -| `component_id` | `str` | `"toolbar-{uuid8}"` | Unique toolbar ID | -| `class_name` | `str` | `""` | Additional CSS class | -| `style` | `str` | `""` | Inline CSS for content area | -| `collapsible` | `bool` | `False` | Show collapse/expand toggle | -| `resizable` | `bool` | `False` | Enable drag-to-resize | -| `script` | `str\|Path` | `None` | Custom JavaScript to inject | - ---- - -### Examples - -
-Complete Example with Multiple Components - -```python -from pywry import PyWry, Toolbar, Button, Select, TextInput, Toggle, Option - -app = PyWry() - -def on_save(data, event_type, label): - app.eval_js("document.getElementById('status').textContent = 'Saved!'", label) - -def on_export(data, event_type, label): - app.eval_js("document.getElementById('status').textContent = 'Exported!'", label) - -def on_theme(data, event_type, label): - is_dark = data["value"] - app.eval_js(f"document.documentElement.classList.toggle('light', {str(not is_dark).lower()})", label) - -def on_search(data, event_type, label): - app.eval_js(f"document.getElementById('status').textContent = 'Searching: {data['value']}'", label) - -toolbar = Toolbar( - position="top", - items=[ - Button(label="Save", event="app:save"), - Button(label="Export", event="app:export", variant="secondary"), - Select( - label="View:", - event="view:change", - options=["Table", "Chart"], - selected="Table", - ), - TextInput(label="Search:", event="search:query", placeholder="Type..."), - Toggle(label="Dark:", event="theme:toggle", value=True, style="margin-left: auto;"), - ], -) - -app.show( - "

My App

Ready

", - toolbars=[toolbar], - callbacks={ - "app:save": on_save, - "app:export": on_export, - "theme:toggle": on_theme, - "search:query": on_search, - }, -) -``` - -
- -
-Styling Buttons - -```python -from pywry import PyWry, HtmlContent, Toolbar, Button - -content = HtmlContent( - html="

Styled Buttons

", - inline_css=""" - .pywry-btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 20px; - padding: 8px 20px; - } - .pywry-btn:hover { transform: scale(1.05); } - .pywry-toolbar { justify-content: center; gap: 12px; } - """ -) - -toolbar = Toolbar( - position="top", - items=[ - Button(label="Primary", event="app:primary"), - Button(label="Secondary", event="app:secondary", variant="secondary"), - Button(label="Danger", event="app:danger", variant="danger"), - ], -) - -app.show(content, toolbars=[toolbar]) -``` - -
- -
-Toolbar with Plotly/AgGrid - -```python -from pywry import PyWry, Toolbar, Button - -app = PyWry() - -# Plotly with toolbar -def on_reset(data, event_type, label): - app.eval_js( - "Plotly.relayout(window.__PYWRY_PLOTLY_DIV__, " - "{xaxis: {autorange: true}, yaxis: {autorange: true}})" - ) - -toolbar = Toolbar( - position="bottom", - items=[Button(label="Reset Zoom", event="app:reset")], -) - -app.show_plotly(fig, toolbars=[toolbar], callbacks={"app:reset": on_reset}) - -# AgGrid with toolbar -def on_export(data, event_type, label): - app.eval_js("window.__PYWRY_GRID_API__.exportDataAsCsv()") - -toolbar = Toolbar( - position="top", - items=[Button(label="Export CSV", event="app:export")], -) - -app.show_dataframe(df, toolbars=[toolbar], callbacks={"app:export": on_export}) -``` - -
- -
-All Toolbar Inputs - No Javascript Required - -```python -from pywry import ( - PyWry, - Toolbar, - Button, - Select, - MultiSelect, - TextInput, - NumberInput, - SliderInput, - DateInput, - RangeInput, - Toggle, - Checkbox, - RadioGroup, - TabGroup, - Option, - Div, -) - - -app = PyWry() - -# State to display current values -component_values = {} -current_theme = "dark" # Track current theme - -def make_handler(name): - """Create a handler that updates the display with the component's value.""" - def handler(data, event_type, label): - # Extract the relevant value(s) from the event data - if "value" in data: - component_values[name] = data["value"] - elif "values" in data: - component_values[name] = data["values"] - elif "start" in data and "end" in data: - component_values[name] = f"{data['start']} - {data['end']}" - elif "btn" in data: - component_values[name] = data["btn"] - else: - component_values[name] = "clicked" - - # Build display text for footer using built-in pywry:set-content - parts = [f"{k}: {v}" for k, v in component_values.items()] - app.emit("pywry:set-content", { - "id": "values-display", - "html": " | ".join(parts) - }, label) - return handler - -def on_theme_toggle(data, event_type, label): - """Toggle between dark and light mode using built-in pywry event.""" - global current_theme - current_theme = "light" if current_theme == "dark" else "dark" - app.emit("pywry:update-theme", {"theme": current_theme}, label) - -def on_title_size(data, event_type, label): - """Change the title size using built-in pywry:set-style event.""" - sizes = {"sm": "16px", "md": "20px", "lg": "26px"} - size = data.get("value", "md") - # Use built-in pywry:set-style event to update element styles - app.emit("pywry:set-style", { - "id": "demo-title", - "styles": {"fontSize": sizes.get(size, "20px")} - }, label) - -def on_label_style(data, event_type, label): - """Change all component label styles using built-in pywry:set-style event.""" - style_map = { - "normal": {"fontWeight": "400", "fontStyle": "normal"}, - "semi": {"fontWeight": "500", "fontStyle": "normal"}, - "bold": {"fontWeight": "700", "fontStyle": "normal"}, - "italic": {"fontWeight": "400", "fontStyle": "italic"}, - } - style = data.get("value", "normal") - # Use built-in pywry:set-style event to update all labels - app.emit("pywry:set-style", { - "selector": ".pywry-input-label", - "styles": style_map.get(style, style_map["normal"]) - }, label) - -def on_accent_color(data, event_type, label): - """Change the accent color using built-in pywry:inject-css event.""" - colors = { - "blue": "#0078d4", - "green": "#28a745", - "purple": "#6f42c1", - "orange": "#fd7e14", - "pink": "#e91e63", - } - color = data.get("value", "blue") - accent = colors.get(color, colors["blue"]) - - # Use built-in pywry:inject-css to dynamically inject CSS - # The 'id' allows replacing the same style block on subsequent calls - app.emit("pywry:inject-css", { - "id": "custom-accent-color", - "css": f""" - :root {{ - --pywry-accent: {accent} !important; - --pywry-accent-hover: {accent}dd !important; - }} - .pywry-btn-neutral {{ - background: {accent} !important; - }} - .pywry-btn-neutral:hover {{ - background: {accent}dd !important; - }} - """ - }) - -# Header toolbar with title and theme toggle -components_header = Toolbar( - position="header", - items=[ - Div( - content="

🧩 All Toolbar Components

", - style="flex: 1;", - ), - Select( - label="Accent:", - event="demo:accent", - options=[ - Option(label="Blue", value="blue"), - Option(label="Green", value="green"), - Option(label="Purple", value="purple"), - Option(label="Orange", value="orange"), - Option(label="Pink", value="pink"), - ], - selected="blue", - ), - Select( - label="Title:", - event="demo:title_size", - options=[Option(label="SM", value="sm"), Option(label="MD", value="md"), Option(label="LG", value="lg")], - selected="md", - ), - Select( - label="Labels:", - event="demo:label_style", - options=[Option(label="Normal", value="normal"), Option(label="Semi", value="semi"), Option(label="Bold", value="bold"), Option(label="Italic", value="italic")], - selected="normal", - ), - Button(label="\u2600\uFE0F", event="demo:theme", variant="ghost", component_id="theme-toggle-btn"), - ], -) - -# Top toolbar with text, number, and date inputs -inputs_row = Toolbar( - position="top", - items=[ - TextInput( - label="Text:", - event="demo:text", - value="Hello", - placeholder="Type here...", - ), - NumberInput( - label="Number:", - event="demo:number", - value=42, - min=0, - max=100, - step=1, - ), - DateInput( - label="Date:", - event="demo:date", - value="2026-01-13", - ), - ], -) - -# Second row with select, multi-select -selects_row = Toolbar( - position="top", - items=[ - Select( - label="Select:", - event="demo:select", - options=[ - Option(label="Option A", value="a"), - Option(label="Option B", value="b"), - Option(label="Option C", value="c"), - ], - selected="a", - ), - MultiSelect( - label="Multi:", - event="demo:multi", - options=[ - Option(label="Red", value="red"), - Option(label="Green", value="green"), - Option(label="Blue", value="blue"), - ], - selected=["red"], - ), - ], -) - -# Third row with sliders and range -sliders_row = Toolbar( - position="top", - items=[ - SliderInput( - label="Slider:", - event="demo:slider", - value=50, - min=0, - max=100, - step=5, - show_value=True, - ), - RangeInput( - label="Range:", - event="demo:range", - min=0, - max=100, - start=20, - end=80, - show_value=True, - ), - ], -) - -# Fourth row with toggle, checkbox, and horizontal radio -booleans_row = Toolbar( - position="top", - items=[ - Toggle(label="Toggle:", event="demo:toggle", value=True), - Div(content="Check:", style="margin-right: 4px;"), - Checkbox(label="", event="demo:checkbox", value=False), - RadioGroup( - label="Radio:", - event="demo:radio", - options=[Option(label="A", value="a"), Option(label="B", value="b"), Option(label="C", value="c")], - selected="a", - direction="horizontal", - ), - ], -) - -# Fifth row with TabGroups -tabs_row = Toolbar( - position="top", - items=[ - TabGroup( - label="View:", - event="demo:tabs", - options=[ - Option(label="Table", value="table"), - Option(label="Chart", value="chart"), - Option(label="Map", value="map"), - ], - selected="table", - ), - TabGroup( - label="Size:", - event="demo:tabsize", - options=["SM", "MD", "LG"], - selected="MD", - size="sm", - ), - ], -) - -# Right sidebar with vertical radio group -right_sidebar = Toolbar( - position="right", - style="padding: 8px 12px;", - items=[ - Div(content="Priority", style="margin-bottom: 4px;"), - RadioGroup( - event="demo:priority", - options=[ - Option(label="Low", value="low"), - Option(label="Medium", value="med"), - Option(label="High", value="high"), - ], - selected="med", - direction="vertical", - ), - ], - collapsible=True, -) - -# Bottom toolbar with buttons (all variants) -buttons_row = Toolbar( - position="top", - items=[ - Div(content="Variants:", style="margin-right: 4px;"), - Button(label="Primary", event="demo:btn", data={"btn": "primary"}, variant="primary"), - Button(label="Secondary", event="demo:btn", data={"btn": "secondary"}, variant="secondary"), - Button(label="Neutral", event="demo:btn", data={"btn": "neutral"}, variant="neutral"), - Button(label="Ghost", event="demo:btn", data={"btn": "ghost"}, variant="ghost"), - Button(label="Outline", event="demo:btn", data={"btn": "outline"}, variant="outline"), - Button(label="Danger", event="demo:btn", data={"btn": "danger"}, variant="danger"), - Button(label="Warning", event="demo:btn", data={"btn": "warning"}, variant="warning"), - Button(label="\u2699", event="demo:btn", data={"btn": "icon"}, variant="icon"), - ], -) - -# Size variants row -sizes_row = Toolbar( - position="top", - items=[ - Div(content="Sizes", style="margin-right: 4px;"), - Button(label="XS", event="demo:btn", data={"btn": "xs"}, variant="neutral", size="xs"), - Button(label="SM", event="demo:btn", data={"btn": "sm"}, variant="neutral", size="sm"), - Button(label="Default", event="demo:btn", data={"btn": "default"}, variant="neutral"), - Button(label="LG", event="demo:btn", data={"btn": "lg"}, variant="neutral", size="lg"), - Button(label="XL", event="demo:btn", data={"btn": "xl"}, variant="neutral", size="xl"), - ], -) - -# Footer with live status display -components_footer = Toolbar( - position="footer", - items=[ - Div( - content="Interact with components above...", - style="color: var(--pywry-text-secondary); width: 100%; text-align: center;", - ), - ], -) - -# No custom HTML or custom JS needed - all interactions use built-in pywry events! -components_html = "" - -components_handle = app.show( - components_html, - title="All Components Demo", - toolbars=[ - components_header, - inputs_row, - selects_row, - sliders_row, - booleans_row, - tabs_row, - buttons_row, - sizes_row, - right_sidebar, - components_footer, - ], - callbacks={ - "demo:text": make_handler("Text"), - "demo:number": make_handler("Number"), - "demo:date": make_handler("Date"), - "demo:select": make_handler("Select"), - "demo:multi": make_handler("Multi"), - "demo:slider": make_handler("Slider"), - "demo:range": make_handler("Range"), - "demo:toggle": make_handler("Toggle"), - "demo:checkbox": make_handler("Checkbox"), - "demo:radio": make_handler("Radio"), - "demo:tabs": make_handler("Tabs"), - "demo:tabsize": make_handler("TabSize"), - "demo:priority": make_handler("Priority"), - "demo:btn": make_handler("Button"), - "demo:theme": on_theme_toggle, - "demo:title_size": on_title_size, - "demo:label_style": on_label_style, - "demo:accent": on_accent_color, - }, - height=375 -) -``` - -
- ---- - -### State Management - -
-Querying Toolbar State - -```python -from pywry import PyWry, Toolbar, Select, Option - -app = PyWry() - -def on_state(data, event_type, label): - """Display current toolbar state in the UI.""" - components = data.get("components", {}) - state_text = ", ".join(f"{k}: {v.get('value')}" for k, v in components.items()) - app.emit("pywry:set-content", {"id": "state", "text": f"State: {state_text}"}, label) - -toolbar = Toolbar( - position="top", - toolbar_id="my-toolbar", - items=[Select(event="app:mode", options=["A", "B", "C"], selected="A")] -) - -handle = app.show( - '
Click refresh to see state...
', - toolbars=[toolbar], - callbacks={"toolbar:state-response": on_state} -) - -# Request toolbar state -handle.emit("toolbar:request-state", {"toolbarId": "my-toolbar"}) -``` - -**Response payload:** -```python -{ - "toolbars": {"toolbar-a1b2c3d4": {"position": "top", "components": ["button-x1y2z3", ...]}}, - "components": {"button-x1y2z3": {"type": "button", "value": None}, ...}, - "timestamp": 1234567890 -} -``` - -
- -
-Setting Toolbar Values - -```python -from pywry import PyWry, Toolbar, Button, Select, NumberInput - -app = PyWry() - -def on_reset(data, event_type, label): - """Reset all toolbar values to defaults.""" - app.emit("toolbar:set-values", { - "values": {"theme-select": "light", "zoom-input": 100} - }, label) - app.emit("pywry:set-content", {"id": "status", "text": "Reset to defaults!"}, label) - -toolbar = Toolbar( - position="top", - items=[ - Select(event="app:theme", component_id="theme-select", options=["light", "dark"], selected="dark"), - NumberInput(event="app:zoom", component_id="zoom-input", value=150, min=50, max=200), - Button(label="Reset", event="app:reset"), - ] -) - -handle = app.show( - '
Adjust settings above...
', - toolbars=[toolbar], - callbacks={"app:reset": on_reset} -) -``` - -**Setting Component Attributes** (labels, disabled state, etc.): - -```python -def on_submit(data, event_type, label): - """Show loading state then re-enable.""" - # Update button label and disable - handle.set_toolbar_value("submit-btn", label="Saving...", disabled=True) - - # ... do work ... - - # Re-enable with original label - handle.set_toolbar_value("submit-btn", label="Submit", disabled=False) - -def on_category_change(data, event_type, label): - """Update dropdown options dynamically.""" - category = data.get("value") - if category == "fruits": - options = [{"label": "Apple", "value": "apple"}, {"label": "Banana", "value": "banana"}] - else: - options = [{"label": "Carrot", "value": "carrot"}, {"label": "Broccoli", "value": "broccoli"}] - - handle.set_toolbar_value("item-select", value=options[0]["value"], options=options) -``` - -Supported attributes for `set_toolbar_value()`: - -| Attribute | Description | Example | -|-----------|-------------|---------| -| `value` | Component value | `value="dark"` | -| `label`/`text` | Text content | `label="Loading..."` | -| `disabled` | Enable/disable | `disabled=True` | -| `variant` | Button style | `variant="danger"` | -| `tooltip`/`description` | Hover text | `tooltip="Click to save"` | -| `options` | Dropdown options | `options=[{"label": "A", "value": "a"}]` | -| `style` | Inline CSS | `style={"color": "red"}` | -| `className` | CSS classes | `className={"add": ["active"]}` | -| `placeholder` | Input hint | `placeholder="Search..."` | -| `min`/`max`/`step` | Input constraints | `min=0, max=100` | - -
- -
-JavaScript Access - -```javascript -// Get all toolbar state -const state = window.__PYWRY_TOOLBAR__.getState(); - -// Get specific toolbar state -const state = window.__PYWRY_TOOLBAR__.getState("toolbar-a1b2c3d4"); - -// Get/set individual component value -const value = window.__PYWRY_TOOLBAR__.getValue("select-a1b2c3d4"); -window.__PYWRY_TOOLBAR__.setValue("select-a1b2c3d4", "light"); -``` - -
- -
- ---- - -## CSS Selectors and Theming - -
-Click to expand - -**In this section:** [Theme Classes](#theme-classes) · [Container Classes](#container-classes) · [Layout Wrappers](#layout-wrappers) · [Toolbar Classes](#toolbar-classes) · [Component Classes](#component-classes) · [Toast Classes](#toast-notification-classes) · [Component ID Targeting](#component-id-targeting) · [CSS Variables](#css-variables) · [Example](#example-custom-styling) - ---- - -PyWry provides a consistent DOM structure across all rendering modes (HTML, Plotly, AgGrid). Understanding the class hierarchy enables precise styling and JavaScript targeting. - -### Theme Classes - -PyWry uses a dual-class theming system for maximum compatibility: - -| Selector | Description | -|----------|-------------| -| `html.dark` | Dark theme on document root (native windows) | -| `html.light` | Light theme on document root (native windows) | -| `html.pywry-native` | Added to `` in native window mode | -| `.pywry-theme-dark` | Dark theme on widget container (notebook/browser mode) | -| `.pywry-theme-light` | Light theme on widget container (notebook/browser mode) | -| `.pywry-theme-system` | System preference theme (follows `prefers-color-scheme`) | - -> **Note:** In native windows, theme classes are applied to ``. In notebooks/inline mode, they're applied to `.pywry-widget` for scoped styling that doesn't affect the notebook theme. - -### Container Classes - -| Selector | Description | -|----------|-------------| -| `.pywry-container` | Root container for native windows (full page, absolute positioned) | -| `.pywry-widget` | Root container for widgets in notebook/browser mode | -| `.pywry-content` | Flex container for user content (HTML/Chart/Grid), includes 16px padding | -| `.pywry-plotly` | Plotly chart container with border styling | -| `.pywry-grid` | AgGrid container element | -| `.plotly-graph-div` | Plotly's internal container (Plotly's own class) | - -### Layout Wrappers - -
-Wrapper hierarchy diagram and classes - -Layout wrappers create the nested flexbox structure for toolbar positioning. They are applied in a specific order to create the visual hierarchy: - -``` -┌────────────────────────────────────────────────────────────────┐ -│ .pywry-wrapper-header (column: header toolbar + rest) │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ .pywry-toolbar-header │ │ -│ ├──────────────────────────────────────────────────────────┤ │ -│ │ .pywry-wrapper-left (row: left toolbar + rest) │ │ -│ │ ┌───────┬────────────────────────────────────────────┐ │ │ -│ │ │ .left │ .pywry-wrapper-right (row: rest + right) │ │ │ -│ │ │ │ ┌──────────────────────────────────┬────┐ │ │ │ -│ │ │ │ │ .pywry-wrapper-top │.rt │ │ │ │ -│ │ │ │ │ ┌────────────────────────────┐ │ │ │ │ │ -│ │ │ │ │ │ .pywry-toolbar-top │ │ │ │ │ │ -│ │ │ │ │ ├────────────────────────────┤ │ │ │ │ │ -│ │ │ │ │ │ .pywry-wrapper-bottom │ │ │ │ │ │ -│ │ │ │ │ │ ┌──────────────────────┐ │ │ │ │ │ │ -│ │ │ │ │ │ │ .pywry-wrapper-ins │ │ │ │ │ │ │ -│ │ │ │ │ │ │ ┌────────────────┐ │ │ │ │ │ │ │ -│ │ │ │ │ │ │ │ .pywry-cont │ │ │ │ │ │ │ │ -│ │ │ │ │ │ │ │ (content) │ │ │ │ │ │ │ │ -│ │ │ │ │ │ │ └────────────────┘ │ │ │ │ │ │ │ -│ │ │ │ │ │ │ .pywry-toolbar-ins │ │ │ │ │ │ │ -│ │ │ │ │ │ └──────────────────────┘ │ │ │ │ │ │ -│ │ │ │ │ │ .pywry-toolbar-bottom │ │ │ │ │ │ -│ │ │ │ │ └────────────────────────────┘ │ │ │ │ │ -│ │ │ │ └──────────────────────────────────┴────┘ │ │ │ -│ │ └───────┴────────────────────────────────────────────┘ │ │ -│ ├──────────────────────────────────────────────────────────┤ │ -│ │ .pywry-toolbar-footer │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────────────────┘ -``` - -| Selector | Description | -|----------|-------------| -| `.pywry-wrapper-header` | Outermost wrapper; column layout for header/footer toolbars | -| `.pywry-wrapper-left` | Row layout; left toolbar extends full height | -| `.pywry-wrapper-right` | Row layout; right toolbar extends full height | -| `.pywry-wrapper-top` | Column layout; top toolbar is inside left/right | -| `.pywry-wrapper-bottom` | Column layout; bottom toolbar is inside left/right | -| `.pywry-wrapper-inside` | Innermost wrapper; positions inside toolbar as overlay | - -> **Nesting Behavior:** Wrappers are nested based on which toolbars are present. If you only have `top` and `bottom` toolbars, only `.pywry-wrapper-top` and `.pywry-wrapper-bottom` are created (no left/right wrappers). The `header` and `footer` positions are always outermost when present. - -
- -### Toolbar Classes - -| Selector | Description | -|----------|-------------| -| `.pywry-toolbar` | Base toolbar container with flex layout | -| `.pywry-toolbar-header` | Full-width toolbar at page top, with bottom border | -| `.pywry-toolbar-footer` | Full-width toolbar at page bottom, with top border | -| `.pywry-toolbar-top` | Toolbar above content, inside left/right toolbars | -| `.pywry-toolbar-bottom` | Toolbar below content, inside left/right toolbars | -| `.pywry-toolbar-left` | Vertical toolbar on left, full height, with right border | -| `.pywry-toolbar-right` | Vertical toolbar on right, full height, with left border | -| `.pywry-toolbar-inside` | Floating toolbar overlay at top-right of content | -| `.pywry-toolbar-content` | Inner container for toolbar items | -| `.pywry-toolbar-toggle` | Collapse/expand button for collapsible toolbars | -| `.pywry-toggle-icon` | Arrow icon inside toggle button | -| `.pywry-collapsed` | State class for collapsed toolbars | -| `.pywry-resize-handle` | Drag handle for resizable toolbars | - -### Component Classes - -
-All component selectors (Buttons, Inputs, Dropdowns, Toggle, Radio, Tabs, Div) - -#### Buttons - -| Selector | Description | -|----------|-------------| -| `.pywry-btn` | Base button with primary styling (default variant) | -| `.pywry-btn-secondary` | Secondary button - subtle gray background | -| `.pywry-btn-neutral` | Neutral button - blue accent, always visible | -| `.pywry-btn-ghost` | Ghost button - transparent, text only | -| `.pywry-btn-outline` | Outline button - border only, no fill | -| `.pywry-btn-danger` | Danger button - red background | -| `.pywry-btn-warning` | Warning button - orange background | -| `.pywry-btn-icon` | Icon-only button - square aspect ratio | -| `.pywry-btn-xs` | Extra small size | -| `.pywry-btn-sm` | Small size | -| `.pywry-btn-lg` | Large size | -| `.pywry-btn-xl` | Extra large size | - -#### Inputs - -| Selector | Description | -|----------|-------------| -| `.pywry-input` | Base input styling (text, number, date) | -| `.pywry-input-text` | Text input specific styling | -| `.pywry-input-number` | Number input with hidden spinners | -| `.pywry-input-date` | Date picker input | -| `.pywry-input-range` | Slider/range input | -| `.pywry-input-group` | Container with label + input | -| `.pywry-input-inline` | Horizontal label + input layout | -| `.pywry-input-label` | Label text styling | -| `.pywry-number-wrapper` | Number input with custom spinner buttons | -| `.pywry-number-spinner` | Custom up/down spinner container | -| `.pywry-range-value` | Current value display for sliders | -| `.pywry-range-group` | Dual-range slider container | -| `.pywry-range-track` | Track element for range sliders | -| `.pywry-range-separator` | "–" separator between min/max values | - -#### Dropdowns & Select - -| Selector | Description | -|----------|-------------| -| `.pywry-select` | Native `