Ver código fonte

Merge branch 'zauberzeug:main' into main

frankvp 1 ano atrás
pai
commit
4e0ade8b98

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.4.2
-date-released: '2023-11-06'
+version: v1.4.4
+date-released: '2023-12-04'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.10075791
+doi: 10.5281/zenodo.10256862

+ 2 - 1
nicegui/air.py

@@ -56,7 +56,8 @@ class Air:
         @self.relay.on('ready')
         def _handle_ready(data: Dict[str, Any]) -> None:
             core.app.urls.add(data['device_url'])
-            print(f'NiceGUI is on air at {data["device_url"]}', flush=True)
+            if core.app.config.show_welcome_message:
+                print(f'NiceGUI is on air at {data["device_url"]}', flush=True)
 
         @self.relay.on('error')
         def _handleerror(data: Dict[str, Any]) -> None:

+ 3 - 0
nicegui/app/app_config.py

@@ -34,6 +34,7 @@ class AppConfig:
     reconnect_timeout: float = field(init=False)
     tailwind: bool = field(init=False)
     prod_js: bool = field(init=False)
+    show_welcome_message: bool = field(init=False)
     _has_run_config: bool = False
 
     def add_run_config(self,
@@ -48,6 +49,7 @@ class AppConfig:
                        reconnect_timeout: float,
                        tailwind: bool,
                        prod_js: bool,
+                       show_welcome_message: bool,
                        ) -> None:
         """Add the run config to the app config."""
         self.reload = reload
@@ -60,6 +62,7 @@ class AppConfig:
         self.reconnect_timeout = reconnect_timeout
         self.tailwind = tailwind
         self.prod_js = prod_js
+        self.show_welcome_message = show_welcome_message
         self._has_run_config = True
 
     @property

+ 19 - 5
nicegui/client.py

@@ -12,9 +12,7 @@ from fastapi import Request
 from fastapi.responses import Response
 from fastapi.templating import Jinja2Templates
 
-from nicegui import json
-
-from . import background_tasks, binding, core, helpers, outbox
+from . import background_tasks, binding, core, helpers, json, outbox
 from .awaitable_response import AwaitableResponse
 from .dependencies import generate_resources
 from .element import Element
@@ -38,6 +36,12 @@ class Client:
     auto_index_client: Client
     """The client that is used to render the auto-index page."""
 
+    shared_head_html = ''
+    """HTML to be inserted in the <head> of every page template."""
+
+    shared_body_html = ''
+    """HTML to be inserted in the <body> of every page template."""
+
     def __init__(self, page: page, *, shared: bool = False) -> None:
         self.id = str(uuid.uuid4())
         self.created = time.time()
@@ -59,8 +63,8 @@ class Client:
 
         self.waiting_javascript_commands: Dict[str, Any] = {}
 
-        self.head_html = ''
-        self.body_html = ''
+        self._head_html = ''
+        self._body_html = ''
 
         self.page = page
 
@@ -84,6 +88,16 @@ class Client:
         """Return True if the client is connected, False otherwise."""
         return self.environ is not None
 
+    @property
+    def head_html(self) -> str:
+        """Return the HTML code to be inserted in the <head> of the page template."""
+        return self.shared_head_html + self._head_html
+
+    @property
+    def body_html(self) -> str:
+        """Return the HTML code to be inserted in the <body> of the page template."""
+        return self.shared_body_html + self._body_html
+
     def __enter__(self):
         self.content.__enter__()
         return self

+ 1 - 1
nicegui/elements/chat_message.py

