Skip to content

Commit 72f7b07

Browse files
authored
feat: Add runnable examples (#761)
This PR introduces a backward-compatible markdown marker that can be used to mark code examples as runnable. `doc-builder` won't execute them but provides an helper for upstream Python projects to do so, via a `pytest` plugin and a base class. And few helpers to make sure the example stays clean when rendered as doc.
1 parent 48d4c5a commit 72f7b07

14 files changed

Lines changed: 1238 additions & 9 deletions

.github/workflows/build_pr_documentation.yml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ on:
5757
type: string
5858
default: "main"
5959
description: "Debug purposes only. Revision of `doc-builder` repo to use. Useful to test changes on `doc-builder`."
60+
fail_on_warning:
61+
type: boolean
62+
default: false
63+
description: "Fail doc build when runnable warnings are emitted. Requires `additional_args` to include `--emit-warning`."
6064

6165

6266
jobs:
@@ -203,20 +207,35 @@ jobs:
203207
source .venv/bin/activate
204208
echo "doc_folder has been set to ${{ env.doc_folder }}"
205209
cd doc-builder
210+
mkdir -p ../build_dir
211+
log_file="../build_dir/doc-build.log"
212+
: > "$log_file"
213+
set -o pipefail
206214
args="--build_dir ../build_dir --clean --version pr_${{ inputs.pr_number }} --html ${{ inputs.additional_args }} --repo_owner ${{ inputs.repo_owner }} --repo_name ${{ inputs.package }} --version_tag_suffix=${{ inputs.version_tag_suffix }}"
207215
208216
if [ -z "${{ inputs.languages }}" ];
209217
then
210218
echo "languages not provided, defaulting to English"
211-
doc-builder build ${{ env.package_name }} ../${{ env.doc_folder }} $args
219+
doc-builder build ${{ env.package_name }} ../${{ env.doc_folder }} $args 2>&1 | tee -a "$log_file"
212220
else
213221
IFS=', ' read -r -a langs <<< "${{ inputs.languages }}"
214222
for lang in "${langs[@]}"
215223
do
216224
echo "Generating docs for language $lang"
217-
doc-builder build ${{ env.package_name }} ../${{ env.doc_folder }}/$lang $args --language $lang
225+
doc-builder build ${{ env.package_name }} ../${{ env.doc_folder }}/$lang $args --language $lang 2>&1 | tee -a "$log_file"
218226
done
219227
fi
228+
229+
warnings_emitted=false
230+
if [[ "${{ inputs.additional_args }}" == *"--emit-warning"* ]] && grep -q "Bare assert found in runnable:" "$log_file"; then
231+
warnings_emitted=true
232+
fi
233+
echo "$warnings_emitted" > ../build_dir/warnings_emitted
234+
235+
if [[ "$warnings_emitted" == "true" && "${{ inputs.fail_on_warning }}" == "true" ]]; then
236+
echo "::error::Doc build failed because warnings were emitted (fail_on_warning=true)."
237+
exit 1
238+
fi
220239
cd ..
221240
222241
- name: Save commit_sha & pr_number

.github/workflows/upload_pr_documentation.yml

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ jobs:
8181
exit 1
8282
fi
8383
84+
- name: Get warning status
85+
id: warning-context
86+
run: |
87+
if [ -f ./build_dir/warnings_emitted ]; then
88+
content_warnings_emitted=$(cat ./build_dir/warnings_emitted)
89+
rm -rf ./build_dir/warnings_emitted
90+
else
91+
content_warnings_emitted=false
92+
fi
93+
94+
if [[ $content_warnings_emitted =~ ^(true|false)$ ]]; then
95+
echo "warnings_emitted=$content_warnings_emitted" >> $GITHUB_OUTPUT
96+
else
97+
echo "Encountered an invalid warnings_emitted value"
98+
exit 1
99+
fi
100+
84101
- name: Set hub_docs_url
85102
id: hfhub-context
86103
run: |
@@ -92,6 +109,20 @@ jobs:
92109
echo "hub_docs_url=${{ inputs.hub_base_path }}/${{ inputs.package_name }}/pr_${{ steps.github-context.outputs.pr_number }}" >> $GITHUB_OUTPUT
93110
fi
94111
112+
- name: Compose doc comment message
113+
id: doc-comment
114+
run: |
115+
warning_message=""
116+
if [ "${{ steps.warning-context.outputs.warnings_emitted }}" = "true" ]; then
117+
warning_message=" Some warnings were emitted during doc build; check the workflow logs for details."
118+
fi
119+
120+
{
121+
echo "message<<EOF"
122+
echo "The docs for this PR live [here](${{ steps.hfhub-context.outputs.hub_docs_url }}). All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.${warning_message}"
123+
echo "EOF"
124+
} >> $GITHUB_OUTPUT
125+
95126
- name: Push to repositories
96127
shell: bash
97128
run: |
@@ -130,16 +161,15 @@ jobs:
130161
uses: thollander/actions-comment-pull-request@v2
131162
if: steps.find_comment.outputs.comment-id == ''
132163
with:
133-
message: 'The docs for this PR live [here](${{ steps.hfhub-context.outputs.hub_docs_url }}). All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.'
164+
message: ${{ steps.doc-comment.outputs.message }}
134165
pr_number: ${{ steps.github-context.outputs.pr_number }}
135166
GITHUB_TOKEN: ${{ steps.auth.outputs.method == 'comment_bot_token' && secrets.comment_bot_token || steps.comment_bot_token.outputs.token }}
136167

137168
- name: Update doc comment if necessary
138-
if: github.event.action == 'reopened' && steps.find_comment.outputs.comment-id != ''
169+
if: steps.find_comment.outputs.comment-id != '' && (github.event.action == 'reopened' || steps.find_comment.outputs.comment-body != steps.doc-comment.outputs.message)
139170
uses: peter-evans/create-or-update-comment@v1
140171
with:
141172
comment-id: ${{ steps.find_comment.outputs.comment-id }}
142173
token: ${{ steps.auth.outputs.method == 'comment_bot_token' && secrets.comment_bot_token || steps.comment_bot_token.outputs.token }}
143174
edit-mode: replace
144-
body: |
145-
The docs for this PR live [here](${{ steps.hfhub-context.outputs.hub_docs_url }}). All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.
175+
body: ${{ steps.doc-comment.outputs.message }}

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,59 @@ doc-builder build hub ~/git/hub-docs/docs/source --build_dir ~/tmp/test-build --
119119
- add `[[open-in-colab]]` in the tutorial for which you want to build a notebook
120120
- add `--notebook_dir {path_to_notebook_folder}` to the build command.
121121

122+
### Runnable code blocks
123+
124+
`doc-builder` recognizes runnable Python fences tagged with `runnable` or `runnable:<label>`:
125+
126+
````md
127+
```py runnable:quickstart
128+
from transformers import pipeline
129+
pipe = pipeline("sentiment-analysis")
130+
print(pipe("I love this!"))
131+
```
132+
````
133+
134+
You can use either `py` or `python` fences:
135+
136+
````md
137+
```python runnable:my_example
138+
print("hello")
139+
```
140+
````
141+
142+
During conversion:
143+
- the runnable annotation is removed from the fence in rendered docs
144+
- the code content is preserved unless lines are hidden with `# doc-builder: hide`
145+
- `# doc-builder: hide` can hide a single line or a full indented block from rendered docs
146+
147+
The label is used in warning messages (for example, `runnable:quickstart`) to identify the block.
148+
149+
`doc-builder` tags and transforms runnable blocks, but it does not execute them by itself.
150+
151+
Use the reusable helper from `hf-doc-builder`:
152+
153+
```python
154+
from pathlib import Path
155+
156+
from doc_builder.testing import DocIntegrationTest
157+
158+
159+
class MyPageDocIntegrationTest(DocIntegrationTest):
160+
doc_path = Path(__file__).resolve().parents[2] / "docs" / "source" / "en" / "my_page.md"
161+
```
162+
163+
`DocIntegrationTest` finds runnable `py`/`python` fences in the target markdown file, creates one test per block, and executes each block with contextual failure output.
164+
165+
Run locally with:
166+
167+
```bash
168+
pytest -q tests/docs/test_my_page_docs.py
169+
```
170+
171+
This executes trusted documentation code with `exec`, so keep it limited to repo-controlled docs and CI.
172+
173+
For continuation blocks, `# pytest-decorator`, bare-assert warnings, and GitHub Actions wiring, see [docs/runnable-code-blocks.md](docs/runnable-code-blocks.md).
174+
122175
## Writing in notebooks
123176

124177
You can write your docs in jupyter notebooks & use doc-builder to: turn jupyter notebooks into mdx files.

docs/runnable-code-blocks.md

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Runnable code blocks
2+
3+
This document describes runnable Python markdown fences handled by `doc-builder`.
4+
5+
## Authoring
6+
7+
`doc-builder` recognizes fenced `py` and `python` blocks tagged with `runnable` or `runnable:<label>`:
8+
9+
During conversion:
10+
11+
- the runnable annotation is removed from the fence in rendered docs
12+
- the code content is preserved unless lines are hidden with `# doc-builder: hide`
13+
- `# doc-builder: hide` can hide a single line or a full indented block from rendered docs
14+
- `# doc-builder: ignore-bare-assert` keeps a bare `assert` in the block while suppressing the bare-assert warning for that line
15+
- the `# doc-builder: ignore-bare-assert` directive is removed from rendered docs
16+
17+
The label is used in warning messages and generated test names.
18+
19+
Example:
20+
21+
````md
22+
```py runnable:quickstart
23+
import torch
24+
from transformers import AutoProcessor, GlmAsrForConditionalGeneration
25+
26+
checkpoint_name = "zai-org/GLM-ASR-Nano-2512"
27+
audio_url = "https://huggingface.co/datasets/eustlb/audio-samples/resolve/main/bcn_weather.mp3"
28+
29+
processor = AutoProcessor.from_pretrained(checkpoint_name)
30+
model = GlmAsrForConditionalGeneration.from_pretrained(checkpoint_name, device_map="auto", dtype="auto")
31+
32+
conversation = [
33+
[
34+
{
35+
"role": "user",
36+
"content": [
37+
{"type": "audio", "url": audio_url},
38+
{"type": "text", "text": "Please transcribe this audio into text"},
39+
],
40+
}
41+
]
42+
]
43+
44+
inputs = processor.apply_chat_template(
45+
conversation, tokenize=True, add_generation_prompt=True, return_dict=True
46+
).to(model.device, dtype=model.dtype)
47+
48+
inputs_transcription = processor.apply_transcription_request([audio_url]).to(model.device, dtype=model.dtype)
49+
50+
for key in inputs: # doc-builder: hide
51+
assert torch.equal(inputs[key], inputs_transcription[key])
52+
53+
outputs = model.generate(**inputs, do_sample=False, max_new_tokens=128)
54+
decoded_outputs = processor.batch_decode(outputs[:, inputs.input_ids.shape[1] :], skip_special_tokens=True)
55+
56+
print(decoded_outputs)
57+
assert decoded_outputs == [
58+
"Yesterday it was thirty five degrees in Barcelona, but today the temperature will go down to minus twenty degrees."
59+
] # doc-builder: ignore-bare-assert
60+
```
61+
````
62+
63+
## Continuation blocks
64+
65+
Use `:2`, `:3`, and so on to continue a runnable block in the same namespace:
66+
67+
````md
68+
```py runnable:test_basic
69+
processor = AutoProcessor.from_pretrained("suno/bark")
70+
inputs = processor("Hello, my dog is cute", voice_preset=voice_preset)
71+
```
72+
73+
```py runnable:test_basic:2
74+
inputs = processor("Amazing! I can speak English too.")
75+
```
76+
````
77+
78+
`runnable:test_basic:2` and later continuations are grouped with `runnable:test_basic`, so later snippets can build on earlier setup.
79+
80+
## Test decorators
81+
82+
Runnable code blocks can declare test decorators with `# pytest-decorator:` comments. The decorator is imported and applied to the generated execution function at runtime.
83+
84+
````md
85+
```py runnable:test_basic
86+
# pytest-decorator: transformers.testing_utils.slow
87+
# pytest-decorator: transformers.testing_utils.require_torch
88+
from transformers import pipeline
89+
pipe = pipeline("sentiment-analysis")
90+
print(pipe("I love this!"))
91+
```
92+
````
93+
94+
Multiple decorators on the same line are also supported:
95+
96+
````md
97+
```py runnable:test_basic
98+
# pytest-decorator: transformers.testing_utils.slow, transformers.testing_utils.require_torch
99+
from transformers import pipeline
100+
pipe = pipeline("sentiment-analysis")
101+
print(pipe("I love this!"))
102+
```
103+
````
104+
105+
How it works:
106+
107+
- each `# pytest-decorator: <dotted.import.path>` line is parsed during collection
108+
- the decorator is imported and applied to the code block execution function
109+
- skip-style decorators such as `@slow` or `@require_torch` raise `unittest.SkipTest`, which pytest reports as a skip
110+
- `# pytest-decorator:` lines are stripped from executed code and rendered documentation
111+
112+
## Running runnable blocks in tests
113+
114+
When `hf-doc-builder` is installed, pytest auto-loads the `doc-builder` plugin. This makes running runnable blocks against `.md` files a supported workflow, and it is the recommended way to execute them in most projects.
115+
116+
You can point pytest directly at a markdown page:
117+
118+
```bash
119+
pytest -q docs/source/en/my_page.md
120+
```
121+
122+
Or at a directory of markdown docs:
123+
124+
```bash
125+
pytest -q docs/source/en/
126+
```
127+
128+
What the pytest plugin does:
129+
130+
- collects runnable blocks from the `.md` files passed to pytest
131+
- finds fenced `py`/`python` blocks marked with `runnable` or `runnable:<label>`
132+
- groups continuation blocks such as `runnable:test_basic:2` with the earlier block
133+
- creates one pytest item per runnable block
134+
- applies `# pytest-decorator:` directives before execution
135+
- reports failures with the markdown file path and runnable code snippet
136+
137+
`DocIntegrationTest` is a lower-level helper. It is not required for markdown-based doc tests. Use it only if you want to manage doc execution from regular Python test files in your project, or if you need tighter control over the Python-side test wrapper.
138+
139+
Example:
140+
141+
```python
142+
from pathlib import Path
143+
144+
from doc_builder.testing import DocIntegrationTest
145+
146+
147+
class MyPageDocIntegrationTest(DocIntegrationTest):
148+
doc_path = Path(__file__).resolve().parents[2] / "docs" / "source" / "en" / "my_page.md"
149+
```
150+
151+
`DocIntegrationTest` reads one markdown file, creates one Python test per runnable block (`runnable:my_case` becomes `test_my_case`), and executes those blocks from a standard pytest test module.
152+
153+
Run locally with:
154+
155+
```bash
156+
pytest -q tests/docs/test_my_page_docs.py
157+
```
158+
159+
Both approaches execute trusted documentation code with `exec`, so keep them limited to repo-controlled docs and CI.
160+
161+
Both approaches run raw markdown code blocks. If you want test behavior to match rendered docs exactly, preprocess the blocks first so `# doc-builder: hide` lines are removed before execution.
162+
163+
## Bare-assert warnings
164+
165+
`doc-builder build` can emit warnings for bare `assert` statements inside runnable Python markdown fences.
166+
167+
This behavior is opt-in:
168+
169+
```bash
170+
doc-builder build {package_name} {path_to_docs} --build_dir {build_dir} --emit-warning
171+
```
172+
173+
When enabled:
174+
175+
- bare `assert` lines in runnable blocks emit warnings with file and line information
176+
- `# doc-builder: hide` removes the line from rendered docs and does not warn
177+
- `# doc-builder: ignore-bare-assert` keeps the line, silences the warning, and is removed from rendered docs
178+
179+
Warning formatting depends on where the build runs:
180+
181+
- local runs: `Warning: docs/source/en/example.md:3: ...`
182+
- GitHub Actions: `::warning file=docs/source/en/example.md,line=3::...`
183+
184+
## GitHub pipeline integration
185+
186+
If you use the reusable PR documentation workflow, pass `--emit-warning` through `additional_args`:
187+
188+
```yaml
189+
jobs:
190+
build:
191+
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@main
192+
with:
193+
# ...
194+
additional_args: --emit-warning
195+
```
196+
197+
To fail the PR documentation build on warnings, also set `fail_on_warning: true`:
198+
199+
```yaml
200+
jobs:
201+
build:
202+
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@main
203+
with:
204+
# ...
205+
additional_args: --emit-warning
206+
fail_on_warning: true
207+
```

0 commit comments

Comments
 (0)