Просмотр исходного кода

Merge branch 'main' into previous-value

Falko Schindler 2 месяцев назад
Родитель
Сommit
6a6ea1a69c

+ 52 - 0
examples/google_oauth2/main.py

@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+from typing import Optional
+
+from authlib.integrations.starlette_client import OAuth, OAuthError
+from fastapi import Request
+from starlette.responses import RedirectResponse
+
+from nicegui import app, ui
+
+# Get the credentials from the Google Cloud Console
+# https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id
+GOOGLE_CLIENT_ID = '...'
+GOOGLE_CLIENT_SECRET = '...'
+
+oauth = OAuth()
+oauth.register(
+    name='google',
+    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
+    client_id=GOOGLE_CLIENT_ID,
+    client_secret=GOOGLE_CLIENT_SECRET,
+    client_kwargs={'scope': 'openid email profile'},
+)
+
+
+@app.get('/auth')
+async def google_oauth(request: Request) -> RedirectResponse:
+    try:
+        user_data = await oauth.google.authorize_access_token(request)
+    except OAuthError as e:
+        print(f'OAuth error: {e}')
+        return RedirectResponse('/')  # or return an error page/message
+    app.storage.user['user_data'] = user_data
+    return RedirectResponse('/')
+
+
+def logout() -> None:
+    del app.storage.user['user_data']
+    ui.navigate.to('/')
+
+
+@ui.page('/')
+async def main(request: Request) -> Optional[RedirectResponse]:
+    user_data = app.storage.user.get('user_data', None)
+    if user_data:
+        ui.label(f'Welcome {user_data.get("userinfo", {}).get("name", "")}!')
+        ui.button('Logout', on_click=logout)
+        return None
+    else:
+        url = request.url_for('google_oauth')
+        return await oauth.google.authorize_redirect(request, url)
+
+ui.run(host='localhost', storage_secret='random secret goes here')

+ 1 - 0
examples/google_oauth2/requirements.txt

@@ -0,0 +1 @@
+authlib

+ 1 - 1
examples/google_one_tap_auth/main.py

@@ -9,7 +9,7 @@ from nicegui import app, ui
 # For local development, you should add http://localhost:8080 to the authorized JavaScript origins.
 # In production, you should add the domain of your website to the authorized JavaScript origins.
 # See https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id.
-GOOGLE_CLIENT_ID = '484798726913-t4es9ner8aglom3miptbnq1m23dsqagi.apps.googleusercontent.com'
+GOOGLE_CLIENT_ID = '...'
 
 
 @ui.page('/')

+ 2 - 0
nicegui/client.py

@@ -56,6 +56,7 @@ class Client:
 
         self.elements: Dict[int, Element] = {}
         self.next_element_id: int = 0
+        self._waiting_for_connection: asyncio.Event = asyncio.Event()
         self.is_waiting_for_connection: bool = False
         self.is_waiting_for_disconnect: bool = False
         self.environ: Optional[Dict[str, Any]] = None
@@ -177,6 +178,7 @@ class Client:
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
         """Block execution until the client is connected."""
         self.is_waiting_for_connection = True
+        self._waiting_for_connection.set()
         deadline = time.time() + timeout
         while not self.has_socket_connection:
             if time.time() > deadline:

+ 2 - 2
nicegui/elements/json_editor.js

