Skip to content

Commit

Permalink
Fix issue detecting PyScript worker (#6998)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Jul 24, 2024
1 parent 974216c commit 92c356c
Show file tree
Hide file tree
Showing 8 changed files with 114 additions and 27 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ jobs:
with:
environments: ${{ matrix.environment }}
id: install
- name: Build pyodide wheels
run: pixi run -e test-ui "python ./scripts/build_pyodide_wheels.py"
- name: Launch JupyterLab
shell: pixi run -e test-ui bash -el {0}
run: |
Expand Down
2 changes: 1 addition & 1 deletion panel/__version.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

# This will fail with LookupError if the package is not installed in
# editable mode or if Git is not installed.
__version__ = get_version(root="..", relative_to=__file__)
__version__ = get_version(root="..", relative_to=__file__, version_scheme="post-release")
else:
raise FileNotFoundError
except (ImportError, LookupError, FileNotFoundError):
Expand Down
33 changes: 26 additions & 7 deletions panel/io/convert.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import base64
import concurrent.futures
import dataclasses
import json
Expand Down Expand Up @@ -42,8 +43,9 @@
PY_VERSION = base_version(__version__)
PYODIDE_VERSION = 'v0.25.0'
PYSCRIPT_VERSION = '2024.2.1'
PANEL_LOCAL_WHL = DIST_DIR / 'wheels' / f'panel-{__version__.replace("-dirty", "")}-py3-none-any.whl'
BOKEH_LOCAL_WHL = DIST_DIR / 'wheels' / f'bokeh-{BOKEH_VERSION}-py3-none-any.whl'
WHL_PATH = DIST_DIR / 'wheels'
PANEL_LOCAL_WHL = WHL_PATH / f'panel-{__version__.replace("-dirty", "")}-py3-none-any.whl'
BOKEH_LOCAL_WHL = WHL_PATH / f'bokeh-{BOKEH_VERSION}-py3-none-any.whl'
PANEL_CDN_WHL = f'{CDN_DIST}wheels/panel-{PY_VERSION}-py3-none-any.whl'
BOKEH_CDN_WHL = f'{CDN_ROOT}wheels/bokeh-{BOKEH_VERSION}-py3-none-any.whl'
PYODIDE_URL = f'https://cdn.jsdelivr.net/pyodide/{PYODIDE_VERSION}/full/pyodide.js'
Expand All @@ -53,6 +55,7 @@
PYSCRIPT_JS = f'<script type="module" src="https://pyscript.net/releases/{PYSCRIPT_VERSION}/core.js"></script>'
PYODIDE_JS = f'<script src="{PYODIDE_URL}"></script>'
PYODIDE_PYC_JS = f'<script src="{PYODIDE_PYC_URL}"></script>'
LOCAL_PREFIX = './'

MINIMUM_VERSIONS = {}

Expand Down Expand Up @@ -178,6 +181,7 @@ def script_to_html(
runtime: Runtimes = 'pyodide',
prerender: bool = True,
panel_version: Literal['auto', 'local'] | str = 'auto',
local_prefix: str = LOCAL_PREFIX,
manifest: str | None = None,
http_patch: bool = True,
inline: bool = False,
Expand All @@ -203,6 +207,8 @@ def script_to_html(
Whether to pre-render the components so the page loads.
panel_version: 'auto' | str
The panel release version to use in the exported HTML.
local_prefix: str
Prefix for the path to serve local wheel files from.
http_patch: bool
Whether to patch the HTTP request stack with the pyodide-http library
to allow urllib3 and requests to work.
Expand Down Expand Up @@ -251,20 +257,22 @@ def script_to_html(

# Environment
if panel_version == 'local':
panel_req = './' + str(PANEL_LOCAL_WHL.as_posix()).split('/')[-1]
bokeh_req = './' + str(BOKEH_LOCAL_WHL.as_posix()).split('/')[-1]
panel_req = local_prefix + str(PANEL_LOCAL_WHL.as_posix()).split('/')[-1]
bokeh_req = local_prefix + str(BOKEH_LOCAL_WHL.as_posix()).split('/')[-1]
elif panel_version == 'auto':
panel_req = PANEL_CDN_WHL
bokeh_req = BOKEH_CDN_WHL
else:
panel_req = f'panel=={panel_version}'
bokeh_req = f'bokeh=={BOKEH_VERSION}'

base_reqs = [bokeh_req, panel_req]
if http_patch:
base_reqs.append('pyodide-http==0.2.1')
reqs = base_reqs + [
req for req in requirements if req not in ('panel', 'bokeh')
]
print(reqs)
for name, min_version in MINIMUM_VERSIONS.items():
if any(name in req for req in reqs):
reqs = [f'{name}>={min_version}' if name in req else req for req in reqs]
Expand Down Expand Up @@ -342,7 +350,13 @@ def script_to_html(
if template in (BASE_TEMPLATE, FILE):
# Add loading.css if not served from Panel template
if inline:
loading_base = (DIST_DIR / "css" / "loading.css").read_text(encoding='utf-8')
svg_name = f'{config.loading_spinner}_spinner.svg'
svg_b64 = base64.b64encode((DIST_DIR / 'assets' / svg_name).read_bytes()).decode('utf-8')
loading_base = (
DIST_DIR / "css" / "loading.css"
).read_text(encoding='utf-8').replace(
f'../assets/{svg_name}', f'data:image/svg+xml;base64,{svg_b64}'
)
loading_style = f'<style type="text/css">\n{loading_base}\n</style>'
else:
loading_style = f'<link rel="stylesheet" href="{CDN_DIST}css/loading.css" type="text/css" />'
Expand Down Expand Up @@ -399,6 +413,7 @@ def convert_app(
prerender: bool = True,
manifest: str | None = None,
panel_version: Literal['auto', 'local'] | str = 'auto',
local_prefix: str = LOCAL_PREFIX,
http_patch: bool = True,
inline: bool = False,
compiled: bool = False,
Expand All @@ -415,7 +430,7 @@ def convert_app(
app, requirements=requirements, runtime=runtime,
prerender=prerender, manifest=manifest,
panel_version=panel_version, http_patch=http_patch,
inline=inline, compiled=compiled
inline=inline, compiled=compiled, local_prefix=local_prefix
)
except KeyboardInterrupt:
return
Expand Down Expand Up @@ -484,6 +499,7 @@ def convert_apps(
pwa_config: dict[Any, Any] = {},
max_workers: int = 4,
panel_version: Literal['auto', 'local'] | str = 'auto',
local_prefix: str = LOCAL_PREFIX,
http_patch: bool = True,
inline: bool = False,
compiled: bool = False,
Expand Down Expand Up @@ -523,6 +539,8 @@ def convert_apps(
The maximum number of parallel workers
panel_version: 'auto' | 'local'] | str
' The panel version to include.
local_prefix: str
Prefix for the path to serve local wheel files from.
http_patch: bool
Whether to patch the HTTP request stack with the pyodide-http library
to allow urllib3 and requests to work.
Expand Down Expand Up @@ -556,7 +574,8 @@ def convert_apps(
'requirements': app_requirements, 'runtime': runtime,
'prerender': prerender, 'manifest': manifest,
'panel_version': panel_version, 'http_patch': http_patch,
'inline': inline, 'verbose': verbose, 'compiled': compiled
'inline': inline, 'verbose': verbose, 'compiled': compiled,
'local_prefix': local_prefix
}

if state._is_pyodide:
Expand Down
6 changes: 3 additions & 3 deletions panel/io/pyodide.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,9 @@
if _IN_PYSCRIPT_WORKER:
from pyscript import window
js.window = window
_IN_WORKER = True
except Exception:
_IN_PYSCRIPT_WORKER = False
_IN_WORKER = False
_IN_WORKER = False
except Exception:
try:
# Initial version of PyScript Next Worker support did not patch js.document
Expand All @@ -60,9 +59,10 @@
from pyscript import document, window
js.document = document
js.window = window
_IN_WORKER = False
except Exception:
_IN_PYSCRIPT_WORKER = False
_IN_WORKER = True
_IN_WORKER = True

# Ensure we don't try to load MPL WASM backend in worker
if _IN_WORKER:
Expand Down
4 changes: 3 additions & 1 deletion panel/io/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,9 @@ def css_raw(self):

# Add loading spinner
if config.global_loading_spinner:
loading_base = (DIST_DIR / "css" / "loading.css").read_text(encoding='utf-8')
loading_base = (DIST_DIR / "css" / "loading.css").read_text(encoding='utf-8').replace(
'../assets', self.dist_dir
)
raw.extend([loading_base, loading_css(
config.loading_spinner, config.loading_color, config.loading_max_height
)])
Expand Down
31 changes: 16 additions & 15 deletions panel/tests/ui/io/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@
import pathlib
import re
import shutil
import sys
import tempfile
import time
import uuid

from subprocess import PIPE, Popen

import pytest

pytest.importorskip("playwright")
Expand All @@ -17,6 +14,7 @@

from panel.config import config
from panel.io.convert import BOKEH_LOCAL_WHL, PANEL_LOCAL_WHL, convert_apps
from panel.tests.util import http_serve_directory

if not (PANEL_LOCAL_WHL.is_file() and BOKEH_LOCAL_WHL.is_file()):
pytest.skip(
Expand All @@ -27,13 +25,16 @@

pytestmark = pytest.mark.ui


if os.name == "wt":
TIMEOUT = 150_000
else:
TIMEOUT = 90_000

_worker_id = os.environ.get("PYTEST_XDIST_WORKER", "0")
HTTP_PORT = 50000 + int(re.sub(r"\D", "", _worker_id))
HTTP_URL = f"http://localhost:{HTTP_PORT}/"
RUNTIMES = ['pyodide', 'pyscript', 'pyodide-worker', 'pyscript-worker']

button_app = """
import panel as pn
Expand Down Expand Up @@ -128,10 +129,10 @@ def http_serve():
except shutil.SameFileError:
pass

process = Popen(
[sys.executable, "-m", "http.server", str(HTTP_PORT), "--directory", str(temp_path)], stdout=PIPE,
)
time.sleep(10) # Wait for server to start
httpd, _ = http_serve_directory(str(temp_path), port=HTTP_PORT)


time.sleep(1)

def write(app):
app_name = uuid.uuid4().hex
Expand All @@ -142,22 +143,22 @@ def write(app):

yield write

process.terminate()
process.wait()
httpd.shutdown()


def wait_for_app(http_serve, app, page, runtime, wait=True, **kwargs):
app_path = http_serve(app)

convert_apps(
[app_path], app_path.parent, runtime=runtime, build_pwa=False,
prerender=False, panel_version='local', inline=True, **kwargs
prerender=False, panel_version='local', inline=True,
local_prefix=HTTP_URL, **kwargs
)

msgs = []
page.on("console", lambda msg: msgs.append(msg))

page.goto(f"http://127.0.0.1:{HTTP_PORT}/{app_path.name[:-3]}.html")
page.goto(f"{HTTP_URL}{app_path.name[:-3]}.html")

cls = f'pn-loading pn-{config.loading_spinner}'
expect(page.locator('body')).to_have_class(cls)
Expand All @@ -174,7 +175,7 @@ def test_pyodide_test_error_handling_worker(http_serve, page):
expect(page.locator('.pn-loading-msg')).to_have_text('RuntimeError: This app is broken', timeout=TIMEOUT)


@pytest.mark.parametrize('runtime', ['pyodide', 'pyscript', 'pyodide-worker'])
@pytest.mark.parametrize('runtime', RUNTIMES)
def test_pyodide_test_convert_button_app(http_serve, page, runtime):
msgs = wait_for_app(http_serve, button_app, page, runtime)

Expand All @@ -187,7 +188,7 @@ def test_pyodide_test_convert_button_app(http_serve, page, runtime):
assert [msg for msg in msgs if msg.type == 'error' and 'favicon' not in msg.location['url']] == []


@pytest.mark.parametrize('runtime', ['pyodide', 'pyscript', 'pyodide-worker'])
@pytest.mark.parametrize('runtime', RUNTIMES)
def test_pyodide_test_convert_template_button_app(http_serve, page, runtime):
msgs = wait_for_app(http_serve, button_app, page, runtime)

Expand All @@ -200,7 +201,7 @@ def test_pyodide_test_convert_template_button_app(http_serve, page, runtime):
assert [msg for msg in msgs if msg.type == 'error' and 'favicon' not in msg.location['url']] == []


@pytest.mark.parametrize('runtime', ['pyodide', 'pyscript', 'pyodide-worker'])
@pytest.mark.parametrize('runtime', RUNTIMES)
def test_pyodide_test_convert_slider_app(http_serve, page, runtime):
msgs = wait_for_app(http_serve, slider_app, page, runtime)

Expand All @@ -214,7 +215,7 @@ def test_pyodide_test_convert_slider_app(http_serve, page, runtime):
assert [msg for msg in msgs if msg.type == 'error' and 'favicon' not in msg.location['url']] == []


@pytest.mark.parametrize('runtime', ['pyodide', 'pyscript', 'pyodide-worker'])
@pytest.mark.parametrize('runtime', RUNTIMES)
def test_pyodide_test_convert_custom_config(http_serve, page, runtime):
wait_for_app(http_serve, config_app, page, runtime)

Expand Down
62 changes: 62 additions & 0 deletions panel/tests/util.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import contextlib
import http.server
import os
import platform
import re
Expand All @@ -8,6 +9,7 @@
import time
import uuid

from functools import partial
from queue import Empty, Queue
from threading import Thread

Expand Down Expand Up @@ -394,3 +396,63 @@ def write_file(content, file_obj):
file_obj.flush()
os.fsync(file_obj)
file_obj.seek(0)


def http_serve_directory(directory=".", port=0):
"""Spawns an http.server.HTTPServer in a separate thread on the given port.
The server serves files from the given *directory*. The port listening on
will automatically be picked by the operating system to avoid race
conditions when trying to bind to an open port that turns out not to be
free after all. The hostname is always "localhost".
Arguments
----------
directory : str, optional
The directory to server files from. Defaults to the current directory.
port : int, optional
Port to serve on, defaults to zero which assigns a random port.
Returns
-------
http.server.HTTPServer
The HTTP server which is serving files from a separate thread.
It is not super necessary but you might want to call shutdown() on the
returned HTTP server object. This will stop the infinite request loop
running in the thread which in turn will then exit. The reason why this
is only optional is that the thread in which the server runs is a daemon
thread which will be terminated when the main thread ends.
By calling shutdown() you'll get a cleaner shutdown because the socket
is properly closed.
str
The address of the server as a string, e.g. "http://localhost:1234".
"""
hostname = "localhost"
directory = os.path.abspath(directory)
handler = partial(_SimpleRequestHandler, directory=directory)
httpd = http.server.HTTPServer((hostname, port), handler, False)
# Block only for 0.5 seconds max
httpd.timeout = 0.5
httpd.server_bind()

address = "http://%s:%d" % (httpd.server_name, httpd.server_port)

httpd.server_activate()

def serve_forever(httpd):
with httpd:
httpd.serve_forever()

thread = Thread(target=serve_forever, args=(httpd, ))
thread.setDaemon(True)
thread.start()

return httpd, address

class _SimpleRequestHandler(http.server.SimpleHTTPRequestHandler):
"""Same as SimpleHTTPRequestHandler with adjusted logging."""

def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'credentialless')
http.server.SimpleHTTPRequestHandler.end_headers(self)
1 change: 1 addition & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ channels = ["pyviz/label/dev", "bokeh", "microsoft", "conda-forge"]
playwright = { version = "*", channel = "microsoft" }
pytest-playwright = "*"
jupyter_server = "*"
packaging = "*"

[feature.test-ui.tasks]
_install-ui = 'playwright install chromium'
Expand Down

0 comments on commit 92c356c

Please sign in to comment.