Repository: zauberzeug/nicegui
Pull request #4564 (2025-04-04 08:50:39)

Introduce more explicit download functions
This PR tries to solve issue #4104 by introducing more explicit download functions
- `ui.download.file`,
- `ui.download.from_url` and
- `ui.download.content`.

I thought about deprecating the original `ui.download()` call, but I'm unsure. Maybe we leave it there for developers who prefer the shorter more generic API.

evnchn: Seems like the goal of NiceGUI is to make it reasonably work no matter what the user pass in the input. 

Thus, would recommend keeping the original `ui.download` function. 

Perhaps to clarify the logic, `ui.download` can call one of the three newly defined sub-functions, such that it is apparent what it does when inspecting the source code. 



Patch:
diff --git a/nicegui/air.py b/nicegui/air.py
index 157b606d..5917e19f 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -12,7 +12,7 @@ from uuid import uuid4
 import socketio
 import socketio.exceptions
 
-from . import background_tasks, core
+from . import background_tasks, core, helpers
 from .client import Client
 from .dataclasses import KWONLY_SLOTS
 from .elements.timer import Timer as timer
@@ -43,6 +43,7 @@ class Air:
         self.connecting = False
         self.streams: Dict[str, Stream] = {}
         self.remote_url: Optional[str] = None
+        self._host_unreachable_warning = f'On Air host "{RELAY_HOST}" is not reachable. Please check your internet connection.'
 
         timer(5, self.connect)  # ensure we stay connected
 
@@ -164,6 +165,8 @@ class Air:
         @self.relay.on('connect')
         async def _handle_connect() -> None:
             self.log.debug('connected.')
+            # NOTE: reset the warning so it can be shown again if connection breaks in the future
+            helpers._shown_warnings.discard(self._host_unreachable_warning)  # pylint: disable=protected-access
 
         @self.relay.on('disconnect')
         async def _handle_disconnect() -> None:
@@ -172,7 +175,10 @@ class Air:
         @self.relay.on('connect_error')
         async def _handle_connect_error(data) -> None:
             message = data.get('message', 'Unknown error') if isinstance(data, dict) else data
-            self.log.warning(f'Connection error: {message}')
+            if message == 'Connection error':
+                helpers.warn_once(self._host_unreachable_warning)
+            else:
+                self.log.warning(f'Connection error: {message}')
 
         @self.relay.on('event')
         def _handle_event(data: Dict[str, Any]) -> None:
diff --git a/nicegui/elements/echart.js b/nicegui/elements/echart.js
index 9ab0e95d..c50766c0 100644
--- a/nicegui/elements/echart.js
+++ b/nicegui/elements/echart.js
@@ -54,13 +54,18 @@ export default {
       this.chart.on(event, (e) => this.$emit(`chart:${event}`, e));
     }
 
-    // Prevent interruption of chart animations due to resize operations.
-    // It is recommended to register the callbacks for such an event before setOption.
-    const createResizeObserver = () => {
-      new ResizeObserver(this.chart.resize).observe(this.$el);
-      this.chart.off("finished", createResizeObserver);
-    };
-    this.chart.on("finished", createResizeObserver);
+    let initialResizeTriggered = false;
+    const initialWidth = this.$el.offsetWidth;
+    const initialHeight = this.$el.offsetHeight;
+    new ResizeObserver(() => {
+      if (!initialResizeTriggered) {
+        initialResizeTriggered = true;
+        if (this.$el.offsetWidth === initialWidth && this.$el.offsetHeight === initialHeight) {
+          return;
+        }
+      }
+      this.chart.resize();
+    }).observe(this.$el);
 
     this.update_chart();
   },
diff --git a/nicegui/functions/download.py b/nicegui/functions/download.py
index 1ad1ec10..e674bbd2 100644
--- a/nicegui/functions/download.py
+++ b/nicegui/functions/download.py
@@ -5,18 +5,72 @@ from .. import core, helpers
 from ..context import context
 
 
-def download(src: Union[str, Path, bytes], filename: Optional[str] = None, media_type: str = '') -> None:
-    """Download
+class Download:
+    """Download functions
 
-    Function to trigger the download of a file, URL or bytes.
+    These functions allow you to download files, URLs or raw data.
 
