Documentation and versioning

Author

Kyle Niemeyer and Su Sun

Documenting code, building a documentation website, and versioning releases

Examples throughout are drawn from the real Cantera codebase — an open-source toolkit for chemical kinetics, thermodynamics, and transport.

Documentation

Professor Carole Goble in “Better Software, Better Research”:

One of my favorite #overlyhonestmethods tweets (a hashtag for lab scientists) is Ian Holmes’s “You can download our code from the URL supplied. Good luck downloading the only postdoc who can get it to run, though.”

Why document?

“It works on my machine” is not reproducible science.

Documentation is what turns code you can run into code others (and future you) can run. It costs little on top of the coding you already do, and pays back many times over:

  • Clarity — colleagues can understand and build on your work
  • Provenance — the scientific process behind a result is recorded
  • Competence — well-documented software signals professional, trustworthy work

Documentation is easier than you think

  • Documentation pays for itself with the time it saves in the long run.
  • Documentation requires little effort beyond writing the software itself.

Most documentation is a small, steady habit — not a separate project.

Types of documentation

Documentation lives at several levels — from the whole project down to a single line:

Level Audience Example
README / guides Anyone arriving at the repo README.rst, INSTALL.md
API documentation People calling your code docstrings → Sphinx site
Self-documenting code People reading your code clear names, structure
Comments People editing a tricky section # why, not # what
Theory / user guides Domain users tutorials, math derivations

We’ll walk up this ladder, then finish with versioning — how you label releases of all of it.

1. README files

The front door

The README sits at the top of the repository. It is the first — and often only — thing a newcomer reads. A good README answers, quickly:

  • What is this? Why would I use it?
  • How do I install it?
  • Where do I go next (docs, examples, community)?

It is usually surrounded by companion files: LICENSE, INSTALL, CITATION.cff, CODE_OF_CONDUCT, CONTRIBUTING, CHANGELOG.

Example: Cantera’s README.rst

Notice the structure — what it is, what it can do, then install and where next. The badges at the top show live status (CI, coverage, DOI, release) at a glance.

|doi| |codecov| |ci| |release|

What is Cantera?
================
Cantera is an open-source collection of object-oriented software tools for
problems involving chemical kinetics, thermodynamics, and transport processes.
Among other things, it can be used to:

* Evaluate thermodynamic and transport properties of mixtures
* Compute chemical equilibrium
* Conduct kinetics simulations with large reaction mechanisms
* Simulate one-dimensional flames

Installation
============
`Installation instructions for the current release of Cantera
<https://cantera.org/stable/install/index.html>`_ are available from the main site.

A minimal README for a small project

# Package

`package` is a simple Python library that contains a single function 
for rescaling arrays.

## Installation

Download the source code and use the package manager 
[pip](https://pip.pypa.io/en/stable/) to install `package`:
`pip install .`

## Contributing
Pull requests are welcome. For major changes, please open an issue 
first to discuss what you would like to change.

Please make sure to update tests as appropriate.

## License
TBD
...

2. Code comments

Comment the why, not the what

Comments provide a way to insert metainformation about code intended for people, right next to the code.

A comment that restates the code is noise; it just goes stale. Comment the things the code can’t say: intent, units, the reason for a non-obvious choice.

Bad comments

It’s entirely possible to pollute code with unnecessary cruft:

def decay(index, database):
    # first, retrieve the decay constants from the database
    mylist = database.decay_constants()
    # next, try to access an element of the list
    try:
        d = mylist[index] # gets decay constant at index in the list
    # if the index doesn't exist
    except IndexError:
        # throw an informative error message
        raise Exception("value not found in the list")
    return d

Useful comments

Code written cleanly will have its own voice. Use intelligent naming to make most lines clear without comments, then use comments sparingly to explain reasons or complicated sections:

def get_decay(index, database):
    """Returns decay constant at index in the database"""
    lambdas = database.decay_constants()
    try:
        lambda_i = lambdas[index]
    except IndexError:
        raise Exception("value not found in the list")
    return lambda_i

❌ Too many comments

Every line is narrated. The comments add nothing the code doesn’t already say — and now there are two things to keep in sync.

def arrhenius(A, Ea, T):
    R = 8.314          # set the gas constant
    x = Ea / (R * T)   # divide Ea by R times T
    k = A * exp(-x)    # multiply A by the exponential of negative x
    return k           # return k

✅ Good names + a comment that earns its place

The names carry the meaning; the single comment records something the reader genuinely needs (units, and the source of the formula).

R_GAS = 8.314  # J/(mol·K)

def arrhenius_rate(pre_exp, activation_energy, temperature):
    # Arrhenius form: k = A * exp(-Ea / R T)
    return pre_exp * exp(-activation_energy / (R_GAS * temperature))

3. Self-documenting code

The cheapest documentation is code that doesn’t need any

Naming: a class, variable, or function name should tell you why it exists, what it does, and how it is used.

Simple functions: functions should be small to be understandable and testable; they should only do one thing.

Consistent style: use a consistent, standardized style; e.g., select variable and function names according to the PEP 8 style guide.

Naming conventions

Kind Convention Example
Packages / modules lowercase cantera, composite
Classes / Exceptions CamelCase Solution, SolutionArray
Functions / methods snake_case restore_data, arrhenius_rate
Constants ALL_CAPS R_GAS
Internal / private _leading_underscore _import_pandas, _phase
Magic methods __dunder__ __init__, __repr__

Guidelines: functions are verbs (compute_equilibrium); booleans read as questions (is_ideal, has_transport). Above all — be consistent.

In code

# packages and modules are short and lowercase
packages
modules

# other objects can be long
ClassesUseCamelCase
ExceptionsAreClassesToo
functions_use_snake_case
CONSTANTS_USE_ALL_CAPS

# variable scope is *suggested* by style convention
_single_leading_underscore  # internal to module
single_trailing_underscore_ # avoids conflicts with Python keywords
__double_leading_trailing__ # these are magic, like __init__

Cantera in practice

This snippet from cantera/interfaces/cython/cantera/composite.py follows the conventions without a single comment explaining whatCamelCase class, _leading_underscore for the deferred-import helper, snake_case function:

class Solution(Transport, Kinetics, ThermoPhase):
    ...

_pandas = None
def _import_pandas():
    # defer import of pandas
    global _pandas
    ...

4. Docstrings

What a docstring is

A docstring is a string literal placed immediately after a def, class, or module statement, typically enclosed by three pairs of double quotes:

def <name>(<args>):
    """<docstring>"""
    <body>

Unlike a comment, it’s part of the object at runtime — reachable via help(obj), obj? in IPython, and obj.__doc__. Sphinx picks them up too.

Try it on any standard library function: help(print)

Writing your own

A docstring should say what the function does, its parameters, and what it returns. Provide an example of how to use it if it takes many parameters.

def rescale(input_array):
    """Linearly rescale an array to the range [0, 1].

    Maps the minimum value of the input to 0 and the maximum to 1,
    scaling all other values proportionally in between.

    Parameters
    ----------
    input_array : numpy.ndarray
        The array of values to rescale.

    Returns
    -------
    numpy.ndarray
        An array the same shape as ``input_array``, with values
        linearly mapped onto the interval [0, 1].

    Examples
    --------
    >>> import numpy as np
    >>> rescale(np.array([1, 2, 3, 4, 5]))
    array([0.  , 0.25, 0.5 , 0.75, 1.  ])
    """

NumPy-style docstrings

Used in Cantera’s doc/sphinx/conf.py:

def executable_script(src_file, gallery_conf):
    """Validate if script has to be run according to gallery configuration.

    Parameters
    ----------
    src_file : str
        path to python script
    gallery_conf : dict
        Contains the configuration of Sphinx-Gallery

    Returns
    -------
    bool
        True if script has to be executed
    """

Google-style docstrings

def function_with_types_in_docstring(param1, param2):
    """Example function with types documented in the docstring.

    `PEP 484`_ type annotations are supported. If attribute, parameter, and
    return types are annotated according to `PEP 484`_, they do not need to be
    included in the docstring:

    Args:
        param1 (int): The first parameter.
        param2 (str): The second parameter.

    Returns:
        bool: The return value. True for success, False otherwise.

    .. _PEP 484:
        https://www.python.org/dev/peps/pep-0484/
    """

reStructuredText (:param:) style

Used in Cantera’s composite.py:

def sort(self, col, reverse=False):
    """
    Sort SolutionArray by column ``col``.

    :param col: Column that is used to sort the SolutionArray.
    :param reverse: If True, the sorted list is reversed (descending order).
    """

Cantera uses both — the napoleon Sphinx extension understands NumPy/Google style, and Sphinx natively understands :param:. Pick one style per project and stick to it.

5. Generating a documentation website with Sphinx

Getting started

Sphinx reads your docstrings (via autodoc) plus hand-written pages, and builds a browsable HTML site — this is how https://cantera.org is produced.

source .venv/bin/activate
pip install sphinx myst-parser
sphinx-quickstart docs         # scaffold; choose 'separate source and build'

This creates docs/ with a conf.py (configuration) and index.rst (the home page). The source directory holds the .rst and .md files you write by hand — user guides, theory manuals — separate from the autogenerated API documentation.

Configuring conf.py

extensions = [
    "sphinx.ext.autodoc",      # pull in docstrings
    "sphinx.ext.autosummary",  # summary tables
    "sphinx.ext.napoleon",     # understand NumPy / Google style docstrings
    "sphinx.ext.intersphinx",  # link to other projects' docs
    "sphinx.ext.mathjax",      # render LaTeX math
    "myst_nb",                 # Markdown + Jupyter notebooks as pages
]

# Generate stub pages for entries listed in autosummary directives
autosummary_generate = True

intersphinx_mapping = {
    "python": ("https://docs.python.org/3", None),
    "numpy": ("https://numpy.org/doc/stable/", None),
    "pandas": ("https://pandas.pydata.org/pandas-docs/version/2.3", None),
}

intersphinx and MyST

intersphinx is the magic that turns a numpy.ndarray in your docstring into a live link to NumPy’s own documentation.

Installing myst_parser / myst_nb lets you author pages in Markdown — or even render live Jupyter notebooks — instead of reStructuredText.

napoleon parses both NumPy and Google style out of the box— napoleon_numpy_docstring and napoleon_google_docstring both default to True. Set one to False if you want to enforce a single style across the project.

Wiring up index.rst (the home page)

conf.py only configures Sphinx — it doesn’t say what to document. That’s the job of index.rst, the landing page. Out of the box, sphinx-quickstart leaves it nearly empty, with just a toctree. Nothing references your code yet, so no docstrings appear.

.. index.rst -- the front door
my-project documentation
========================

A small demonstration package showing how docstrings and Sphinx fit together.

.. toctree::
   :maxdepth: 2
   :caption: Contents:

   api

…and api.rst (the generated reference)

API reference
=============

.. autosummary::

   rescale.rescale

.. automodule:: rescale.rescale
   :members:
   :undoc-members:
   :show-inheritance:

automodule is what actually imports rescale.rescale and renders every docstring in it. The toctree in index.rst then stitches index → api → rescale.rescale into the site’s navigation.

Why a separate api.rst?

Separation of concerns. index.rst is narrative plus a map; api.rst is generated reference.

With one module you could inline automodule into index.rst — but the moment you add a second module, a tutorial, or examples, splitting them keeps the home page readable and lets each section grow independently.

Note

For autodoc to find your package, it must be importable — e.g. pip install -e . — or you must add its path via sys.path in conf.py.

Building the site

sphinx-build -b html docs/source docs/build
# then open docs/build/index.html

Cantera wraps this in its build system: doc/SConscript calls sphinx-build -b html ... to produce build/doc/html/index.html.

Other Sphinx goodness

  • You can configure it to generate a LaTeX-based PDF (i.e., a single user manual).
  • You can have versioned documentation, and simultaneously host “devel” docs for unreleased changes.

6. Publishing to GitHub Pages

Keep the site online and current

A documentation site is most useful when it’s online and always current. A GitHub Actions workflow can rebuild and redeploy it on every push:

# .github/workflows/docs.yml
on:
  push:
    branches: [main]
    paths: ["docs/**", "src/**"]

jobs:
  docs:
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install . sphinx myst-parser
      - run: sphinx-build -b html docs/source docs/build
      - uses: actions/upload-pages-artifact@v3
        with: { path: docs/build }
      - uses: actions/deploy-pages@v4

Setup

  1. Add sphinx.ext.githubpages to extensions in conf.py
  2. Add a docs/requirements.txt (or a docs extra in pyproject.toml) for documentation dependencies
  3. On GitHub: Settings → Pages, and set the source to GitHub Actions

The older pattern — pushing built HTML to a gh-pages branch with an action such as peaceiris/actions-gh-pages — still works, and you’ll see it in many existing projects.

⭐ Bonus: docstrings as a source of truth

(A deeper example — skip if you’re short on time.)

Generating code from documentation

Docstrings aren’t only for humans reading help(). When they’re structured and machine-readable, a tool can parse them and generate code from them — deterministically (same input → same output, every time).

The payoff is the DRY principle applied to interfaces: document something once, in one place, then have a tool derive a downstream artifact from it automatically — no hand-copying, and no drift between the code and the wrapper around it.

Cantera’s sourcegen

Cantera’s core is C++. Other languages reach it through a thin C interface, “CLib”. Writing that layer by hand is repetitive boilerplate: for every C++ method you need a C function that looks up the object, converts types, forwards the call, and catches exceptions.

sourcegen generates that CLib layer automatically, by reading the Doxygen docstrings in the C++ headers plus a small YAML recipe listing which methods to wrap:

  C++ header + Doxygen docstring ─┐
                                  ├─►  sourcegen  ─►  CLib (C API: generated AND documented)
  YAML recipe (what to wrap)     ─┘                     │
                                                        └─►  MATLAB / Fortran / C# / Python
                                                             bindings (still hand-written)

Recipe in, documented C API out

The C++ method, documented once with Doxygen:

//! Return the mole fraction of a single species
//!     @param  k  species index
//!     @return Mole fraction of the species
double moleFraction(size_t k) const;

A one-line YAML recipe says what to wrap:

- name: moleFraction
  wraps: moleFraction(size_t)

…and sourcegen emits the C function

/**
 *  Return the mole fraction of a single species.
 *
 *  Wraps C++ method: `double Phase::moleFraction(size_t)`
 *
 *  @param handle       Handle to queried Phase object.
 *  @param k            species index
 */
double thermo_moleFraction(int32_t handle, int32_t k);
  • the brief description and @param k text carried straight through from the C++ Doxygen comment — docs never drift from the source
  • the C++ size_t was crosswalked to C’s int32_t, and a handle argument added for object lookup
  • re-running sourcegen after any C++ change regenerates the whole C API

7. Versioning

Why version numbers matter

Once people depend on your software, they need to know what changed between releases — and whether an update will break them. A version number communicates that.

Common schemes:

  • SemVer (semantic versioning) — meaning-carrying MAJOR.MINOR.PATCH
  • CalVer — date-based, e.g. 2026.06
  • ZeroVer — perpetually 0.x 😉

Semantic versioning: MAJOR.MINOR.PATCH

Given a version number, you increment the:

  • MAJOR when you make incompatible API changes
  • MINOR when you add functionality in a backward-compatible way
  • PATCH for backward-compatible bug fixes

Pre-1.0.0 (0.x.y) means anything may change — the public API is not yet stable. Pre-releases get a suffix like a1 (alpha), b2 (beta), rc1 (release candidate).

Cantera’s versions

Cantera demonstrates this exactly. From SConstruct:

cantera_version = "4.0.0a1"   # 4.0.0, alpha 1 -- a pre-release of the next MAJOR

and the current stable release recorded in CITATION.cff:

version: "3.2.0"
date-released: 2025-11-17
doi: 10.5281/zenodo.17620923

So 3.2.0 is in the wild, while 4.0.0a1 is an alpha of a major release — the bump from 3.x to 4.0 signals intentional, possibly breaking, API changes.

Where the version lives

Define the version once, and derive everything else from it. Three modern approaches:

  1. Static: the string lives in pyproject.toml:
pyproject.toml
[project]
version = "4.0.0a1"

Read it back at runtime (no second copy in source):

from importlib.metadata import version
__version__ = version("mypackage")

Where the version lives

  1. VCS-derived: the Git tag is the source of truth; setuptools-scm / hatch-vcs generate a _version.py for you (never hand-edited):
pyproject.toml
[project]
dynamic = ["version"]
[tool.setuptools_scm]
git tag -a v4.0.0a1 -m "v4.0.0a1"

Where the version lives

  1. Hand-written string literal in the package:
_version.py
__version__ = "4.0.0a1"
pyproject.toml
[project]
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "mypackage._version.__version__"}

Cantera instead sets it in SConstruct and threads it into the C++ headers, the Python module (cantera.__version__), and the docs (conf.py reads CANTERA_VERSION, so the rendered site is always labelled with the right release). One source of truth, many consumers.

Tagging the release

git tag -a v3.2.0 -m "Release 3.2.0"
git push origin v3.2.0

The tag is what triggers the release job in CI, what Zenodo archives to mint a DOI, and what CITATION.cff should agree with.

Recap

Documentation is a ladder, and versioning labels each rung’s releases:

  1. README — the front door: what, why, how to install
  2. Comments — explain the why, never the obvious what
  3. Self-documenting code — consistent PEP 8 names do the heavy lifting
  4. Docstrings — runtime-accessible API docs; pick one style (NumPy / Google / RST)
  5. Sphinx — turns docstrings plus hand-written pages into a website
  6. GitHub Pages + Actions — keep that site live and current
  7. Versioning (SemVer)MAJOR.MINOR.PATCH tells users what an update means

None of it is a separate project — it’s a habit layered onto the code you already write.