diff --git a/MANIFEST.in b/MANIFEST.in index 03df33b1ac..468cf35595 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -12,6 +12,8 @@ include panel/models/vtk/*.ts include panel/_templates/*.css include panel/_templates/*.js include panel/_templates/*.html +include panel/tests/assets/*.css +include panel/tests/assets/*.js include panel/tests/test_data/*.png include panel/tests/pane/assets/*.mp3 include panel/tests/pane/assets/*.mp4 diff --git a/panel/command/serve.py b/panel/command/serve.py index 80f7592646..d9fd35c157 100644 --- a/panel/command/serve.py +++ b/panel/command/serve.py @@ -221,9 +221,8 @@ def customize_kwargs(self, args, server_kwargs): # Handle tranquilized functions in the supplied functions kwargs['extra_patterns'] = patterns = kwargs.get('extra_patterns', []) - if args.static_dirs: - static_dirs = parse_vars(args.static_dirs) - patterns += get_static_routes(static_dirs) + static_dirs = parse_vars(args.static_dirs) if args.static_dirs else {} + patterns += get_static_routes(static_dirs) files = [] for f in args.files: diff --git a/panel/io/resources.py b/panel/io/resources.py index 36e58ca31a..6fc8ba5442 100644 --- a/panel/io/resources.py +++ b/panel/io/resources.py @@ -4,6 +4,7 @@ """ import copy import glob +import importlib import json import os @@ -25,7 +26,7 @@ from jinja2.environment import Environment from jinja2.loaders import FileSystemLoader -from ..util import url_path +from ..util import isurl, url_path from .state import state @@ -83,6 +84,19 @@ def set_resource_mode(mode): RESOURCE_MODE = old_mode _settings.resources.set_value(old_resources) +def resolve_custom_path(obj, path): + """ + Attempts to resolve a path relative to some component. + """ + if not path: + return + elif path.startswith(os.path.sep): + return os.path.isfile(path) + try: + mod = importlib.import_module(obj.__module__) + return (Path(mod.__file__).parent / path).is_file() + except Exception: + return None def loading_css(): from ..config import config @@ -96,7 +110,6 @@ def loading_css(): }} """ - def bundled_files(model, file_type='javascript'): bdir = os.path.join(PANEL_DIR, 'dist', 'bundled', model.__name__.lower()) name = model.__name__.lower() @@ -111,7 +124,6 @@ def bundled_files(model, file_type='javascript'): files.append(url) return files - def bundle_resources(roots, resources): from ..config import panel_extension as ext global RESOURCE_MODE @@ -177,6 +189,20 @@ def from_bokeh(cls, bkr): root_dir=bkr.root_dir, **kwargs ) + def extra_resources(self, resources, resource_type): + from ..reactive import ReactiveHTML + custom_path = "components" + if state.rel_path: + custom_path = f"{state.rel_path}/{custom_path}" + for model in param.concrete_descendents(ReactiveHTML).values(): + if not (getattr(model, resource_type, None) and model._loaded()): + continue + for resource in getattr(model, resource_type, []): + if not isurl(resource) and not resource.startswith('static/extensions'): + resource = f'{custom_path}/{model.__module__}/{model.__name__}/{resource_type}/{resource}' + if resource not in resources: + resources.append(resource) + @property def css_raw(self): from ..config import config @@ -203,15 +229,9 @@ def css_raw(self): @property def js_files(self): from ..config import config - from ..reactive import ReactiveHTML files = super(Resources, self).js_files - - for model in param.concrete_descendents(ReactiveHTML).values(): - if getattr(model, '__javascript__', None) and model._loaded(): - for jsfile in model.__javascript__: - if jsfile not in files: - files.append(jsfile) + self.extra_resources(files, '__javascript__') js_files = [] for js_file in files: @@ -244,27 +264,16 @@ def js_files(self): @property def js_modules(self): from ..config import config - from ..reactive import ReactiveHTML modules = list(config.js_modules.values()) - for model in param.concrete_descendents(ReactiveHTML).values(): - if hasattr(model, '__javascript_modules__') and model._loaded(): - for jsmodule in model.__javascript_modules__: - if jsmodule not in modules: - modules.append(jsmodule) + self.extra_resources(modules, '__javascript_modules__') return modules @property def css_files(self): from ..config import config - from ..reactive import ReactiveHTML files = super(Resources, self).css_files - - for model in param.concrete_descendents(ReactiveHTML).values(): - if getattr(model, '__css__', None) and model._loaded(): - for css_file in model.__css__: - if css_file not in files: - files.append(css_file) + self.extra_resources(files, '__css__') for cssf in config.css_files: if os.path.isfile(cssf) or cssf in files: @@ -277,6 +286,7 @@ def css_files(self): dist_dir = LOCAL_DIST else: dist_dir = CDN_DIST + for cssf in glob.glob(str(DIST_DIR / 'css' / '*.css')): if self.mode == 'inline': break diff --git a/panel/io/server.py b/panel/io/server.py index 779c61d5e3..33693a7344 100644 --- a/panel/io/server.py +++ b/panel/io/server.py @@ -4,6 +4,7 @@ import datetime as dt import gc import html +import importlib import logging import os import pathlib @@ -41,7 +42,7 @@ # Tornado imports from tornado.ioloop import IOLoop -from tornado.web import RequestHandler, StaticFileHandler, authenticated +from tornado.web import HTTPError, RequestHandler, StaticFileHandler, authenticated from tornado.wsgi import WSGIContainer # Internal imports @@ -292,6 +293,88 @@ async def get(self, *args, **kwargs): per_app_patterns[3] = (r'/autoload.js', AutoloadJsHandler) + +class ComponentResourceHandler(StaticFileHandler): + """ + A handler that serves local resources relative to a Python module. + The handler resolves a specific Panel component by module reference + and name, then resolves an attribute on that component to check + if it contains the requested resource path. + + ///// + """ + + _resource_attrs = [ + '__css__', '__javascript__', '__js_module__', '_resources', + '_css', '_js', 'base_css', 'css' + ] + + def initialize(self, path=None, default_filename=None): + self.root = path + self.default_filename = default_filename + + def parse_url_path(self, path): + """ + Resolves the resource the URL pattern refers to. + """ + parts = path.split('/') + if len(parts) < 4: + raise HTTPError(400, 'Malformed URL') + module, cls, rtype, *subpath = parts + try: + module = importlib.import_module(module) + except ModuleNotFoundError: + raise HTTPError(404, 'Module not found') + try: + component = getattr(module, cls) + except AttributeError: + raise HTTPError(404, 'Component not found') + + # May only access resources listed in specific attributes + if rtype not in self._resource_attrs: + raise HTTPError(403, 'Requested resource type not valid.') + + try: + resources = getattr(component, rtype) + except AttributeError: + raise HTTPError(404, 'Resource type not found') + + # Handle template resources + if rtype == '_resources': + rtype = subpath[0] + subpath = subpath[1:] + if rtype in resources: + resources = resources[rtype] + else: + raise HTTPError(404, 'Resource type not found') + + if isinstance(resources, dict): + resources = list(resources.values()) + + if subpath[0] == '': + subpath = tuple('/')+subpath[1:] + path = '/'.join(subpath) + + # Important: May only access resources explicitly listed on the component + # Otherwise this potentially exposes all files to the web + if path not in resources and f'./{path}' not in resources: + raise HTTPError(403, 'Requested resource was not listed.') + + if not path.startswith('/'): + path = pathlib.Path(module.__file__).parent / path + return path + + def get_absolute_path(self, root, path): + return path + + def validate_absolute_path(self, root, absolute_path): + if not os.path.exists(absolute_path): + raise HTTPError(404) + if not os.path.isfile(absolute_path): + raise HTTPError(403, "%s is not a file", self.path) + return absolute_path + + def modify_document(self, doc): from bokeh.io.doc import set_curdoc as bk_set_curdoc from ..config import config @@ -505,6 +588,9 @@ def get_static_routes(static_dirs): patterns.append( (r"%s/(.*)" % slug, StaticFileHandler, {"path": path}) ) + patterns.append(( + '/components/(.*)', ComponentResourceHandler, {} + )) return patterns diff --git a/panel/template/base.py b/panel/template/base.py index 6b247d23f7..fac7a746a6 100644 --- a/panel/template/base.py +++ b/panel/template/base.py @@ -21,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, BUNDLE_DIR +from ..io.resources import CDN_DIST, LOCAL_DIST, BUNDLE_DIR, resolve_custom_path from ..io.save import save from ..io.state import state from ..layout import Column, ListLike, GridSpec @@ -29,7 +29,7 @@ from ..pane import panel as _panel, HTML, Str, HoloViews from ..pane.image import ImageBase from ..reactive import ReactiveHTML -from ..util import url_path +from ..util import isurl, url_path from ..viewable import ServableMixin, Viewable from ..widgets import Button from ..widgets.indicators import BooleanIndicator, LoadingSpinner @@ -525,10 +525,15 @@ def _apply_hooks(self, viewable, root): root.document.theme = theme.bokeh_theme def _get_theme(self): - return self.theme.find_theme(type(self))() + for cls in type(self).__mro__: + try: + return self.theme.find_theme(cls)() + except Exception: + pass def _template_resources(self): - name = type(self).__name__.lower() + clsname = type(self).__name__ + name = clsname.lower() resources = _settings.resources(default="server") if resources == 'server': if state.rel_path: @@ -538,17 +543,31 @@ def _template_resources(self): else: dist_path = self._CDN + custom_path = "components" + if state.rel_path: + custom_path = f"{state.rel_path}/{custom_path}" + # External resources css_files = dict(self._resources.get('css', {})) for cssname, css in css_files.items(): css_path = url_path(css) if (BUNDLE_DIR / 'css' / css_path.replace('/', os.path.sep)).is_file(): css_files[cssname] = dist_path + f'bundled/css/{css_path}' + elif isurl(css): + css_files[cssname] = css + elif resolve_custom_path(self, css): + css_files[cssname] = f'{custom_path}/{self.__module__}/{clsname}/_resources/css/{css}' + js_files = dict(self._resources.get('js', {})) for jsname, js in js_files.items(): js_path = url_path(js) if (BUNDLE_DIR / 'js' / js_path.replace('/', os.path.sep)).is_file(): js_files[jsname] = dist_path + f'bundled/js/{js_path}' + elif isurl(js): + js_files[jsname] = js + elif resolve_custom_path(self, js): + js_files[jsname] = f'{custom_path}/{self.__module__}/{clsname}/_resources/js/{js}' + js_modules = dict(self._resources.get('js_modules', {})) for jsname, js in js_modules.items(): js_path = url_path(js) @@ -558,6 +577,11 @@ def _template_resources(self): js_path += '.mjs' if os.path.isfile(BUNDLE_DIR / js_path.replace('/', os.path.sep)): js_modules[jsname] = dist_path + f'bundled/js/{js_path}' + elif isurl(js): + js_modules[jsname] = js + elif resolve_custom_path(self, js): + js_modules[jsname] = f'{custom_path}/{self.__module__}/{clsname}/_resources/js_modules/{js}' + for name, js in self.config.js_files.items(): if not '//' in js and state.rel_path: js = f'{state.rel_path}/{js}' @@ -586,9 +610,10 @@ def _template_resources(self): 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()) + elif isurl(css): + css_files[f'base_{css_file}'] = css + elif resolve_custom_path(self, css): + css_files[f'base_{css_file}' ] = f'{custom_path}/{self.__module__}/{clsname}/_css/{css}' # JS files base_js = self._js @@ -600,28 +625,35 @@ def _template_resources(self): tmpl_js = cls._js if isinstance(cls._js, list) else [cls._js] if js in tmpl_js: tmpl_name = cls.__name__.lower() - js = os.path.basename(js) - if (BUNDLE_DIR / tmpl_name / js).is_file(): - js_files[f'base_{js}'] = dist_path + f'bundled/{tmpl_name}/{js}' + js_name = os.path.basename(js) + if (BUNDLE_DIR / tmpl_name / js_name).is_file(): + js_files[f'base_{js_name}'] = dist_path + f'bundled/{tmpl_name}/{js_name}' + elif isurl(js): + js_files[f'base_{js_name}'] = js + elif resolve_custom_path(self, js): + js_files[f'base_{js_name}' ] = f'{custom_path}/{self.__module__}/{clsname}/_js/{js}' if self.theme: - theme = self.theme.find_theme(type(self)) + theme = self._get_theme() + theme_name = type(theme).__name__ if theme: if theme.base_css: basename = os.path.basename(theme.base_css) - owner = theme.param.base_css.owner.__name__.lower() + owner = type(theme.param.base_css.owner).__name__.lower() 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()) + elif isurl(theme.base_css): + css_files['theme_base'] = theme.base_css + elif resolve_custom_path(theme, theme.base_css): + css_files['theme_base'] = f'{custom_path}/{theme.__module__}/{theme_name}/base_css/{theme.base_css}' if theme.css: basename = os.path.basename(theme.css) if (BUNDLE_DIR / name / basename).is_file(): css_files['theme'] = dist_path + f'bundled/{name}/{basename}' - else: - with open(theme.css, encoding='utf-8') as f: - raw_css.append(f.read()) + elif isurl(theme.css): + css_files['theme'] = theme.css + elif resolve_custom_path(theme, theme.css): + css_files['theme'] = f'{custom_path}/{theme.__module__}/{theme_name}/css/{theme.css}' return { 'css': css_files, diff --git a/panel/tests/assets/custom.css b/panel/tests/assets/custom.css new file mode 100644 index 0000000000..7c37b30b91 --- /dev/null +++ b/panel/tests/assets/custom.css @@ -0,0 +1 @@ +/* Test */ diff --git a/panel/tests/test_server.py b/panel/tests/test_server.py index 75b1430684..f0e10447db 100644 --- a/panel/tests/test_server.py +++ b/panel/tests/test_server.py @@ -10,12 +10,13 @@ from panel.config import config from panel.io import state +from panel.io.resources import DIST_DIR +from panel.io.server import get_server, serve, set_curdoc from panel.layout import Row from panel.models import HTML as BkHTML from panel.models.tabulator import TableEditEvent from panel.pane import Markdown -from panel.io.resources import DIST_DIR -from panel.io.server import get_server, serve, set_curdoc +from panel.reactive import ReactiveHTML from panel.template import BootstrapTemplate from panel.widgets import Button, Tabulator @@ -110,7 +111,7 @@ def test_server_template_static_resources_with_subpath_and_prefix_relative_url() template = BootstrapTemplate() port = 6004 - serve({'/subpath/template': template}, port=6004, threaded=True, show=False, prefix='prefix') + serve({'/subpath/template': template}, port=port, threaded=True, show=False, prefix='prefix') # Wait for server to start time.sleep(1) @@ -554,3 +555,125 @@ def loaded(): # Checks whether onload callbacks were executed concurrently assert max(counts) >= 2 + + +class CustomBootstrapTemplate(BootstrapTemplate): + + _css = './assets/custom.css' + + +def test_server_template_custom_resources(): + template = CustomBootstrapTemplate() + + port = 6019 + serve({'template': template}, port=port, threaded=True, show=False) + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/components/panel.tests.test_server/CustomBootstrapTemplate/_css/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_template_custom_resources_with_prefix(): + template = CustomBootstrapTemplate() + + port = 6020 + serve({'template': template}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/components/panel.tests.test_server/CustomBootstrapTemplate/_css/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_template_custom_resources_with_prefix_relative_url(): + template = CustomBootstrapTemplate() + + port = 6021 + serve({'template': template}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/template") + content = r.content.decode('utf-8') + assert 'href="components/panel.tests.test_server/CustomBootstrapTemplate/_css/./assets/custom.css"' in content + + +def test_server_template_custom_resources_with_subpath_and_prefix_relative_url(): + template = CustomBootstrapTemplate() + + port = 6022 + serve({'/subpath/template': template}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/subpath/template") + content = r.content.decode('utf-8') + assert 'href="../components/panel.tests.test_server/CustomBootstrapTemplate/_css/./assets/custom.css"' in content + + +class CustomComponent(ReactiveHTML): + + __css__ = ['./assets/custom.css'] + + +def test_server_component_custom_resources(): + component = CustomComponent() + + port = 6023 + serve({'component': component}, port=port, threaded=True, show=False) + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/components/panel.tests.test_server/CustomComponent/__css__/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_component_custom_resources_with_prefix(): + component = CustomComponent() + + port = 6024 + serve({'component': component}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/components/panel.tests.test_server/CustomComponent/__css__/assets/custom.css") + with open(pathlib.Path(__file__).parent / 'assets' / 'custom.css', encoding='utf-8') as f: + assert f.read() == r.content.decode('utf-8').replace('\r\n', '\n') + + +def test_server_component_custom_resources_with_prefix_relative_url(): + component = CustomComponent() + + port = 6025 + serve({'component': component}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/component") + content = r.content.decode('utf-8') + assert 'href="components/panel.tests.test_server/CustomComponent/__css__/./assets/custom.css"' in content + + +def test_server_component_custom_resources_with_subpath_and_prefix_relative_url(): + component = CustomComponent() + + port = 6026 + serve({'/subpath/component': component}, port=port, threaded=True, show=False, prefix='prefix') + + # Wait for server to start + time.sleep(1) + + r = requests.get(f"http://localhost:{port}/prefix/subpath/component") + content = r.content.decode('utf-8') + assert 'href="../components/panel.tests.test_server/CustomComponent/__css__/./assets/custom.css"' in content diff --git a/panel/util.py b/panel/util.py index 380af2a8d2..f5010955a8 100644 --- a/panel/util.py +++ b/panel/util.py @@ -38,7 +38,7 @@ def isfile(path): return False -def isurl(obj, formats): +def isurl(obj, formats=None): if not isinstance(obj, str): return False lower_string = obj.lower().split('?')[0].split('#')[0]