|
""" |
|
Altair Plot Sphinx Extension |
|
============================ |
|
|
|
This extension provides a means of inserting live-rendered Altair plots within |
|
sphinx documentation. There are two directives defined: ``altair-setup`` and |
|
``altair-plot``. ``altair-setup`` code is used to set-up various options |
|
prior to running the plot code. For example:: |
|
|
|
.. altair-plot:: |
|
:output: none |
|
|
|
from altair import * |
|
import pandas as pd |
|
data = pd.DataFrame({'a': list('CCCDDDEEE'), |
|
'b': [2, 7, 4, 1, 2, 6, 8, 4, 7]}) |
|
|
|
.. altair-plot:: |
|
|
|
Chart(data).mark_point().encode( |
|
x='a', |
|
y='b' |
|
) |
|
|
|
In the case of the ``altair-plot`` code, the *last statement* of the code-block |
|
should contain the chart object you wish to be rendered. |
|
|
|
Options |
|
------- |
|
The directives have the following options:: |
|
|
|
.. altair-plot:: |
|
:namespace: # specify a plotting namespace that is persistent within the doc |
|
:hide-code: # if set, then hide the code and only show the plot |
|
:code-below: # if set, then code is below rather than above the figure |
|
:output: [plot|repr|stdout|none] |
|
:alt: text # Alternate text when plot cannot be rendered |
|
:links: editor source export # specify one or more of these options |
|
:chart-var-name: chart # name of variable in namespace containing output |
|
|
|
|
|
Additionally, this extension introduces a global configuration |
|
``altairplot_links``, set in your ``conf.py`` which is a dictionary |
|
of links that will appear below plots, unless the ``:links:`` option |
|
again overrides it. It should look something like this:: |
|
|
|
# conf.py |
|
# ... |
|
altairplot_links = {'editor': True, 'source': True, 'export': True} |
|
# ... |
|
|
|
If this configuration is not specified, all are set to True. |
|
""" |
|
|
|
import contextlib |
|
import io |
|
import os |
|
import json |
|
import warnings |
|
|
|
import jinja2 |
|
|
|
from docutils import nodes |
|
from docutils.parsers.rst import Directive |
|
from docutils.parsers.rst.directives import flag, unchanged |
|
|
|
from sphinx.locale import _ |
|
|
|
import altair as alt |
|
from altair.utils.execeval import eval_block |
|
|
|
|
|
VEGA_JS_URL_DEFAULT = "https://cdn.jsdelivr.net/npm/vega@{}".format(alt.VEGA_VERSION) |
|
VEGALITE_JS_URL_DEFAULT = "https://cdn.jsdelivr.net/npm/vega-lite@{}".format( |
|
alt.VEGALITE_VERSION |
|
) |
|
VEGAEMBED_JS_URL_DEFAULT = "https://cdn.jsdelivr.net/npm/vega-embed@{}".format( |
|
alt.VEGAEMBED_VERSION |
|
) |
|
|
|
|
|
VGL_TEMPLATE = jinja2.Template( |
|
""" |
|
<div id="{{ div_id }}"> |
|
<script> |
|
// embed when document is loaded, to ensure vega library is available |
|
// this works on all modern browsers, except IE8 and older |
|
document.addEventListener("DOMContentLoaded", function(event) { |
|
var spec = {{ spec }}; |
|
var opt = { |
|
"mode": "{{ mode }}", |
|
"renderer": "{{ renderer }}", |
|
"actions": {{ actions}} |
|
}; |
|
vegaEmbed('#{{ div_id }}', spec, opt).catch(console.err); |
|
}); |
|
</script> |
|
</div> |
|
""" |
|
) |
|
|
|
|
|
class altair_plot(nodes.General, nodes.Element): |
|
pass |
|
|
|
|
|
def purge_altair_namespaces(app, env, docname): |
|
if not hasattr(env, "_altair_namespaces"): |
|
return |
|
env._altair_namespaces.pop(docname, {}) |
|
|
|
|
|
DEFAULT_ALTAIRPLOT_LINKS = {"editor": True, "source": True, "export": True} |
|
|
|
|
|
def validate_links(links): |
|
if links.strip().lower() == "none": |
|
return False |
|
|
|
links = links.strip().split() |
|
diff = set(links) - set(DEFAULT_ALTAIRPLOT_LINKS.keys()) |
|
if diff: |
|
raise ValueError("Following links are invalid: {}".format(list(diff))) |
|
return {link: link in links for link in DEFAULT_ALTAIRPLOT_LINKS} |
|
|
|
|
|
def validate_output(output): |
|
output = output.strip().lower() |
|
if output not in ["plot", "repr", "stdout", "none"]: |
|
raise ValueError(":output: flag must be one of [plot|repr|stdout|none]") |
|
return output |
|
|
|
|
|
class AltairPlotDirective(Directive): |
|
has_content = True |
|
|
|
option_spec = { |
|
"hide-code": flag, |
|
"code-below": flag, |
|
"namespace": unchanged, |
|
"output": validate_output, |
|
"alt": unchanged, |
|
"links": validate_links, |
|
"chart-var-name": unchanged, |
|
} |
|
|
|
def run(self): |
|
env = self.state.document.settings.env |
|
app = env.app |
|
|
|
show_code = "hide-code" not in self.options |
|
code_below = "code-below" in self.options |
|
|
|
if not hasattr(env, "_altair_namespaces"): |
|
env._altair_namespaces = {} |
|
namespace_id = self.options.get("namespace", "default") |
|
namespace = env._altair_namespaces.setdefault(env.docname, {}).setdefault( |
|
namespace_id, {} |
|
) |
|
|
|
code = "\n".join(self.content) |
|
|
|
if show_code: |
|
source_literal = nodes.literal_block(code, code) |
|
source_literal["language"] = "python" |
|
|
|
|
|
rst_source = self.state_machine.document["source"] |
|
rst_dir = os.path.dirname(rst_source) |
|
rst_filename = os.path.basename(rst_source) |
|
|
|
|
|
serialno = env.new_serialno("altair-plot") |
|
rst_base = rst_filename.replace(".", "-") |
|
div_id = "{}-altair-plot-{}".format(rst_base, serialno) |
|
target_id = "{}-altair-source-{}".format(rst_base, serialno) |
|
target_node = nodes.target("", "", ids=[target_id]) |
|
|
|
|
|
|
|
plot_node = altair_plot() |
|
plot_node["target_id"] = target_id |
|
plot_node["div_id"] = div_id |
|
plot_node["code"] = code |
|
plot_node["namespace"] = namespace |
|
plot_node["relpath"] = os.path.relpath(rst_dir, env.srcdir) |
|
plot_node["rst_source"] = rst_source |
|
plot_node["rst_lineno"] = self.lineno |
|
plot_node["links"] = self.options.get( |
|
"links", app.builder.config.altairplot_links |
|
) |
|
plot_node["output"] = self.options.get("output", "plot") |
|
plot_node["chart-var-name"] = self.options.get("chart-var-name", None) |
|
|
|
if "alt" in self.options: |
|
plot_node["alt"] = self.options["alt"] |
|
|
|
result = [target_node] |
|
|
|
if code_below: |
|
result += [plot_node] |
|
if show_code: |
|
result += [source_literal] |
|
if not code_below: |
|
result += [plot_node] |
|
|
|
return result |
|
|
|
|
|
def html_visit_altair_plot(self, node): |
|
|
|
namespace = node["namespace"] |
|
try: |
|
f = io.StringIO() |
|
with contextlib.redirect_stdout(f): |
|
chart = eval_block(node["code"], namespace) |
|
stdout = f.getvalue() |
|
except Exception as e: |
|
warnings.warn( |
|
"altair-plot: {}:{} Code Execution failed:" |
|
"{}: {}".format( |
|
node["rst_source"], node["rst_lineno"], e.__class__.__name__, str(e) |
|
) |
|
) |
|
raise nodes.SkipNode |
|
|
|
chart_name = node["chart-var-name"] |
|
if chart_name is not None: |
|
if chart_name not in namespace: |
|
raise ValueError( |
|
"chart-var-name='{}' not present in namespace" "".format(chart_name) |
|
) |
|
chart = namespace[chart_name] |
|
|
|
output = node["output"] |
|
|
|
if output == "none": |
|
raise nodes.SkipNode |
|
elif output == "stdout": |
|
if not stdout: |
|
raise nodes.SkipNode |
|
else: |
|
output_literal = nodes.literal_block(stdout, stdout) |
|
output_literal["language"] = "none" |
|
node.extend([output_literal]) |
|
elif output == "repr": |
|
if chart is None: |
|
raise nodes.SkipNode |
|
else: |
|
rep = " " + repr(chart).replace("\n", "\n ") |
|
repr_literal = nodes.literal_block(rep, rep) |
|
repr_literal["language"] = "none" |
|
node.extend([repr_literal]) |
|
elif output == "plot": |
|
if isinstance(chart, alt.TopLevelMixin): |
|
|
|
try: |
|
spec = chart.to_dict() |
|
except alt.utils.schemapi.SchemaValidationError: |
|
raise ValueError("Invalid chart: {0}".format(node["code"])) |
|
actions = node["links"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
html = VGL_TEMPLATE.render( |
|
div_id=node["div_id"], |
|
spec=json.dumps(spec), |
|
mode="vega-lite", |
|
renderer="canvas", |
|
actions=json.dumps(actions), |
|
) |
|
self.body.append(html) |
|
else: |
|
warnings.warn( |
|
"altair-plot: {}:{} Malformed block. Last line of " |
|
"code block should define a valid altair Chart object." |
|
"".format(node["rst_source"], node["rst_lineno"]) |
|
) |
|
raise nodes.SkipNode |
|
|
|
|
|
def generic_visit_altair_plot(self, node): |
|
|
|
if "alt" in node.attributes: |
|
self.body.append(_("[ graph: %s ]") % node["alt"]) |
|
else: |
|
self.body.append(_("[ graph ]")) |
|
raise nodes.SkipNode |
|
|
|
|
|
def depart_altair_plot(self, node): |
|
return |
|
|
|
|
|
def builder_inited(app): |
|
app.add_js_file(app.config.altairplot_vega_js_url) |
|
app.add_js_file(app.config.altairplot_vegalite_js_url) |
|
app.add_js_file(app.config.altairplot_vegaembed_js_url) |
|
|
|
|
|
def setup(app): |
|
setup.app = app |
|
setup.config = app.config |
|
setup.confdir = app.confdir |
|
|
|
app.add_config_value("altairplot_links", DEFAULT_ALTAIRPLOT_LINKS, "env") |
|
|
|
app.add_config_value("altairplot_vega_js_url", VEGA_JS_URL_DEFAULT, "html") |
|
app.add_config_value("altairplot_vegalite_js_url", VEGALITE_JS_URL_DEFAULT, "html") |
|
app.add_config_value( |
|
"altairplot_vegaembed_js_url", VEGAEMBED_JS_URL_DEFAULT, "html" |
|
) |
|
|
|
app.add_directive("altair-plot", AltairPlotDirective) |
|
|
|
app.add_css_file("altair-plot.css") |
|
|
|
app.add_node( |
|
altair_plot, |
|
html=(html_visit_altair_plot, depart_altair_plot), |
|
latex=(generic_visit_altair_plot, depart_altair_plot), |
|
texinfo=(generic_visit_altair_plot, depart_altair_plot), |
|
text=(generic_visit_altair_plot, depart_altair_plot), |
|
man=(generic_visit_altair_plot, depart_altair_plot), |
|
) |
|
|
|
app.connect("env-purge-doc", purge_altair_namespaces) |
|
app.connect("builder-inited", builder_inited) |
|
|
|
return {"version": "0.1"} |
|
|