Skip to content

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:

pyproject.toml
[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

# every page
uv run pytest docs/

# one page
uv run pytest docs/guides/dynamic-targets.md

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

  1. Use a ```pycon fence and >>> prompts.
  2. Leave a blank line before the closing fence. doctest reads expected output until a blank line; without one it tries to match the ``` too.
  3. Use names from the table above rather than adding imports, unless the import is itself the point of the example.
  4. Prefer showing a real value over asserting True. Printing the state a flow landed in is more useful to a reader than assert x == y.
  5. 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:

class Article(StateEngine):
    ...

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.