mirror of
https://github.com/sphinx-doc/sphinx.git
synced 2025-02-25 18:55:22 -06:00
doc: Add "recipe" tutorial
This is based on a post from opensource.com [1] and demonstrates how one can use indexes for cross-referencing and domains to group these indexes along with domains and roles. The source code was taken from [2] after getting the license changed [3]. [1] https://opensource.com/article/18/11/building-custom-workflows-sphinx [2] https://github.com/ofosos/sphinxrecipes [3] https://github.com/ofosos/sphinxrecipes/issues/1 Signed-off-by: Stephen Finucane <stephen@that.guru>
This commit is contained in:
parent
93081e2fce
commit
b1ebdcd3c6
239
doc/development/tutorials/examples/recipe.py
Normal file
239
doc/development/tutorials/examples/recipe.py
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import docutils
|
||||||
|
from docutils import nodes
|
||||||
|
from docutils.parsers import rst
|
||||||
|
from docutils.parsers.rst import directives
|
||||||
|
import sphinx
|
||||||
|
from sphinx import addnodes
|
||||||
|
from sphinx.directives import ObjectDescription
|
||||||
|
from sphinx.domains import Domain
|
||||||
|
from sphinx.domains import Index
|
||||||
|
from sphinx.domains.std import StandardDomain
|
||||||
|
from sphinx.roles import XRefRole
|
||||||
|
from sphinx.util.nodes import make_refnode
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeDirective(ObjectDescription):
|
||||||
|
"""A custom directive that describes a recipe."""
|
||||||
|
|
||||||
|
has_content = True
|
||||||
|
required_arguments = 1
|
||||||
|
option_spec = {
|
||||||
|
'contains': directives.unchanged_required
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_signature(self, sig, signode):
|
||||||
|
signode += addnodes.desc_name(text=sig)
|
||||||
|
signode += addnodes.desc_type(text='Recipe')
|
||||||
|
return sig
|
||||||
|
|
||||||
|
def add_target_and_index(self, name_cls, sig, signode):
|
||||||
|
signode['ids'].append('recipe' + '-' + sig)
|
||||||
|
if 'noindex' not in self.options:
|
||||||
|
name = '{}.{}.{}'.format('rcp', type(self).__name__, sig)
|
||||||
|
imap = self.env.domaindata['rcp']['obj2ingredient']
|
||||||
|
imap[name] = list(self.options.get('contains').split(' '))
|
||||||
|
objs = self.env.domaindata['rcp']['objects']
|
||||||
|
objs.append((name,
|
||||||
|
sig,
|
||||||
|
'Recipe',
|
||||||
|
self.env.docname,
|
||||||
|
'recipe' + '-' + sig,
|
||||||
|
0))
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientIndex(Index):
|
||||||
|
"""A custom directive that creates an ingredient matrix."""
|
||||||
|
|
||||||
|
name = 'ing'
|
||||||
|
localname = 'Ingredient Index'
|
||||||
|
shortname = 'Ingredient'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(IngredientIndex, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def generate(self, docnames=None):
|
||||||
|
"""Return entries for the index given by *name*.
|
||||||
|
|
||||||
|
If *docnames* is given, restrict to entries referring to these
|
||||||
|
docnames. The return value is a tuple of ``(content, collapse)``,
|
||||||
|
where:
|
||||||
|
|
||||||
|
*collapse* is a boolean that determines if sub-entries should
|
||||||
|
start collapsed (for output formats that support collapsing
|
||||||
|
sub-entries).
|
||||||
|
|
||||||
|
*content* is a sequence of ``(letter, entries)`` tuples, where *letter*
|
||||||
|
is the "heading" for the given *entries*, usually the starting letter.
|
||||||
|
|
||||||
|
*entries* is a sequence of single entries, where a single entry is a
|
||||||
|
sequence ``[name, subtype, docname, anchor, extra, qualifier, descr]``.
|
||||||
|
|
||||||
|
The items in this sequence have the following meaning:
|
||||||
|
|
||||||
|
- `name` -- the name of the index entry to be displayed
|
||||||
|
- `subtype` -- sub-entry related type:
|
||||||
|
- ``0`` -- normal entry
|
||||||
|
- ``1`` -- entry with sub-entries
|
||||||
|
- ``2`` -- sub-entry
|
||||||
|
- `docname` -- docname where the entry is located
|
||||||
|
- `anchor` -- anchor for the entry within `docname`
|
||||||
|
- `extra` -- extra info for the entry
|
||||||
|
- `qualifier` -- qualifier for the description
|
||||||
|
- `descr` -- description for the entry
|
||||||
|
|
||||||
|
Qualifier and description are not rendered by some builders, such as
|
||||||
|
the LaTeX builder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = {}
|
||||||
|
|
||||||
|
objs = {name: (dispname, typ, docname, anchor)
|
||||||
|
for name, dispname, typ, docname, anchor, prio
|
||||||
|
in self.domain.get_objects()}
|
||||||
|
|
||||||
|
imap = {}
|
||||||
|
ingr = self.domain.data['obj2ingredient']
|
||||||
|
for name, ingr in ingr.items():
|
||||||
|
for ig in ingr:
|
||||||
|
imap.setdefault(ig,[])
|
||||||
|
imap[ig].append(name)
|
||||||
|
|
||||||
|
for ingredient in imap.keys():
|
||||||
|
lis = content.setdefault(ingredient, [])
|
||||||
|
objlis = imap[ingredient]
|
||||||
|
for objname in objlis:
|
||||||
|
dispname, typ, docname, anchor = objs[objname]
|
||||||
|
lis.append((
|
||||||
|
dispname, 0, docname,
|
||||||
|
anchor,
|
||||||
|
docname, '', typ
|
||||||
|
))
|
||||||
|
re = [(k, v) for k, v in sorted(content.items())]
|
||||||
|
|
||||||
|
return (re, True)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeIndex(Index):
|
||||||
|
name = 'rcp'
|
||||||
|
localname = 'Recipe Index'
|
||||||
|
shortname = 'Recipe'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(RecipeIndex, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def generate(self, docnames=None):
|
||||||
|
"""Return entries for the index given by *name*.
|
||||||
|
|
||||||
|
If *docnames* is given, restrict to entries referring to these
|
||||||
|
docnames. The return value is a tuple of ``(content, collapse)``,
|
||||||
|
where:
|
||||||
|
|
||||||
|
*collapse* is a boolean that determines if sub-entries should
|
||||||
|
start collapsed (for output formats that support collapsing
|
||||||
|
sub-entries).
|
||||||
|
|
||||||
|
*content* is a sequence of ``(letter, entries)`` tuples, where *letter*
|
||||||
|
is the "heading" for the given *entries*, usually the starting letter.
|
||||||
|
|
||||||
|
*entries* is a sequence of single entries, where a single entry is a
|
||||||
|
sequence ``[name, subtype, docname, anchor, extra, qualifier, descr]``.
|
||||||
|
|
||||||
|
The items in this sequence have the following meaning:
|
||||||
|
|
||||||
|
- `name` -- the name of the index entry to be displayed
|
||||||
|
- `subtype` -- sub-entry related type:
|
||||||
|
- ``0`` -- normal entry
|
||||||
|
- ``1`` -- entry with sub-entries
|
||||||
|
- ``2`` -- sub-entry
|
||||||
|
- `docname` -- docname where the entry is located
|
||||||
|
- `anchor` -- anchor for the entry within `docname`
|
||||||
|
- `extra` -- extra info for the entry
|
||||||
|
- `qualifier` -- qualifier for the description
|
||||||
|
- `descr` -- description for the entry
|
||||||
|
|
||||||
|
Qualifier and description are not rendered by some builders, such as
|
||||||
|
the LaTeX builder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = {}
|
||||||
|
items = ((name, dispname, typ, docname, anchor)
|
||||||
|
for name, dispname, typ, docname, anchor, prio
|
||||||
|
in self.domain.get_objects())
|
||||||
|
items = sorted(items, key=lambda item: item[0])
|
||||||
|
for name, dispname, typ, docname, anchor in items:
|
||||||
|
lis = content.setdefault('Recipe', [])
|
||||||
|
lis.append((
|
||||||
|
dispname, 0, docname,
|
||||||
|
anchor,
|
||||||
|
docname, '', typ
|
||||||
|
))
|
||||||
|
re = [(k, v) for k, v in sorted(content.items())]
|
||||||
|
|
||||||
|
return (re, True)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeDomain(Domain):
|
||||||
|
|
||||||
|
name = 'rcp'
|
||||||
|
label = 'Recipe Sample'
|
||||||
|
|
||||||
|
roles = {
|
||||||
|
'reref': XRefRole()
|
||||||
|
}
|
||||||
|
|
||||||
|
directives = {
|
||||||
|
'recipe': RecipeDirective,
|
||||||
|
}
|
||||||
|
|
||||||
|
indices = {
|
||||||
|
RecipeIndex,
|
||||||
|
IngredientIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
initial_data = {
|
||||||
|
'objects': [], # object list
|
||||||
|
'obj2ingredient': {}, # name -> object
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_full_qualified_name(self, node):
|
||||||
|
"""Return full qualified name for a given node"""
|
||||||
|
return "{}.{}.{}".format('rcp',
|
||||||
|
type(node).__name__,
|
||||||
|
node.arguments[0])
|
||||||
|
|
||||||
|
def get_objects(self):
|
||||||
|
for obj in self.data['objects']:
|
||||||
|
yield(obj)
|
||||||
|
|
||||||
|
def resolve_xref(self, env, fromdocname, builder, typ, target, node,
|
||||||
|
contnode):
|
||||||
|
|
||||||
|
match = [(docname, anchor)
|
||||||
|
for name, sig, typ, docname, anchor, prio
|
||||||
|
in self.get_objects() if sig == target]
|
||||||
|
|
||||||
|
if len(match) > 0:
|
||||||
|
todocname = match[0][0]
|
||||||
|
targ = match[0][1]
|
||||||
|
|
||||||
|
return make_refnode(builder, fromdocname, todocname, targ,
|
||||||
|
contnode, targ)
|
||||||
|
else:
|
||||||
|
print("Awww, found nothing")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app):
|
||||||
|
app.add_domain(RecipeDomain)
|
||||||
|
|
||||||
|
StandardDomain.initial_data['labels']['recipeindex'] = (
|
||||||
|
'rcp-rcp', '', 'Recipe Index')
|
||||||
|
StandardDomain.initial_data['labels']['ingredientindex'] = (
|
||||||
|
'rcp-ing', '', 'Ingredient Index')
|
||||||
|
|
||||||
|
StandardDomain.initial_data['anonlabels']['recipeindex'] = (
|
||||||
|
'rcp-rcp', '')
|
||||||
|
StandardDomain.initial_data['anonlabels']['ingredientindex'] = (
|
||||||
|
'rcp-ing', '')
|
||||||
|
|
||||||
|
return {'version': '0.1'} # identifies the version of our extension
|
@ -9,3 +9,4 @@ Refer to the following tutorials to get started with extension development.
|
|||||||
|
|
||||||
helloworld
|
helloworld
|
||||||
todo
|
todo
|
||||||
|
recipe
|
||||||
|
224
doc/development/tutorials/recipe.rst
Normal file
224
doc/development/tutorials/recipe.rst
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
Developing a "recipe" extension
|
||||||
|
===============================
|
||||||
|
|
||||||
|
The objective of this tutorial is to illustrate roles, directives and domains.
|
||||||
|
Once complete, we will be able to use this extension to describe a recipe and
|
||||||
|
reference that recipe from elsewhere in our documentation.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This tutorial is based on a guide first published on `opensource.com`_ and
|
||||||
|
is provided here with the original author's permission.
|
||||||
|
|
||||||
|
.. _opensource.com: https://opensource.com/article/18/11/building-custom-workflows-sphinx
|
||||||
|
|
||||||
|
|
||||||
|
Overview
|
||||||
|
--------
|
||||||
|
|
||||||
|
We want the extension to add the following to Sphinx:
|
||||||
|
|
||||||
|
* A ``recipe`` :term:`directive`, containing some content describing the recipe
|
||||||
|
steps, along with a ``:contains:`` argument highlighting the main ingredients
|
||||||
|
of the recipe.
|
||||||
|
|
||||||
|
* A ``reref`` :term:`role`, which provides a cross-reference to the recipe
|
||||||
|
itself.
|
||||||
|
|
||||||
|
* A ``rcp`` :term:`domain`, which allows us to tie together the above role and
|
||||||
|
domain, along with things like indices.
|
||||||
|
|
||||||
|
For that, we will need to add the following elements to Sphinx:
|
||||||
|
|
||||||
|
* A new directive called ``recipe``
|
||||||
|
|
||||||
|
* New indexes to allow us to reference ingredient and recipes
|
||||||
|
|
||||||
|
* A new domain called ``rcp``, which will contain the ``recipe`` directive and
|
||||||
|
``reref`` role
|
||||||
|
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
-------------
|
||||||
|
|
||||||
|
As with :doc:`the previous extensions <todo>`, we will not be distributing this
|
||||||
|
plugin via PyPI so once again we need a Sphinx project to call this from. You
|
||||||
|
can use an existing project or create a new one using
|
||||||
|
:program:`sphinx-quickstart`.
|
||||||
|
|
||||||
|
We assume you are using separate source (:file:`source`) and build
|
||||||
|
(:file:`build`) folders. Your extension file could be in any folder of your
|
||||||
|
project. In our case, let's do the following:
|
||||||
|
|
||||||
|
#. Create an :file:`_ext` folder in :file:`source`
|
||||||
|
#. Create a new Python file in the :file:`_ext` folder called :file:`recipe.py`
|
||||||
|
|
||||||
|
Here is an example of the folder structure you might obtain:
|
||||||
|
|
||||||
|
.. code-block:: text
|
||||||
|
|
||||||
|
└── source
|
||||||
|
├── _ext
|
||||||
|
│ └── todo.py
|
||||||
|
├── conf.py
|
||||||
|
└── index.rst
|
||||||
|
|
||||||
|
|
||||||
|
Writing the extension
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
Open :file:`receipe.py` and paste the following code in it, all of which we
|
||||||
|
will explain in detail shortly:
|
||||||
|
|
||||||
|
.. literalinclude:: examples/recipe.py
|
||||||
|
:language: python
|
||||||
|
:linenos:
|
||||||
|
|
||||||
|
Let's look at each piece of this extension step-by-step to explain what's going
|
||||||
|
on.
|
||||||
|
|
||||||
|
.. rubric:: The directive class
|
||||||
|
|
||||||
|
The first thing to examine is the ``RecipeNode`` directive:
|
||||||
|
|
||||||
|
.. literalinclude:: examples/recipe.py
|
||||||
|
:language: python
|
||||||
|
:linenos:
|
||||||
|
:lines: 15-40
|
||||||
|
|
||||||
|
Unlike :doc:`helloworld` and :doc:`todo`, this directive doesn't derive from
|
||||||
|
:class:`docutils.parsers.rst.Directive` and doesn't define a ``run`` method.
|
||||||
|
Instead, it derives from :class:`sphinx.directives.ObjectDescription` and
|
||||||
|
defines ``handle_signature`` and ``add_target_and_index`` methods. This is
|
||||||
|
because ``ObjectDescription`` is a special-purpose directive that's intended
|
||||||
|
for describing things like classes, functions, or, in our case, recipes. More
|
||||||
|
specifically, ``handle_signature`` implements parsing the signature of the
|
||||||
|
directive and passes on the object's name and type to its superclass, while
|
||||||
|
``add_taget_and_index`` adds a target (to link to) and an entry to the index
|
||||||
|
for this node.
|
||||||
|
|
||||||
|
We also see that this directive defines ``has_content``, ``required_arguments``
|
||||||
|
and ``option_spec``. Unlike the ``TodoDirective`` directive added in the
|
||||||
|
:doc:`previous tutorial <todo>`, this directive takes a single argument, the
|
||||||
|
recipe name, and an optional argument, ``contains``, in addition to the nested
|
||||||
|
reStructuredText in the body.
|
||||||
|
|
||||||
|
.. rubric:: The index classes
|
||||||
|
|
||||||
|
.. currentmodule:: sphinx.domains
|
||||||
|
|
||||||
|
.. todo:: Add brief overview of indices
|
||||||
|
|
||||||
|
.. literalinclude:: examples/recipe.py
|
||||||
|
:language: python
|
||||||
|
:linenos:
|
||||||
|
:lines: 44-172
|
||||||
|
|
||||||
|
Both ``IngredientIndex`` and ``RecipeIndex`` are derived from :class:`Index`.
|
||||||
|
They implement custom logic to generate a tuple of values that define the
|
||||||
|
index. Note that ``RecipeIndex`` is a degenerate index that has only one entry.
|
||||||
|
Extending it to cover more object types is not yet part of the code.
|
||||||
|
|
||||||
|
Both indices use the method :meth:`Index.generate` to do their work. This
|
||||||
|
method combines the information from our domain, sorts it, and returns it in a
|
||||||
|
list structure that will be accepted by Sphinx. This might look complicated but
|
||||||
|
all it really is is a list of tuples like ``('tomato', 'TomatoSoup', 'test',
|
||||||
|
'rec-TomatoSoup',...)``. Refer to the :doc:`domain API guide
|
||||||
|
</extdev/domainapi>` for more information on this API.
|
||||||
|
|
||||||
|
.. rubric:: The domain
|
||||||
|
|
||||||
|
A Sphinx domain is a specialized container that ties together roles,
|
||||||
|
directives, and indices, among other things. Let's look at the domain we're
|
||||||
|
creating here.
|
||||||
|
|
||||||
|
.. literalinclude:: examples/recipe.py
|
||||||
|
:language: python
|
||||||
|
:linenos:
|
||||||
|
:lines: 175-223
|
||||||
|
|
||||||
|
There are some interesting things to note about this ``rcp`` domain and domains
|
||||||
|
in general. Firstly, we actually register our directives, roles and indices
|
||||||
|
here, via the ``directives``, ``roles`` and ``indices`` attributes, rather than
|
||||||
|
via calls later on in ``setup``. We can also note that we aren't actually
|
||||||
|
defining a custom role and are instead reusing the
|
||||||
|
:class:`sphinx.roles.XRefRole` role and defining the
|
||||||
|
:class:`sphinx.domains.Domain.resolve_xref` method. This method takes two
|
||||||
|
arguments, ``typ`` and ``target``, which refer to the cross-reference type and
|
||||||
|
its target name. We'll use ``target`` to resolve our destination from our
|
||||||
|
domain's ``objects`` because we currently have only one type of node.
|
||||||
|
|
||||||
|
Moving on, we can see that we've defined two items in ``intitial_data``:
|
||||||
|
``objects`` and ``obj2ingredient``. These contain a list of all objects defined
|
||||||
|
(i.e. all recipes) and a hash that maps a canonical ingredient name to the list
|
||||||
|
of objects. The way we name objects is common across our extension and is
|
||||||
|
defined in the ``get_full_qualified_name`` method. For each object created, the
|
||||||
|
canonical name is ``rcp.<typename>.<objectname>``, where ``<typename>`` is the
|
||||||
|
Python type of the object, and ``<objectname>`` is the name the documentation
|
||||||
|
writer gives the object. This enables the extension to use different object
|
||||||
|
types that share the same name. Having a canonical name and central place for
|
||||||
|
our objects is a huge advantage. Both our indices and our cross-referencing
|
||||||
|
code use this feature.
|
||||||
|
|
||||||
|
.. rubric:: The ``setup`` function
|
||||||
|
|
||||||
|
.. currentmodule:: sphinx.application
|
||||||
|
|
||||||
|
:doc:`As always <todo>`, the ``setup`` function is a requirement and is used to
|
||||||
|
hook the various parts of our extension into Sphinx. Let's look at the
|
||||||
|
``setup`` function for this extension.
|
||||||
|
|
||||||
|
.. literalinclude:: examples/recipe.py
|
||||||
|
:language: python
|
||||||
|
:linenos:
|
||||||
|
:lines: 226-
|
||||||
|
|
||||||
|
This looks a little different to what we're used to seeing. There are no calls
|
||||||
|
to :meth:`~Sphinx.add_directive` or even :meth:`~Sphinx.add_role`. Instead, we
|
||||||
|
have a single call to :meth:`~Sphinx.add_domain` followed by some
|
||||||
|
initialization of the :ref:`standard domain <domains-std>`. This is because we
|
||||||
|
had already registered our directives, roles and indexes as part of the
|
||||||
|
directive itself.
|
||||||
|
|
||||||
|
|
||||||
|
Using the extension
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
You can now use the extension throughout your project. For example:
|
||||||
|
|
||||||
|
.. code-block:: rst
|
||||||
|
:caption: index.rst
|
||||||
|
|
||||||
|
Joe's Recipes
|
||||||
|
=============
|
||||||
|
|
||||||
|
Below are a collection of my favourite receipes. I highly recommend the
|
||||||
|
:rcp:reref:`TomatoSoup` receipe in particular!
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
|
||||||
|
tomato-soup
|
||||||
|
|
||||||
|
.. code-block:: rst
|
||||||
|
:caption: tomato-soup.rst
|
||||||
|
|
||||||
|
The recipe contains `tomato` and `cilantro`.
|
||||||
|
|
||||||
|
.. rcp:recipe:: TomatoSoup
|
||||||
|
:contains: tomato cilantro salt pepper
|
||||||
|
|
||||||
|
This recipe is a tasty tomato soup, combine all ingredients
|
||||||
|
and cook.
|
||||||
|
|
||||||
|
The important things to note are the use of the ``:rcp:recipe:`` role to
|
||||||
|
cross-reference the recipe actually defined elsewhere (using the
|
||||||
|
``:rcp:recipe:`` directive.
|
||||||
|
|
||||||
|
|
||||||
|
Further reading
|
||||||
|
---------------
|
||||||
|
|
||||||
|
For more information, refer to the `docutils`_ documentation and
|
||||||
|
:doc:`/extdev/index`.
|
||||||
|
|
||||||
|
.. _docutils: http://docutils.sourceforge.net/docs/
|
@ -1195,6 +1195,7 @@ Configuration Variables
|
|||||||
|
|
||||||
See :ref:`cpp-config`.
|
See :ref:`cpp-config`.
|
||||||
|
|
||||||
|
.. _domains-std:
|
||||||
|
|
||||||
The Standard Domain
|
The Standard Domain
|
||||||
-------------------
|
-------------------
|
||||||
|
Loading…
Reference in New Issue
Block a user