@@ -8,7 +8,7 @@ from .html import Html
 class ChatMessage(Element):
 
     def __init__(self,
-                 text: Union[str, List[str]] = ..., *,
+                 text: Union[str, List[str]] = ..., *,  # type: ignore
                  name: Optional[str] = None,
                  label: Optional[str] = None,
                  stamp: Optional[str] = None,

+ 18 - 3
nicegui/elements/highchart.py

@@ -1,10 +1,25 @@
 from .. import optional_features
+from ..element import Element
+from ..logging import log
+from .markdown import Markdown
 
 try:
     from nicegui_highcharts import highchart
     optional_features.register('highcharts')
     __all__ = ['highchart']
 except ImportError:
-    class highchart:  # type: ignore
-        def __init__(self, *args, **kwargs) -> None:
-            raise NotImplementedError('Highcharts is not installed. Please run `pip install nicegui[highcharts]`.')
+    class highchart(Element):  # type: ignore
+        def __init__(self, *args, **kwargs) -> None:  # pylint: disable=unused-argument
+            """Highcharts chart
+
+            An element to create a chart using `Highcharts <https://www.highcharts.com/>`_.
+            Updates can be pushed to the chart by changing the `options` property.
+            After data has changed, call the `update` method to refresh the chart.
+
+            Due to Highcharts' restrictive license, this element is not part of the standard NiceGUI package.
+            It is maintained in a `separate repository <https://github.com/zauberzeug/nicegui-highcharts/>`_
+            and can be installed with `pip install nicegui[highcharts]`.
+            """
+            super().__init__()
+            Markdown('Highcharts is not installed. Please run `pip install nicegui[highcharts]`.')
+            log.warning('Highcharts is not installed. Please run "pip install nicegui[highcharts]".')

+ 11 - 6
nicegui/elements/image.py

@@ -4,15 +4,20 @@ import time
 from pathlib import Path
 from typing import Union
 
-from PIL.Image import Image as PIL_Image
-
+from .. import optional_features
 from .mixins.source_element import SourceElement
 
+try:
+    from PIL.Image import Image as PIL_Image
+    optional_features.register('pillow')
+except ImportError:
+    pass
+
 
 class Image(SourceElement, component='image.js'):
     PIL_CONVERT_FORMAT = 'PNG'
 
-    def __init__(self, source: Union[str, Path, PIL_Image] = '') -> None:
+    def __init__(self, source: Union[str, Path, 'PIL_Image'] = '') -> None:
         """Image
 
         Displays an image.
@@ -22,8 +27,8 @@ class Image(SourceElement, component='image.js'):
         """
         super().__init__(source=source)
 
-    def _set_props(self, source: Union[str, Path]) -> None:
-        if isinstance(source, PIL_Image):
+    def _set_props(self, source: Union[str, Path, 'PIL_Image']) -> None:
+        if optional_features.has('pillow') and isinstance(source, PIL_Image):
             source = pil_to_base64(source, self.PIL_CONVERT_FORMAT)
         super()._set_props(source)
 
@@ -33,7 +38,7 @@ class Image(SourceElement, component='image.js'):
         self.update()
 
 
-def pil_to_base64(pil_image: PIL_Image, image_format: str) -> str:
+def pil_to_base64(pil_image: 'PIL_Image', image_format: str) -> str:
     """Convert a PIL image to a base64 string which can be used as image source.
 
     :param pil_image: the PIL image

+ 10 - 5
nicegui/elements/interactive_image.py

@@ -4,20 +4,25 @@ import time
 from pathlib import Path
 from typing import Any, Callable, List, Optional, Union, cast
 
-from PIL.Image import Image as PIL_Image
-
+from .. import optional_features
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
 from .image import pil_to_base64
 from .mixins.content_element import ContentElement
 from .mixins.source_element import SourceElement
 
+try:
+    from PIL.Image import Image as PIL_Image
+    optional_features.register('pillow')
+except ImportError:
+    pass
+
 
 class InteractiveImage(SourceElement, ContentElement, component='interactive_image.js'):
     CONTENT_PROP = 'content'
     PIL_CONVERT_FORMAT = 'PNG'
 
     def __init__(self,
-                 source: Union[str, Path] = '', *,
+                 source: Union[str, Path, 'PIL_Image'] = '', *,
                  content: str = '',
                  on_mouse: Optional[Callable[..., Any]] = None,
                  events: List[str] = ['click'],
@@ -61,8 +66,8 @@ class InteractiveImage(SourceElement, ContentElement, component='interactive_ima
             handle_event(on_mouse, arguments)
         self.on('mouse', handle_mouse)
 
-    def _set_props(self, source: Union[str, Path]) -> None:
-        if isinstance(source, PIL_Image):
+    def _set_props(self, source: Union[str, Path, 'PIL_Image']) -> None:
+        if optional_features.has('pillow') and isinstance(source, PIL_Image):
             source = pil_to_base64(source, self.PIL_CONVERT_FORMAT)
         super()._set_props(source)
 

+ 31 - 6
nicegui/functions/html.py

@@ -1,11 +1,36 @@
 from .. import context
+from ..client import Client
 
 
-def add_body_html(code: str) -> None:
-    """Add HTML code to the body of the page."""
-    context.get_client().body_html += code + '\n'
+def add_head_html(code: str, *, shared: bool = False) -> None:
+    """Add HTML code to the head of the page.
 
+    Note that this function can only be called before the page is sent to the client.
 
-def add_head_html(code: str) -> None:
-    """Add HTML code to the head of the page."""
-    context.get_client().head_html += code + '\n'
+    :param code: HTML code to add
+    :param shared: if True, the code is added to all pages
+    """
+    if shared:
+        Client.shared_head_html += code + '\n'
+    else:
+        client = context.get_client()
+        if client.has_socket_connection:
+            raise RuntimeError('Cannot add head HTML after the page has been sent to the client.')
+        client._head_html += code + '\n'  # pylint: disable=protected-access
+
+
+def add_body_html(code: str, *, shared: bool = False) -> None:
+    """Add HTML code to the body of the page.
+
+    Note that this function can only be called before the page is sent to the client.
+
+    :param code: HTML code to add
+    :param shared: if True, the code is added to all pages
+    """
+    if shared:
+        Client.shared_body_html += code + '\n'
+    else:
+        client = context.get_client()
+        if client.has_socket_connection:
+            raise RuntimeError('Cannot add body HTML after the page has been sent to the client.')
+        client._body_html += code + '\n'  # pylint: disable=protected-access

+ 2 - 2
nicegui/native/native_mode.py

@@ -21,7 +21,7 @@ try:
         # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
         warnings.filterwarnings('ignore', category=DeprecationWarning)
         import webview
-    optional_features.register('native')
+    optional_features.register('webview')
 except ModuleNotFoundError:
     pass
 
@@ -104,7 +104,7 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
             time.sleep(0.1)
         _thread.interrupt_main()
 
-    if not optional_features.has('native'):
+    if not optional_features.has('webview'):
         log.error('Native mode is not supported in this configuration.\n'
                   'Please run "pip install pywebview" to use it.')
         sys.exit(1)

+ 5 - 4
nicegui/nicegui.py

@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, Response
 from fastapi.staticfiles import StaticFiles
 from fastapi_socketio import SocketManager
 
-from . import air, background_tasks, binding, core, favicon, helpers, json, outbox, run
+from . import air, background_tasks, binding, core, favicon, helpers, json, outbox, run, welcome
 from .app import App
 from .client import Client
 from .dependencies import js_components, libraries
@@ -26,7 +26,7 @@ from .version import __version__
 
 @asynccontextmanager
 async def _lifespan(_: App):
-    _startup()
+    await _startup()
     yield
     await _shutdown()
 
@@ -76,9 +76,8 @@ def _get_component(key: str) -> FileResponse:
     raise HTTPException(status_code=404, detail=f'component "{key}" not found')
 
 
-def _startup() -> None:
+async def _startup() -> None:
     """Handle the startup event."""
-    # NOTE ping interval and timeout need to be lower than the reconnect timeout, but can't be too low
     if not app.config.has_run_config:
         raise RuntimeError('\n\n'
                            'You must call ui.run() to start the server.\n'
@@ -87,6 +86,8 @@ def _startup() -> None:
                            'remove the guard or replace it with\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            'to allow for multiprocessing.')
+    await welcome.collect_urls()
+    # NOTE ping interval and timeout need to be lower than the reconnect timeout, but can't be too low
     sio.eio.ping_interval = max(app.config.reconnect_timeout * 0.8, 4)
     sio.eio.ping_timeout = max(app.config.reconnect_timeout * 0.4, 2)
     if core.app.config.favicon:

+ 12 - 3
nicegui/optional_features.py

@@ -1,13 +1,22 @@
-from typing import Set
+from typing import Literal, Set
 
 _optional_features: Set[str] = set()
 
+FEATURE = Literal[
+    'highcharts',
+    'matplotlib',
+    'pandas',
+    'pillow',
+    'plotly',
+    'webview',
+]
 
-def register(feature: str) -> None:
+
+def register(feature: FEATURE) -> None:
     """Register an optional feature."""
     _optional_features.add(feature)
 
 
-def has(feature: str) -> bool:
+def has(feature: FEATURE) -> bool:
     """Check if an optional feature is registered."""
     return feature in _optional_features

+ 5 - 4
nicegui/ui_run.py

@@ -11,7 +11,6 @@ from uvicorn.supervisors import ChangeReload, Multiprocess
 
 from . import air, core, helpers
 from . import native as native_module
-from . import welcome
 from .client import Client
 from .language import Language
 from .logging import log
@@ -45,6 +44,7 @@ def run(*,
         prod_js: bool = True,
         endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none',
         storage_secret: Optional[str] = None,
+        show_welcome_message: bool = True,
         **kwargs: Any,
         ) -> None:
     """ui.run
@@ -76,6 +76,7 @@ def run(*,
     :param prod_js: whether to use the production version of Vue and Quasar dependencies (default: `True`)
     :param endpoint_documentation: control what endpoints appear in the autogenerated OpenAPI docs (default: 'none', options: 'none', 'internal', 'page', 'all')
     :param storage_secret: secret key for browser-based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
+    :param show_welcome_message: whether to show the welcome message (default: `True`)
     :param kwargs: additional keyword arguments are passed to `uvicorn.run`    
     """
     core.app.config.add_run_config(
@@ -89,6 +90,7 @@ def run(*,
         reconnect_timeout=reconnect_timeout,
         tailwind=tailwind,
         prod_js=prod_js,
+        show_welcome_message=show_welcome_message,
     )
     core.app.config.endpoint_documentation = endpoint_documentation
 
@@ -103,8 +105,6 @@ def run(*,
     if on_air:
         air.instance = air.Air('' if on_air is True else on_air)
 
-    core.app.on_startup(welcome.print_message)
-
     if multiprocessing.current_process().name != 'MainProcess':
         return
 
@@ -125,9 +125,10 @@ def run(*,
         width, height = window_size or (800, 600)
         native_module.activate(host, port, title, width, height, fullscreen, frameless)
     else:
-        port = port or 8080     
+        port = port or 8080
         host = host or '0.0.0.0'
     assert host is not None
+    assert port is not None
 
     # NOTE: We save host and port in environment variables so the subprocess started in reload mode can access them.
     os.environ['NICEGUI_HOST'] = host

+ 3 - 2
nicegui/ui_run_with.py

@@ -49,6 +49,7 @@ def run_with(
         reconnect_timeout=reconnect_timeout,
         tailwind=tailwind,
         prod_js=prod_js,
+        show_welcome_message=False,
     )
 
     storage.set_storage_secret(storage_secret)
@@ -58,9 +59,9 @@ def run_with(
 
     @asynccontextmanager
     async def lifespan_wrapper(app):
-        _startup()
+        await _startup()
         async with main_app_lifespan(app):
             yield
-        _shutdown()
+        await _shutdown()
 
     app.router.lifespan_context = lifespan_wrapper

+ 7 - 5
nicegui/welcome.py

@@ -13,15 +13,17 @@ def _get_all_ips() -> List[str]:
     return ips
 
 
-async def print_message() -> None:
+async def collect_urls() -> None:
     """Print a welcome message with URLs to access the NiceGUI app."""
-    print('NiceGUI ready to go ', end='', flush=True)
-    host = os.environ['NICEGUI_HOST']
-    port = os.environ['NICEGUI_PORT']
+    host = os.environ.get('NICEGUI_HOST')
+    port = os.environ.get('NICEGUI_PORT')
+    if not host or not port:
+        return
     ips = set((await run.io_bound(_get_all_ips)) if host == '0.0.0.0' else [])
     ips.discard('127.0.0.1')
     urls = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
     core.app.urls.update(urls)
     if len(urls) >= 2:
         urls[-1] = 'and ' + urls[-1]
-    print(f'on {", ".join(urls)}', flush=True)
+    if core.app.config.show_welcome_message:
+        print(f'NiceGUI ready to go on {", ".join(urls)}', flush=True)

+ 14 - 0
website/documentation/content/run_documentation.py

@@ -66,3 +66,17 @@ def svg_favicon():
     '''
 
     # ui.run(favicon=smiley)
+
+
+@doc.demo('Custom welcome message', '''
+    You can mute the default welcome message on the command line setting the `show_welcome_message` to `False`.
+    Instead you can print your own welcome message with a custom startup handler.
+''')
+def custom_welcome_message():
+    from nicegui import app
+
+    ui.label('App with custom welcome message')
+    #
+    # app.on_startup(lambda: print('Visit your app on one of these URLs:', app.urls))
+    #
+    # ui.run(show_welcome_message=False)

+ 2 - 2
website/documentation/rendering.py

@@ -44,9 +44,9 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                     description = part.description.replace('param ', '')
                     html = docutils.core.publish_parts(description, writer_name='html5_polyglot')['html_body']
                     html = apply_tailwind(html)
-                    ui.html(html)
+                    ui.html(html).classes('bold-links arrow-links')
                 else:
-                    ui.markdown(part.description)
+                    ui.markdown(part.description).classes('bold-links arrow-links')
             if part.ui:
                 part.ui()
             if part.demo: