Repository: zauberzeug/nicegui (python)

Issue:
ui.image() source does not auto-create route to local image when using binding
### Description

```py
a = { 'a': '/home/kon/Pictures/DSC06478.JPG'}

ui.image().bind_source_from(a, 'a')
```

Will throw error:
```
http://127.0.0.1:8080/home/kon/Pictures/DSC06478.JPG not found
```

Same is true if source is set with `img1.source = some_local_path`.

Pull request description:
allow binding source with local file paths
This PR fixes #1853 by checking weather to create a route to local file whenever the image source changes.

Patch:
diff --git a/nicegui/elements/mixins/source_element.py b/nicegui/elements/mixins/source_element.py
index 9ea0499a..c00099ba 100644
--- a/nicegui/elements/mixins/source_element.py
+++ b/nicegui/elements/mixins/source_element.py
@@ -1,5 +1,5 @@
 from pathlib import Path
-from typing import Any, Callable, Union
+from typing import Any, Callable, Optional, Union
 
 from typing_extensions import Self, cast
 
@@ -15,10 +15,9 @@ class SourceElement(Element):
 
     def __init__(self, *, source: Union[str, Path], **kwargs: Any) -> None:
         super().__init__(**kwargs)
-        if is_file(source):
-            source = core.app.add_static_file(local_file=source)
+        self.auto_route: Optional[str] = None
         self.source = source
-        self._props['src'] = source
+        self._set_props(source)
 
     def bind_source_to(self,
                        target_object: Any,
@@ -82,5 +81,18 @@ class SourceElement(Element):
 
         :param source: The new source.
         """
-        self._props['src'] = source
+        self._set_props(source)
         self.update()
+
+    def _set_props(self, source: Union[str, Path]) -> None:
+        if is_file(source):
+            if self.auto_route:
+                core.app.remove_route(self.auto_route)
+            source = core.app.add_static_file(local_file=source)
+            self.auto_route = source
+        self._props['src'] = source
+
+    def _handle_delete(self) -> None:
+        if self.auto_route:
+            core.app.remove_route(self.auto_route)
+        return super()._handle_delete()


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 57b922bf..7261e4ea 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -44,6 +44,9 @@ def capabilities(capabilities: Dict) -> Dict:
 
 @pytest.fixture(autouse=True)
 def reset_globals() -> Generator[None, None, None]:
+    for route in app.routes:
+        if route.path.startswith('/_nicegui/auto/static/'):
+            app.remove_route(route.path)
     for path in {'/'}.union(Client.page_routes.values()):
         app.remove_route(path)
     app.openapi_schema = None
@@ -58,6 +61,7 @@ def reset_globals() -> Generator[None, None, None]:
     Client.page_routes.clear()
     Client.auto_index_client = Client(page('/'), shared=True).__enter__()
     app.reset()
+    # NOTE we need to re-add the auto index route because we removed all routes above
     app.get('/')(Client.auto_index_client.build_response)
     binding.reset()
 
diff --git a/tests/screen.py b/tests/screen.py
index e2b63331..7f3e47f8 100644
--- a/tests/screen.py
+++ b/tests/screen.py
@@ -113,6 +113,14 @@ class Screen:
             self.wait(0.1)
         raise AssertionError(f'Could not find input with value "{text}"')
 
+    def should_load_image(self, image: WebElement, *, timeout: float = 2.0) -> None:
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            js = 'return arguments[0].naturalWidth > 0 && arguments[0].naturalHeight > 0'
+            if self.selenium.execute_script(js, image):
+                return
+        raise AssertionError(f'Image not loaded: {image.get_attribute("outerHTML")}')
+
     def click(self, target_text: str) -> WebElement:
         element = self.find(target_text)
         try:
diff --git a/tests/test_image.py b/tests/test_image.py
index d26e15e6..e27830bb 100644
--- a/tests/test_image.py
+++ b/tests/test_image.py
@@ -1,7 +1,11 @@
-from nicegui import ui
+from pathlib import Path
+
+from nicegui import app, ui
 
 from .screen import Screen
 
+example_file = Path(__file__).parent / '../examples/slideshow/slides/slide1.jpg'
+
 
 def test_base64_image(screen: Screen):
     data = ('data:image/png;base64,'
@@ -30,3 +34,45 @@ def test_base64_image(screen: Screen):
     screen.wait(0.2)
     image = screen.find_by_class('q-img__image')
     assert 'data:image/png;base64,iVB' in image.get_attribute('src')
+
+
+def test_setting_local_file(screen: Screen):
+    ui.image(example_file)
+
+    screen.open('/')
+    image = screen.find_by_class('q-img__image')
+    screen.should_load_image(image)
+
+
+def test_binding_local_file(screen: Screen):
+    images = {'one': example_file}
+    ui.image().bind_source_from(images, 'one')
+
+    screen.open('/')
+    image = screen.find_by_class('q-img__image')
+    screen.should_load_image(image)
+
+
+def test_set_source_with_local_file(screen: Screen):
+    ui.image().set_source(example_file)
+
+    screen.open('/')
+    image = screen.find_by_class('q-img__image')
+    screen.should_load_image(image)
+
+
+def test_removal_of_generated_routes(screen: Screen):
+    img = ui.image(example_file)
+    ui.button('Slide 2', on_click=lambda: img.set_source(str(example_file).replace('slide1', 'slide2')))
+    ui.button('Slide 3', on_click=lambda: img.set_source(str(example_file).replace('slide1', 'slide3')))
+
+    screen.open('/')
+    number_of_routes = len(app.routes)
+
+    screen.click('Slide 2')
+    screen.wait(0.5)
+    assert len(app.routes) == number_of_routes
+
+    screen.click('Slide 3')
+    screen.wait(0.5)
+    assert len(app.routes) == number_of_routes