2

I'm building a custom sphinx extension and Directive to render interactive charts on sphinx and ReadTheDocs. The actual data for a chart resides in a .json file.

In a .rst document, including a chart looks like this:

.. chart:: charts/test.json
    :width: 400px
    :height: 250px

    This is the caption of the chart, yay...

The steps I take are:

  1. Render the documentation with a placeholder the same size as the chart (a loading spinner)

  2. Use jQuery (in an added javascript file) to get the URI (which I add as an attribute to the dom node in my Directive) of the json file (in this case charts/test.json)

  3. Use jQuery to fetch the file and parse the JSON

  4. On successful fetch of the data, use the plotly library to render it into a chart and use jQuery to remove the placeholder


The directive looks like:


class PlotlyChartDirective(Directive):
    """ Top-level plotly chart directive """

    has_content = True

    def px_value(argument):
        # This is not callable as self.align.  We cannot make it a
        # staticmethod because we're saving an unbound method in
        # option_spec below.
        return directives.length_or_percentage_or_unitless(argument, 'px')

    required_arguments = 1
    optional_arguments = 0

    option_spec = {
        # TODO allow static images for PDF renders   'altimage': directives.unchanged,
        'height': px_value,
        'width': px_value,
    }

    def run(self):
        """ Parse a plotly chart directive """

        self.assert_has_content()
        env = self.state.document.settings.env
        # Ensure the current chart ID is initialised in the environment
        if 'next_plotly_chart_id' not in env.temp_data:
            env.temp_data['next_plotly_chart_id'] = 0

        id = env.temp_data['next_plotly_chart_id']

        # Handle the URI of the *.json asset
        uri = directives.uri(self.arguments[0])

        # Create the main node container and store the URI of the file which will be collected later
        node = nodes.container()
        node['classes'] = ['sphinx-plotly']

        # Increment the ID counter ready for the next chart
        env.temp_data['next_plotly_chart_id'] += 1

        # Only if its a supported builder do we proceed (otherwise return an empty node)
        if env.app.builder.name in get_compatible_builders(env.app):

            chart_node = nodes.container()
            chart_node['classes'] = ['sphinx-plotly-chart', f"sphinx-plotly-chart-id-{id}", f"sphinx-plotly-chart-uri-{uri}"]

            placeholder_node = nodes.container()
            placeholder_node['classes'] = ['sphinx-plotly-placeholder', f"sphinx-plotly-placeholder-{id}"]
            placeholder_node += nodes.caption('', 'Loading...')

            node += chart_node
            node += placeholder_node

            # Add optional chart caption and legend (inspired by Figure directive)
            if self.content:
                caption_node = nodes.Element()  # Anonymous container for parsing
                self.state.nested_parse(self.content, self.content_offset, caption_node)
                first_node = caption_node[0]
                if isinstance(first_node, nodes.paragraph):
                    caption = nodes.caption(first_node.rawsource, '', *first_node.children)
                    caption.source = first_node.source
                    caption.line = first_node.line
                    node += caption
                elif not (isinstance(first_node, nodes.comment) and len(first_node) == 0):
                    error = self.state_machine.reporter.error(
                        'Chart caption must be a paragraph or empty comment.',
                        nodes.literal_block(self.block_text, self.block_text),
                        line=self.lineno)
                    return [node, error]
                if len(caption_node) > 1:
                    node += nodes.legend('', *caption_node[1:])

        return [node]

No matter where I look in the source code of the Figure and Image directives (on which I'm basing this) I can't figure out how to copy the acutal image from its input location, into the static folder in the build directory.

Without copying the *.json file specified in the argument to my directive, I always get a file not found!

I've tried hard to find supporting methods (I assumed that the Sphinx app instance would have an add_static_file() method just like it has an add_css_file() method).

I've also tried adding a list of files to copy to the app instance, then copying assets at the end of the build (but am being thwarted because you can't add attributes to the Sphinx class)

QUESTION IN A NUTSHELL

