Skip to content

Commit

Permalink
Improve handling of stylesheet URLs (#4540)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr committed Mar 20, 2023
1 parent bf32f23 commit b7c033a
Show file tree
Hide file tree
Showing 27 changed files with 397 additions and 201 deletions.
35 changes: 5 additions & 30 deletions panel/compiler.py
Expand Up @@ -20,7 +20,7 @@
from .io.resources import RESOURCE_URLS
from .reactive import ReactiveHTML
from .template.base import BasicTemplate
from .theme import Design, Theme
from .theme import Design

BASE_DIR = pathlib.Path(__file__).parent
BUNDLE_DIR = pathlib.Path(__file__).parent / 'dist' / 'bundled'
Expand Down Expand Up @@ -211,19 +211,6 @@ def bundle_templates(verbose=False, external=True):


def bundle_themes(verbose=False, external=True):
# Bundle Theme classes
for name, theme in param.concrete_descendents(Theme).items():
if verbose:
print(f'Bundling {name} theme resources')
if theme.base_css:
theme_bundle_dir = BUNDLE_DIR / theme.param.base_css.owner.__name__.lower()
theme_bundle_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(theme.base_css, theme_bundle_dir / os.path.basename(theme.base_css))
if theme.css:
tmplt_bundle_dir = BUNDLE_DIR / theme.param.css.owner.__name__.lower()
tmplt_bundle_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(theme.css, tmplt_bundle_dir / os.path.basename(theme.css))

# Bundle design stylesheets
for name, design in param.concrete_descendents(Design).items():
if verbose:
Expand All @@ -233,23 +220,11 @@ def bundle_themes(verbose=False, external=True):
if design._resources.get('bundle', True) and external:
write_component_resources(name, design)

for scls, modifiers in design._modifiers.items():
cls_modifiers = design._modifiers.get(scls, {})
if 'stylesheets' not in cls_modifiers:
continue
theme_bundle_dir = BUNDLE_DIR / 'theme'
theme_bundle_dir.mkdir(parents=True, exist_ok=True)
for design_css in glob.glob(str(BASE_DIR / 'theme' / 'css' / '*.css')):
shutil.copyfile(design_css, theme_bundle_dir / os.path.basename(design_css))

# Find the Design class the options were first defined on
def_cls = [
super_cls for super_cls in design.__mro__[::-1]
if getattr(super_cls, '_modifiers', {}).get(scls) is cls_modifiers
][0]
def_path = pathlib.Path(inspect.getmodule(def_cls).__file__).parent
for sts in cls_modifiers['stylesheets']:
if not isinstance(sts, str) or not sts.endswith('.css') or sts.startswith('http') or sts.startswith('/'):
continue
bundled_path = BUNDLE_DIR / def_cls.__name__.lower() / sts
bundled_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(def_path / sts, bundled_path)

def bundle_models(verbose=False, external=True):
for imp in panel_extension._imports.values():
Expand Down
110 changes: 75 additions & 35 deletions panel/io/resources.py
Expand Up @@ -2,12 +2,15 @@
Patches bokeh resources to make it easy to add external JS and CSS
resources via the panel.config object.
"""
from __future__ import annotations

import copy
import importlib
import json
import logging
import mimetypes
import os
import pathlib
import re
import textwrap

Expand Down Expand Up @@ -141,63 +144,76 @@ def process_raw_css(raw_css):
"""
return [BK_PREFIX_RE.sub('.', css) for css in raw_css]

def resolve_custom_path(obj, path):
def loading_css():
from ..config import config
with open(ASSETS_DIR / f'{config.loading_spinner}_spinner.svg', encoding='utf-8') as f:
svg = f.read().replace('\n', '').format(color=config.loading_color)
b64 = b64encode(svg.encode('utf-8')).decode('utf-8')
return textwrap.dedent(f"""
:host(.pn-loading).{config.loading_spinner}:before, .pn-loading.{config.loading_spinner}:before {{
background-image: url("data:image/svg+xml;base64,{b64}");
background-size: auto calc(min(50%, {config.loading_max_height}px));
}}""")

def resolve_custom_path(
obj, path: str | os.PathLike, relative: bool = False
) -> pathlib.Path | None:
"""
Attempts to resolve a path relative to some component.
Arguments
---------
obj: type | object
The component to resolve the path relative to.
path: str | os.PathLike
Absolute or relative path to a resource.
relative: bool
Whether to return a relative path.
Returns
-------
path: pathlib.Path | None
"""
if not path:
return
path = str(path)
if path.startswith(os.path.sep):
return os.path.isfile(path)
if not isinstance(obj, type):
obj = type(obj)
try:
mod = importlib.import_module(obj.__module__)
return (Path(mod.__file__).parent / path).is_file()
module_path = Path(mod.__file__).parent
assert module_path.exists()
except Exception:
return None

def component_rel_path(component, path):
"""
Computes the absolute to a component resource.
"""
if not isinstance(component, type):
component = type(component)
mod = importlib.import_module(component.__module__)
rel_dir = Path(mod.__file__).parent
if os.path.isabs(path):
path = pathlib.Path(path)
if path.is_absolute():
abs_path = path
else:
abs_path = os.path.abspath(os.path.join(rel_dir, path))
return os.path.relpath(abs_path, rel_dir)
abs_path = module_path / path
if not abs_path.is_file():
return None
abs_path = abs_path.resolve()
if not relative:
return abs_path
return os.path.relpath(abs_path, module_path)

def component_resource_path(component, attr, path):
"""
Generates a canonical URL for a component resource.
To be used in conjunction with the `panel.io.server.ComponentResourceHandler`
which allows dynamically resolving resources defined on components.
"""
if not isinstance(component, type):
component = type(component)
component_path = COMPONENT_PATH
if state.rel_path:
component_path = f"{state.rel_path}/{component_path}"
rel_path = component_rel_path(component, path).replace(os.path.sep, '/')
rel_path = str(resolve_custom_path(component, path, relative=True)).replace(os.path.sep, '/')
return f'{component_path}{component.__module__}/{component.__name__}/{attr}/{rel_path}'

def loading_css():
from ..config import config
with open(ASSETS_DIR / f'{config.loading_spinner}_spinner.svg', encoding='utf-8') as f:
svg = f.read().replace('\n', '').format(color=config.loading_color)
b64 = b64encode(svg.encode('utf-8')).decode('utf-8')
return textwrap.dedent(f"""
:host(.pn-loading).{config.loading_spinner}:before, .pn-loading.{config.loading_spinner}:before {{
background-image: url("data:image/svg+xml;base64,{b64}");
background-size: auto calc(min(50%, {config.loading_max_height}px));
}}""")

def patch_stylesheet(stylesheet, dist_url):
url = stylesheet.url
if not url.startswith('http') and not url.startswith(dist_url):
patched_url = f'{dist_url}{url}'
elif url.startswith(CDN_DIST+dist_url) and dist_url != CDN_DIST:
if url.startswith(CDN_DIST+dist_url) and dist_url != CDN_DIST:
patched_url = url.replace(CDN_DIST+dist_url, dist_url)
elif url.startswith(CDN_DIST) and dist_url != CDN_DIST:
patched_url = url.replace(CDN_DIST, dist_url)
Expand All @@ -208,6 +224,30 @@ def patch_stylesheet(stylesheet, dist_url):
except Exception:
pass

def resolve_stylesheet(cls, stylesheet: str, attribute: str | None = None):
"""
Resolves a stylesheet definition, e.g. originating on a component
Reactive._stylesheets or a Design.modifiers attribute. Stylesheets
may be defined as one of the following:
- Absolute URL defined with http(s) protocol
- A path relative to the component
Arguments
---------
cls: type | object
Object or class defining the stylesheet
stylesheet: str
The stylesheet definition
"""
stylesheet = str(stylesheet)
if not stylesheet.startswith('http') and attribute and (custom_path:= resolve_custom_path(cls, stylesheet)):
if not state._is_pyodide and state.curdoc and state.curdoc.session_context:
stylesheet = component_resource_path(cls, attribute, stylesheet)
else:
stylesheet = custom_path.read_text('utf-8')
return stylesheet

def patch_model_css(root, dist_url):
"""
Temporary patch for Model.css property used by Panel to provide
Expand Down Expand Up @@ -359,7 +399,7 @@ def adjust_paths(self, resources):
"""
new_resources = []
for resource in resources:
if resource.startswith(CDN_DIST) and self.notebook:
if self.mode == 'server' and self.notebook:
resource = resource.replace(CDN_DIST, '')
resource = f'/panel-preview/static/extensions/panel/{resource}'
elif (resource.startswith(state.base_url) or resource.startswith('static/')):
Expand All @@ -374,7 +414,7 @@ def adjust_paths(self, resources):

@property
def dist_dir(self):
if self.notebook:
if self.notebook and self.mode == 'server':
dist_dir = '/panel-preview/static/extensions/panel/'
elif self.mode == 'server':
if state.rel_path:
Expand Down Expand Up @@ -500,7 +540,7 @@ def _adjust_paths(self, resources):
resource = resource.replace('https://unpkg.com', config.npm_cdn)
if resource.startswith(cdn_base):
resource = resource.replace(cdn_base, CDN_DIST)
if resource.startswith(CDN_DIST) and self.notebook:
if RESOURCE_MODE == 'server' and self.notebook:
resource = resource.replace(CDN_DIST, '')
resource = f'/panel-preview/static/extensions/panel/{resource}'
elif (resource.startswith('static/') and state.rel_path):
Expand Down
11 changes: 8 additions & 3 deletions panel/io/server.py
Expand Up @@ -74,7 +74,7 @@
from .reload import autoreload_watcher
from .resources import (
BASE_TEMPLATE, CDN_DIST, COMPONENT_PATH, ERROR_TEMPLATE, LOCAL_DIST,
Resources, _env, bundle_resources, component_rel_path, patch_model_css,
Resources, _env, bundle_resources, patch_model_css, resolve_custom_path,
)
from .state import set_curdoc, state

Expand Down Expand Up @@ -421,7 +421,7 @@ class ComponentResourceHandler(StaticFileHandler):

_resource_attrs = [
'__css__', '__javascript__', '__js_module__', '__javascript_modules__', '_resources',
'_css', '_js', 'base_css', 'css'
'_css', '_js', 'base_css', 'css', '_stylesheets', 'modifiers'
]

def initialize(self, path: Optional[str] = None, default_filename: Optional[str] = None):
Expand Down Expand Up @@ -462,13 +462,18 @@ def parse_url_path(self, path: str) -> str:
raise HTTPError(404, 'Resource type not found')
resources = resources[rtype]
rtype = f'_resources/{rtype}'
elif rtype == 'modifiers':
resources = [
st for rs in resources.values() for st in rs.get('stylesheets', [])
if isinstance(st, str)
]

if isinstance(resources, dict):
resources = list(resources.values())
elif isinstance(resources, (str, pathlib.PurePath)):
resources = [resources]
resources = [
component_rel_path(component, resource).replace(os.path.sep, '/')
str(resolve_custom_path(component, resource, relative=True)).replace(os.path.sep, '/')
for resource in resources
]

Expand Down
5 changes: 4 additions & 1 deletion panel/layout/card.py
Expand Up @@ -6,6 +6,7 @@

import param

from ..io.resources import CDN_DIST
from ..models import Card as BkCard
from .base import Column, ListPanel, Row

Expand Down Expand Up @@ -74,7 +75,9 @@ class Card(Column):
'title': None, 'header': None, 'title_css_classes': None
}

