Skip to content

Docstrings

Documentation is part of the vocabulary. You write it once, on the C++ declaration, and it reaches every audience welder can carry it to: the target language's runtime docstring (Python __doc__, via the binding rod), the C++ API reference (via the Doxygen filter), and the generated stubs (.pyi for Python, LuaCATS ---@meta for Lua — the latter is where Lua's docs live, since Lua has no runtime slot).

Annotation Applies to Becomes
doc("text") class, namespace, function, parameter, data member the summary docstring
returns("text") function a Returns: block
tparam("T", "text") template (repeatable, ordered) template-parameter docs

In the cookbook

Recipe 07 — One library, two languages carries one set of doc/returns annotations into Python __doc__, a .pyi stub and a LuaCATS stub.

doc and returns

struct
[[=welder::weld(welder::lang::py), =welder::doc("A 2-D vector.")]]
Vec2 {
    double x{0.0}, y{0.0};

    [[=welder::doc("Euclidean length."), =welder::returns("the magnitude")]]
    double length() const;
};

[[
  =welder::weld(welder::lang::py),
  =welder::doc("Scale a length by a factor."),
  =welder::returns("the scaled length")
]]
double scale(
    [[=welder::doc("the length to scale")]] double length,
    [[=welder::doc("the multiplier")]] double factor);

The docstring engine (doc.hpp, backend-agnostic) folds the pieces — summary + parameter docs + return doc — under a pluggable style. The default google_style produces Args: / Returns: blocks:

>>> print(scale.__doc__)
scale(length: float, factor: float) -> float

Scale a length by a factor.

Args:
    length: the length to scale
    factor: the multiplier

Returns:
    the scaled length

Why returns is separate from doc

A return value isn't a reflectable entity, so its doc can't live on one. It rides on the function as a distinct spec type (welder::detail::return_doc_spec), told apart from the summary by spec type — which also keeps the door open for future Raises: / Note: blocks without re-breaking the style API.

Choosing a docstring style

The style is a property of the rod, selected through its DocStyle template parameter. The Python rods ship three, matching the dialects the Python ecosystem's doc tools understand — pick one by naming it in the rod type:

Style Rod Renders as
welder::rods::python::google_style rod<> (default) Args: / Returns: blocks (Sphinx Napoleon)
welder::rods::python::numpy_style rod<numpy_style> underlined Parameters / Returns sections (numpydoc; also Napoleon)
welder::rods::python::sphinx_style rod<sphinx_style> :param name: / :returns: reST field lists (autodoc native)
#include <welder/rods/python/pybind11/rod.hpp>
namespace py = welder::rods::python;

PYBIND11_MODULE(mymod, m) {
    // default (Google):        welder::rods::pybind11::rod<>
    // NumPy:  welder::rods::pybind11::rod<py::numpy_style>
    // Sphinx: welder::rods::pybind11::rod<py::sphinx_style>
    welder::welder<welder::rods::pybind11::rod<py::numpy_style>>
        ::weld_namespace<^^mymod>(m);
}

The same scale above, welded through rod<py::numpy_style> and rod<py::sphinx_style> respectively:

>>> print(scale.__doc__)          # numpy_style
Scale a length by a factor.

Parameters
----------
length
    the length to scale
factor
    the multiplier

Returns
-------
the scaled length
>>> print(scale.__doc__)          # sphinx_style
Scale a length by a factor.

:param length: the length to scale
:param factor: the multiplier
:returns: the scaled length

welder has no target-language type text to place after numpydoc's name : type colon, so it emits the bare name form (which numpydoc accepts). The choice is per-welder::welder instantiation, so different modules — or even different submodules — can carry different styles. And the set isn't closed: a style is a one-function contract, so a house dialect of your own is a few lines — see Writing a doc style.

Styles are constexpr

Each style's format() is a plain constexpr std::string assembly, so it is unit-testable by static_assert (like cleandoc) and usable in any compile-time context. That is deliberately not written with std::format: std::format is not constexpr in the standard library (as of gcc-16), so a constexpr docstring builder cannot call it — the compile-time doc paths (cleandoc, annotation reading) rule it out, and the styles stay hand-rolled for one consistent story.

Multiline docstrings

Function docs often carry examples that span several lines, so use a raw string literal — it's still a const char[N], so newlines, blank lines, quotes and backslashes all flow through doc to __doc__.

You can indent the text to line up with the surrounding source: welder dedents it the way Python's inspect.cleandoc (PEP 257) does. The common leading indentation is stripped, leading/trailing blank lines are trimmed, and an example block's relative extra indentation is kept.

[[
  =welder::weld(welder::lang::py),
  =welder::doc(R"(
      Parse an integer.

      Example:
          >>> parse("42")
          42
  )")
]]
int parse(const std::string& text);
>>> print(parse.__doc__)
Parse an integer.

Example:
    >>> parse("42")
    42

The source indentation is gone; the four-space example indent — relative to the prose — survives.

Dedent details

Only the whitespace common to every line (after the first) is removed, so relative structure is preserved. The first line is stripped separately, so both doc(R"(First line here … and a doc that opens on its own line work. Indent with spaces — tabs are treated as single characters, not expanded.

Continuation lines of a parameter or return doc are likewise kept indented under their Args: / Returns: block, so a multiline entry reads as one.

Data members

A doc on a data member rides onto its Python attribute. pybind11 binds members as properties (data descriptors on the class), so the doc becomes the property's __doc__ — and flows into the .pyi stubs:

struct
[[=welder::weld(welder::lang::py), =welder::doc("A circle.")]]
Circle {
    [[=welder::doc("The radius.")]] double r{0.0};
    [[=welder::doc("The immutable id.")]] const int id{0};  // read-only
};
>>> Circle.r.__doc__
'The radius.'

A const member is bound read-only (get, no set); a mutable one is read/write. Only the getter's doc is surfaced — a Python property has a single __doc__ — so there is no separate setter docstring.

tparam — documenting templates

Template parameters aren't reflectable entities either, so their docs ride on the template itself, keyed by name and ordered:

template <class T>
struct
[[
  =welder::weld(welder::lang::py),
  =welder::tparam("T", "the stored element type")
]]
Box { T value; };

tparam becomes an @tparam in the C++ docs, and is read back via tparam_docs<Ent>() off an instantiation for rod docstrings.

What rods ignore

  • Namespace variable docs are ignored by binding rods — a bound module attribute has no __doc__. (Class data members do carry docs, via properties — see Data members above; and the Doxygen filter surfaces variable docs on the C++ side.)
  • Per-enumerator docs are omitted at runtime by every rod — neither Python binding framework exposes a per-member docstring slot for enum members, and Lua has no runtime docstring at all. They still reach the C++ reference.

Docs are stored inline

Doc text is stored inline as a welder::detail::fixed_string. A const char* to a string literal isn't a permitted annotation constant on gcc-16, so welder can't hold a pointer — it holds the characters.

Stubs

Everything above also lands in the generated stubs — the .pyi file your Python users' editors and type checkers read, and the LuaCATS (---@meta) file that is the docs' only home on Lua. Stubs get their own page: Stubs.

Next: Stubs.