Documentation and versioning
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
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 dUseful 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 what — CamelCase 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.
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.htmlCantera 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@v4Setup
- Add
sphinx.ext.githubpagestoextensionsinconf.py - Add a
docs/requirements.txt(or adocsextra inpyproject.toml) for documentation dependencies - 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.
More on Sphinx with GitHub Actions: https://www.sphinx-doc.org/en/master/tutorial/deploying.html
⭐ 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 ktext carried straight through from the C++ Doxygen comment — docs never drift from the source - the C++
size_twas crosswalked to C’sint32_t, and ahandleargument added for object lookup - re-running
sourcegenafter 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 MAJORand the current stable release recorded in CITATION.cff:
version: "3.2.0"
date-released: 2025-11-17
doi: 10.5281/zenodo.17620923So 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:
- 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
- VCS-derived: the Git tag is the source of truth;
setuptools-scm/hatch-vcsgenerate a_version.pyfor 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
- 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.0The 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:
- README — the front door: what, why, how to install
- Comments — explain the why, never the obvious what
- Self-documenting code — consistent PEP 8 names do the heavy lifting
- Docstrings — runtime-accessible API docs; pick one style (NumPy / Google / RST)
- Sphinx — turns docstrings plus hand-written pages into a website
- GitHub Pages + Actions — keep that site live and current
- Versioning (SemVer) —
MAJOR.MINOR.PATCHtells users what an update means
None of it is a separate project — it’s a habit layered onto the code you already write.
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.