_stylesheets: ClassVar[List[str]] = ['css/card.css']
_stylesheets: ClassVar[List[str]] = [
f'{CDN_DIST}css/card.css'
]

def __init__(self, *objects, **params):
self._header_layout = Row(css_classes=['card-header-row'], sizing_mode='stretch_width')
Expand Down
5 changes: 4 additions & 1 deletion panel/layout/grid.py
Expand Up @@ -17,6 +17,7 @@
from bokeh.models import FlexBox as BkFlexBox, GridBox as BkGridBox

from ..io.model import hold
from ..io.resources import CDN_DIST
from .base import (
ListPanel, Panel, _col, _row,
)
Expand Down Expand Up @@ -64,7 +65,9 @@ class GridBox(ListPanel):
'scroll': None, 'objects': None
}

_stylesheets = ['css/gridbox.css']
_stylesheets: ClassVar[List[str]] = [
f'{CDN_DIST}css/gridbox.css'
]

@classmethod
def _flatten_grid(cls, layout, nrows=None, ncols=None):
Expand Down
6 changes: 4 additions & 2 deletions panel/layout/gridstack.py
Expand Up @@ -6,7 +6,7 @@
import param

from ..config import config
from ..io.resources import bundled_files
from ..io.resources import CDN_DIST, bundled_files
from ..reactive import ReactiveHTML
from ..util import classproperty
from .grid import GridSpec
Expand Down Expand Up @@ -133,7 +133,9 @@ class GridStack(ReactiveHTML, GridSpec):
'nrows': 'nrows', 'ncols': 'ncols', 'objects': 'objects'
}

