Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve handling of stylesheet URLs #4540

Merged
merged 11 commits into from
Mar 20, 2023
Merged
35 changes: 5 additions & 30 deletions panel/compiler.py
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading