We wanted to have a configurable and easy-to-use Sphinx API documentation generator for our C++ projects. To achieve this we leaned on others for inspiration:
- Breathe (https://github.com/michaeljones/breathe): Excellent extension and the default choice for many.
- Gasp (https://github.com/troelsfr/Gasp): Gasp inspired us by allowing templates to control the output. Unfortunately development of Gaps seems to have stopped.
So what is wurfapi
:
- Essentially we picked up where Gasp let go. We have borrowed the idea of templates to make it highly configurable.
- We made it easy to use by automatically running Doxygen to generate the initial API documentation.
- We parse the Doxygen XML into an easy to use Python dictionary. Which can be consumed in the templates.
- We prepared the extension for other backends (replacing Doxygen) e.g. https://github.com/foonathan/standardese once they become ready.
We currently use wurfapi in the following projects:
- https://rely.steinwurf.com/docs/10.0.0/
- https://kodo.steinwurf.com/docs/11.0.0/
- https://otacast.steinwurf.com/docs/6.0.0/
... and many more.
We recommend that you install wurfapi and sphinx in a virtual environment. To use the extension, the following steps are needed:
Create a virtual environment:
Follow the https://docs.python.org/3/tutorial/venv.html
Install the extension:
pip install sphinx pip install wurfapi
Generate the initial
Sphinx
documentation by running:mkdir docs cd docs python sphinx-quickstart
You will need to enter some basic information about your project such as the project name etc.
Open the
conf.py
generated bysphinx-quickstart
and add the the following:# Append or insert 'wurfapi' in the extensions list extensions = ['wurfapi'] # wurfapi options - relative to your docs dir wurfapi = { 'source_paths': ['../src'], 'recursive': True, 'parser': {'type': 'doxygen', 'download': True, 'warnings_as_error': True} }
Note
source_path
If you separate source and build dir in sphinx your 'source_path' should be something like '../../src'.
recursive
Set recursive
True
if you want recursively scan thesource_paths
download
If you do not want to automatically download Doxygen, set
download
toFalse
. In that casewurfapi
will try to invoke plaindoxygen
without specifying any path or similar. This means itdoxygen
must be available in the path.warnings_as_error
If Doxygen emits many warnings you might want to set warnings_as_error to False until they have been fixed.
To generate the API documentation for a class open a
.rst
file e.g.index.rst
if you ransphinx-quickstart
. Say we want to generate docs for a class calledtest
in the namespaceproject
.To do this we add the following directive to the rst file:
.. wurfapi:: class_synopsis.rst :selector: project::coffee::machine
Such that
index.rst
becomes something like:Welcome to Coffee's documentation! =================================== .. toctree:: :maxdepth: 2 :caption: Contents: .. wurfapi:: class_synopsis.rst :selector: project::coffee::machine .. wurfapi:: class_synopsis.rst :selector: project::coffee::recipe Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` To do this we use the ``class_synopsis.rst`` template.
Generate the Documentation
make html
To reference different elements in the API, we have added a custom Sphinx role :wurfapi:
The :wurfapi:
role will try to deduce the unique-name
from the text given.
E.g if you want to reference the unique-name
foo::bar::baz::func(std::string var)
and there are
no other member functions in foo::bar::baz
named func
, you can reference it
by writing :wurfapi:`foo::bar::baz::func`
.
On the other hand if there was a function with unique-name
foo::bar::baz::function(std::string var)
:wurfapi:`foo::bar::baz::func`
could match with both func and function and will throw an error. In This
case this can be fixed by adding the left parenthesis: :wurfapi:`foo::bar::baz::func(`
.
You can read more about unique names later in this README.
To use this on readthedocs.org you need to have the wurfapi
Sphinx
extension installed. This can be done by adding a requirements.txt
in the
documentation folder. readthedocs.org can be configured to use the
requirements.txt
when building a project. Simply put wurfapi
in to the
requirements.txt
.
Nothing is perfect, neither is Doxygen. Sometimes Doxygen gets it wrong e.g. in the following example:
class foo { private: class bar; };
Doxygen incorrectly reports that bar
has public scope (also reported here
https://bit.ly/2BWPllZ). To deal with such issues, until a fix lands in
Doxygen, you can do the following:
Add a list of patches to the API to your conf.py
file. Extending the
example from before, we can add the following fix:
wurfapi = { 'source_paths': ['../src'], 'recursive': True, 'parser': { 'type': 'doxygen', 'download': True, 'warnings_as_error': True, 'patch_api': [ {'selector': 'foo::bar', 'key': 'access', 'value': 'private'} ] } }
The patch_api
allows you to reach in to the parsed API information and
update certain values. The selector
is the unique-name
of the
entity you want to update. Check the "Dictionary layout" section further down
for more information.
For symbol versioning you may use inline namespaces
, however typically
you don't want these to show up in the docs, as these are mostly
invisible for your users.
With wurfapi
you can collapse the inline namespace such that it
is removed form the scopes etc.
Example:
namespace foo { inline namespace v1_2_3 { struct bar{}; } }
The scope to bar is foo::v1_2_3
. If you collapse the inline namespace it will
just be foo
.
First issue you have to deal with is that Doxygen currently does not support inline namespaces. So we need to patch the API first:
wurfapi = { 'source_paths': ['../src'], 'recursive': True, 'parser': { 'type': 'doxygen', 'download': True, 'warnings_as_error': True, 'patch_api': [ {'selector': 'foo::v1_2_3', 'key': 'inline', 'value': True} ] } }
After this we can collapse the namespace:
wurfapi = { 'source_paths': ['../src'], 'recursive': True, 'parser': { 'type': 'doxygen', 'download': True, 'warnings_as_error': True, 'patch_api': [ {'selector': 'foo::v1_2_3', 'key': 'inline', 'value': True} ], 'collapse_inline_namespaces': [ "foo::v1_2_3" ] } }
Now you will be able to refer to bar
as foo::bar
. Note, that
collapsing the namespace will affect the selectors you write when
generating the documentation.
You can write your own custom templates for generating the rst output.
To this you simply write a Jinja2 compatible rst template and place
it in some folder. Adding the user_templates
key to the wurfapi
configuration dictionary in the conf.py
file will make it available.
For example:
wurfapi = { 'source_paths': ['../src', '../examples/header/header.h'], 'recursive': True, 'user_templates': 'rst_templates', 'parser': { 'type': 'doxygen', 'download': True, 'warnings_as_error': True } } exclude_patterns = ['rst_templates/*.rst']
Now we can use *.rst
files inside the rst_templates
folder e.g. if
we had a class_list.rst
template we could use it like this:
.. wurfapi:: class_list.rst :selector: project::coffee
Edit
NEWS.rst
,wscript
andsrc/wurfapi/wurfapi.py
(set correctVERSION
)Run
./waf upload
The tests will run automatically by passing --run_tests
to waf:
./waf --run_tests
This follows what seems to be "best practice" advice, namely to install the package in editable mode in a virtualenv.
A bunch of the tests use a library called pytest-datarecorder
.
The library is used to store the output as files from different parsing and
rendering operations.
E.g. say we want to make sure that a parser function returns a certain
dict
object. Then we can record that dict
:
datarecorder.record_data( data={'foo': 2, 'bar': 3}, recording_file="/tmp/recording/test.json" )
If data
changes compared to a previous recording a mismatch will be
detected. To update a recording simply delete the recording file.
You will also notice that a bunch of the tests take a parameter called
testdirectory
. The testdirectory
is a pytest fixture, which
represents a temporary directory on the filesystem. When running the tests
you will notice these temporary test directories pop up under the
pytest_temp
directory in the project root.
You can read more about that here:
The sphinx documentation on creating extensions: http://www.sphinx-doc.org/en/stable/extdev/index.html#dev-extensions
- An extension is a Python module. When an extension loads, Sphinx will import
it and execute its
setup()
function. - Understanding how to put together docutils nodes seems pretty difficult. One suggesting form the mailinglist was to look at the following document: https://github.com/docutils-mirror/docutils/blob/master/test/functional/expected/standalone_rst_pseudoxml.txt
- While researching how to do this, there seem to be three potential approaches:
- Use the standard Sphinx approach and operate with the doctree.
- Create RST based on jinja templates
- Create HTML based on jinja templates
- Inspiration - Sphinx extensions that were used as inspiration while developing this extension.
- Understanding how to write stuff with docutils: * http://agateau.com/2015/docutils-snippets/
- Creating a custom directive * http://www.xavierdupre.fr/blog/2015-06-07_nojs.html
- Nice looking Sphinx extensions * https://github.com/bokeh/bokeh/tree/master/bokeh/sphinxext
- This part of the documentation was useful in order to understand the need for ViewLists etc. in the directives run(...) function. http://www.sphinx-doc.org/en/stable/extdev/markupapi.html
- This link provided inspiration for the text json format: https://github.com/micnews/html-to-article-json
- More xml->json for the text: https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html
We want to support different "backends" like Doxygen to parse the source code. To make this possible we define an internal source code description format. We then translate e.g. Doxygen XML to this and use that to render the API documentation.
This way a different "backend" e.g. Doxygen2 could be use used as the source code parser and the API documentation could be generated.
In order to be able to reference the different entities in the API we need to assign them a name.
We use a similar approach here as described in standardese.
This means that the unique-name
of an entity is the name with all
scopes e.g. foo::bar::baz
.
For functions the unique name contains the signature (parameter types and for member functions cv-qualifier and ref-qualifier) e.g.
foo::bar::baz::func()
orfoo::bar::baz::func(int a, char*) const
. See cppreference for more information.For class template specializations the unique name includes the specialization arguments. For example:
// Here the unique-name is just 'foo' template<class T> class foo {}; // Here the unique name is foo<int> template<> class foo<int> {};
In addition to types, we also have entries for the parsed files. For files the unique name will be the relative path from the project root.
For defines we will use the name of the define. As an example:
#define PROJECT_VERSION "1.0.0"
Here
unique-name
will bePROJECT_VERSION
.
The internal structure is a dicts with the different API entities. The
unique-name
of the entity is the key and the entity type also a
Python dictionary is the value e.g:
api = { 'unique-name': { ... }, 'unique-name': { ... }, ... }
To make this a bit more concrete consider the following code:
namespace ns1 { class shape { void print(int a) const; }; namespace ns2 { struct box { void hello(); }; void print(); } }
Parsing the above code would produce the following API dictionary:
api = { 'ns1': { 'kind': 'namespace', ...}, 'ns1::shape': { 'kind': 'class', ... }, 'ns1::shape::print(int) const': { kind': function' ... }, 'ns1::ns2': { 'kind': 'namespace', ... }, 'ns1::ns2::box': { 'kind': 'struct', ... }, 'ns1::ns2::box::hello()': { kind': function' ... }, 'ns1::ns2::print()': { 'kind': 'function', ...}, 'ns1.hpp': { 'kind': 'file', ...} }
The different entity kinds expose different information about the API. We will document the different kinds in the following.
We make some keys optional this is marked in the following way:
api = { 'unique-name': { 'some_key': ... Optional('an_optional_key'): ... }, ... }
Python dictionary representing a C++ namespace:
info = { 'kind': 'namespace', 'name': 'unqualified-name', 'scope': 'unique-name' | None, 'members: [ 'unique-name', 'unique-name' ], 'briefdescription': paragraphs, 'detaileddescription': paragraphs, 'inline': True | False }
Note: Currently Doxygen does not support parsing inline namespaces
. So
you need to use the patch API to change the value from False
to True
manually. Maybe at some point doxygen/doxygen#6741
it will be supported.
Python dictionary representing a C++ class or struct:
info = { 'kind': 'class' | 'struct', 'name': 'unqualified-name', 'location': location, 'scope': 'unique-name' | None, 'access': 'public' | 'protected' | 'private', Optional('template_parameters'): template_parameters, 'members: [ 'unique-name', 'unique-name' ], 'briefdescription': paragraphs, 'detaileddescription': paragraphs }
Python dictionary representing a C++ enum or enum class:
info = { 'kind': 'enum', 'name': 'unqualified-name', 'location': location, 'scope': 'unique-name' | None, 'access': 'public' | 'protected' | 'private', 'values: [ { 'name': 'somename', 'briefdescription': paragraphs, 'detaileddescription': paragraphs, Optional('value'): 'some value' } ], 'briefdescription': paragraphs, 'detaileddescription': paragraphs }
Python dictionary representing a C++ using or typedef statement:
info = { 'kind': 'typedef' | 'using', 'name': 'unqualified-name', 'location': location, 'scope': 'unique-name' | None, 'access': 'public' | 'protected' | 'private', 'type': type, 'briefdescription': paragraphs, 'detaileddescription': paragraphs }
Python dictionary representing a C/C++ define:
info = { 'kind': 'define', 'name': 'name', 'location': location, Optional('initializer'): 'some_value', Optional('parameters'): [{ 'name': 'somestring', Optional('description'): paragraphs }], 'briefdescription': paragraphs, 'detaileddescription': paragraphs }
The content of the define will be in the initializer
field. If the define
takes documented paremeters these will be under the parameter
key.
Examples:
Define initializer:
#define VERSION "1.0.2"
Define initalizer with parameters:
#define min(X, Y) ((X) < (Y) ? (X) : (Y))
Python dictionary representing a file in the project:
info = { 'kind': 'file', 'name': 'somefile.hpp', 'path': 'relative/path/to/somefile.hpp', }
Python dictionary representing a C++ function:
info = { 'kind': 'function', 'name': 'unqualified-name', 'location': location, 'scope': 'unique-name' | None, Optional('return'): { 'type': type, 'description': paragraphs } Optional('template_parameters'): template_parameters, 'is_const': True | False, 'is_static': True | False, 'is_virtual': True | False, 'is_explicit': True | False, 'is_inline': True | False, 'is_constructor': True | False, 'is_destructor': True | False, 'trailing_return': True | False, 'access': 'public' | 'protected' | 'private', 'briefdescription: paragraphs, 'detaileddescription: paragraphs, 'parameters': [ { 'type': type, Optional('name'): 'somename', 'description': paragraphs }, ... ] }
The return key is optional if the function is either a constructor or destructor.
Python dictionary representing a C++ variable:
info = { 'kind': 'variable', 'name': 'unqualified-name', Optional('value'): 'some value', 'type': type, 'location': location, 'is_static': True | False, 'is_mutable': True | False, 'is_volatile': True | False, 'is_const': True | False, 'is_constexpr': True | False, 'scope': 'unique-name' | None, 'access': 'public' | 'protected' | 'private', 'briefdescription: paragraphs, 'detaileddescription: paragraphs, }
Python dictionary representing a location:
location = { Optional('include'): 'some/header.h', 'path': 'src/project/header.h', 'line': 10 }
- The
include
will be relative to anyinclude_paths
specified in thewurfapi
dictionary in your Sphinxconf.py
. - The
path
will be relative to the project root folder.
Python list representing a C++ type:
type = [ { 'value': 'sometext', Optional('link'): link }, ... ]
Having the type as a list of items we can create links to nested types e.g. say we have a std::unique_ptr<impl> and we would like to make impl a link. This could look like:
"type": [ { "value": "std::unique_ptr<" }, { "link": {"url": False, "value": "project::impl"}, "value": "impl" }, { "value": ">" } ]
Any spaces in the type list should be preserved all the way from the Doxygen output and into the type list. In the rst it should be sufficient to simply output the values of the type. No spaces or other stuff should be injected.
Python dictionary representing a link:
link = { 'url': True | False, 'value': 'somestring' }
If url is True we have a basic extrenal reference otherwise we have a link to an internal type in the API.
Dictionary representing a function parameter:
parameter = { 'type': type, Optional('name'): 'somestring', Optional('description'): paragraphs }
For the parameter, the name is also included in the type list. The reason is that some parameters can be pretty complex, with the name embedded inside the type e.g.:
void function(int (*(*foo)())[3]);
This is a function that takes one parameter foo which is a pointer function returning a pointer to array 3 of int - nice right? Anyway, in such cases the parameter name is embedded inside the type of the parameter. We therefore took the easy way out and wurfapi will always include the parameter name in the type.
As an example the parameter dictionary for a function void test(int b) could be:
{ 'type': [{'value': 'int '}, {'value': 'b'}], 'name': 'b' }
Python list of dictionaries representing template parameters:
template_parameters = [{ 'type': type, 'name': 'somestring', Optional('default'): type, Optional('description'): paragraphs }]
Text information is stored in a list of paragraphs:
paragraphs = [paragraph]
A paragraph consists of a list of paragraph elements:
paragraph = [ { "kind": "text" | "code" | "list" | "bold" | "italic", ... }, ]
Paragraph elements can be one of three kinds, "text", "code" or "list":
text = { 'kind': 'text', 'content': 'hello', Optional('link'): link } code = { 'kind': 'code', 'content': 'void print();', 'is_block': true | false } list = { 'kind': 'list', 'ordered': true | false, 'items': [paragraphs] # Each item is a list of paragraphs }
Issue equivalent C++ function signatures can be written in a number of different ways:
void hello(const int *x); // x is a pointer to const int void hello(int const *x); // x is a pointer to const int
We can also move the asterisk (*
) to the left:
void hello(const int* x); // x is a pointer to const int void hello(int const* x); // x is a pointer to const int
So we need some way to normalize the function signature when transforming it
to unique-name
. We cannot simply rely on sting comparisons.
According to the numerous google searches it is hard to write a regex for this. Instead we will try to use a parser:
- Python parser: https://github.com/erezsh/lark
- C++ Grammar: http://www.externsoft.ch/media/swf/cpp11-iso.html#parameters_and_qualifiers
We only need to parse the function parameter list denoted as the
http://www.externsoft.ch/media/swf/cpp11-iso.html#parameters_and_qualifiers
.
Since we are going to be using Doxygen's XML output as input to the
extension we need a place to store it. We store it system temporary folder e.g.
if the project name is "foobar" on Linux this would be
/tmp/wurfapi-foobar-123456
where 123456
is a hash of the source
directory paths. In addition to Doxygen's XML we also store the generated rst
for the different directives there. This is nice for debugging to see whether
we generate broken rst.
The API in json format can be found in the _build/.doctree/wurfapi_api.json
.
- Source directory: In Sphinx the source
6832
directory is where our .rst files are
located. This is what you pass to
sphinx-build
when building your documentation. We will use this in our extension to find the C++ source code and output customization templates.
- Why use an
src
folder (https://hynek.me/articles/testing-packaging/). tl;dr you should run your tests in the same environment as your users would run your code. So by placing the source files in a non-importable folder you avoid accidentally having access to resources not added to the Python package your users will install... - Python packaging guide: https://packaging.python.org/distributing/