Repository: zauberzeug/nicegui (python)

Issue:
404 error page not rendered on static file routes
### Description

As noticed by @Anindya088 (#2568), the 404 error page isn't rendered correctly when accessed via a (sub-)path of a static route. Take this code for example:

```py
app.add_static_files('/static', '.')
ui.link('Static', '/static')
```

When visiting `/static` or `/static/subpage`, the HTML page is displayed, but without any CSS and JS assets. They are requested from a wrong path and, thus, not used for rendering the page.

Pull request description:
implement static files serving without app.mount
This pull request fixes #2570 by not using `app.mount` to serve those files. See https://github.com/zauberzeug/nicegui/issues/2570#issuecomment-2014345947 for details of why I think this is the best solution.

Patch:
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index ae0db92e..403b91b2 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -153,7 +153,12 @@ class App(FastAPI):
         """
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
-        self.mount(url_path, StaticFiles(directory=str(local_directory), follow_symlink=follow_symlink))
+
+        handler = StaticFiles(directory=local_directory, follow_symlink=follow_symlink)
+
+        @self.get(url_path + '/{path:path}')
+        async def static_file(request: Request, path: str = '') -> Response:
+            return await handler.get_response(path, request.scope)
 
     def add_static_file(self, *,
                         local_file: Union[str, Path],
diff --git a/website/documentation/demo.py b/website/documentation/demo.py
index 7986b69c..bdd6ed73 100644
--- a/website/documentation/demo.py
+++ b/website/documentation/demo.py
@@ -4,7 +4,7 @@ from typing import Callable, Optional, Union
 
 import isort
 
-from nicegui import helpers, ui
+from nicegui import helpers, json, ui
 
 from .intersection_observer import IntersectionObserver as intersection_observer
 from .windows import browser_window, python_window
@@ -40,7 +40,7 @@ def demo(f: Callable, *, lazy: bool = True, tab: Optional[Union[str, Callable]]
             ui.markdown(f'````python\n{full_code}\n````')
             ui.icon('content_copy', size='xs') \
                 .classes('absolute right-2 top-10 opacity-10 hover:opacity-80 cursor-pointer') \
-                .on('click', js_handler=f'() => navigator.clipboard.writeText(`{full_code}`)') \
+                .on('click', js_handler=f'() => navigator.clipboard.writeText({json.dumps(full_code)})') \
                 .on('click', lambda: ui.notify('Copied to clipboard', type='positive', color='primary'), [])
         with browser_window(title=tab,
                             classes='w-full max-w-[44rem] min-[1500px]:max-w-[20rem] min-h-[10rem] browser-window') as window:


Test patch:
diff --git a/tests/test_serving_files.py b/tests/test_serving_files.py
index 1ddad77b..aebd0943 100644
--- a/tests/test_serving_files.py
+++ b/tests/test_serving_files.py
@@ -1,4 +1,5 @@
 
+import re
 from pathlib import Path
 
 import httpx
@@ -30,13 +31,12 @@ def assert_video_file_streaming(path: str) -> None:
         r = http_client.get(
             path if 'http' in path else f'http://localhost:{Screen.PORT}{path}',
             headers={'Range': 'bytes=0-1000'},
-            timeout=1,
         )
-    assert r.status_code == 206
-    assert r.headers['Accept-Ranges'] == 'bytes'
-    assert r.headers['Content-Range'].startswith('bytes 0-1000/')
-    assert r.headers['Content-Length'] == '1001'
-    assert r.headers['Content-Type'] == 'video/mp4'
+        assert r.status_code == 206
+        assert r.headers['Accept-Ranges'] == 'bytes'
+        assert r.headers['Content-Range'].startswith('bytes 0-1000/')
+        assert r.headers['Content-Length'] == '1001'
+        assert r.headers['Content-Type'] == 'video/mp4'
 
 
 def test_media_files_can_be_streamed(screen: Screen):
@@ -53,6 +53,27 @@ def test_adding_single_media_file(screen: Screen):
     assert_video_file_streaming(url_path)
 
 
+@pytest.mark.parametrize('url_path', ['/static', '/static/'])
+def test_get_from_static_files_dir(url_path: str, screen: Screen):
+    app.add_static_files(url_path, Path(TEST_DIR).parent)
+
+    screen.open('/')
+    with httpx.Client() as http_client:
+        r = http_client.get(f'http://localhost:{Screen.PORT}/static/examples/slideshow/slides/slide1.jpg')
+        assert r.status_code == 200
+
+
+def test_404_for_non_existing_static_file(screen: Screen):
+    app.add_static_files('/static', Path(TEST_DIR))
+
+    screen.open('/')
+    with httpx.Client() as http_client:
+        r = http_client.get(f'http://localhost:{Screen.PORT}/static/does_not_exist.jpg')
+        screen.assert_py_logger('WARNING', re.compile('.*does_not_exist.jpg not found'))
+        assert r.status_code == 404
+        assert 'static/_nicegui' not in r.text, 'should use root_path, see https://github.com/zauberzeug/nicegui/issues/2570'
+
+
 def test_adding_single_static_file(screen: Screen):
     url_path = app.add_static_file(local_file=IMAGE_FILE)
 
@@ -81,13 +102,8 @@ def test_auto_serving_file_from_video_source(screen: Screen):
 
     screen.open('/')
     video = screen.find_by_tag('video')
-    src = video.get_attribute('src')
-    assert src is not None
-    ic()
-    assert '/_nicegui/auto/media/' in src
-    ic()
-    assert_video_file_streaming(src)
-    ic()
+    assert '/_nicegui/auto/media/' in video.get_attribute('src')
+    assert_video_file_streaming(video.get_attribute('src'))
 
 
 def test_mimetypes_of_static_files(screen: Screen):