Skip to content

Commit 15f3f42

Browse files
author
deeleeramone
committed
2 parents 55043d4 + 5cbfc1f commit 15f3f42

5 files changed

Lines changed: 139 additions & 19 deletions

File tree

pywry/docs/docs/components/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ All interactive components emit events via the `event` parameter:
101101

102102
```python
103103
Button(label="Save", event="file:save")
104-
Select(label="Theme", event="settings:theme", ...)
104+
Select(label="Theme", event="settings:theme", options=[...])
105105
```
106106

107107
Use namespaced events like `category:action` for organization:

pywry/docs/docs/getting-started/why-pywry.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Native windows open in under a second. There's no server to spin up, no browser
7070
PyWry isn't just for prototyping and single-user applications:
7171

7272
- **Deploy Mode** with Redis backend for horizontal scaling and RBAC
73-
- **0Auth2"** authentication system for both native and deploy modes
73+
- **OAuth2** authentication system for both native and deploy modes
7474
- **Token authentication** and CSRF protection out of the box
7575
- **CSP headers** and security presets for production environments
7676
- **TOML-based configuration** with layered precedence (defaults → project → user → env vars)

pywry/docs/docs/guides/window-management.md

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ handle = app.show(
149149

150150
## The Window Handle
151151

152-
`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.
152+
`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.
153153

154154
### Common Methods (all modes)
155155

@@ -158,10 +158,14 @@ handle = app.show(
158158
| `handle.emit(event, data)` | Send an event from Python to the window's JavaScript |
159159
| `handle.on(event, callback)` | Register a Python callback for events from the window |
160160
| `handle.label` | The window/widget label |
161+
| `handle.alert(message, ...)` | Show a toast notification |
162+
| `handle.set_toolbar_value(id, value, ...)` | Update a toolbar component's value |
163+
| `handle.set_toolbar_values({id: value, ...})` | Update multiple toolbar components |
164+
| `handle.request_toolbar_state()` | Request current toolbar state |
161165

162166
### Native-only Methods (WindowProxy)
163167

164-
The `WindowProxy` exposes the full set of OS window operations:
168+
The `WindowProxy` (accessible via `handle.proxy`) exposes the full set of OS window operations:
165169

166170
**Window state:**
167171

@@ -187,28 +191,27 @@ The `WindowProxy` exposes the full set of OS window operations:
187191
| `set_resizable(bool)` | Toggle resizing |
188192
| `set_theme(ThemeMode)` | Change theme |
189193

190-
**Read-only properties:**
194+
**Read-only properties (via `handle.proxy`):**
191195

192196
```python
193-
handle.title # Current title
194-
handle.inner_size # Content area size (PhysicalSize)
195-
handle.outer_size # Window frame size
196-
handle.inner_position # Content position on screen
197-
handle.is_fullscreen # bool
198-
handle.is_maximized # bool
199-
handle.is_focused # bool
200-
handle.is_visible # bool
201-
handle.current_monitor # Monitor info (name, size, position, scale)
197+
handle.proxy.inner_size # Content area size (PhysicalSize)
198+
handle.proxy.outer_size # Window frame size
199+
handle.proxy.inner_position # Content position on screen
200+
handle.proxy.is_fullscreen # bool
201+
handle.proxy.is_maximized # bool
202+
handle.proxy.is_focused # bool
203+
handle.proxy.is_visible # bool
204+
handle.proxy.current_monitor # Monitor info (name, size, position, scale)
202205
```
203206

204207
**JavaScript execution:**
205208

206209
```python
207210
# Fire-and-forget
208-
handle.eval("document.title = 'Hello'")
211+
handle.eval_js("document.title = 'Hello'")
209212

210-
# With return value (blocks up to timeout)
211-
result = handle.eval_with_result("document.querySelectorAll('li').length", timeout=5.0)
213+
# With return value (blocks up to timeout, via proxy)
214+
result = handle.proxy.eval_with_result("document.querySelectorAll('li').length", timeout=5.0)
212215
```
213216

214217
## Updating Content

pywry/docs/docs/reference/events.md

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ All events follow the `namespace:event-name` pattern:
1111
| namespace | Starts with letter, alphanumeric | `app`, `plotly`, `grid`, `myapp` |
1212
| event-name | Starts with letter, alphanumeric + hyphens | `click`, `row-select`, `update-data` |
1313

14-
**Reserved namespaces:** `pywry:*`, `plotly:*`, `grid:*`, `toolbar:*`
14+
**Reserved namespaces:** `pywry:*`, `plotly:*`, `grid:*`, `toolbar:*`, `auth:*`
1515

1616
---
1717

@@ -187,6 +187,53 @@ All events follow the `namespace:event-name` pattern:
187187

188188
---
189189

190+
## Auth Events (auth:*)
191+
192+
The `auth:*` namespace is used by the built-in OAuth2 authentication system.
193+
Events flow in both directions: the frontend can request login/logout, and the
194+
backend notifies the frontend when auth state changes (e.g. after a token
195+
refresh or successful logout).
196+
197+
!!! note "Availability"
198+
Auth events are only active when `PYWRY_DEPLOY__AUTH_ENABLED=true` and a
199+
valid `PYWRY_OAUTH2__*` configuration is present. In native mode the full
200+
flow is handled by `app.login()` / `app.logout()` — these events apply to
201+
the frontend integration via `window.pywry.auth`.
202+
203+
### Auth Requests (JS → Python)
204+
205+
| Event | Payload | Description |
206+
|-------|---------|-------------|
207+
| `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`. |
208+
| `auth:logout-request` | `{}` | Frontend requests logout (calls `window.pywry.auth.logout()`). The backend revokes tokens, destroys the session, and emits `auth:logout` back. |
209+
210+
### Auth Notifications (Python → JS)
211+
212+
| Event | Payload | Description |
213+
|-------|---------|-------------|
214+
| `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. |
215+
| `auth:token-refresh` | `{token_type, expires_in?}` | Access token was refreshed in the background. Updates the current session without requiring re-login. |
216+
| `auth:logout` | `{}` | Server-side logout completed. Clears `window.__PYWRY_AUTH__` and notifies registered `onAuthStateChange` handlers. |
217+
218+
**`auth:state-changed` payload detail:**
219+
220+
```python
221+
{
222+
"authenticated": True,
223+
"user_id": "user@example.com", # sub / id / email from userinfo
224+
"roles": ["viewer", "editor"], # from session roles list
225+
"token_type": "Bearer" # OAuth2 token type
226+
}
227+
```
228+
229+
When `authenticated` is `false` only the key itself is present:
230+
231+
```python
232+
{"authenticated": False}
233+
```
234+
235+
---
236+
190237
## Component Event Payloads
191238

192239
Every toolbar component emits its custom event with these payloads:
@@ -271,6 +318,40 @@ window.__PYWRY_TOOLBAR__.getValue("component-id") // Get value
271318
window.__PYWRY_TOOLBAR__.setValue("component-id", value) // Set value
272319
```
273320

321+
### Auth Globals (window.pywry.auth)
322+
323+
When `auth_enabled=True` the `auth-helpers.js` script is injected and the
324+
`window.pywry.auth` namespace becomes available.
325+
326+
```javascript
327+
// Check authentication state
328+
window.pywry.auth.isAuthenticated() // boolean
329+
330+
// Get the full auth state
331+
window.pywry.auth.getState()
332+
// Returns: { authenticated, user_id, roles, token_type }
333+
334+
// Trigger OAuth2 login flow (emits auth:login-request to Python)
335+
window.pywry.auth.login()
336+
337+
// Trigger logout (emits auth:logout-request to Python)
338+
window.pywry.auth.logout()
339+
340+
// React to auth state changes (from auth:state-changed / auth:logout events)
341+
window.pywry.auth.onAuthStateChange(function(state) {
342+
if (state.authenticated) {
343+
console.log("Logged in as", state.user_id, "with roles", state.roles);
344+
} else {
345+
console.log("Logged out");
346+
}
347+
});
348+
```
349+
350+
**`window.__PYWRY_AUTH__`** is injected server-side for authenticated requests and
351+
contains `{ user_id, roles, token_type }`. Use `window.pywry.auth.getState()`
352+
rather than reading it directly — the helper normalizes the value and handles
353+
the unauthenticated case.
354+
274355
### Tauri Access (Native Mode Only)
275356

276357
In native desktop mode, a subset of Tauri APIs and the PyTauri IPC bridge are available via `window.__TAURI__`. PyWry does **not** expose the full Tauri plugin ecosystem — only the APIs listed below are bundled and configured.

pywry/tests/test_window_proxy.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
import sys
1313
import time
1414

15-
from typing import Any
15+
from collections.abc import Callable
16+
from functools import wraps
17+
from typing import Any, TypeVar
1618

1719
import pytest
1820

@@ -28,6 +30,39 @@
2830
from tests.conftest import ReadyWaiter
2931

3032

33+
F = TypeVar("F", bound=Callable[..., Any])
34+
35+
36+
def retry_on_subprocess_failure(max_attempts: int = 3, delay: float = 1.0) -> Callable[[F], F]:
37+
"""Retry decorator for tests that may fail due to transient subprocess issues.
38+
39+
On Windows, WebView2 sometimes fails to start due to resource contention
40+
("Failed to unregister class Chrome_WidgetWin_0"). On Linux with xvfb,
41+
WebKit initialization may have timing issues. This decorator retries
42+
the test after a delay to allow resources to be released.
43+
"""
44+
45+
def decorator(func: F) -> F:
46+
@wraps(func)
47+
def wrapper(*args: Any, **kwargs: Any) -> Any:
48+
last_error: Exception | None = None
49+
for attempt in range(max_attempts):
50+
try:
51+
return func(*args, **kwargs)
52+
except (TimeoutError, AssertionError) as e:
53+
last_error = e
54+
if attempt < max_attempts - 1:
55+
# Clean up and wait before retry
56+
runtime.stop()
57+
# Progressive backoff for CI stability
58+
time.sleep(delay * (attempt + 1))
59+
raise last_error # type: ignore[misc]
60+
61+
return wrapper # type: ignore[return-value]
62+
63+
return decorator
64+
65+
3166
# Note: cleanup_runtime fixture is now in conftest.py and auto-used
3267

3368

@@ -273,6 +308,7 @@ def test_hide_show(self) -> None:
273308
assert proxy.is_visible is True
274309
app.close()
275310

311+
@retry_on_subprocess_failure(max_attempts=3, delay=1.0)
276312
@pytest.mark.skipif(
277313
os.environ.get("CI") == "true" and sys.platform == "linux",
278314
reason="Always-on-top requires a real window manager (not available on Linux CI)",

0 commit comments

Comments
 (0)