Skip to content

Commit

Permalink
Merge pull request #963 from zauberzeug/improved-favicon-routing
Browse files Browse the repository at this point in the history
Fixed and improved favicon routing
  • Loading branch information
falkoschindler authored May 30, 2023
2 parents 56328c8 + 494c65c commit 1aac7c0
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 19 deletions.
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 31 additions & 15 deletions nicegui/favicon.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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://')

Expand All @@ -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'''
<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg" >
<style>
@supports (-moz-appearance:none) {{
Expand All @@ -70,9 +81,14 @@ def char_to_data_url(char: str) -> str:
<text y=".9em" font-size="128" font-family="Georgia, sans-serif">{char}</text>
</svg>
'''
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)
9 changes: 8 additions & 1 deletion nicegui/nicegui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion tests/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pytest-selenium
pytest-asyncio
selenium
autopep8
icecream
icecream
beautifulsoup4
4 changes: 2 additions & 2 deletions tests/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tests/test_favicon.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 1aac7c0

Please sign in to comment.