text: """
  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
  
  # These default URLs can be changed in conf.py; see setup() below.
  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"
  
          # get the name of the source file we are currently processing
          rst_source = self.state_machine.document["source"]
          rst_dir = os.path.dirname(rst_source)
          rst_filename = os.path.basename(rst_source)
  
          # use the source file name to construct a friendly target_id
          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])
  
          # create the node in which the plot will appear;
          # this will be processed by html_visit_altair_plot
          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):
      # Execute the code, saving output and namespace
      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):
              # Last line should be a chart; convert to spec dict
              try:
                  spec = chart.to_dict()
              except alt.utils.schemapi.SchemaValidationError:
                  raise ValueError("Invalid chart: {0}".format(node["code"]))
              actions = node["links"]
  
              # TODO: add an option to save spects to file & load from there.
              # TODO: add renderer option
  
              # Write spec to a *.vl.json file
              # dest_dir = os.path.join(self.builder.outdir, node['relpath'])
              # if not os.path.exists(dest_dir):
              #     os.makedirs(dest_dir)
              # filename = "{0}.vl.json".format(node['target_id'])
              # dest_path = os.path.join(dest_dir, filename)
              # with open(dest_path, 'w') as f:
              #     json.dump(spec, f)
  
              # Pass relevant info into the template and append to the output
              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):
      # TODO: generate PNGs and insert them here
      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_javascript(app.config.altairplot_vega_js_url)
      app.add_javascript(app.config.altairplot_vegalite_js_url)
      app.add_javascript(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_stylesheet("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"}
  