Skip to content

Commit eef8741

Browse files
committed
Add doc-ci:skip directive and richer run reporting
Authors can now exempt a snippet from execution without polluting the copyable code: 'doc-ci:skip' in the fence info string, or an HTML comment '<!-- doc-ci:skip -->' on the line above the fence. Such snippets classify as directive-skip and are never run. The run reporter now shows captured error output for failures (the point of the tool) and a clear OK/FAIL verdict. README's own usage example is marked skip, so the README has no runnable snippets and CI now dogfoods 'doc-ci run' too. 58 tests passing.
1 parent 8732d41 commit eef8741

8 files changed

Lines changed: 135 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ jobs:
2222
run: pytest
2323
- name: Dogfood — inventory our own README
2424
run: doc-ci scan README.md
25+
- name: Dogfood — run our own README examples
26+
run: doc-ci run README.md

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,27 @@ README.md:22-26 [bash] 3 line(s) needs-network
3737
To actually execute the runnable examples (in a sandbox) and get a pass/fail
3838
report suitable for CI:
3939

40-
```bash
40+
```bash doc-ci:skip
4141
doc-ci run README.md
4242
doc-ci run docs/ --timeout 20 --json
4343
```
4444

4545
`run` exits non-zero if any snippet fails, so it works as a CI gate. Add
4646
`--json` to either command for machine-readable output.
4747

48+
### Skipping a snippet
49+
50+
An example that shouldn't be executed — it needs a real service, is purely
51+
illustrative, or is intentionally broken — can be marked so `doc-ci run`
52+
leaves it alone, without adding noise to the code readers copy. Either:
53+
54+
- put `doc-ci:skip` in the fence info string, right after the language
55+
(e.g. an opening fence of `` ```python doc-ci:skip ``), or
56+
- put `<!-- doc-ci:skip -->` on the line immediately above the fence.
57+
58+
The marker never appears in the copyable code, and the snippet is reported as
59+
`directive-skip` instead of being run.
60+
4861
## Safety design (the rule that governs this project)
4962

5063
Documentation snippets are untrusted input. When execution lands, it will be:

src/doc_ci/classifier.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
PLACEHOLDER = "placeholder"
2525
UNSAFE = "unsafe"
2626
NEEDS_NETWORK = "needs-network"
27+
DIRECTIVE_SKIP = "directive-skip"
2728