@@ -4,10 +4,10 @@ export default {
   template: "<div></div>",
   mounted() {
     this.properties.onChange = (updatedContent, previousContent, { contentErrors, patchResult }) => {
-      this.$emit("change", { content: updatedContent, errors: contentErrors });
+      this.$emit("content_change", { content: updatedContent, errors: contentErrors });
     };
     this.properties.onSelect = (selection) => {
-      this.$emit("select", { selection: selection });
+      this.$emit("content_select", { selection: selection });
     };
 
     this.checkValidation();

+ 2 - 2
nicegui/elements/json_editor.py

@@ -48,14 +48,14 @@ class JsonEditor(Element, component='json_editor.js', dependencies=['lib/vanilla
         """Add a callback to be invoked when the content changes."""
         def handle_on_change(e: GenericEventArguments) -> None:
             handle_event(callback, JsonEditorChangeEventArguments(sender=self, client=self.client, **e.args))
-        self.on('change', handle_on_change, ['content', 'errors'])
+        self.on('content_change', handle_on_change, ['content', 'errors'])
         return self
 
     def on_select(self, callback: Handler[JsonEditorSelectEventArguments]) -> Self:
         """Add a callback to be invoked when some of the content has been selected."""
         def handle_on_select(e: GenericEventArguments) -> None:
             handle_event(callback, JsonEditorSelectEventArguments(sender=self, client=self.client, **e.args))
-        self.on('select', handle_on_select, ['selection'])
+        self.on('content_select', handle_on_select, ['selection'])
         return self
 
     @property

+ 13 - 0
nicegui/elements/notification.py

@@ -177,6 +177,19 @@ class Notification(Element, component='notification.js'):
         self._props['options']['spinner'] = value
         self.update()
 
+    @property
+    def timeout(self) -> float:
+        """Timeout of the notification in seconds.
+
+        *Added in version 2.13.0*
+        """
+        return self._props['options']['timeout'] / 1000
+
+    @timeout.setter
+    def timeout(self, value: Optional[float]) -> None:
+        self._props['options']['timeout'] = (value or 0) * 1000
+        self.update()
+
     @property
     def close_button(self) -> Union[bool, str]:
         """Whether the notification has a close button."""

+ 7 - 6
nicegui/page.py

@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import asyncio
 import inspect
-import time
 from functools import wraps
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Callable, Optional, Union
@@ -124,11 +123,13 @@ class page:
                     with client:
                         return await result
                 task = background_tasks.create(wait_for_result())
-                deadline = time.time() + self.response_timeout
-                while task and not client.is_waiting_for_connection and not task.done():
-                    if time.time() > deadline:
-                        raise TimeoutError(f'Response not ready after {self.response_timeout} seconds')
-                    await asyncio.sleep(0.1)
+                try:
+                    await asyncio.wait([
+                        task,
+                        asyncio.create_task(client._waiting_for_connection.wait()),  # pylint: disable=protected-access
+                    ], timeout=self.response_timeout, return_when=asyncio.FIRST_COMPLETED)
+                except asyncio.TimeoutError as e:
+                    raise TimeoutError(f'Response not ready after {self.response_timeout} seconds') from e
                 if task.done():
                     result = task.result()
                 else:

+ 9 - 10
tests/test_dialog.py

@@ -1,5 +1,3 @@
-from typing import List
-
 from selenium.webdriver.common.keys import Keys
 
 from nicegui import ui
@@ -24,22 +22,23 @@ def test_open_close_dialog(screen: Screen):
 
 def test_await_dialog(screen: Screen):
     with ui.dialog() as dialog, ui.card():
-        ui.label('Are you sure?')
-        with ui.row():
-            ui.button('Yes', on_click=lambda: dialog.submit('Yes'))
-            ui.button('No', on_click=lambda: dialog.submit('No'))
+        ui.button('Yes', on_click=lambda: dialog.submit('Yes'))
+        ui.button('No', on_click=lambda: dialog.submit('No'))
 
     async def show() -> None:
-        results.append(await dialog)
-    results: List[str] = []
+        ui.notify(f'Result: {await dialog}')
+
     ui.button('Open', on_click=show)
 
     screen.open('/')
     screen.click('Open')
     screen.click('Yes')
+    screen.should_contain('Result: Yes')
+
     screen.click('Open')
     screen.click('No')
+    screen.should_contain('Result: No')
+
     screen.click('Open')
     screen.type(Keys.ESCAPE)
-    screen.wait(0.5)
-    assert results == ['Yes', 'No', None]
+    screen.should_contain('Result: None')

+ 4 - 1
tests/test_teleport.py

@@ -47,7 +47,9 @@ def test_update(screen: Screen):
     def rebuild_card():
         card.delete()
         ui.card().classes('card')
-        teleport.update()  # type: ignore
+        assert teleport is not None
+        teleport.update()
+        ui.notify('Card rebuilt')
 
     ui.button('rebuild card', on_click=rebuild_card)
 
@@ -55,4 +57,5 @@ def test_update(screen: Screen):
     screen.click('create')
     screen.should_contain('Hello')
     screen.click('rebuild card')
+    screen.should_contain('Card rebuilt')
     assert screen.find_by_css('.card > div').text == 'Hello'

+ 3 - 1
website/examples.py

@@ -13,7 +13,7 @@ class Example:
 
     def __post_init__(self) -> None:
         """Post-initialization hook."""
-        name = self.title.lower().replace(' ', '_')
+        name = self.title.lower().replace(' ', '_').replace('-', '_')
         content = [p for p in (PATH / name).glob('*') if not p.name.startswith(('__pycache__', '.', 'test_'))]
         filename = 'main.py' if len(content) == 1 else ''
         self.url = f'https://github.com/zauberzeug/nicegui/tree/main/examples/{name}/{filename}'
@@ -71,4 +71,6 @@ examples: List[Example] = [
     Example('Signature Pad', 'A custom element based on [signature_pad](https://www.npmjs.com/package/signature_pad'),
     Example('OpenAI Assistant', "Using OpenAI's Assistant API with async/await"),
     Example('Redis Storage', 'Use Redis storage to share data across multiple instances behind a reverse proxy or load balancer'),
+    Example('Google One-Tap Auth', 'Authenticate users via Google One-Tap'),
+    Example('Google OAuth2', 'Authenticate with Google OAuth2')
 ]