-    :param src: target URL, local path of a file or raw data which should be downloaded
-    :param filename: name of the file to download (default: name of the file on the server)
-    :param media_type: media type of the file to download (default: "")
+    *Added in version 2.x.0*
     """
-    if not isinstance(src, bytes):
-        if helpers.is_file(src):
-            src = core.app.add_static_file(local_file=src, single_use=True)
+
+    def __call__(self, src: Union[str, Path, bytes], filename: Optional[str] = None, media_type: str = '') -> None:
+        """Download
+
+        Function to trigger the download of a file, URL or bytes.
+
+        :param src: target URL, local path of a file or raw data which should be downloaded
+        :param filename: name of the file to download (default: name of the file on the server)
+        :param media_type: media type of the file to download (default: "")
+        """
+        if isinstance(src, bytes):
+            self.content(src, filename, media_type)
+        elif helpers.is_file(src):
+            self.file(src, filename, media_type)
         else:
-            src = str(src)
-    context.client.download(src, filename, media_type)
+            assert isinstance(src, str)
+            self.from_url(src, filename, media_type)
+
+    def file(self, path: Union[str, Path], filename: Optional[str] = None, media_type: str = '') -> None:
+        """Download file from local path
+
+        Function to trigger the download of a file.
+
+        *Added in version 2.x.0*
+
+        :param path: local path of the file
+        :param filename: name of the file to download (default: name of the file on the server)
+        :param media_type: media type of the file to download (default: "")
+        """
+        src = core.app.add_static_file(local_file=path, single_use=True)
+        context.client.download(src, filename, media_type)
+
+    def from_url(self, url: str, filename: Optional[str] = None, media_type: str = '') -> None:
+        """Download from a URL
+
+        Function to trigger the download from a URL.
+
+        *Added in version 2.x.0*
+
+        :param url: URL
+        :param filename: name of the file to download (default: name of the file on the server)
+        :param media_type: media type of the file to download (default: "")
+        """
+        context.client.download(url, filename, media_type)
+
+    def content(self, content: Union[bytes, str], filename: Optional[str] = None, media_type: str = '') -> None:
+        """Download raw bytes or string content
+
+        Function to trigger the download of raw data.
+
+        *Added in version 2.x.0*
+
+        :param content: raw bytes or string
+        :param filename: name of the file to download (default: name of the file on the server)
+        :param media_type: media type of the file to download (default: "")
+        """
+        if isinstance(content, str):
+            content = content.encode('utf-8')
+        context.client.download(content, filename, media_type)
+
+
+download = Download()
diff --git a/website/documentation/content/aggrid_documentation.py b/website/documentation/content/aggrid_documentation.py
index 28325677..abea7b0d 100644
--- a/website/documentation/content/aggrid_documentation.py
+++ b/website/documentation/content/aggrid_documentation.py
@@ -42,36 +42,39 @@ def main_demo() -> None:
     See the [AG Grid documentation](https://www.ag-grid.com/javascript-data-grid/row-selection/#example-single-row-selection) for more information.
 ''')
 def aggrid_with_selectable_rows():
-    grid = ui.aggrid({
-        'columnDefs': [
-            {'headerName': 'Name', 'field': 'name', 'checkboxSelection': True},
-            {'headerName': 'Age', 'field': 'age'},
-        ],
-        'rowData': [
-            {'name': 'Alice', 'age': 18},
-            {'name': 'Bob', 'age': 21},
-            {'name': 'Carol', 'age': 42},
-        ],
-        'rowSelection': 'multiple',
-    }).classes('max-h-40')
-
-    async def output_selected_rows():
-        rows = await grid.get_selected_rows()
-        if rows:
-            for row in rows:
+    # @ui.page('/')
+    def page():
+        grid = ui.aggrid({
+            'columnDefs': [
+                {'headerName': 'Name', 'field': 'name', 'checkboxSelection': True},
+                {'headerName': 'Age', 'field': 'age'},
+            ],
+            'rowData': [
+                {'name': 'Alice', 'age': 18},
+                {'name': 'Bob', 'age': 21},
+                {'name': 'Carol', 'age': 42},
+            ],
+            'rowSelection': 'multiple',
+        }).classes('max-h-40')
+
+        async def output_selected_rows():
+            rows = await grid.get_selected_rows()
+            if rows:
+                for row in rows:
+                    ui.notify(f"{row['name']}, {row['age']}")
+            else:
+                ui.notify('No rows selected.')
+
+        async def output_selected_row():
+            row = await grid.get_selected_row()
+            if row:
                 ui.notify(f"{row['name']}, {row['age']}")
-        else:
-            ui.notify('No rows selected.')
-
-    async def output_selected_row():
-        row = await grid.get_selected_row()
-        if row:
-            ui.notify(f"{row['name']}, {row['age']}")
-        else:
-            ui.notify('No row selected!')
-
-    ui.button('Output selected rows', on_click=output_selected_rows)
-    ui.button('Output selected row', on_click=output_selected_row)
+            else:
+                ui.notify('No row selected!')
+
+        ui.button('Output selected rows', on_click=output_selected_rows)
+        ui.button('Output selected row', on_click=output_selected_row)
+    page()  # HIDE
 
 
 @doc.demo('Filter Rows using Mini Filters', '''
diff --git a/website/documentation/content/download_documentation.py b/website/documentation/content/download_documentation.py
index bc973c4f..7ab396ef 100644
--- a/website/documentation/content/download_documentation.py
+++ b/website/documentation/content/download_documentation.py
@@ -5,11 +5,21 @@ from . import doc
 
 @doc.demo(ui.download)
 def main_demo() -> None:
-    ui.button('Logo', on_click=lambda: ui.download('https://nicegui.io/logo.png'))
+    ui.button('Local file', on_click=lambda: ui.download.file('main.py'))
+    ui.button('From URL', on_click=lambda: ui.download.from_url('https://nicegui.io/logo.png'))
+    ui.button('Content', on_click=lambda: ui.download.content('Hello World', 'hello.txt'))
 
 
-@doc.demo('Download raw bytes from memory', '''
-    The `download` function can also be used to download raw bytes from memory.
-''')
-def raw_bytes():
-    ui.button('Download', on_click=lambda: ui.download(b'Hello World', 'hello.txt'))
+@doc.demo(ui.download.from_url)
+def from_url_demo() -> None:
+    ui.button('Download', on_click=lambda: ui.download.from_url('https://nicegui.io/logo.png'))
+
+
+@doc.demo(ui.download.content)
+def content_demo() -> None:
+    ui.button('Download', on_click=lambda: ui.download.content('Hello World', 'hello.txt'))
+
+
+@doc.demo(ui.download.file)
+def file_demo() -> None:
+    ui.button('Download', on_click=lambda: ui.download.file('main.py'))
diff --git a/website/documentation/content/section_styling_appearance.py b/website/documentation/content/section_styling_appearance.py
index 1eeda669..de9fb244 100644
--- a/website/documentation/content/section_styling_appearance.py
+++ b/website/documentation/content/section_styling_appearance.py
@@ -75,8 +75,10 @@ def styling_demo():
                     with ui.row().classes('items-center gap-0 w-full'):
                         def handle_props(e: events.ValueChangeEventArguments):
                             element.props.clear()
-                            element.props['label'] = 'Button'
-                            element.props['color'] = 'primary'
+                            if isinstance(element, (ui.button, ui.input, ui.textarea)):
+                                element.props['label'] = 'element'
+                            if isinstance(element, ui.button):
+                                element.props['color'] = 'primary'
                             try:
                                 element.props(e.value)
                             except ValueError:


Test patch:
diff --git a/nicegui/testing/user_download.py b/nicegui/testing/user_download.py
index c020eb6f..21f05865 100644
--- a/nicegui/testing/user_download.py
+++ b/nicegui/testing/user_download.py
@@ -8,12 +8,13 @@ from typing import TYPE_CHECKING, Any, List, Optional, Union
 import httpx
 
 from .. import background_tasks
+from ..functions.download import Download
 
 if TYPE_CHECKING:
     from .user import User
 
 
-class UserDownload:
+class UserDownload(Download):
 
     def __init__(self, user: User) -> None:
         self.http_responses: List[httpx.Response] = []
@@ -22,6 +23,15 @@ class UserDownload:
     def __call__(self, src: Union[str, Path, bytes], filename: Optional[str] = None, media_type: str = '') -> Any:
         background_tasks.create(self._get(src))
 
+    def file(self, path: Union[str, Path], filename: Optional[str] = None, media_type: str = '') -> None:
+        self(path)
+
+    def from_url(self, url: str, filename: Optional[str] = None, media_type: str = '') -> None:
+        self(url)
+
+    def content(self, content: Union[bytes, str], filename: Optional[str] = None, media_type: str = '') -> None:
+        self(content)
+
     async def _get(self,  src: Union[str, Path, bytes]) -> None:
         if isinstance(src, bytes):
             await asyncio.sleep(0)
diff --git a/tests/test_download.py b/tests/test_download.py
index 4e584118..95291a85 100644
--- a/tests/test_download.py
+++ b/tests/test_download.py
@@ -17,33 +17,55 @@ def test_route() -> Generator[str, None, None]:
 
 def test_download_text_file(screen: Screen, test_route: str):  # pylint: disable=redefined-outer-name
     @app.get(test_route)
-    def test():
-        return PlainTextResponse('test')
+    def test(number: str):
+        return PlainTextResponse(f'test {number}')
 
-    ui.button('Download', on_click=lambda: ui.download(test_route))
+    ui.button('Download 1', on_click=lambda: ui.download(test_route + '?number=1', 'test1.txt'))
+    ui.button('Download 2', on_click=lambda: ui.download.from_url(test_route + '?number=2', 'test2.txt'))
 
     screen.open('/')
-    screen.click('Download')
+    screen.click('Download 1')
     screen.wait(0.5)
-    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text(encoding='utf-8') == 'test'
+    assert (screen_plugin.DOWNLOAD_DIR / 'test1.txt').read_text(encoding='utf-8') == 'test 1'
+
+    screen.click('Download 2')
+    screen.wait(0.5)
+    assert (screen_plugin.DOWNLOAD_DIR / 'test2.txt').read_text(encoding='utf-8') == 'test 2'
 
 
 def test_downloading_local_file_as_src(screen: Screen):
-    IMAGE_FILE = Path(__file__).parent.parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
-    ui.button('download', on_click=lambda: ui.download(IMAGE_FILE))
+    IMAGE_FILE1 = Path(__file__).parent.parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
+    IMAGE_FILE2 = Path(__file__).parent.parent / 'examples' / 'slideshow' / 'slides' / 'slide2.jpg'
+    ui.button('Download 1', on_click=lambda: ui.download(IMAGE_FILE1))
+    ui.button('Download 2', on_click=lambda: ui.download.file(IMAGE_FILE2))
 
     screen.open('/')
     route_count_before_download = len(app.routes)
-    screen.click('download')
+    screen.click('Download 1')
     screen.wait(0.5)
     assert (screen_plugin.DOWNLOAD_DIR / 'slide1.jpg').exists()
     assert len(app.routes) == route_count_before_download
 
+    screen.click('Download 2')
+    screen.wait(0.5)
+    assert (screen_plugin.DOWNLOAD_DIR / 'slide2.jpg').exists()
+    assert len(app.routes) == route_count_before_download
+
 
 def test_download_raw_data(screen: Screen):
-    ui.button('download', on_click=lambda: ui.download(b'test', 'test.txt'))
+    ui.button('Download 1', on_click=lambda: ui.download(b'test 1', 'test1.txt'))
+    ui.button('Download 2', on_click=lambda: ui.download.content(b'test 2', 'test2.txt'))
+    ui.button('Download 3', on_click=lambda: ui.download.content('test 3', 'test3.txt'))
 
     screen.open('/')
-    screen.click('download')
+    screen.click('Download 1')
+    screen.wait(0.5)
+    assert (screen_plugin.DOWNLOAD_DIR / 'test1.txt').read_text(encoding='utf-8') == 'test 1'
+
+    screen.click('Download 2')
+    screen.wait(0.5)
+    assert (screen_plugin.DOWNLOAD_DIR / 'test2.txt').read_text(encoding='utf-8') == 'test 2'
+
+    screen.click('Download 3')
     screen.wait(0.5)
-    assert (screen_plugin.DOWNLOAD_DIR / 'test.txt').read_text(encoding='utf-8') == 'test'
+    assert (screen_plugin.DOWNLOAD_DIR / 'test3.txt').read_text(encoding='utf-8') == 'test 3'
diff --git a/tests/test_user_simulation.py b/tests/test_user_simulation.py
index 3f3bc308..14a3f5c9 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -454,7 +454,10 @@ async def test_download_file(user: User, data: Union[str, bytes]) -> None:
 
     @ui.page('/')
     def page():
-        ui.button('Download', on_click=lambda: ui.download(data))
+        if isinstance(data, str):
+            ui.button('Download', on_click=lambda: ui.download.file(data))
+        else:
+            ui.button('Download', on_click=lambda: ui.download.content(data))
 
     await user.open('/')
     assert len(user.download.http_responses) == 0