Library documentation stays honest when generation is split into three lanes: a machine inventory, optional narrative glue, and human-signed examples. Models can restate names, arguments, and test titles that already exist in the repository. They should not invent runnable snippets, support windows, or security caveats, which become contracts once they land in a README. Treat the scripts below as a proposed local method, and run them on your own tree before you trust any generated sentence.
This workflow compiles a doc ownership ledger from one Python module and one pytest-style test file. The ledger is JSON plus Markdown, and every cell is marked draftable or human-owned before any prose is requested. The compile step is offline, deterministic, and cheap enough to run in CI on every docs pull request.
Why mixed-lane docs fail review
Generated API pages usually mix three kinds of text that do not share a source of truth. Inventory text can be compiled from function definitions, annotations, and collected test identifiers. Narrative text can summarize that inventory for a reader who does not want to open the module. Contract text tells a caller what remains true across versions, and only a maintainer can own that promise.
When those lanes collapse into one chat transcript, examples drift away from tests, and deprecation notes appear without a tag. Reviewers then argue with fluent paragraphs instead of with a table of unsigned cells. A ledger makes the unsigned cells visible before merge, which is the actual engineering control.
Adjacent industry talk about treating casual generation as engineering is a topic signal, not a method. The method here is the split: inventory first, narrative second, signed examples last. Extra model access does not repair a missing inventory, and it cannot witness that a fenced example executed.
Decision table: what may be drafted
Use this table as the contract for any later language-model step. Cells marked No must stay empty in model output; empty is safer than a fluent guess.
| Ledger cell | Evidence on disk | Model may draft? | Human must own |
|---|---|---|---|
| Public symbol list | AST FunctionDef / AsyncFunctionDef without a leading _
|
Restate names only | Confirm the public surface |
| Parameter names and annotations | AST arguments | Restate; never invent types | Fill or reject missing annotations |
| Existing docstring first line | ast.get_docstring |
Echo or lightly rephrase | Keep or reject the stated intent |
| Related tests | pytest test_* identifiers |
List substring matches | Confirm the test still covers the symbol |
| Overview / audience | Product intent, not AST | Hypothesis only, from inventory rows | Audience, non-goals, support status |
| Runnable example | Tests or a recorded session | Skeleton comments only | Must execute; prefer copying assertions |
| Compatibility / deprecation | Tags, changelog, issue ids | No | Yes |
| Secrets, tokens, hostnames | Team policy | No | Yes, placeholders only |
| When not to use | Product intent | No | Yes |
Substring test matching is a heuristic. False positives are expected on short names, and a human still has to accept or drop each related-test row.
What you need on disk
You need Python 3.11 or newer for ast.unparse, plus a small public module and at least one test file. The compiler parses tests with AST instead of importing them, so collection stays offline and does not execute fixtures. Replace the sample paths with a real package when you adopt the workflow.
1. Illustrative public module
# sample_ops.py
from __future__ import annotations
def normalize_slug(value: str, *, max_len: int = 64) -> str:
"""Collapse whitespace and lowercase a slug candidate."""
parts = "_".join(value.strip().split())
return parts.lower()[:max_len]
def bounded_retry(attempts: int) -> int:
"""Return a clamped retry count for callers that cannot loop forever."""
if attempts < 1:
return 1
if attempts > 8:
return 8
return attempts
2. Tests that mention the symbols
# tests/test_sample_ops.py
from sample_ops import bounded_retry, normalize_slug
def test_normalize_slug_collapses_spaces():
assert normalize_slug("Hello World") == "hello_world"
def test_bounded_retry_clamps_high_values():
assert bounded_retry(99) == 8
Those test names are evidence that an example could be derived later. They are not documentation examples until a human copies the assertions into a fenced block and runs that block.
Compile the ownership ledger
The compiler walks one module and one test file, then writes ledger.json and ledger.md. Treat the script as a proposed tool, not as a published package, and read the output before any narrative draft.
3. Proposed compiler (doc_ownership_ledger.py)
#!/usr/bin/env python3
"""Compile a doc ownership ledger from one module and pytest-style tests."""
from __future__ import annotations
import argparse
import ast
import json
from pathlib import Path
from typing import Any
def is_public(name: str) -> bool:
return not name.startswith("_")
def func_records(tree: ast.AST) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for node in tree.body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if not is_public(node.name):
continue
args = []
arg_nodes = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
for arg in arg_nodes:
ann = ast.unparse(arg.annotation) if arg.annotation else None
args.append({"name": arg.arg, "annotation": ann})
doc = ast.get_docstring(node)
first = (doc or "").splitlines()[0] if doc else None
records.append(
{
"name": node.name,
"lineno": node.lineno,
"async": isinstance(node, ast.AsyncFunctionDef),
"args": args,
"returns": ast.unparse(node.returns) if node.returns else None,
"docstring_first_line": first,
}
)
return records
def test_index(path: Path) -> list[dict[str, Any]]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
found: list[dict[str, Any]] = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
found.append({"file": str(path), "id": node.name, "lineno": node.lineno})
return found
def match_tests(symbol: str, tests: list[dict[str, Any]]) -> list[dict[str, Any]]:
key = symbol.lower()
return [t for t in tests if key in t["id"].lower()]
def ledger_row(record: dict[str, Any], tests: list[dict[str, Any]]) -> dict[str, Any]:
related = match_tests(record["name"], tests)
return {
"symbol": record["name"],
"inventory": record,
"related_tests": related,
"cells": {
"symbol_restatement": {"model_may_draft": True, "value": None},
"param_restatement": {"model_may_draft": True, "value": None},
"docstring_echo": {
"model_may_draft": True,
"value": record["docstring_first_line"],
},
"related_tests": {
"model_may_draft": True,
"value": [t["id"] for t in related],
},
"overview_audience": {"model_may_draft": False, "value": None},
"runnable_example": {"model_may_draft": False, "value": None},
"compatibility": {"model_may_draft": False, "value": None},
"secrets_policy": {"model_may_draft": False, "value": None},
"when_not_to_use": {"model_may_draft": False, "value": None},
},
}
def render_markdown(rows: list[dict[str, Any]]) -> str:
lines = [
"# Doc ownership ledger",
"",
"Signed cells must remain blank until a human fills them.",
"",
]
for row in rows:
inv = row["inventory"]
args = ", ".join(
f"{a['name']}: {a['annotation'] or 'UNTYPED'}" for a in inv["args"]
)
tests = row["cells"]["related_tests"]["value"] or []
lines.extend(
[
f"## `{row['symbol']}`",
"",
f"- Inventory args: `{args}`",
f"- Returns: `{inv['returns'] or 'UNTYPED'}`",
f"- Docstring first line: {inv['docstring_first_line'] or '(none)'}",
f"- Related tests: {', '.join(tests) if tests else '(none matched)'}",
"",
"### Model may draft",
"",
"- symbol_restatement:",
"- param_restatement:",
"",
"### Human must own (leave blank in model output)",
"",
"- overview_audience:",
"- runnable_example:",
"- compatibility:",
"- secrets_policy:",
"- when_not_to_use:",
"",
]
)
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description="Compile a doc ownership ledger")
parser.add_argument("--module", required=True, type=Path)
parser.add_argument("--tests", required=True, type=Path)
parser.add_argument("--out", required=True, type=Path)
args = parser.parse_args()
source = args.module.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(args.module))
tests = test_index(args.tests)
rows = [ledger_row(rec, tests) for rec in func_records(tree)]
args.out.mkdir(parents=True, exist_ok=True)
(args.out / "ledger.json").write_text(
json.dumps(rows, indent=2) + "\n", encoding="utf-8"
)
(args.out / "ledger.md").write_text(render_markdown(rows), encoding="utf-8")
print(f"wrote {args.out / 'ledger.json'} and {args.out / 'ledger.md'}")
if __name__ == "__main__":
main()
4. Commands to compile, then fail closed on unsigned examples
python doc_ownership_ledger.py \
--module sample_ops.py \
--tests tests/test_sample_ops.py \
--out ledger
python - <<'PY'
import json
from pathlib import Path
rows = json.loads(Path("ledger/ledger.json").read_text())
unsigned = []
for row in rows:
example = row["cells"]["runnable_example"]["value"]
if not example:
unsigned.append(row["symbol"])
if unsigned:
raise SystemExit("unsigned examples: " + ", ".join(unsigned))
print("all runnable_example cells are filled")
PY
The second command is a proposed CI gate, not a claim about any hosted runner. Keep it failing until a reviewer pastes real examples. Do not let a model fill runnable_example to silence the gate.
Fill narrative cells without mixing lanes
After ledger.json exists, a model may draft only cells with "model_may_draft": true. Paste that JSON into a prompt that forbids filling signed cells, and keep the model step optional. If the inventory is wrong, fix the compiler inputs instead of asking for a longer overview.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode's free model access and free server option can sit in that optional narrative step, after the ledger file is already committed. The compile step does not need a remote model, and the signed cells should remain blank in any model file you store beside the ledger. If you use that free-model path at all, keep it downstream of doc_ownership_ledger.py, never as a substitute for AST inventory or pytest evidence.
A tight prompt shape looks like the following unlabeled template. It is a proposal, not a recorded session.
You receive ledger.json.
Write Markdown only for cells where model_may_draft is true.
Leave overview_audience, runnable_example, compatibility,
secrets_policy, and when_not_to_use completely empty.
Do not invent types, default values, hostnames, or version guarantees.
If related_tests is empty, say unmatched rather than guessing coverage.
Store model output as ledger/narrative.draft.md, never as the published README. Diff that draft against ledger.md and reject any heading that appeared under Human must own.
Promote tests into signed examples
Human ownership of examples is a copy-and-run procedure, not a rewrite contest. The goal is a fenced block whose assertions already exist in pytest, plus a recorded command that still passes.
- Open the related test id from the ledger row and copy the assertion lines, not a paraphrased story about them.
- Wrap those lines in a module-level example or a README fence that imports the public symbol only.
- Run the example with the same interpreter the tests use, and paste the command plus exit status under
runnable_example. - Fill
when_not_to_usewith one concrete misuse, such as passing untrusted HTML tonormalize_slugand expecting escaping. - Leave
compatibilityempty unless a changelog entry or tag already states the window; do not date a promise from memory.
python -c "from sample_ops import normalize_slug; assert normalize_slug('Hello World') == 'hello_world'"
python -m pytest tests/test_sample_ops.py -q
If either command fails, the ledger row stays unsigned. Documentation that cannot replay those two commands is still a draft, even when the overview paragraph reads smoothly.
Proposed checks for the compiler itself
Label these as unexecuted checks in this article; run them locally if you adopt the script. They protect the lane split more than they protect slug behavior.
# test_doc_ownership_ledger.py (proposed)
import json
from pathlib import Path
import doc_ownership_ledger as dol
def test_private_functions_are_omitted(tmp_path: Path):
mod = tmp_path / "m.py"
mod.write_text("def _hidden():\n return 1\n\ndef visible(x: int) -> int:\n return x\n")
tree = __import__("ast").parse(mod.read_text())
names = [r["name"] for r in dol.func_records(tree)]
assert names == ["visible"]
def test_signed_cells_start_empty(tmp_path: Path):
# After a real compile, runnable_example must be JSON null.
sample = json.loads('{"cells": {"runnable_example": {"model_may_draft": false, "value": null}}}')
assert sample["cells"]["runnable_example"]["value"] is None
assert sample["cells"]["runnable_example"]["model_may_draft"] is False
Add a check that bounded_retry matches test_bounded_retry_clamps_high_values and that a symbol with no test is listed as unmatched. Unmatched is a valid inventory state; it is not a license to invent coverage narrative.
Limitations
The compiler sees only top-level functions in one module and test_* functions in one file. It does not expand pytest.mark.parametrize ids, class-based tests, doctest blocks, stub files, or C extensions. Re-exports, __all__ mismatches, overloaded @typing.overload stacks, and runtime-built functions will be missing or duplicated, and reviewers must correct those rows by hand.
Narrative drafts can still smuggle contracts into restatements, especially default values and raised exceptions that were never in the AST snapshot. Ban exception behavior and version numbers from draft cells even if a docstring mentions them. The CI gate above only proves that runnable_example is non-empty; it does not prove the example still matches HEAD after a later refactor.
This method does not generate Sphinx inventories, OpenAPI examples, or man pages. Those formats need their own evidence sources, and copying this Markdown skeleton into them would hide the lane split.
Who should not use this approach
Do not use this split as a substitute for legal, safety, or incident-response documentation, where an invented sentence has operational cost. Do not use it to document credential handling, production hostnames, or customer data flows, because model restatement does not make those examples safe. Teams without pytest (or an equivalent named test suite) should stop at the AST inventory and skip related-test matching entirely.
Skip the model step when the public surface is smaller than a single screen. Hand-written examples from tests will be faster, and the ledger still helps as a review checklist. If your docs are generated solely from a type checker or an OpenAPI source of truth, keep that pipeline; this article is for libraries whose README still mixes inventory with promises.
The durable output is not a generated overview. It is a file that shows which sentences were compiled, which were drafted, and which still need a human signature before a caller should believe them.
Top comments (0)