2829
#: Languages the (future) sandbox knows how to run, normalized.
2930
SUPPORTED_LANGS = {
@@ -105,6 +106,10 @@ def _first_match(code: str, patterns: list[re.Pattern]) -> str | None:
105106

106107
def classify(snippet: Snippet) -> Classification:
107108
"""Classify *snippet* without executing anything."""
109+
# An explicit author directive wins over everything else.
110+
if snippet.skip:
111+
return Classification(DIRECTIVE_SKIP, "doc-ci:skip directive")
112+
108113
lang = SUPPORTED_LANGS.get(snippet.lang)
109114
if lang is None:
110115
shown = snippet.lang or "none"

src/doc_ci/cli.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,32 @@ def cmd_run(args: argparse.Namespace) -> int:
7878
if args.json:
7979
print(json.dumps([r.to_dict() for r in results], indent=2))
8080
else:
81-
for r in results:
82-
lang = r.snippet.lang or "no-lang"
83-
loc = f"{r.snippet.path}:{r.snippet.start_line}-{r.snippet.end_line}"
84-
print(f"{loc} [{lang}] {r.status.upper()} ({r.reason})")
85-
counts = summarize(results)
86-
summary = ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
87-
print(f"\n{len(results)} snippet(s)" + (f" — {summary}" if summary else ""))
81+
_print_run_report(results)
8882

8983
# CI gate: fail on any failed/errored snippet, or on read errors.
90-
failed = summarize(results).get("failed", 0) + summarize(results).get("error", 0)
84+
counts = summarize(results)
85+
failed = counts.get("failed", 0) + counts.get("error", 0)
9186
return 1 if (failed or errors) else 0
9287

9388

89+
def _print_run_report(results: list) -> None:
90+
for r in results:
91+
lang = r.snippet.lang or "no-lang"
92+
loc = f"{r.snippet.path}:{r.snippet.start_line}-{r.snippet.end_line}"
93+
print(f"{r.status.upper():7} {loc} [{lang}] {r.reason}")
94+
# For real failures, show why: the captured error output, indented.
95+
if r.status in ("failed", "error") and r.outcome is not None:
96+
detail = (r.outcome.stderr or r.outcome.stdout or "").strip()
97+
if detail:
98+
for line in detail.splitlines()[-8:]:
99+
print(f" | {line}")
100+
101+
counts = summarize(results)
102+
summary = ", ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
103+
verdict = "FAIL" if (counts.get("failed", 0) or counts.get("error", 0)) else "OK"
104+
print(f"\n[{verdict}] {len(results)} snippet(s)" + (f" — {summary}" if summary else ""))
105+
106+
94107
def build_parser() -> argparse.ArgumentParser:
95108
parser = argparse.ArgumentParser(
96109
prog="doc-ci",

src/doc_ci/extractor.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,24 @@
55
whose first word is treated as the language, closed by a fence of the same
66
character at least as long as the opener. Unclosed fences run to end of file,
77
matching how most renderers display them.
8+
9+
Authors can mark a snippet to be skipped (never executed by `doc-ci run`)
10+
without polluting the copyable code, in either of two ways:
11+
12+
```python doc-ci:skip (annotation in the fence info string)
13+
14+
<!-- doc-ci:skip --> (HTML comment on the line above the fence)
15+
```python
816
"""
917

1018
from __future__ import annotations
1119

20+
import re
1221
from dataclasses import asdict, dataclass
1322

23+
# Matches the skip directive, tolerating a space after the colon, case-insensitive.
24+
_SKIP_DIRECTIVE = re.compile(r"doc-ci:\s*skip\b", re.IGNORECASE)
25+
1426

1527
@dataclass
1628
class Snippet:
@@ -21,11 +33,17 @@ class Snippet:
2133
code: str
2234
start_line: int # 1-based line number of the opening fence
2335
end_line: int # 1-based line number of the closing fence (or last line if unclosed)
36+
info: str = "" # full info string after the opening fence (lang + any annotations)
37+
skip: bool = False # author asked to skip execution via a doc-ci:skip directive
2438

2539
def to_dict(self) -> dict:
2640
return asdict(self)
2741

2842

43+
def _has_skip_directive(text: str) -> bool:
44+
return bool(_SKIP_DIRECTIVE.search(text))
45+
46+
2947
def _fence_open(line: str) -> tuple[str, int, str] | None:
3048
"""Return (fence_char, fence_length, info_string) if *line* opens a fence."""
3149
stripped = line.lstrip(" ")
@@ -62,6 +80,8 @@ def extract_snippets(text: str, path: str = "<string>") -> list[Snippet]:
6280
fence_char = ""
6381
fence_len = 0
6482
lang = ""
83+
info = ""
84+
skip = False
6585
buf: list[str] = []
6686
start_line = 0
6787
last_line = 0
@@ -73,6 +93,9 @@ def extract_snippets(text: str, path: str = "<string>") -> list[Snippet]:
7393
if opened is not None:
7494
fence_char, fence_len, info = opened
7595
lang = info.split()[0].lower() if info else ""
96+
# Skip directive: in the info string, or on the preceding line.
97+
prev = lines[lineno - 2] if lineno >= 2 else ""
98+
skip = _has_skip_directive(info) or _has_skip_directive(prev)
7699
in_fence = True
77100
buf = []
78101
start_line = lineno
@@ -85,6 +108,8 @@ def extract_snippets(text: str, path: str = "<string>") -> list[Snippet]:
85108
code="\n".join(buf) + ("\n" if buf else ""),
86109
start_line=start_line,
87110
end_line=lineno,
111+
info=info,
112+
skip=skip,
88113
)
89114
)
90115
in_fence = False
@@ -99,6 +124,8 @@ def extract_snippets(text: str, path: str = "<string>") -> list[Snippet]:
99124
code="\n".join(buf) + ("\n" if buf else ""),
100125
start_line=start_line,
101126
end_line=last_line,
127+
info=info,
128+
skip=skip,
102129
)
103130
)
104131

tests/test_classifier.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for the snippet classifier. Nothing here executes snippet content."""
22

33
from doc_ci.classifier import (
4+
DIRECTIVE_SKIP,
45
NEEDS_NETWORK,
56
PLACEHOLDER,
67
RUNNABLE,
@@ -15,6 +16,20 @@ def snip(code: str, lang: str = "bash") -> Snippet:
1516
return Snippet(path="x.md", lang=lang, code=code, start_line=1, end_line=2)
1617

1718

19+
def test_skip_directive_wins_over_everything():
20+
# Even an otherwise-runnable snippet is directive-skip when marked.
21+
s = Snippet(path="x.md", lang="bash", code="echo hi\n",
22+
start_line=1, end_line=2, skip=True)
23+
assert classify(s).category == DIRECTIVE_SKIP
24+
25+
26+
def test_skip_directive_wins_over_unsafe():
27+
# The directive short-circuits before any other check.
28+
s = Snippet(path="x.md", lang="bash", code="rm -rf /\n",
29+
start_line=1, end_line=2, skip=True)
30+
assert classify(s).category == DIRECTIVE_SKIP
31+
32+
1833
def test_plain_echo_is_runnable():
1934
assert classify(snip("echo hello\n")).category == RUNNABLE
2035

tests/test_extractor.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,44 @@ def test_crlf_tolerance():
9595
snips = extract_snippets(text)
9696
assert len(snips) == 1
9797
assert "echo hi" in snips[0].code
98+
99+
100+
def test_info_string_captured():
101+
text = "```python title=example.py\nx = 1\n```\n"
102+
snips = extract_snippets(text)
103+
assert snips[0].info == "python title=example.py"
104+
assert snips[0].lang == "python"
105+
106+
107+
def test_no_skip_by_default():
108+
text = "```bash\necho hi\n```\n"
109+
assert extract_snippets(text)[0].skip is False
110+
111+
112+
def test_skip_directive_in_info_string():
113+
text = "```bash doc-ci:skip\necho hi\n```\n"
114+
snips = extract_snippets(text)
115+
assert snips[0].skip is True
116+
assert snips[0].lang == "bash" # lang still parsed as first token
117+
118+
119+
def test_skip_directive_in_preceding_html_comment():
120+
text = "<!-- doc-ci:skip -->\n```bash\necho hi\n```\n"
121+
snips = extract_snippets(text)
122+
assert snips[0].skip is True
123+
124+
125+
def test_skip_directive_tolerates_space_after_colon():
126+
text = "```bash doc-ci: skip\necho hi\n```\n"
127+
assert extract_snippets(text)[0].skip is True
128+
129+
130+
def test_skip_directive_case_insensitive():
131+
text = "```bash DOC-CI:SKIP\necho hi\n```\n"
132+
assert extract_snippets(text)[0].skip is True
133+
134+
135+
def test_directive_two_lines_above_does_not_apply():
136+
# Only the immediately preceding line counts, to stay predictable.
137+
text = "<!-- doc-ci:skip -->\n\n```bash\necho hi\n```\n"
138+
assert extract_snippets(text)[0].skip is False

tests/test_runner.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ def test_unsupported_language_is_skipped():
9292
assert box.calls == []
9393

9494

95+
def test_skip_directive_snippet_is_not_executed():
96+
box = FakeSandbox()
97+
marked = Snippet(path="x.md", lang="bash", code="echo hi\n",
98+
start_line=1, end_line=2, skip=True)
99+
results = run_snippets([marked], box)
100+
assert results[0].status == SKIPPED
101+
assert "directive-skip" in results[0].reason
102+
assert box.calls == [] # author said skip; never ran
103+
104+
95105
def test_runnable_snippet_skipped_when_no_sandbox():
96106
box = FakeSandbox(available=False)
97107
results = run_snippets([snip("echo hi\n")], box)

0 commit comments

Comments
 (0)