In a custom directive, how do you copy an asset (whose path is specified by an argument to the directive) to the build's _static directory?

mzjn
  • 48,958
  • 13
  • 128
  • 248
thclark
  • 4,784
  • 3
  • 39
  • 65

1 Answers1

1

I realised it was straightforward to use sphinx's copyfile utility (which only copies if a file is changed, so is quick) right within the directive's run() method.

See how I get the src_uri and build_uri and copy the file directly within this updated Directive:

class PlotlyChartDirective(Directive):
    """ Top-level plotly chart directive """

    has_content = True

    def px_value(argument):
        # This is not callable as self.align.  We cannot make it a
        # staticmethod because we're saving an unbound method in
        # option_spec below.
        return directives.length_or_percentage_or_unitless(argument, 'px')

    required_arguments = 1
    optional_arguments = 0

    option_spec = {
        # TODO allow static images for PDF renders   'altimage': directives.unchanged,
        'height': px_value,
        'width': px_value,
    }

    def run(self):
        """ Parse a plotly chart directive """
        self.assert_has_content()
        env = self.state.document.settings.env

        # Ensure the current chart ID is initialised in the environment
        if 'next_plotly_chart_id' not in env.temp_data:
            env.temp_data['next_plotly_chart_id'] = 0

        # Get the ID of this chart
        id = env.temp_data['next_plotly_chart_id']

        # Handle the src and destination URI of the *.json asset
        uri = directives.uri(self.arguments[0])
        src_uri = os.path.join(env.app.builder.srcdir, uri)
        build_uri = os.path.join(env.app.builder.outdir, '_static', uri)

        # Create the main node container and store the URI of the file which will be collected later
        node = nodes.container()
        node['classes'] = ['sphinx-plotly']

        # Increment the ID counter ready for the next chart
        env.temp_data['next_plotly_chart_id'] += 1

        # Only if its a supported builder do we proceed (otherwise return an empty node)
        if env.app.builder.name in get_compatible_builders(env.app):

            # Make the directories and copy file (if file has changed)
            destdir = os.path.dirname(build_uri)
            if not os.path.exists(destdir):
                os.makedirs(destdir)

            copyfile(src_uri, build_uri)

            width = self.options.pop('width', DEFAULT_WIDTH)
            height = self.options.pop('height', DEFAULT_HEIGHT)

            chart_node = nodes.container()
            chart_node['classes'] = ['sphinx-plotly-chart', f"sphinx-plotly-chart-id-{id}", f"sphinx-plotly-chart-uri-{uri}"]

            placeholder_node = nodes.container()
            placeholder_node['classes'] = ['sphinx-plotly-placeholder', f"sphinx-plotly-placeholder-{id}"]
            placeholder_node += nodes.caption('', 'Loading...')

            node += chart_node
            node += placeholder_node

            # Add optional chart caption and legend (as per figure directive)
            if self.content:
                caption_node = nodes.Element()  # Anonymous container for parsing
                self.state.nested_parse(self.content, self.content_offset, caption_node)
                first_node = caption_node[0]
                if isinstance(first_node, nodes.paragraph):
                    caption = nodes.caption(first_node.rawsource, '', *first_node.children)
                    caption.source = first_node.source
                    caption.line = first_node.line
                    node += caption
                elif not (isinstance(first_node, nodes.comment) and len(first_node) == 0):
                    error = self.state_machine.reporter.error(
                        'Chart caption must be a paragraph or empty comment.',
                        nodes.literal_block(self.block_text, self.block_text),
                        line=self.lineno)
                    return [node, error]
                if len(caption_node) > 1:
                    node += nodes.legend('', *caption_node[1:])

        return [node]

thclark
  • 4,784
  • 3
  • 39
  • 65
  • I'm using the built-in `.. raw:: html` and I'm attempting to reference to an image foo.jpg at the same directory of the index.rst, however the output on ReadTheDocs does not contain my image foo.jpg. Do I have to use a customized directive for such a presumably common need? – RayLuo May 06 '21 at 23:59