diff --git a/panel/command/serve.py b/panel/command/serve.py index 1195945753..9888b33278 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -8,7 +8,6 @@ import logging # isort:skip from glob import glob -from urllib.parse import urljoin from bokeh.command.subcommands.serve import Serve as _BkServe from bokeh.command.util import build_single_handler_applications @@ -19,7 +18,6 @@ from ..io.reload import record_modules, watch from ..io.server import INDEX_HTML, get_static_routes from ..io.state import state -from ..util import edit_readonly log = logging.getLogger(__name__) @@ -147,12 +145,6 @@ def customize_kwargs(self, args, server_kwargs): else: files.append(f) - prefix = args.prefix or '' - if not prefix.endswith('/'): - prefix += '/' - with edit_readonly(state): - state.base_url = urljoin('/', prefix) - # Handle tranquilized functions in the supplied functions if args.rest_provider in REST_PROVIDERS: pattern = REST_PROVIDERS[args.rest_provider](files, args.rest_endpoint) diff --git a/panel/io/resources.py b/panel/io/resources.py index f98b132eb1..cb0d34aa3d 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -9,7 +9,6 @@ from base64 import b64encode from collections import OrderedDict from pathlib import Path -from urllib.parse import urljoin from bokeh.embed.bundle import ( Bundle as BkBundle, _bundle_extensions, extension_dirs, @@ -20,6 +19,7 @@ from jinja2 import Environment, Markup, FileSystemLoader from ..util import url_path +from .state import state with open(Path(__file__).parent.parent / 'package.json') as f: @@ -43,6 +43,7 @@ def conffilter(value): RESOURCE_MODE = 'server' PANEL_DIR = Path(__file__).parent.parent DIST_DIR = PANEL_DIR / 'dist' +BUNDLE_DIR = DIST_DIR / 'bundled' ASSETS_DIR = PANEL_DIR / 'assets' BASE_TEMPLATE = _env.get_template('base.html') DEFAULT_TITLE = "Panel Application" @@ -159,12 +160,23 @@ def css_raw(self): def js_files(self): from ..config import config files = super(Resources, self).js_files - js_files = files + list(config.js_files.values()) + js_files = [] + for js_file in files: + if (js_file.startswith(state.base_url) or js_file.startswith('static/')): + if js_file.startswith(state.base_url): + js_file = js_file[len(state.base_url):] + if state.rel_path: + js_file = f'{state.rel_path}/{js_file}' + js_files.append(js_file) + js_files += list(config.js_files.values()) # Load requirejs last to avoid interfering with other libraries require_index = [i for i, jsf in enumerate(js_files) if 'require' in jsf] if self.mode == 'server': - dist_dir = urljoin(self.root_url, LOCAL_DIST) + if state.rel_path: + dist_dir = f'{state.rel_path}/{LOCAL_DIST}' + else: + dist_dir = LOCAL_DIST else: dist_dir = CDN_DIST if require_index: @@ -191,7 +203,10 @@ def css_files(self): continue files.append(cssf) if self.mode == 'server': - dist_dir = urljoin(self.root_url, LOCAL_DIST) + if state.rel_path: + dist_dir = f'{state.rel_path}/{LOCAL_DIST}' + else: + dist_dir = LOCAL_DIST else: dist_dir = CDN_DIST for cssf in glob.glob(str(DIST_DIR / 'css' / '*.css')): @@ -228,7 +243,16 @@ def from_bokeh(cls, bk_bundle): ) def _render_js(self): + js_files = [] + for js_file in self.js_files: + if (js_file.startswith(state.base_url) or js_file.startswith('static/')): + if js_file.startswith(state.base_url): + js_file = js_file[len(state.base_url):] + + if state.rel_path: + js_file = f'{state.rel_path}/{js_file}' + js_files.append(js_file) return JS_RESOURCES.render( - js_raw=self.js_raw, js_files=self.js_files, + js_raw=self.js_raw, js_files=js_files, js_modules=self.js_modules, hashes=self.hashes ) diff --git a/panel/io/server.py b/panel/io/server.py index 34a9691a3c..3e57096026 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -16,7 +16,7 @@ from contextlib import contextmanager from functools import partial, wraps from types import FunctionType, MethodType -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse import param import bokeh @@ -45,6 +45,7 @@ from tornado.wsgi import WSGIContainer # Internal imports +from ..util import edit_readonly from .reload import autoreload_watcher from .resources import BASE_TEMPLATE, Resources, bundle_resources from .state import state @@ -168,36 +169,52 @@ async def on_session_created(self, session_context): bokeh.command.util.Application = Application + +class SessionPrefixHandler: + + @contextmanager + def _session_prefix(self): + prefix = self.request.uri.replace(self.application_context._url, '') + if not prefix.endswith('/'): + prefix += '/' + base_url = urljoin('/', prefix) + rel_path = '/'.join(['..'] * self.application_context._url.strip('/').count('/')) + old_url, old_rel = state.base_url, state.rel_path + with edit_readonly(state): + state.base_url = base_url + state.rel_path = rel_path + try: + yield + finally: + with edit_readonly(state): + state.base_url = old_url + state.rel_path = old_rel + # Patch Bokeh DocHandler URL -class DocHandler(BkDocHandler): +class DocHandler(BkDocHandler, SessionPrefixHandler): @authenticated async def get(self, *args, **kwargs): - session = await self.get_session() - r = self.request - prefix = '/'.join(r.uri.split('/')[:-1]) - state.root_url = f"{r.protocol}://{r.host}{prefix}" - resources = Resources.from_bokeh(self.application.resources()) - page = server_html_page_for_session( - session, resources=resources, title=session.document.title, - template=session.document.template, - template_variables=session.document.template_variables - ) - + with self._session_prefix(): + session = await self.get_session() + resources = Resources.from_bokeh(self.application.resources()) + page = server_html_page_for_session( + session, resources=resources, title=session.document.title, + template=session.document.template, + template_variables=session.document.template_variables + ) self.set_header("Content-Type", 'text/html') self.write(page) per_app_patterns[0] = (r'/?', DocHandler) # Patch Bokeh Autoload handler -class AutoloadJsHandler(BkAutoloadJsHandler): +class AutoloadJsHandler(BkAutoloadJsHandler, SessionPrefixHandler): ''' Implements a custom Tornado handler for the autoload JS chunk ''' async def get(self, *args, **kwargs): - session = await self.get_session() - element_id = self.get_argument("bokeh-autoload-element", default=None) if not element_id: self.send_error(status_code=400, reason='No bokeh-autoload-element query parameter') @@ -211,8 +228,10 @@ async def get(self, *args, **kwargs): else: server_url = None - resources = self.application.resources(server_url) - js = autoload_js_script(resources, session.token, element_id, app_path, absolute_url) + with self._session_prefix(): + session = await self.get_session() + resources = Resources.from_bokeh(self.application.resources(server_url)) + js = autoload_js_script(resources, session.token, element_id, app_path, absolute_url) self.set_header("Content-Type", 'application/javascript') self.write(js) diff --git a/panel/io/state.py b/panel/io/state.py index 79833033f3..c33fb015dc 100644 --- a/panel/io/state.py +++ b/panel/io/state.py @@ -40,8 +40,8 @@ class _state(param.Parameterized): Object with encrypt and decrypt methods to support encryption of secret variables including OAuth information.""") - root_url = param.String(default=None, doc=""" - The root URL of the running server.""") + rel_path = param.String(default='', readonly=True, doc=""" + Relative path from the current app being served to the root URL.""") session_info = param.Dict(default={'total': 0, 'live': 0, 'sessions': OrderedDict()}, doc=""" diff --git a/panel/template/base.py b/panel/template/base.py index 449c3d0722..24df5e7051 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -8,7 +8,6 @@ from collections import OrderedDict from functools import partial -from urllib.parse import urljoin import param @@ -22,7 +21,7 @@ from ..config import _base_config, config, panel_extension from ..io.model import add_to_doc from ..io.notebook import render_template -from ..io.resources import CDN_DIST, LOCAL_DIST, DIST_DIR +from ..io.resources import CDN_DIST, LOCAL_DIST, BUNDLE_DIR from ..io.save import save from ..io.state import state from ..layout import Column, ListLike, GridSpec @@ -403,16 +402,28 @@ class BasicTemplate(BaseTemplate): location = param.Boolean(default=True, readonly=True) + ############# + # Resources # + ############# + + # Resource locations for bundled resources + _CDN = CDN_DIST + _LOCAL = LOCAL_DIST + + # pathlib.Path pointing to local CSS file(s) _css = None + # pathlib.Path pointing to local JS file(s) _js = None + # pathlib.Path pointing to local Jinja2 template _template = None - _modifiers = {} - + # External resources _resources = {'css': {}, 'js': {}, 'js_modules': {}, 'tarball': {}} + _modifiers = {} + __abstract = True def __init__(self, **params): @@ -465,20 +476,24 @@ def _template_resources(self): name = type(self).__name__.lower() resources = _settings.resources(default="server") if resources == 'server': - base_url = state.base_url[1:] if state.base_url.startswith('/') else state.base_url - dist_path = '/' + urljoin(base_url, LOCAL_DIST) + if state.rel_path: + dist_path = f'{state.rel_path}/{self._LOCAL}' + else: + dist_path = self._LOCAL else: - dist_path = CDN_DIST + dist_path = self._CDN # External resources css_files = dict(self._resources.get('css', {})) for cssname, css in css_files.items(): css_path = url_path(css) - css_files[cssname] = dist_path + f'bundled/css/{css_path}' + if (BUNDLE_DIR / 'css' / css_path.replace('/', os.path.sep)).is_file(): + css_files[cssname] = dist_path + f'bundled/css/{css_path}' js_files = dict(self._resources.get('js', {})) for jsname, js in js_files.items(): js_path = url_path(js) - js_files[jsname] = dist_path + f'bundled/js/{js_path}' + if (BUNDLE_DIR / 'js' / js_path.replace('/', os.path.sep)).is_file(): + js_files[jsname] = dist_path + f'bundled/js/{js_path}' js_modules = dict(self._resources.get('js_modules', {})) for jsname, js in js_modules.items(): js_path = url_path(js) @@ -486,7 +501,7 @@ def _template_resources(self): js_path += '/index.mjs' else: js_path += '.mjs' - if os.path.isfile(DIST_DIR / 'bundled' / 'js' / js_path.replace('/', os.path.sep)): + if os.path.isfile(BUNDLE_DIR / js_path.replace('/', os.path.sep)): js_modules[jsname] = dist_path + f'bundled/js/{js_path}' js_files.update(self.config.js_files) js_modules.update(self.config.js_modules) @@ -503,8 +518,12 @@ def _template_resources(self): tmpl_css = cls._css if isinstance(cls._css, list) else [cls._css] if css in tmpl_css: tmpl_name = cls.__name__.lower() - css = os.path.basename(css) - css_files[f'base_{css}'] = dist_path + f'bundled/{tmpl_name}/{css}' + css_file = os.path.basename(css) + if (BUNDLE_DIR / tmpl_name / css_file).is_file(): + css_files[f'base_{css_file}'] = dist_path + f'bundled/{tmpl_name}/{css_file}' + else: + with open(css, encoding='utf-8') as f: + raw_css.append(f.read()) # JS files base_js = self._js @@ -517,7 +536,8 @@ def _template_resources(self): if js in tmpl_js: tmpl_name = cls.__name__.lower() js = os.path.basename(js) - js_files[f'base_{js}'] = dist_path + f'bundled/{tmpl_name}/{js}' + if (BUNDLE_DIR / tmpl_name / js).is_file(): + js_files[f'base_{js}'] = dist_path + f'bundled/{tmpl_name}/{js}' if self.theme: theme = self.theme.find_theme(type(self)) @@ -525,10 +545,19 @@ def _template_resources(self): if theme.base_css: basename = os.path.basename(theme.base_css) owner = theme.param.base_css.owner.__name__.lower() - css_files['theme_base'] = dist_path + f'bundled/{owner}/{basename}' + if (BUNDLE_DIR / owner / basename).is_file(): + css_files['theme_base'] = dist_path + f'bundled/{owner}/{basename}' + else: + with open(theme.base_css, encoding='utf-8') as f: + raw_css.append(f.read()) if theme.css: basename = os.path.basename(theme.css) - css_files['theme'] = dist_path + f'bundled/{name}/{basename}' + if (BUNDLE_DIR / name / basename).is_file(): + css_files['theme'] = dist_path + f'bundled/{name}/{basename}' + else: + with open(theme.base_css, encoding='utf-8') as f: + raw_css.append(f.read()) + return { 'css': css_files, 'extra_css': extra_css, diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 13f110a7e0..25ecd60598 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -10,7 +10,9 @@ from panel.io import state from panel.models import HTML as BkHTML from panel.pane import Markdown +from panel.io.resources import DIST_DIR from panel.io.server import get_server, serve, set_curdoc +from panel.template import BootstrapTemplate from panel.widgets import Button @@ -47,20 +49,83 @@ def test_server_static_dirs(): html = Markdown('# Title') static = {'tests': os.path.dirname(__file__)} - server = serve(html, port=5008, threaded=True, static_dirs=static, show=False) + server = serve(html, port=6000, threaded=True, static_dirs=static, show=False) # Wait for server to start time.sleep(1) - r = requests.get("http://localhost:5008/tests/test_server.py") - try: + r = requests.get("http://localhost:6000/tests/test_server.py") with open(__file__, encoding='utf-8') as f: assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') finally: server.stop() +def test_server_template_static_resources(): + template = BootstrapTemplate() + + server = serve({'template': template}, port=6001, threaded=True, show=False) + + # Wait for server to start + time.sleep(1) + + try: + r = requests.get("http://localhost:6001/static/extensions/panel/bundled/bootstraptemplate/bootstrap.css") + with open(DIST_DIR / 'bundled' / 'bootstraptemplate' / 'bootstrap.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + finally: + server.stop() + + +def test_server_template_static_resources_with_prefix(): + template = BootstrapTemplate() + + server = serve({'template': template}, port=6004, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + try: + r = requests.get("http://localhost:6004/prefix/static/extensions/panel/bundled/bootstraptemplate/bootstrap.css") + with open(DIST_DIR / 'bundled' / 'bootstraptemplate' / 'bootstrap.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + finally: + server.stop() + + +def test_server_template_static_resources_with_prefix_relative_url(): + template = BootstrapTemplate() + + server = serve({'template': template}, port=6005, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + try: + r = requests.get("http://localhost:6005/prefix/template") + content = r.content.decode('utf-8') + assert 'href="static/extensions/panel/bundled/bootstraptemplate/bootstrap.css"' in content + finally: + server.stop() + + +def test_server_template_static_resources_with_subpath_and_prefix_relative_url(): + template = BootstrapTemplate() + + server = serve({'/subpath/template': template}, port=6005, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + try: + r = requests.get("http://localhost:6005/prefix/subpath/template") + content = r.content.decode('utf-8') + assert 'href="../static/extensions/panel/bundled/bootstraptemplate/bootstrap.css"' in content + finally: + server.stop() + + def test_server_async_callbacks(): button = Button(name='Click') @@ -75,12 +140,12 @@ async def cb(event, count=[0]): button.on_click(cb) - server = serve(button, port=5008, threaded=True, show=False) + server = serve(button, port=6002, threaded=True, show=False) # Wait for server to start time.sleep(1) - requests.get("http://localhost:5008/") + requests.get("http://localhost:6002/") doc = list(button._models.values())[0][0].document with set_curdoc(doc): @@ -101,12 +166,12 @@ def test_server_session_info(): with config.set(session_history=-1): html = Markdown('# Title') - server = serve(html, port=5009, threaded=True, show=False) + server = serve(html, port=6003, threaded=True, show=False) # Wait for server to start time.sleep(1) - requests.get("http://localhost:5009/") + requests.get("http://localhost:6003/") assert state.session_info['total'] == 1 assert len(state.session_info['sessions']) == 1 @@ -164,4 +229,4 @@ def test_serve_can_serve_panel_app_from_file(): def test_serve_can_serve_bokeh_app_from_file(): path = pathlib.Path(__file__).parent / "io"/"bk_app.py" server = get_server({"bk-app": path}) - assert "/bk-app" in server._tornado.applications \ No newline at end of file + assert "/bk-app" in server._tornado.applications