Software Engineering · Documentation
What an API Doc Generator Can Recover From Source Alone
Parsing and runtime introspection produce different documentation from the same code. Which one a toolchain uses decides what it can see, what it silently omits, and what it can never know.
Every generated reference page is a reconstruction. The generator reads something — source text, an abstract syntax tree, a compiled module — and emits an entry per symbol with a signature, a docstring, and links. Because the input differs between toolchains, two generators run over the same library produce different pages, and the differences are systematic rather than cosmetic: they determine which symbols appear, which signatures are accurate, and which cross-references resolve.
The two families are parsing and loading. A parser reads declarations without executing them; a loader imports the module and asks the resulting objects what they are. Javadoc parses Java source. Doxygen parses C, C++, and other languages with its own front end. Sphinx’s autodoc imports the module at build time and uses Python’s introspection facilities. Each family has a characteristic blind spot, and knowing which one is in use explains most of what a page does and does not show.
Parsing sees declarations; importing sees objects
A parser has the advantage of not needing the code to be runnable. It can document a library whose dependencies are absent, a platform-specific module on the wrong platform, or a package with import-time side effects such as reading configuration or opening a connection. It also sees conditional compilation, which a loader cannot: a function declared inside #ifdef _WIN32 exists in the parsed source and in the running interpreter on exactly one platform.
Its weakness is that it sees what is written, not what the code means. A macro that expands to a function declaration is a macro to the parser unless the preprocessor runs, and running the preprocessor means choosing a configuration, which is why Doxygen exposes preprocessing options and why the choice changes the output. Template instantiation, typedef chains, and generated headers have the same character: the declaration the reader needs exists after a transformation the parser may not perform.
A loader has the opposite profile. It sees the objects that actually exist in the built package, with their real attributes, so it cannot drift from the installed version. But it must be able to import the module, which requires every dependency and makes the documentation build fail for reasons unrelated to documentation. Sphinx’s autodoc handles this with a mock-import option that substitutes stub modules for missing dependencies, and the consequence is that annotations referring to those stubs resolve to placeholders rather than to the real classes.
The import has a second cost that is easy to overlook. Module-level code runs. Any registry populated at import, any plugin discovery scan, any connection attempted at import time executes during the build, in the build’s environment, with the build’s credentials absent. This is the mechanism behind the advice that documentation builds should import the package the same way a user would.
Decorators erase the signature the reader needs
Introspection recovers a signature from the callable object. When a decorator returns a wrapper defined as (*args, **kwargs), that wrapper is the object, and the generated page shows (*args, **kwargs) — an accurate description of the wrapper and a useless one for the reader. The fix is the one the standard library provides: functools.wraps copies the wrapped function’s metadata onto the wrapper and sets __wrapped__, and signature inspection follows that attribute.
import functools, inspect
def traced(fn):
@functools.wraps(fn) # copies __doc__, __name__, __wrapped__
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@traced
def scale(x: float, factor: float = 2.0) -> float:
"""Multiply x by factor."""
return x * factor
print(inspect.signature(scale)) # (x: float, factor: float = 2.0) -> float
print(inspect.signature(scale.__wrapped__))
Without functools.wraps, the first line prints the wrapper’s signature and the second raises AttributeError, because the wrapper carries no __wrapped__ attribute. The failure is quiet because the page still renders, the entry still has a description, and only the parameter list is wrong. Decorators that add parameters — a retry decorator that injects a policy argument, a framework route decorator that appends a request object — are the hard case: following __wrapped__ hides the injected parameter, and not following it hides everything else. Neither answer is right, which is why some frameworks attach an explicit signature override that the generator is expected to consult.
Defaults and annotations are evaluated values, not text
A loader reports the default as an object. Rendering it means calling repr, and the result depends on the object: 2.0 renders as 2.0, an enum member renders as <Color.RED: 1>, and an instance of a class without a custom __repr__ renders with its memory address, which changes on every build. A default that is a sentinel object renders as <object object at 0x...> and tells the reader nothing about the documented convention that passing it means “no value supplied”.
Annotations have a parallel problem. A parser sees the annotation expression as written, which is usually what the author intended to communicate. At run time, annotations are objects, and under postponed evaluation they are strings that must be resolved against the defining module’s globals. typing.get_type_hints performs that resolution, and it fails when a forward reference names something not yet imported, when a module is imported under a stub, or when a type is defined inside a function. The failure is a build error at best and a rendered page full of quoted strings at worst.
Overloads expose the difference most sharply. A parser sees three declarations with one name. A loader sees one callable unless the runtime offers an overload registry, and the page then shows the union signature or the last definition. For libraries that dispatch on argument types at run time, the signature in the documentation is genuinely ambiguous, and the generator’s choice is a presentation decision that affects whether a reader believes the function is type-safe.
C and C++ are read through a preprocessor and a type system
For header-defined interfaces, the declaration the reader needs is spread across a header, an included type definition, and a macro. Doxygen runs a C preprocessor by default, but only partially: it evaluates conditional compilation and records macro definitions without expanding macros. A macro-generated declaration therefore appears only when MACRO_EXPANSION is enabled, and disabling preprocessing leaves both arms of every conditional in the parse rather than removing them. Documentation comments are associated with the declaration by position and by structural rules, and a comment placed between a template header and a function is attached according to conventions that differ by language.
Two properties of C and C++ make the generated page less complete than the header suggests. First, default arguments appear only in the declaration, so a definition and its declaration must not both repeat them, and the generated page shows whichever the parser treats as canonical. Second, overloading means one name maps to many signatures, and the page must group them while keeping each describable separately; the reader looking for the overload that takes a std::string needs the entry for that signature, not a merged list.
Javadoc sits between the families. It parses source with a compiler-aware tool and therefore sees real types and real overloads, but it requires source rather than bytecode, so a dependency distributed as compiled classes has no documentation to extract. This is the reason the Java ecosystem publishes source jars alongside binaries.
Cross-references resolve in a second pass, or not at all
A reference in a docstring rarely names a fully qualified symbol. It names something short, in the hope that the generator can find it. Resolution therefore runs as a separate pass over the collected entries, sometimes over an inventory exported by another project: Sphinx can read an inventory file published by another documentation set and resolve references across project boundaries.
The pass has three outcomes. A unique match becomes a link. Multiple matches produce an ambiguity warning, or a silent choice of one, depending on configuration. No match produces a warning if the build is configured to warn, and literal text otherwise. The last case is the dangerous one, because an unresolved reference renders as code font and looks intentional. Treating unresolved references as errors is the only setting that keeps a large reference tree honest, and it is also the setting that makes every build fail the first time it is enabled.
The boundary the generator cannot cross
Everything above concerns the shape of an interface. None of it recovers the contract. A generated page can state that a parameter is a float with default 2.0; it cannot state that the value is a multiplier applied before rounding, that a negative value reverses the result, or that the function is not safe to call concurrently. Those facts exist only if someone wrote them in the comment the generator extracted, and a generator can no more verify them than a compiler can.
That is the division of labour worth designing around. The generator owns the facts that change with the code — names, signatures, defaults, types, module membership — and produces them without human intervention, which removes an entire category of error. The comment owns the facts that do not appear in the code at all, and those are exactly the elements that a complete function entry has to state. A page that is entirely generated therefore looks authoritative and describes only the half of the interface that the compiler already enforces.
Two operational consequences follow. Generated pages should not be edited in place, because the next build discards the edit; the “edit this page” link should point at the source, and the page should say so. And the prose inside docstrings needs the same drift detection as prose anywhere else, since the generator faithfully republishes whatever the comment said three releases ago, as drift detection addresses. The generator guarantees that the signature is current. It guarantees nothing about the sentence underneath it.
References
- Sphinx documentation: Including documentation from docstrings (autodoc) — Sphinx project
- inspect — Inspect live objects — Python Software Foundation
- Doxygen Manual: Preprocessing — Doxygen project
- PEP 484: Type Hints — Python Software Foundation
- Oracle: javadoc Tool — Oracle