How These Docs Are Tested¶
Every console transcript on this site is a test. If the library's behaviour changes, the affected page fails in CI — documentation cannot silently drift.
The mechanism¶
Pages are collected by pytest as doctests:
[tool.pytest.ini_options]
addopts = "--doctest-glob=*.md --doctest-continue-on-failure"
testpaths = ["tests", "docs"]
pythonpath = ["."]
doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS", "IGNORE_EXCEPTION_DETAIL"]
A block written like this:
```pycon
>>> article = publication.Article('Hello', body='A body.')
>>> article.submit()
>>> article.state
<ReviewState.IN_REVIEW: 'IN_REVIEW'>
```
…is executed, and the lines after the prompt are compared against what the library actually returned.
Each page is one doctest, so names defined in an early block are still in scope in a later one. That is what lets a guide build an example up over several sections.
What the examples run against¶
docs/conftest.py gives every page a preloaded namespace, so the pages stay
readable without a setup block at the top of each one:
| Name | Value |
|---|---|
State, StateEngine, Transition, CURRENT |
The public API |
NoTransition, TransitionConditionsUnmet, InvalidTargetState, TransitionNotAllowed |
The exceptions |
get_state_field, get_transitions, get_outgoing_transitions, get_available_transitions |
The engine functions |
this, DEFAULT |
From open_fsm._compat |
publication, conditions, dynamic_target, inheritance, order, dataclass_flow |
The modules in examples/ |
Enum, pickle |
Standard-library helpers |
There is no database and no framework. open-fsm has no runtime dependencies,
so neither do its docs.
Running them¶
A failure prints the expected and actual output side by side, pointing at the line in the Markdown file:
File "docs/guides/transitions.md", line 80, in transitions.md
Failed example:
article.archive()
Expected:
Traceback (most recent call last):
...
open_fsm.base.NoTransition: Archive :: no transition from "ARCHIVED"
Got nothing
Writing a new example¶
- Use a
```pyconfence and>>>prompts. - Leave a blank line before the closing fence. doctest reads expected
output until a blank line; without one it tries to match the
```too. - Use names from the table above rather than adding imports, unless the import is itself the point of the example.
- Prefer showing a real value over asserting
True. Printing the state a flow landed in is more useful to a reader thanassert x == y. - Run the page. Do not hand-write the expected output — paste what the library produced, once you have confirmed it is correct.
That last rule is the one that matters. Several claims in these guides were
wrong on the first draft and were caught by running them, including one about
State.ANY that read perfectly well and described behaviour the library does
not have.
Enums in expected output¶
Never interpolate an enum member directly into an f-string.
Enum.__format__ changed in Python 3.12: a str-mixin member formats as
DRAFT on 3.10 and 3.11, and as ReviewState.DRAFT from 3.12 on. A page
written that way passes locally and fails on half the CI matrix.
str() and repr() are stable on every supported version, so write
f'{str(transition.source)}' rather than f'{transition.source}'. Bare
>>> transition.source at the prompt is fine — that is repr().
Exceptions¶
Show the traceback in doctest form:
```pycon
>>> get_state_field(object)
Traceback (most recent call last):
...
TypeError: object declares no State field
```
When only part of a long message matters, end it with ... — ELLIPSIS is
enabled.
Snippets from real files¶
Flow definitions are included from examples/ with
pymdownx.snippets,
so the code shown is the code the examples import:
```python title="publication.py"
class Article(StateEngine):
state = State(ReviewState, default=ReviewState.DRAFT)
def __init__(self, title: str, body: str = '') -> None:
self.title = title
self.body = body
@state.transition(
source=ReviewState.DRAFT,
target=ReviewState.IN_REVIEW,
conditions=[lambda article: bool(article.body)],
)
def submit(self) -> None:
"""Send a draft to the review queue, once it has a body."""
@state.transition(source=ReviewState.IN_REVIEW, target=ReviewState.APPROVED)
def approve(self) -> None:
"""Accept a reviewed article."""
@state.transition(source=ReviewState.IN_REVIEW, target=ReviewState.REJECTED)
def reject(self) -> None:
"""Send a reviewed article back to its author."""
@state.transition(source=ReviewState.APPROVED, target=ReviewState.PUBLISHED)
def publish(self) -> None:
"""Make an approved article public."""
@state.transition(source=State.ANY, target=ReviewState.ARCHIVED)
def archive(self) -> None:
"""Retire an article, from wherever it currently is."""
```
The markers live in the Python file:
mkdocs build --strict fails if a referenced snippet or marker is missing.
Snippets are not executed by the doctest run
The --8<-- markup sits inside a ```python fence, which doctest
ignores. A broken include is caught by mkdocs build --strict and by
test_referenced_snippets_exist in tests/test_examples.py, not by the
doctests. The content is covered, because tests/test_examples.py imports
and exercises the same modules.
The two layers¶
| Layer | Covers | Fails when |
|---|---|---|
Doctests over docs/**.md |
Every transcript on the site | The library's output changes |
tests/test_examples.py |
The examples/ modules, plus links, markers and nav |
The structure rots |
The second layer is deliberately dull: it asserts that every page is in the navigation, that every guide contains an executed block at all, that the integration pages still carry their "not implemented yet" warning, and that each example tutorial links to its source. None of that is caught by running transcripts.
In CI¶
The doctests run on every push, on every supported Python version, and the documentation build runs alongside them. A page whose output no longer matches fails the build in the same way a unit test does.
Related pages¶
- Publishing — how the site is deployed
- Credits and Attribution