# ----------------------------------------------------------------------------- # Copyright (c) 2012 - 2021, Anaconda, Inc., and Bokeh Contributors. # All rights reserved. # # The full license is in the file LICENSE.txt, distributed with this software. # ----------------------------------------------------------------------------- """ The resources module provides the Resources class for easily configuring how BokehJS code and CSS resources should be located, loaded, and embedded in Bokeh documents. Additionally, functions for retrieving `Subresource Integrity`_ hashes for Bokeh JavaScript files are provided here. Some pre-configured Resources objects are made available as attributes. Attributes: CDN : load minified BokehJS from CDN INLINE : provide minified BokehJS from library static directory .. _Subresource Integrity: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity """ # ----------------------------------------------------------------------------- # Boilerplate # ----------------------------------------------------------------------------- from __future__ import annotations import logging # isort:skip log = logging.getLogger(__name__) # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- # Standard library imports import json import re from os.path import basename, join, relpath from typing import ( Callable, ClassVar, Dict, List, Tuple, Union, cast, ) # External imports from typing_extensions import Literal, Protocol, get_args # Bokeh imports from . import __version__ from .core.templates import CSS_RESOURCES, JS_RESOURCES from .core.types import ID, PathLike from .model import Model from .settings import LogLevel, settings from .util.dataclasses import dataclass, field from .util.paths import ROOT_DIR, bokehjsdir from .util.token import generate_session_id from .util.version import is_full_release # ----------------------------------------------------------------------------- # Globals and constants # ----------------------------------------------------------------------------- DEFAULT_SERVER_HOST = "localhost" DEFAULT_SERVER_PORT = 5006 DEFAULT_SERVER_HTTP_URL = f"http://{DEFAULT_SERVER_HOST}:{DEFAULT_SERVER_PORT}/" BaseMode = Literal["inline", "cdn", "server", "relative", "absolute"] DevMode = Literal["server-dev", "relative-dev", "absolute-dev"] ResourcesMode = Union[BaseMode, DevMode] # __all__ defined at the bottom on the class module # ----------------------------------------------------------------------------- # General API # ----------------------------------------------------------------------------- # ----------------------------------------------------------------------------- # Dev API # ----------------------------------------------------------------------------- Hashes = Dict[str, str] _SRI_HASHES: Dict[str, Hashes] | None = None def get_all_sri_hashes() -> Dict[str, Hashes]: """ Report SRI script hashes for all versions of BokehJS. Bokeh provides `Subresource Integrity`_ hashes for all JavaScript files that are published to CDN for full releases. This function returns a dictionary that maps version strings to sub-dictionaries that JavaScipt filenames to their hashes. Returns: dict Example: The returned dict will map version strings to sub-dictionaries for each version: .. code-block:: python { '1.4.0': { 'bokeh-1.4.0.js': 'vn/jmieHiN+ST+GOXzRU9AFfxsBp8gaJ/wvrzTQGpIKMsdIcyn6U1TYtvzjYztkN', 'bokeh-1.4.0.min.js': 'mdMpUZqu5U0cV1pLU9Ap/3jthtPth7yWSJTu1ayRgk95qqjLewIkjntQDQDQA5cZ', ... } '1.3.4': { ... } ... } .. _Subresource Integrity: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity """ global _SRI_HASHES if not _SRI_HASHES: with open(join(ROOT_DIR, "_sri.json")) as f: _SRI_HASHES = json.load(f) assert _SRI_HASHES is not None return dict(_SRI_HASHES) def get_sri_hashes_for_version(version: str) -> Hashes: """ Report SRI script hashes for a specific version of BokehJS. Bokeh provides `Subresource Integrity`_ hashes for all JavaScript files that are published to CDN for full releases. This function returns a dictionary that maps JavaScript filenames to their hashes, for a single version of Bokeh. Args: version (str) : The Bokeh version to return SRI hashes for. Hashes are only provided for full releases, e.g "1.4.0", and not for "dev" builds or release candidates. Returns: dict Raises: KeyError: if the specified version does not exist Example: The returned dict for a single version will map filenames for that version to their SRI hashes: .. code-block:: python { 'bokeh-1.4.0.js': 'vn/jmieHiN+ST+GOXzRU9AFfxsBp8gaJ/wvrzTQGpIKMsdIcyn6U1TYtvzjYztkN', 'bokeh-1.4.0.min.js': 'mdMpUZqu5U0cV1pLU9Ap/3jthtPth7yWSJTu1ayRgk95qqjLewIkjntQDQDQA5cZ', 'bokeh-api-1.4.0.js': 'Y3kNQHt7YjwAfKNIzkiQukIOeEGKzUU3mbSrraUl1KVfrlwQ3ZAMI1Xrw5o3Yg5V', 'bokeh-api-1.4.0.min.js': '4oAJrx+zOFjxu9XLFp84gefY8oIEr75nyVh2/SLnyzzg9wR+mXXEi+xyy/HzfBLM', 'bokeh-tables-1.4.0.js': 'I2iTMWMyfU/rzKXWJ2RHNGYfsXnyKQ3YjqQV2RvoJUJCyaGBrp0rZcWiTAwTc9t6', 'bokeh-tables-1.4.0.min.js': 'pj14Cq5ZSxsyqBh+pnL2wlBS3UX25Yz1gVxqWkFMCExcnkN3fl4mbOF8ZUKyh7yl', 'bokeh-widgets-1.4.0.js': 'scpWAebHEUz99AtveN4uJmVTHOKDmKWnzyYKdIhpXjrlvOwhIwEWUrvbIHqA0ke5', 'bokeh-widgets-1.4.0.min.js': 'xR3dSxvH5hoa9txuPVrD63jB1LpXhzFoo0ho62qWRSYZVdyZHGOchrJX57RwZz8l' } .. _Subresource Integrity: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity """ hashes = get_all_sri_hashes() return hashes[version] def verify_sri_hashes() -> None: """ Verify the SRI hashes in a full release package. This function compares the computed SRI hashes for the BokehJS files in a full release package to the values in the SRI manifest file. Returns None if all hashes match, otherwise an exception will be raised. .. note:: This function can only be called on full release (e.g "1.2.3") packages. Returns: None Raises: ValueError If called outside a full release package RuntimeError If there are missing, extra, or mismatched files """ if not is_full_release(): raise ValueError("verify_sri_hashes() can only be used with full releases") from glob import glob paths = glob(join(bokehjsdir(), "js/bokeh*.js")) hashes = get_sri_hashes_for_version(__version__) if len(hashes) < len(paths): raise RuntimeError("There are unexpected 'bokeh*.js' files in the package") if len(hashes) > len(paths): raise RuntimeError("There are 'bokeh*.js' files missing in the package") bad: List[str] = [] for path in paths: name, suffix = basename(path).split(".", 1) filename = f"{name}-{__version__}.{suffix}" sri_hash = _compute_single_hash(path) if hashes[filename] != sri_hash: bad.append(path) if bad: raise RuntimeError(f"SRI Hash mismatches in the package: {bad!r}") PathVersioner = Callable[[str], str] Kind = Literal["css", "js"] @dataclass class RuntimeMessage: type: Literal["warn"] text: str # XXX: https://github.com/python/mypy/issues/5485 class UrlsFn(Protocol): @staticmethod def __call__(components: List[str], kind: Kind) -> List[str]: ... class HashesFn(Protocol): @staticmethod def __call__(components: List[str], kind: Kind) -> Hashes: ... @dataclass class Urls: urls: UrlsFn messages: List[RuntimeMessage] = field(default_factory=list) hashes: HashesFn | None = None ResourceAttr = Literal["__css__", "__javascript__"] class BaseResources: _default_root_dir = "." _default_root_url = DEFAULT_SERVER_HTTP_URL mode: BaseMode messages: List[RuntimeMessage] _log_level: LogLevel _js_components: ClassVar[List[str]] _css_components: ClassVar[List[str]] def __init__( self, mode: ResourcesMode | None = None, version: str | None = None, root_dir: PathLike | None = None, minified: bool | None = None, legacy: bool | None = None, log_level: LogLevel | None = None, root_url: str | None = None, path_versioner: PathVersioner | None = None, components: List[str] | None = None, base_dir: str | None = None, # TODO: PathLike ): self._components = components if hasattr(self, "_js_components"): self.js_components = self._js_components if hasattr(self, "_css_components"): self.css_components = self._css_components mode = settings.resources(mode) self.dev = mode.endswith("-dev") self.mode = cast(BaseMode, mode[:-4] if self.dev else mode) if self.mode not in get_args(BaseMode): raise ValueError( "wrong value for 'mode' parameter, expected " f"'inline', 'cdn', 'server(-dev)', 'relative(-dev)' or 'absolute(-dev)', got {mode}" ) if root_dir and not self.mode.startswith("relative"): raise ValueError("setting 'root_dir' makes sense only when 'mode' is set to 'relative'") if version and not self.mode.startswith("cdn"): raise ValueError("setting 'version' makes sense only when 'mode' is set to 'cdn'") if root_url and not self.mode.startswith("server"): raise ValueError("setting 'root_url' makes sense only when 'mode' is set to 'server'") self.root_dir = settings.rootdir(root_dir) del root_dir self.version = settings.cdn_version(version) del version self.minified = settings.minified(minified) del minified self.legacy = settings.legacy(legacy) del legacy self.log_level = settings.log_level(log_level) del log_level self.path_versioner = path_versioner del path_versioner if root_url and not root_url.endswith("/"): # root_url should end with a /, adding one root_url = root_url + "/" self._root_url = root_url self.messages = [] if self.mode == "cdn": cdn = self._cdn_urls() self.messages.extend(cdn.messages) elif self.mode == "server": server = self._server_urls() self.messages.extend(server.messages) self.base_dir = base_dir or bokehjsdir(self.dev) # Properties -------------------------------------------------------------- @property def log_level(self) -> LogLevel: return self._log_level @log_level.setter def log_level(self, level: LogLevel) -> None: valid_levels = get_args(LogLevel) if not (level is None or level in valid_levels): raise ValueError(f"Unknown log level '{level}', valid levels are: {valid_levels}") self._log_level = level @property def root_url(self) -> str: if self._root_url is not None: return self._root_url else: return self._default_root_url # Public methods ---------------------------------------------------------- def components(self, kind: Kind) -> List[str]: components = self.js_components if kind == "js" else self.css_components if self._components is not None: components = [c for c in components if c in self._components] return components def _file_paths(self, kind: Kind) -> List[str]: minified = ".min" if not self.dev and self.minified else "" legacy = ".legacy" if self.legacy else "" files = [f"{component}{legacy}{minified}.{kind}" for component in self.components(kind)] paths = [join(self.base_dir, kind, file) for file in files] return paths def _collect_external_resources(self, resource_attr: ResourceAttr) -> List[str]: """ Collect external resources set on resource_attr attribute of all models.""" external_resources: List[str] = [] for _, cls in sorted(Model.model_class_reverse_map.items(), key=lambda arg: arg[0]): external: List[str] | str | None = getattr(cls, resource_attr, None) if isinstance(external, str): if external not in external_resources: external_resources.append(external) elif isinstance(external, list): for e in external: if e not in external_resources: external_resources.append(e) return external_resources def _cdn_urls(self) -> Urls: return _get_cdn_urls(self.version, self.minified, self.legacy) def _server_urls(self) -> Urls: return _get_server_urls(self.root_url, False if self.dev else self.minified, self.legacy, self.path_versioner) def _resolve(self, kind: Kind) -> Tuple[List[str], List[str], Hashes]: paths = self._file_paths(kind) files, raw = [], [] hashes = {} if self.mode == "inline": raw = [self._inline(path) for path in paths] elif self.mode == "relative": root_dir = self.root_dir or self._default_root_dir files = [relpath(path, root_dir) for path in paths] elif self.mode == "absolute": files = list(paths) elif self.mode == "cdn": cdn = self._cdn_urls() files = list(cdn.urls(self.components(kind), kind)) if cdn.hashes: hashes = cdn.hashes(self.components(kind), kind) elif self.mode == "server": server = self._server_urls() files = list(server.urls(self.components(kind), kind)) return (files, raw, hashes) @staticmethod def _inline(path: str) -> str: filename = basename(path) begin = f"/* BEGIN {filename} */" with open(path, "rb") as f: middle = f.read().decode("utf-8") end = f"/* END {filename} */" return f"{begin}\n{middle}\n{end}" class JSResources(BaseResources): """ The Resources class encapsulates information relating to loading or embedding Bokeh Javascript. Args: mode (str) : How should Bokeh JS be included in output See below for descriptions of available modes version (str, optional) : what version of Bokeh JS to load Only valid with the ``'cdn'`` mode root_dir (str, optional) : root directory for loading Bokeh JS assets Only valid with ``'relative'`` and ``'relative-dev'`` modes minified (bool, optional) : whether JavaScript should be minified or not (default: True) root_url (str, optional) : URL and port of Bokeh Server to load resources from (default: None) If ``None``, absolute URLs based on the default server configuration will be generated. ``root_url`` can also be the empty string, in which case relative URLs, e.g., "static/js/bokeh.min.js", are generated. Only valid with ``'server'`` and ``'server-dev'`` modes The following **mode** values are available for configuring a Resource object: * ``'inline'`` configure to provide entire Bokeh JS and CSS inline * ``'cdn'`` configure to load Bokeh JS and CSS from ``https://cdn.bokeh.org`` * ``'server'`` configure to load from a Bokeh Server * ``'server-dev'`` same as ``server`` but supports non-minified assets * ``'relative'`` configure to load relative to the given directory * ``'relative-dev'`` same as ``relative`` but supports non-minified assets * ``'absolute'`` configure to load from the installed Bokeh library static directory * ``'absolute-dev'`` same as ``absolute`` but supports non-minified assets Once configured, a Resource object exposes the following public attributes: Attributes: css_raw : any raw CSS that needs to be places inside ``