Generating Public and Private API Documentation in Sphinx

Motivation

In my last blog post, I talked about my experience learning Sphinx. One thing I encountered is that I wanted the users to not see any of the private methods/classes/modules in the documentation (becuase they’re not part of the public-facing API and they’re subject to change), but I also wanted it to be available in nice html format for developers if they were interested. I wanted to essentially include a separate private and public API in the same documentation page. Doing this with autodoc is difficult, because there’s no way to explicitly exclude public members (private members are excluded by default, and can easily be un-excluded by specifying :private-members: in the automodule directive in the generated .rst files). Another thing is that, if you want to include private members (along with public members), you can specify that in the autodoc_default_options in the conf.py, but that configuration is used for generating all of the documentation, so it wasn’t immediately clear how I could change this for generating a separate public and private API.

A Solution

One way I came up with to solve this was to manually edit the templates used by autodoc to create the .rst documentation, and so I created a separate template for the public and private documentation in the _templates directory. The main difference being in this section of the package.rst_t:

_templates/private/package.rst_t

.. api_type:: private
.. _private_api:

Private API Documentation
=========================
**NOTE**: This is the private API documentation for the ``tsunami_ip_utils`` package. This documentation is intended for developers who 
are working on the Tsunami IP Utils package itself. If you are a user of the ``tsunami_ip_utils`` package, you should refer to the 
:ref:`public_api` instead.

_templates/public/package.rst_t

.. api_type:: public
.. _public_api:

Public API Documentation
========================

In these templates, you can see that there’s a custom description (a warning in the case of the private API documentation), a custom reference (either .. _private_api or .. _public_api), and a custom directive .. api_type:: private or .. api_type:: public. When autodoc processes these templates, these custom directives are read and influence how the documentation is generated. This directive is implemented in the conf.py setup(app) function

def setup(app):
    app.add_config_value('api_type', 'public', 'env')
    app.add_directive('api_type', ApiTypeDirective)
    app.connect('autodoc-skip-member', skip_member)

    # Add filter to Sphinx logger to suppress duplicate object warnings
    logger = logging.getLogger('sphinx')
    logger.addFilter(FilterDuplicateObjectWarnings())

the api_type directive is implemented via

class ApiTypeDirective(Directive):
    has_content = False
    required_arguments = 1

    def run(self):
        env = self.state.document.settings.env
        env.config.api_type = self.arguments[0]
        return []

and a series of custom functions for skipping members based on teh specified api_type

def has_private_members(obj):
    """Check if the given object has any private members."""
    for name, member in inspect.getmembers(obj):
        if name.startswith('_') and not name.startswith('__'):
            return True
    return False

def skip_by_api_type(api_type, name, obj):
    if inspect.isclass(obj):
        # Decide based on whether the class has private members
        if api_type == 'public':
            return False  # Do not skip, include in documentation for public API
        elif api_type == 'private' and not has_private_members(obj):
            return True  # Skip if it's meant to be private but has no private members
    else:
        # For non-class objects, use the existing name-based logic
        private_method = name.startswith('_') and ( not name.startswith('__') )
        if api_type == 'public' and private_method:
            return True
        elif api_type == 'private' and not private_method:
            return True
        
def skip_unwanted_inherited_members(name):
    unwanted_inherited_members = ['__class__'] # Not sure why this is being inherited
    if name in unwanted_inherited_members:
        return True
    return None

def skip_member(app, what, name, obj, skip, options):
    api_type = app.config.api_type
    skip = skip_by_api_type(api_type, name, obj) or skip_unwanted_inherited_members(name)

    # Exclude inherited members for specific classes
    classes_to_exclude_inherited_members = ['EnhancedPlotlyFigure']
    if isinstance(obj, type) and obj.__name__ in classes_to_exclude_inherited_members:
        options['inherited-members'] = {}

and to generate the documentation, a custom build script must also be specified. In the Makefile:

# Custom targets for generating API docs
.PHONY: apidoc
apidoc: apidoc-public apidoc-private

apidoc-public:
	sphinx-apidoc --templatedir=source/_templates/public -f -o "$(SOURCEDIR)/public_api" "$(PACKAGE_DIR)"

apidoc-private:
	sphinx-apidoc --private --templatedir=source/_templates/private -f -o "$(SOURCEDIR)/private_api/" "$(PACKAGE_DIR)"

and in this case some added dependencies that allow for customizing the names of the generated .rst files via a python script reanme.py

import os

def rename_and_remove_files(file_path):
    os.rename(f"{file_path}/tsunami_ip_utils.rst", f"{file_path}/index.rst")
    os.remove(f"{file_path}/modules.rst")

# Rename the tsunami_ip_utils.rst file to index.rst, and remove the modules.rst files
rename_and_remove_files("source/public_api")
rename_and_remove_files("source/private_api")

which is implemented in the makefile via

# Python script to customize .rst files
.PHONY: customize-rst
customize-rst:
	python rename.py

# Modify the html target to depend on the new apidoc and customize-rst targets
html: apidoc customize-rst
	@$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

A note on Filtering Warnings

This approach requires generating documentation using autodoc twice, and it may be the case that the same module has both private and public functions, so you’ll get an error saying 'duplicate object description of public/module1, other instance in private/index.rst, use :no-index: for one of them'. This is because the generated .rst documentation literally references the same module in both the private and public_api index.rst, but what Sphinx doesn’t know in this case is that the set of members for which documentation is generated in the different documentations for that module are mutually exclusive (i.e. the public/index.rst only generates documentation for the public members, and the private/index.rst only for the private members). In most cases, a duplicate object description will lead to a broken toctree, but in this case it’s actually okay because of this mutual exclusivity. To suppress these annoying warnings when generating the documentation, we have to add a custom filter to the Sphinx logger (this is done in the setup(app) function of the conf.py as shown before) defined by

# -- Custom Class for Filtering Duplicate Object Warnings --------------------
"""NOTE: These warnings could not be suppressed using the sphinx suppress_warnings option,
so this manual approach was necessary. NOTE: also that although the .. automodule:: my_module directive is
used twice for each module (once for private API and once for public API), there is no overlap in the index because
the methods/classes in the private and public APIs are mutually exclusive."""

class FilterDuplicateObjectWarnings(logging.Filter):
    def filter(self, record):
        is_duplicate_warning = 'duplicate object description of %s, other instance in %s, use :no-index: for one of them' in record.msg
        return not is_duplicate_warning

Conclusions and Critiques

I’d say the end result (shown in the documentation here) is what I wanted, but I’ve realized that it’s kind of inconvenient (as a developer) to only see the private methods/functions/classes/etc. and it’s a lot nicer to just see everything. But of course, you can’t do this in a single Sphinx documentation page, because otherwise each public function would have references to both the public API and the developer API. So I guess the best approach for having a unified developer API (with both public and private members) is to create two separate API’s, which is often what’s done anyways. Although, if the inconvenience is no bother as a developer, and it’s more convenient to just build and deploy one set of documentation, then this approach could still be useful.

Comments

If you're logged in, you may write a comment here. (markdown formatting is supported)

No comments yet.

Copyright © 2024 Matthew Louis   •  Powered by Jekyll and Ruby on Rails   •  Theme  Moonwalk