_stylesheets: ClassVar[List[str]] = ['css/gridstack.css']
_stylesheets: ClassVar[List[str]] = [
f'{CDN_DIST}css/gridstack.css'
]

@classproperty
def __js_skip__(cls):
Expand Down
8 changes: 7 additions & 1 deletion panel/layout/spacer.py
@@ -1,11 +1,15 @@
"""
Spacer components to add horizontal or vertical space to a layout.
"""
from __future__ import annotations

from typing import ClassVar, List

import param

from bokeh.models import Div as BkDiv, Spacer as BkSpacer

from ..io.resources import CDN_DIST
from ..reactive import Reactive


Expand Down Expand Up @@ -101,7 +105,9 @@ class Divider(Reactive):

_bokeh_model = BkDiv

_stylesheets = ["css/divider.css"]
_stylesheets: ClassVar[List[str]] = [
f'{CDN_DIST}css/divider.css'
]

def _get_model(self, doc, root=None, parent=None, comm=None):
properties = self._process_param_change(self._init_params())
Expand Down
8 changes: 7 additions & 1 deletion panel/layout/swipe.py
@@ -1,9 +1,13 @@
"""
The Swipe layout enables you to quickly compare two panels
"""
from __future__ import annotations

from typing import ClassVar, List

import param

from ..io.resources import CDN_DIST
from ..reactive import ReactiveHTML
from .base import ListLike

Expand Down Expand Up @@ -83,7 +87,9 @@ class Swipe(ListLike, ReactiveHTML):
"""
}

_stylesheets = ['css/swipe.css']
_stylesheets: ClassVar[List[str]] = [
f'{CDN_DIST}css/swipe.css'
]

def __init__(self, *objects, **params):
if 'objects' in params and objects:
Expand Down

0 comments on commit b7c033a

Please sign in to comment.