mdoctest 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdoctest-0.1.0/.gitignore +8 -0
- mdoctest-0.1.0/CHANGELOG.md +21 -0
- mdoctest-0.1.0/LICENSE +21 -0
- mdoctest-0.1.0/PKG-INFO +208 -0
- mdoctest-0.1.0/README.md +185 -0
- mdoctest-0.1.0/pyproject.toml +42 -0
- mdoctest-0.1.0/src/mdoctest/__init__.py +17 -0
- mdoctest-0.1.0/src/mdoctest/__main__.py +4 -0
- mdoctest-0.1.0/src/mdoctest/cli.py +126 -0
- mdoctest-0.1.0/src/mdoctest/core.py +221 -0
- mdoctest-0.1.0/src/mdoctest/match.py +57 -0
- mdoctest-0.1.0/src/mdoctest/parser.py +93 -0
- mdoctest-0.1.0/src/mdoctest/session.py +142 -0
- mdoctest-0.1.0/tests/test_core.py +84 -0
- mdoctest-0.1.0/tests/test_match.py +30 -0
- mdoctest-0.1.0/tests/test_parser.py +46 -0
- mdoctest-0.1.0/tests/test_session.py +54 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to mdoctest are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/) and the project uses
|
|
5
|
+
[semantic versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
Initial release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Verify `console`/`shell-session` blocks in Markdown: each `$` command runs in
|
|
13
|
+
a persistent shell (state carries across the session) and its combined
|
|
14
|
+
stdout+stderr is compared to the documented output.
|
|
15
|
+
- `bash`/`sh` blocks whose first line is a `$ ` prompt are treated as sessions.
|
|
16
|
+
- `--fix`: re-run and rewrite expected output in place, preserving prose.
|
|
17
|
+
- `...` wildcard matching (inline and whole-line) for non-deterministic output.
|
|
18
|
+
- `<!-- mdoctest: run -->` to execute a code block in its language and assert
|
|
19
|
+
exit 0; `<!-- mdoctest: skip -->` to leave a block alone.
|
|
20
|
+
- Zero dependencies, stdlib only, Python 3.8+.
|
|
21
|
+
- Ships as a pre-commit hook and a GitHub Action.
|
mdoctest-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ingrid Owusu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mdoctest-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mdoctest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: doctest for Markdown, in any language: run the console sessions and code in your docs and verify (or auto-fix) their output. Zero dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ingrid-owusu/mdoctest
|
|
6
|
+
Project-URL: Repository, https://github.com/ingrid-owusu/mdoctest
|
|
7
|
+
Project-URL: Issues, https://github.com/ingrid-owusu/mdoctest/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/ingrid-owusu/mdoctest/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Ingrid Owusu
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cli,console,docs-as-tests,doctest,documentation,examples,markdown,pre-commit,readme,testing
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Documentation
|
|
19
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# mdoctest
|
|
25
|
+
|
|
26
|
+
**doctest for Markdown — in any language.** Run the console sessions and code
|
|
27
|
+
blocks in your READMEs and docs, and check that their output still matches. When
|
|
28
|
+
something drifts, `--fix` rewrites the expected output for you. Zero
|
|
29
|
+
dependencies, single install, works with `bash`/`sh` sessions and any
|
|
30
|
+
interpreter you already have.
|
|
31
|
+
|
|
32
|
+
> Maintained by **Ingrid Owusu**, an autonomous AI agent. mdoctest is built and
|
|
33
|
+
> released automatically; issues and PRs are read and acted on by the agent.
|
|
34
|
+
|
|
35
|
+
[](https://github.com/ingrid-owusu/mdoctest/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/mdoctest/)
|
|
37
|
+
[](https://pypi.org/project/mdoctest/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## The problem
|
|
43
|
+
|
|
44
|
+
Every README has commands and code in it. They rot silently — a flag changes, an
|
|
45
|
+
output format changes, an example starts throwing — and the *first thing a new
|
|
46
|
+
user does* is run your example and hit something broken. Your docs are untested
|
|
47
|
+
code.
|
|
48
|
+
|
|
49
|
+
Python has `doctest` for docstrings, but nothing that (a) tests the fenced
|
|
50
|
+
blocks in your **Markdown**, (b) handles **shell/console** sessions, not just
|
|
51
|
+
Python, and (c) **fixes** them for you. That's mdoctest.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
<!-- mdoctest: skip -->
|
|
56
|
+
```console
|
|
57
|
+
$ pip install mdoctest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or run it without installing:
|
|
61
|
+
|
|
62
|
+
<!-- mdoctest: skip -->
|
|
63
|
+
```console
|
|
64
|
+
$ pipx run mdoctest README.md
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quick start
|
|
68
|
+
|
|
69
|
+
Write a normal console session in your Markdown, exactly the way you already do:
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
$ echo "2024-01-15 ok" | tr -s ' '
|
|
73
|
+
2024-01-15 ok
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then check it:
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
$ echo "2024-01-15 ok" | tr -s ' '
|
|
80
|
+
2024-01-15 ok
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
mdoctest runs each `$` command in a **persistent** shell (so `cd`, variables and
|
|
84
|
+
functions carry across the session, just like a real terminal), captures its
|
|
85
|
+
combined stdout+stderr, and compares it to the text you documented. Run it over
|
|
86
|
+
your docs:
|
|
87
|
+
|
|
88
|
+
<!-- mdoctest: skip -->
|
|
89
|
+
```console
|
|
90
|
+
$ mdoctest README.md
|
|
91
|
+
PASS README.md:42 (session)
|
|
92
|
+
...
|
|
93
|
+
OK checked 6 block(s), 0 failed
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Exit code is non-zero if anything drifted, so it drops straight into CI.
|
|
97
|
+
|
|
98
|
+
## Keep docs correct automatically: `--fix`
|
|
99
|
+
|
|
100
|
+
Changed your CLI and now the documented output is stale? Don't hand-edit it —
|
|
101
|
+
regenerate it:
|
|
102
|
+
|
|
103
|
+
<!-- mdoctest: skip -->
|
|
104
|
+
```console
|
|
105
|
+
$ mdoctest --fix README.md
|
|
106
|
+
FIXED fixed 1 block(s) across 1 file(s)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`--fix` re-runs every command and rewrites the expected output in place,
|
|
110
|
+
preserving all your surrounding prose. Review the diff, commit, done.
|
|
111
|
+
|
|
112
|
+
## Wildcards for noisy output
|
|
113
|
+
|
|
114
|
+
Real output has timestamps, durations and temp paths. Use `...` to elide them —
|
|
115
|
+
inline, or on a line of its own to skip whole chunks:
|
|
116
|
+
|
|
117
|
+
```console
|
|
118
|
+
$ printf 'build 12345 finished\n'
|
|
119
|
+
build ... finished
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A bare `...` line matches any number of lines (including none).
|
|
123
|
+
|
|
124
|
+
## Running code blocks, not just sessions
|
|
125
|
+
|
|
126
|
+
To assert that a code block simply *runs* (exit 0), tag it with a directive.
|
|
127
|
+
mdoctest uses the interpreter for the block's language (`python`, `bash`,
|
|
128
|
+
`node`, `ruby`, ...):
|
|
129
|
+
|
|
130
|
+
<!-- mdoctest: run -->
|
|
131
|
+
```python
|
|
132
|
+
import json
|
|
133
|
+
assert json.loads('{"a": 1}')["a"] == 1
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
And use `skip` to tell mdoctest to leave an illustrative block alone:
|
|
137
|
+
|
|
138
|
+
<!-- mdoctest: skip -->
|
|
139
|
+
```console
|
|
140
|
+
$ rm -rf / --no-preserve-root # never actually run
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## What runs, and what doesn't
|
|
144
|
+
|
|
145
|
+
mdoctest is conservative on purpose — it will not execute a block unless it is
|
|
146
|
+
clearly meant to be executable:
|
|
147
|
+
|
|
148
|
+
| Block | Runs? |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| ` ```console ` / ` ```shell-session ` with `$` prompts | ✅ session, output checked |
|
|
151
|
+
| ` ```bash `/` ```sh ` whose first line starts with `$ ` | ✅ session, output checked |
|
|
152
|
+
| ` ```bash ` that's just a command listing (no `$`) | ⛔ ignored |
|
|
153
|
+
| any block preceded by `<!-- mdoctest: run -->` | ✅ run, must exit 0 |
|
|
154
|
+
| any block preceded by `<!-- mdoctest: skip -->` | ⛔ ignored |
|
|
155
|
+
| everything else (` ```python `, ` ```json `, ...) | ⛔ ignored |
|
|
156
|
+
|
|
157
|
+
## Use it in CI (GitHub Action)
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
# .github/workflows/docs.yml
|
|
161
|
+
name: docs
|
|
162
|
+
on: [push, pull_request]
|
|
163
|
+
jobs:
|
|
164
|
+
mdoctest:
|
|
165
|
+
runs-on: ubuntu-latest
|
|
166
|
+
steps:
|
|
167
|
+
- uses: actions/checkout@v4
|
|
168
|
+
- uses: ingrid-owusu/mdoctest@v1
|
|
169
|
+
with:
|
|
170
|
+
files: "README.md docs/*.md"
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Use it as a pre-commit hook
|
|
174
|
+
|
|
175
|
+
```yaml
|
|
176
|
+
# .pre-commit-config.yaml
|
|
177
|
+
repos:
|
|
178
|
+
- repo: https://github.com/ingrid-owusu/mdoctest
|
|
179
|
+
rev: v0.1.0
|
|
180
|
+
hooks:
|
|
181
|
+
- id: mdoctest
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## CLI
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
mdoctest [PATHS ...] [--fix] [--shell bash] [--prompt '$ '] [--timeout 30]
|
|
188
|
+
[--cwd DIR] [--color auto|always|never] [-q]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- **PATHS** — Markdown files or globs. Defaults to `README.md`.
|
|
192
|
+
- **--fix** — rewrite expected output in place to match reality.
|
|
193
|
+
- **--cwd** — working directory for commands (default: the Markdown file's dir).
|
|
194
|
+
- **--timeout** — per-command timeout in seconds (default: 30).
|
|
195
|
+
|
|
196
|
+
## How it compares
|
|
197
|
+
|
|
198
|
+
| | mdoctest | phmdoctest / pytest-markdown | byexample | mdbook test |
|
|
199
|
+
| --- | --- | --- | --- | --- |
|
|
200
|
+
| Shell/console sessions | ✅ | ❌ (Python only) | ✅ | ❌ |
|
|
201
|
+
| Any language | ✅ | ❌ | ✅ | ❌ |
|
|
202
|
+
| Auto-fix expected output | ✅ | ❌ | ❌ | ❌ |
|
|
203
|
+
| Zero dependencies | ✅ | ❌ | ❌ | (Rust) |
|
|
204
|
+
| Zero config | ✅ | ⚠️ | ⚠️ | ✅ |
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT. See [LICENSE](LICENSE).
|
mdoctest-0.1.0/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# mdoctest
|
|
2
|
+
|
|
3
|
+
**doctest for Markdown — in any language.** Run the console sessions and code
|
|
4
|
+
blocks in your READMEs and docs, and check that their output still matches. When
|
|
5
|
+
something drifts, `--fix` rewrites the expected output for you. Zero
|
|
6
|
+
dependencies, single install, works with `bash`/`sh` sessions and any
|
|
7
|
+
interpreter you already have.
|
|
8
|
+
|
|
9
|
+
> Maintained by **Ingrid Owusu**, an autonomous AI agent. mdoctest is built and
|
|
10
|
+
> released automatically; issues and PRs are read and acted on by the agent.
|
|
11
|
+
|
|
12
|
+
[](https://github.com/ingrid-owusu/mdoctest/actions/workflows/ci.yml)
|
|
13
|
+
[](https://pypi.org/project/mdoctest/)
|
|
14
|
+
[](https://pypi.org/project/mdoctest/)
|
|
15
|
+
[](LICENSE)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The problem
|
|
20
|
+
|
|
21
|
+
Every README has commands and code in it. They rot silently — a flag changes, an
|
|
22
|
+
output format changes, an example starts throwing — and the *first thing a new
|
|
23
|
+
user does* is run your example and hit something broken. Your docs are untested
|
|
24
|
+
code.
|
|
25
|
+
|
|
26
|
+
Python has `doctest` for docstrings, but nothing that (a) tests the fenced
|
|
27
|
+
blocks in your **Markdown**, (b) handles **shell/console** sessions, not just
|
|
28
|
+
Python, and (c) **fixes** them for you. That's mdoctest.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
<!-- mdoctest: skip -->
|
|
33
|
+
```console
|
|
34
|
+
$ pip install mdoctest
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or run it without installing:
|
|
38
|
+
|
|
39
|
+
<!-- mdoctest: skip -->
|
|
40
|
+
```console
|
|
41
|
+
$ pipx run mdoctest README.md
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Write a normal console session in your Markdown, exactly the way you already do:
|
|
47
|
+
|
|
48
|
+
```console
|
|
49
|
+
$ echo "2024-01-15 ok" | tr -s ' '
|
|
50
|
+
2024-01-15 ok
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Then check it:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
$ echo "2024-01-15 ok" | tr -s ' '
|
|
57
|
+
2024-01-15 ok
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
mdoctest runs each `$` command in a **persistent** shell (so `cd`, variables and
|
|
61
|
+
functions carry across the session, just like a real terminal), captures its
|
|
62
|
+
combined stdout+stderr, and compares it to the text you documented. Run it over
|
|
63
|
+
your docs:
|
|
64
|
+
|
|
65
|
+
<!-- mdoctest: skip -->
|
|
66
|
+
```console
|
|
67
|
+
$ mdoctest README.md
|
|
68
|
+
PASS README.md:42 (session)
|
|
69
|
+
...
|
|
70
|
+
OK checked 6 block(s), 0 failed
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Exit code is non-zero if anything drifted, so it drops straight into CI.
|
|
74
|
+
|
|
75
|
+
## Keep docs correct automatically: `--fix`
|
|
76
|
+
|
|
77
|
+
Changed your CLI and now the documented output is stale? Don't hand-edit it —
|
|
78
|
+
regenerate it:
|
|
79
|
+
|
|
80
|
+
<!-- mdoctest: skip -->
|
|
81
|
+
```console
|
|
82
|
+
$ mdoctest --fix README.md
|
|
83
|
+
FIXED fixed 1 block(s) across 1 file(s)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`--fix` re-runs every command and rewrites the expected output in place,
|
|
87
|
+
preserving all your surrounding prose. Review the diff, commit, done.
|
|
88
|
+
|
|
89
|
+
## Wildcards for noisy output
|
|
90
|
+
|
|
91
|
+
Real output has timestamps, durations and temp paths. Use `...` to elide them —
|
|
92
|
+
inline, or on a line of its own to skip whole chunks:
|
|
93
|
+
|
|
94
|
+
```console
|
|
95
|
+
$ printf 'build 12345 finished\n'
|
|
96
|
+
build ... finished
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A bare `...` line matches any number of lines (including none).
|
|
100
|
+
|
|
101
|
+
## Running code blocks, not just sessions
|
|
102
|
+
|
|
103
|
+
To assert that a code block simply *runs* (exit 0), tag it with a directive.
|
|
104
|
+
mdoctest uses the interpreter for the block's language (`python`, `bash`,
|
|
105
|
+
`node`, `ruby`, ...):
|
|
106
|
+
|
|
107
|
+
<!-- mdoctest: run -->
|
|
108
|
+
```python
|
|
109
|
+
import json
|
|
110
|
+
assert json.loads('{"a": 1}')["a"] == 1
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
And use `skip` to tell mdoctest to leave an illustrative block alone:
|
|
114
|
+
|
|
115
|
+
<!-- mdoctest: skip -->
|
|
116
|
+
```console
|
|
117
|
+
$ rm -rf / --no-preserve-root # never actually run
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## What runs, and what doesn't
|
|
121
|
+
|
|
122
|
+
mdoctest is conservative on purpose — it will not execute a block unless it is
|
|
123
|
+
clearly meant to be executable:
|
|
124
|
+
|
|
125
|
+
| Block | Runs? |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| ` ```console ` / ` ```shell-session ` with `$` prompts | ✅ session, output checked |
|
|
128
|
+
| ` ```bash `/` ```sh ` whose first line starts with `$ ` | ✅ session, output checked |
|
|
129
|
+
| ` ```bash ` that's just a command listing (no `$`) | ⛔ ignored |
|
|
130
|
+
| any block preceded by `<!-- mdoctest: run -->` | ✅ run, must exit 0 |
|
|
131
|
+
| any block preceded by `<!-- mdoctest: skip -->` | ⛔ ignored |
|
|
132
|
+
| everything else (` ```python `, ` ```json `, ...) | ⛔ ignored |
|
|
133
|
+
|
|
134
|
+
## Use it in CI (GitHub Action)
|
|
135
|
+
|
|
136
|
+
```yaml
|
|
137
|
+
# .github/workflows/docs.yml
|
|
138
|
+
name: docs
|
|
139
|
+
on: [push, pull_request]
|
|
140
|
+
jobs:
|
|
141
|
+
mdoctest:
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
steps:
|
|
144
|
+
- uses: actions/checkout@v4
|
|
145
|
+
- uses: ingrid-owusu/mdoctest@v1
|
|
146
|
+
with:
|
|
147
|
+
files: "README.md docs/*.md"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Use it as a pre-commit hook
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
# .pre-commit-config.yaml
|
|
154
|
+
repos:
|
|
155
|
+
- repo: https://github.com/ingrid-owusu/mdoctest
|
|
156
|
+
rev: v0.1.0
|
|
157
|
+
hooks:
|
|
158
|
+
- id: mdoctest
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## CLI
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
mdoctest [PATHS ...] [--fix] [--shell bash] [--prompt '$ '] [--timeout 30]
|
|
165
|
+
[--cwd DIR] [--color auto|always|never] [-q]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **PATHS** — Markdown files or globs. Defaults to `README.md`.
|
|
169
|
+
- **--fix** — rewrite expected output in place to match reality.
|
|
170
|
+
- **--cwd** — working directory for commands (default: the Markdown file's dir).
|
|
171
|
+
- **--timeout** — per-command timeout in seconds (default: 30).
|
|
172
|
+
|
|
173
|
+
## How it compares
|
|
174
|
+
|
|
175
|
+
| | mdoctest | phmdoctest / pytest-markdown | byexample | mdbook test |
|
|
176
|
+
| --- | --- | --- | --- | --- |
|
|
177
|
+
| Shell/console sessions | ✅ | ❌ (Python only) | ✅ | ❌ |
|
|
178
|
+
| Any language | ✅ | ❌ | ✅ | ❌ |
|
|
179
|
+
| Auto-fix expected output | ✅ | ❌ | ❌ | ❌ |
|
|
180
|
+
| Zero dependencies | ✅ | ❌ | ❌ | (Rust) |
|
|
181
|
+
| Zero config | ✅ | ⚠️ | ⚠️ | ✅ |
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mdoctest"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "doctest for Markdown, in any language: run the console sessions and code in your docs and verify (or auto-fix) their output. Zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Ingrid Owusu" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"markdown", "doctest", "documentation", "testing", "readme",
|
|
15
|
+
"console", "examples", "pre-commit", "cli", "docs-as-tests",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Documentation",
|
|
24
|
+
"Topic :: Software Development :: Testing",
|
|
25
|
+
"Topic :: Software Development :: Documentation",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/ingrid-owusu/mdoctest"
|
|
31
|
+
Repository = "https://github.com/ingrid-owusu/mdoctest"
|
|
32
|
+
Issues = "https://github.com/ingrid-owusu/mdoctest/issues"
|
|
33
|
+
Changelog = "https://github.com/ingrid-owusu/mdoctest/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
mdoctest = "mdoctest.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/mdoctest"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["src/mdoctest", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""mdoctest -- doctest for Markdown, in any language.
|
|
2
|
+
|
|
3
|
+
Run the console sessions and code in your Markdown docs and verify their output
|
|
4
|
+
still matches; ``--fix`` updates them in place. Zero dependencies, single
|
|
5
|
+
package, POSIX shells.
|
|
6
|
+
|
|
7
|
+
Maintained by Ingrid Owusu, an autonomous AI agent.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from .core import Options, process_file, render_diff # noqa: E402,F401
|
|
14
|
+
from .match import matches, normalize # noqa: E402,F401
|
|
15
|
+
|
|
16
|
+
__all__ = ["Options", "process_file", "render_diff", "matches", "normalize",
|
|
17
|
+
"__version__"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Command-line interface for mdoctest."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .core import Options, process_file, render_diff
|
|
12
|
+
from .session import SessionTimeout
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _expand(paths: List[str]) -> List[str]:
|
|
16
|
+
out: List[str] = []
|
|
17
|
+
for p in paths:
|
|
18
|
+
if any(ch in p for ch in "*?["):
|
|
19
|
+
out.extend(sorted(glob.glob(p, recursive=True)))
|
|
20
|
+
else:
|
|
21
|
+
out.append(p)
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _want_color(choice: str) -> bool:
|
|
26
|
+
if choice == "always":
|
|
27
|
+
return True
|
|
28
|
+
if choice == "never":
|
|
29
|
+
return False
|
|
30
|
+
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
p = argparse.ArgumentParser(
|
|
35
|
+
prog="mdoctest",
|
|
36
|
+
description="doctest for Markdown, in any language: run the code and "
|
|
37
|
+
"console sessions in your docs and verify their output "
|
|
38
|
+
"still matches. --fix updates them for you.",
|
|
39
|
+
)
|
|
40
|
+
p.add_argument("paths", nargs="*", default=None,
|
|
41
|
+
help="Markdown files or globs (default: README.md)")
|
|
42
|
+
p.add_argument("--fix", action="store_true",
|
|
43
|
+
help="rewrite expected output in place to match reality")
|
|
44
|
+
p.add_argument("--shell", default="bash", help="shell for sessions (default: bash)")
|
|
45
|
+
p.add_argument("--prompt", default="$ ", help="command prompt (default: '$ ')")
|
|
46
|
+
p.add_argument("--cont", default="> ", help="continuation prompt (default: '> ')")
|
|
47
|
+
p.add_argument("--timeout", type=float, default=30.0,
|
|
48
|
+
help="per-command timeout in seconds (default: 30)")
|
|
49
|
+
p.add_argument("--cwd", default=None,
|
|
50
|
+
help="working directory (default: the Markdown file's dir)")
|
|
51
|
+
p.add_argument("--color", choices=["auto", "always", "never"], default="auto")
|
|
52
|
+
p.add_argument("-q", "--quiet", action="store_true", help="only print failures")
|
|
53
|
+
p.add_argument("-V", "--version", action="version",
|
|
54
|
+
version="mdoctest %s" % __version__)
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv=None) -> int:
|
|
59
|
+
args = build_parser().parse_args(argv)
|
|
60
|
+
color = _want_color(args.color)
|
|
61
|
+
paths = _expand(args.paths) if args.paths else (
|
|
62
|
+
["README.md"] if os.path.exists("README.md") else [])
|
|
63
|
+
if not paths:
|
|
64
|
+
sys.stderr.write("mdoctest: no Markdown files given and README.md not found\n")
|
|
65
|
+
return 2
|
|
66
|
+
|
|
67
|
+
opts = Options(shell=args.shell, prompt=args.prompt, cont=args.cont,
|
|
68
|
+
timeout=args.timeout, cwd=args.cwd)
|
|
69
|
+
|
|
70
|
+
total_checked = 0
|
|
71
|
+
total_failures = 0
|
|
72
|
+
total_fixed = 0
|
|
73
|
+
|
|
74
|
+
def c(code, s):
|
|
75
|
+
return "\033[%sm%s\033[0m" % (code, s) if color else s
|
|
76
|
+
|
|
77
|
+
for path in paths:
|
|
78
|
+
try:
|
|
79
|
+
fr = process_file(path, opts, fix=args.fix)
|
|
80
|
+
except SessionTimeout as e:
|
|
81
|
+
sys.stderr.write("%s: %s\n" % (path, e))
|
|
82
|
+
total_failures += 1
|
|
83
|
+
continue
|
|
84
|
+
if fr.error:
|
|
85
|
+
sys.stderr.write("%s: %s\n" % (path, fr.error))
|
|
86
|
+
total_failures += 1
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
total_checked += fr.checked
|
|
90
|
+
total_failures += fr.failures
|
|
91
|
+
total_fixed += fr.fixed
|
|
92
|
+
|
|
93
|
+
for b in fr.blocks:
|
|
94
|
+
if b.skipped_reason:
|
|
95
|
+
continue
|
|
96
|
+
loc = "%s:%d" % (path, b.open_line)
|
|
97
|
+
if b.ok:
|
|
98
|
+
if not args.quiet and not args.fix:
|
|
99
|
+
print("%s %s (%s)" % (c("32", "PASS"), loc, b.kind))
|
|
100
|
+
continue
|
|
101
|
+
print("%s %s (%s)" % (c("31", "FAIL"), loc, b.kind))
|
|
102
|
+
if b.kind == "session":
|
|
103
|
+
for cr in b.cmds:
|
|
104
|
+
if cr.ok:
|
|
105
|
+
continue
|
|
106
|
+
print(" %s %s" % (c("36", "$"), cr.command.replace("\n", "\n ")))
|
|
107
|
+
diff = render_diff(cr.expected, cr.actual, color=color)
|
|
108
|
+
for dl in diff.split("\n"):
|
|
109
|
+
print(" " + dl)
|
|
110
|
+
elif b.message:
|
|
111
|
+
for dl in b.message.split("\n"):
|
|
112
|
+
print(" " + dl)
|
|
113
|
+
|
|
114
|
+
if args.fix:
|
|
115
|
+
print("%s fixed %d block(s) across %d file(s)"
|
|
116
|
+
% (c("32", "FIXED"), total_fixed, len(paths)))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
summary = "checked %d block(s), %d failed" % (total_checked, total_failures)
|
|
120
|
+
print(("%s %s" % (c("31" if total_failures else "32",
|
|
121
|
+
"FAIL" if total_failures else "OK"), summary)))
|
|
122
|
+
return 1 if total_failures else 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
raise SystemExit(main())
|