diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aefac4ea6..5dada4199 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,7 @@ jobs: poetry install # install packages to run the examples pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy + pip install -r tests/requirements.txt # try fix issue with importlib_resources pip install importlib-resources - name: test startup diff --git a/nicegui/favicon.py b/nicegui/favicon.py index 872d434c6..afe7e14f0 100644 --- a/nicegui/favicon.py +++ b/nicegui/favicon.py @@ -1,8 +1,10 @@ +import base64 +import io import urllib.parse from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Tuple -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response, StreamingResponse from . import __version__, globals @@ -11,32 +13,41 @@ def create_favicon_route(path: str, favicon: Optional[str]) -> None: - if favicon and (is_remote_url(favicon) or is_char(favicon)): - return - fallback = Path(__file__).parent / 'static' / 'favicon.ico' - path = f'{"" if path == "/" else path}/favicon.ico' - globals.app.remove_route(path) - globals.app.add_route(path, lambda _: FileResponse(favicon or globals.favicon or fallback)) + if favicon and Path(favicon).exists(): + globals.app.add_route(f'{path}/favicon.ico', lambda _: FileResponse(favicon)) def get_favicon_url(page: 'page', prefix: str) -> str: favicon = page.favicon or globals.favicon - if favicon and is_remote_url(favicon): - return favicon - elif not favicon: + if not favicon: return f'{prefix}/_nicegui/{__version__}/static/favicon.ico' + favicon = str(favicon) + if is_remote_url(favicon): + return favicon elif is_data_url(favicon): return favicon elif is_svg(favicon): return svg_to_data_url(favicon) elif is_char(favicon): - return char_to_data_url(favicon) + return svg_to_data_url(char_to_svg(favicon)) elif page.path == '/': return f'{prefix}/favicon.ico' else: return f'{prefix}{page.path}/favicon.ico' +def get_favicon_response() -> Response: + if is_svg(globals.favicon): + return Response(globals.favicon, media_type='image/svg+xml') + elif is_data_url(globals.favicon): + media_type, bytes = data_url_to_bytes(globals.favicon) + return StreamingResponse(io.BytesIO(bytes), media_type=media_type) + elif is_char(globals.favicon): + return Response(char_to_svg(globals.favicon), media_type='image/svg+xml') + else: + raise ValueError(f'invalid favicon: {globals.favicon}') + + def is_remote_url(favicon: str) -> bool: return favicon.startswith('http://') or favicon.startswith('https://') @@ -53,8 +64,8 @@ def is_data_url(favicon: str) -> bool: return favicon.startswith('data:') -def char_to_data_url(char: str) -> str: - svg = f''' +def char_to_svg(char: str) -> str: + return f''' ''' - return svg_to_data_url(svg) def svg_to_data_url(svg: str) -> str: svg_urlencoded = urllib.parse.quote(svg) return f'data:image/svg+xml,{svg_urlencoded}' + + +def data_url_to_bytes(data_url: str) -> Tuple[str, bytes]: + media_type, base64_image = data_url.split(",", 1) + media_type = media_type.split(":")[1].split(";")[0] + return media_type, base64.b64decode(base64_image) diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py index 0929e426c..341f4f614 100644 --- a/nicegui/nicegui.py +++ b/nicegui/nicegui.py @@ -15,7 +15,7 @@ from nicegui import json from nicegui.json import NiceGUIJSONResponse -from . import __version__, background_tasks, binding, globals, outbox +from . import __version__, background_tasks, binding, favicon, globals, outbox from .app import App from .client import Client from .dependencies import js_components, js_dependencies @@ -66,6 +66,13 @@ def handle_startup(with_welcome_message: bool = True) -> None: 'remove the guard or replace it with\n' ' if __name__ in {"__main__", "__mp_main__"}:\n' 'to allow for multiprocessing.') + if globals.favicon: + if Path(globals.favicon).exists(): + globals.app.add_route('/favicon.ico', lambda _: FileResponse(globals.favicon)) + else: + globals.app.add_route('/favicon.ico', lambda _: favicon.get_favicon_response()) + else: + globals.app.add_route('/favicon.ico', lambda _: FileResponse(Path(__file__).parent / 'static' / 'favicon.ico')) globals.state = globals.State.STARTING globals.loop = asyncio.get_running_loop() with globals.index_client: diff --git a/tests/conftest.py b/tests/conftest.py index 78495d175..8e1064b98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,6 +39,8 @@ def selenium(selenium: webdriver.Chrome) -> webdriver.Chrome: def reset_globals() -> Generator[None, None, None]: for path in {'/'}.union(globals.page_routes.values()): globals.app.remove_route(path) + # NOTE favicon routes must be removed seperately because they are not "pages" + [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')] importlib.reload(globals) globals.index_client = Client(page('/'), shared=True).__enter__() globals.app.get('/')(globals.index_client.build_response) diff --git a/tests/requirements.txt b/tests/requirements.txt index 7329269dc..953685b38 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -3,4 +3,5 @@ pytest-selenium pytest-asyncio selenium autopep8 -icecream \ No newline at end of file +icecream +beautifulsoup4 diff --git a/tests/screen.py b/tests/screen.py index c795f5288..34db98ce0 100644 --- a/tests/screen.py +++ b/tests/screen.py @@ -21,16 +21,16 @@ class Screen: IMPLICIT_WAIT = 4 SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'screenshots') - UI_RUN_KWARGS = {'port': PORT, 'show': False, 'reload': False} def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None: self.selenium = selenium self.caplog = caplog self.server_thread = None + self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False} def start_server(self) -> None: '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.''' - self.server_thread = threading.Thread(target=ui.run, kwargs=self.UI_RUN_KWARGS) + self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs) self.server_thread.start() @property diff --git a/tests/test_favicon.py b/tests/test_favicon.py new file mode 100644 index 000000000..6eb7c7170 --- /dev/null +++ b/tests/test_favicon.py @@ -0,0 +1,92 @@ +from pathlib import Path +from typing import Union + +import requests +from bs4 import BeautifulSoup + +from nicegui import favicon, ui + +from .screen import PORT, Screen + +DEFAULT_FAVICON_PATH = Path(__file__).parent.parent / 'nicegui' / 'static' / 'favicon.ico' +LOGO_FAVICON_PATH = Path(__file__).parent.parent / 'website' / 'static' / 'logo_square.png' + + +def assert_favicon_url_starts_with(screen: Screen, content: str): + soup = BeautifulSoup(screen.selenium.page_source, 'html.parser') + icon_link = soup.find("link", rel="icon") + assert icon_link['href'].startswith(content) + + +def assert_favicon(content: Union[Path, str, bytes], url_path: str = '/favicon.ico'): + response = requests.get(f'http://localhost:{PORT}{url_path}') + assert response.status_code == 200 + if isinstance(content, Path): + assert content.read_bytes() == response.content + elif isinstance(content, str): + assert content == response.text + elif isinstance(content, bytes): + assert content == response.content + else: + raise TypeError(f'Unexpected type: {type(content)}') + + +def test_default(screen: Screen): + ui.label('Hello, world') + + screen.open('/') + assert_favicon(DEFAULT_FAVICON_PATH) + + +def test_emoji(screen: Screen): + ui.label('Hello, world') + + screen.ui_run_kwargs['favicon'] = '👋' + screen.open('/') + assert_favicon_url_starts_with(screen, 'data:image/svg+xml') + assert_favicon(favicon.char_to_svg('👋')) + + +def test_data_url(screen: Screen): + ui.label('Hello, world') + + icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' + screen.ui_run_kwargs['favicon'] = icon + screen.open('/') + assert_favicon_url_starts_with(screen, 'data:image/png;base64') + _, bytes = favicon.data_url_to_bytes(icon) + assert_favicon(bytes) + + +def test_custom_file(screen: Screen): + ui.label('Hello, world') + + screen.ui_run_kwargs['favicon'] = LOGO_FAVICON_PATH + screen.open('/') + assert_favicon_url_starts_with(screen, '/favicon.ico') + assert_favicon(screen.ui_run_kwargs['favicon']) + + +def test_page_specific_icon(screen: Screen): + @ui.page('/subpage', favicon=LOGO_FAVICON_PATH) + def sub(): + ui.label('Subpage') + + ui.label('Main') + + screen.open('/subpage') + assert_favicon(LOGO_FAVICON_PATH, url_path='/subpage/favicon.ico') + screen.open('/') + + +def test_page_specific_emoji(screen: Screen): + @ui.page('/subpage', favicon='👋') + def sub(): + ui.label('Subpage') + + ui.label('Main') + + screen.open('/subpage') + assert_favicon_url_starts_with(screen, 'data:image/svg+xml') + screen.open('/') + assert_favicon(DEFAULT_FAVICON_PATH)