# -----------------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------------
""" Include Bokeh plots in Sphinx HTML documentation.
For other output types, the placeholder text ``[graph]`` will
be generated.
The ``bokeh-plot`` directive can be used by either supplying:
**A path to a source file** as the argument to the directive::
.. bokeh-plot:: path/to/plot.py
.. note::
.py scripts are not scanned automatically! In order to include
certain directories into .py scanning process use following directive
in sphinx conf.py file: bokeh_plot_pyfile_include_dirs = ["dir1","dir2"]
**Inline code** as the content of the directive::
.. bokeh-plot::
from bokeh.plotting import figure, output_file, show
output_file("example.html")
x = [1, 2, 3, 4, 5]
y = [6, 7, 6, 4, 5]
p = figure(title="example", width=300, height=300)
p.line(x, y, line_width=2)
p.circle(x, y, size=10, fill_color="white")
show(p)
This directive also works in conjunction with Sphinx autodoc, when
used in docstrings.
The ``bokeh-plot`` directive accepts the following options:
process-docstring (bool):
Whether to display the docstring in a formatted block
separate from the source.
source-position (enum('above', 'below', 'none')):
Where to locate the the block of formatted source
code (if anywhere).
linenos (bool):
Whether to display line numbers along with the source.
Examples
--------
The inline example code above produces the following output:
.. bokeh-plot::
from bokeh.plotting import figure, output_file, show
output_file("example.html")
x = [1, 2, 3, 4, 5]
y = [6, 7, 6, 4, 5]
p = figure(title="example", width=300, height=300)
p.line(x, y, line_width=2)
p.circle(x, y, size=10, fill_color="white")
show(p)
"""
#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations
# use the wrapped sphinx logger
from sphinx.util import logging # isort:skip
log = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
# Standard library imports
import re
import warnings
from os import getenv
from os.path import basename, dirname, join
from uuid import uuid4
# External imports
from docutils import nodes
from docutils.parsers.rst.directives import choice, flag
from sphinx.errors import SphinxError
from sphinx.util import copyfile, ensuredir, status_iterator
from sphinx.util.nodes import set_source_info
# Bokeh imports
from bokeh.document import Document
from bokeh.embed import autoload_static
from bokeh.model import Model
from bokeh.util.warnings import BokehDeprecationWarning
# Bokeh imports
from . import PARALLEL_SAFE
from .bokeh_directive import BokehDirective
from .example_handler import ExampleHandler
from .util import get_sphinx_resources
# -----------------------------------------------------------------------------
# Globals and constants
# -----------------------------------------------------------------------------
__all__ = (
"autoload_script",
"BokehPlotDirective",
"setup",
)
GOOGLE_API_KEY = getenv("GOOGLE_API_KEY")
RESOURCES = get_sphinx_resources()
# -----------------------------------------------------------------------------
# General API
# -----------------------------------------------------------------------------
class autoload_script(nodes.General, nodes.Element):
@staticmethod
def visit_html(visitor, node):
script_tag = node["script_tag"]
height_hint = node["height_hint"]
if height_hint:
visitor.body.append(f'
')
visitor.body.append(script_tag)
if height_hint:
visitor.body.append("
")
raise nodes.SkipNode
html = visit_html.__func__, None
class BokehPlotDirective(BokehDirective):
has_content = True
optional_arguments = 2
option_spec = {
"process-docstring": lambda x: flag(x) is None,
"source-position": lambda x: choice(x, ("below", "above", "none")),
"linenos": lambda x: flag(x) is None,
}
def run(self):
source, path = self.process_args_or_content()
dashed_docname = self.env.docname.replace("/", "-")
js_filename = f"bokeh-content-{uuid4().hex}-{dashed_docname}.js"
try:
(script_tag, js_path, source, docstring, height_hint) = self.process_source(source, path, js_filename)
except Exception as e:
raise SphinxError(f"Error generating {js_filename}: \n\n{e}")
self.env.bokeh_plot_files.add((js_path, dirname(self.env.docname)))
# use the source file name to construct a friendly target_id
target_id = f"{dashed_docname}.{basename(js_path)}"
target = [nodes.target("", "", ids=[target_id])]
process_docstring = self.options.get("process-docstring", False)
intro = self.parse(docstring, '') if docstring and process_docstring else []
above, below = self.process_code_block(source, docstring)
autoload = [autoload_script(height_hint=height_hint, script_tag=script_tag)]
return target + intro + above + autoload + below
def process_code_block(self, source: str, docstring: str|None):
source_position = self.options.get("source-position", "below")
if source_position == "none":
return [], []
source = _remove_module_docstring(source, docstring).strip()
linenos = self.options.get("linenos", False)
code_block = nodes.literal_block(source, source, language="python", linenos=linenos, classes=[])
set_source_info(self, code_block)
if source_position == "above":
return [code_block], []
if source_position == "below":
return [], [code_block]
def process_args_or_content(self):
# filename *or* python code content, but not both
if self.arguments and self.content:
raise SphinxError("bokeh-plot:: directive can't have both args and content")
if self.content:
log.debug(f"[bokeh-plot] handling inline content in {self.env.docname!r}")
path = self.env.bokeh_plot_auxdir # code runner just needs any real path
return "\n".join(self.content), path
path = self.arguments[0]
log.debug(f"[bokeh-plot] handling external content in {self.env.docname!r}: {path}")
if not path.startswith("/"):
path = join(self.env.app.srcdir, path)
try:
with open(path) as f:
return f.read(), path
except Exception as e:
raise SphinxError(f"bokeh-plot:: error reading source for {self.env.docname!r}: {e!r}")
def process_source(self, source, path, js_filename):
Model._clear_extensions()
root, docstring = _evaluate_source(source, path, self.env)
height_hint = root._sphinx_height_hint()
js_path = join(self.env.bokeh_plot_auxdir, js_filename)
js, script_tag = autoload_static(root, RESOURCES, js_filename)
with open(js_path, "w") as f:
f.write(js)
return (script_tag, js_path, source, docstring, height_hint)
# -----------------------------------------------------------------------------
# Dev API
# -----------------------------------------------------------------------------
def builder_inited(app):
app.env.bokeh_plot_auxdir = join(app.env.doctreedir, "bokeh_plot")
ensuredir(app.env.bokeh_plot_auxdir) # sphinx/build/doctrees/bokeh_plot
if not hasattr(app.env, "bokeh_plot_files"):
app.env.bokeh_plot_files = set()
def build_finished(app, exception):
files = sorted(app.env.bokeh_plot_files)
files_iter = status_iterator(files, "copying bokeh-plot files... ", "brown", len(files), app.verbosity, stringify_func=lambda x: basename(x[0]))
for (file, docpath) in files_iter:
target = join(app.builder.outdir, docpath, basename(file))
ensuredir(dirname(target))
try:
copyfile(file, target)
except OSError as e:
raise SphinxError(f"cannot copy local file {file!r}, reason: {e}")
def env_merge_info(app, env, docnames, other):
env.bokeh_plot_files |= other.bokeh_plot_files
def setup(app):
""" Required Sphinx extension setup function. """
app.add_directive("bokeh-plot", BokehPlotDirective)
app.add_node(autoload_script, html=autoload_script.html)
app.add_config_value("bokeh_missing_google_api_key_ok", True, "html")
app.connect("builder-inited", builder_inited)
app.connect("build-finished", build_finished)
app.connect("env-merge-info", env_merge_info)
return PARALLEL_SAFE
# -----------------------------------------------------------------------------
# Private API
# -----------------------------------------------------------------------------
# quick and dirty way to inject Google API key
def _replace_google_api_key(source: str, env) -> str:
if "GOOGLE_API_KEY" not in source:
return source
if GOOGLE_API_KEY is None:
if env.config.bokeh_missing_google_api_key_ok:
return source.replace("GOOGLE_API_KEY", "MISSING_API_KEY")
raise SphinxError(
"The GOOGLE_API_KEY environment variable is not set. Set GOOGLE_API_KEY to a valid API key, "
"or set bokeh_missing_google_api_key_ok=True in conf.py to build anyway (with broken GMaps)"
)
return source.replace("GOOGLE_API_KEY", GOOGLE_API_KEY)
def _evaluate_source(source: str, filename: str, env):
source = _replace_google_api_key(source, env)
c = ExampleHandler(source=source, filename=filename)
d = Document()
# We may need to instantiate deprecated objects as part of documenting them
# in the reference guide. Suppress warnings here to keep the build clean
with warnings.catch_warnings():
if "reference" in env.docname:
warnings.filterwarnings("ignore", category=BokehDeprecationWarning)
c.modify_document(d)
if c.error:
raise RuntimeError(f"bokeh-plot:: error:\n\n{c.error_detail}\n\nevaluating source:\n\n{source}")
if len(d.roots) != 1:
raise RuntimeError(f"bokeh-plot:: directive expects a single Document root, got {len(d.roots)}")
return d.roots[0], c.doc.strip() if c.doc else None
def _remove_module_docstring(source, docstring):
if docstring is None:
return source
return re.sub(rf'(\'\'\'|\"\"\")\s*{re.escape(docstring)}\s*(\'\'\'|\"\"\")', "", source)
# -----------------------------------------------------------------------------
# Code
# -----------------------------------------------------------------------------