├── .gitattributes
├── .github
├── dependabot.yml
└── workflows
│ ├── ci.yml
│ ├── dependabot-merge.yml
│ ├── publish-site.yml
│ └── release.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .vscode
├── extensions.json
└── settings.json
├── CHANGELOG.rst
├── CODE_OF_CONDUCT.rst
├── LICENSE
├── Makefile
├── README.rst
├── bin
└── doccmd-wrapper.py
├── codecov.yaml
├── docs
├── Makefile
└── source
│ ├── __init__.py
│ ├── changelog.rst
│ ├── commands.rst
│ ├── conf.py
│ ├── contributing.rst
│ ├── file-names-and-linter-ignores.rst
│ ├── group-code-blocks.rst
│ ├── index.rst
│ ├── install.rst
│ ├── release-process.rst
│ ├── skip-code-blocks.rst
│ └── usage-example.rst
├── pyproject.toml
├── spelling_private_dict.txt
├── src
└── doccmd
│ ├── __init__.py
│ ├── __main__.py
│ ├── _languages.py
│ └── py.typed
└── tests
├── __init__.py
├── test_doccmd.py
└── test_doccmd
└── test_help.txt
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | ---
2 | version: 2
3 |
4 | updates:
5 | - package-ecosystem: pip
6 | directory: /
7 | schedule:
8 | interval: daily
9 | open-pull-requests-limit: 10
10 | - package-ecosystem: github-actions
11 | directory: /
12 | schedule:
13 | interval: daily
14 | open-pull-requests-limit: 10
15 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | name: CI
4 |
5 | on:
6 | push:
7 | branches: [main]
8 | pull_request:
9 | branches: [main]
10 | schedule:
11 | # * is a special character in YAML so you have to quote this string
12 | # Run at 1:00 every day
13 | - cron: 0 1 * * *
14 |
15 | jobs:
16 | build:
17 |
18 | strategy:
19 | matrix:
20 | python-version: ['3.10', '3.11', '3.12', '3.13']
21 | platform: [ubuntu-latest, windows-latest]
22 |
23 | runs-on: ${{ matrix.platform }}
24 |
25 | steps:
26 | - uses: actions/checkout@v4
27 | # We need our tags in order to calculate the version
28 | # in the Sphinx setup.
29 | with:
30 | fetch-depth: 0
31 | fetch-tags: true
32 |
33 | - name: Install uv
34 | uses: astral-sh/setup-uv@v6
35 | with:
36 | # Avoid https://github.com/astral-sh/uv/issues/12260.
37 | version: 0.6.6
38 | enable-cache: true
39 | cache-dependency-glob: '**/pyproject.toml'
40 |
41 | - name: Lint
42 | run: |
43 | uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose
44 | uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose
45 | uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose
46 | env:
47 | UV_PYTHON: ${{ matrix.python-version }}
48 |
49 | - name: Run tests
50 | run: |
51 | # We run tests against "." and not the tests directory as we test the README
52 | # and documentation.
53 | uv run --extra=dev pytest -s -vvv --cov-fail-under=100 --cov=src/ --cov=tests . --cov-report=xml
54 | env:
55 | UV_PYTHON: ${{ matrix.python-version }}
56 |
57 | - name: Upload coverage to Codecov
58 | uses: codecov/codecov-action@v5
59 | with:
60 | fail_ci_if_error: true
61 | token: ${{ secrets.CODECOV_TOKEN }}
62 |
63 | - uses: pre-commit-ci/lite-action@v1.1.0
64 | # Do not run pre-commit on Windows in order to avoid issues with
65 | # line endings.
66 | # We only need to run the changes from one runner in any case.
67 | if: always()
68 |
69 | completion-ci:
70 | needs: build
71 | runs-on: ubuntu-latest
72 | if: always() # Run even if one matrix job fails
73 | steps:
74 | - name: Check matrix job status
75 | run: |-
76 | if ! ${{ needs.build.result == 'success' }}; then
77 | echo "One or more matrix jobs failed"
78 | exit 1
79 | fi
80 |
--------------------------------------------------------------------------------
/.github/workflows/dependabot-merge.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | name: Dependabot auto-merge
4 | on: pull_request
5 |
6 | permissions:
7 | contents: write
8 | pull-requests: write
9 |
10 | jobs:
11 | dependabot:
12 | runs-on: ubuntu-latest
13 | if: github.actor == 'dependabot[bot]'
14 | steps:
15 | - name: Dependabot metadata
16 | id: metadata
17 | uses: dependabot/fetch-metadata@v2
18 | with:
19 | github-token: ${{ secrets.GITHUB_TOKEN }}
20 | - name: Enable auto-merge for Dependabot PRs
21 | run: gh pr merge --auto --merge "$PR_URL"
22 | env:
23 | PR_URL: ${{github.event.pull_request.html_url}}
24 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
25 |
--------------------------------------------------------------------------------
/.github/workflows/publish-site.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Deploy documentation
3 |
4 | on:
5 | push:
6 | branches: [main]
7 | pull_request:
8 | branches: [main]
9 |
10 | jobs:
11 | pages:
12 | runs-on: ubuntu-latest
13 | environment:
14 | name: ${{ github.ref_name == 'main' && 'github-pages' || 'development' }}
15 | url: ${{ steps.deployment.outputs.page_url }}
16 | permissions:
17 | pages: write
18 | id-token: write
19 | steps:
20 | - uses: actions/checkout@v4
21 | # We need our tags in order to calculate the version
22 | # in the Sphinx setup.
23 | with:
24 | fetch-depth: 0
25 | fetch-tags: true
26 |
27 | - id: deployment
28 | uses: sphinx-notes/pages@v3
29 | with:
30 | documentation_path: docs/source
31 | pyproject_extras: dev
32 | python_version: '3.13'
33 | sphinx_build_options: -W
34 | publish: ${{ github.ref_name == 'main' }}
35 | checkout: false
36 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | ---
2 |
3 | name: Release
4 |
5 | on: workflow_dispatch
6 |
7 | jobs:
8 | build:
9 | name: Publish a release
10 | runs-on: ubuntu-latest
11 |
12 | # Specifying an environment is strongly recommended by PyPI.
13 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing.
14 | environment: release
15 |
16 | permissions:
17 | # This is needed for PyPI publishing.
18 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing.
19 | id-token: write
20 | # This is needed for https://github.com/stefanzweifel/git-auto-commit-action.
21 | contents: write
22 |
23 | steps:
24 | - uses: actions/checkout@v4
25 | with:
26 | # See
27 | # https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches
28 | token: ${{ secrets.RELEASE_PAT }}
29 | # Fetch all history including tags.
30 | # Needed to find the latest tag.
31 | #
32 | # Also, avoids
33 | # https://github.com/stefanzweifel/git-auto-commit-action/issues/99.
34 | fetch-depth: 0
35 |
36 | - name: Install uv
37 | uses: astral-sh/setup-uv@v6
38 | with:
39 | # Avoid https://github.com/astral-sh/uv/issues/12260.
40 | version: 0.6.6
41 | enable-cache: true
42 | cache-dependency-glob: '**/pyproject.toml'
43 |
44 | - name: Get current version
45 | id: get_current_version
46 | run: |
47 | version="$(git describe --tags --abbrev=0)"
48 | echo "version=${version}" >> "$GITHUB_OUTPUT"
49 |
50 | - name: Calver calculate version
51 | uses: StephaneBour/actions-calver@master
52 | id: calver
53 | with:
54 | date_format: '%Y.%m.%d'
55 | release: false
56 | env:
57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58 |
59 | - name: Get the changelog underline
60 | id: changelog_underline
61 | run: |
62 | underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')"
63 | echo "underline=${underline}" >> "$GITHUB_OUTPUT"
64 |
65 | - name: Update changelog
66 | uses: jacobtomlinson/gha-find-replace@v3
67 | with:
68 | find: "Next\n----"
69 | replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\
70 | \ }}"
71 | include: CHANGELOG.rst
72 | regex: false
73 |
74 | - uses: stefanzweifel/git-auto-commit-action@v5
75 | id: commit
76 | with:
77 | commit_message: Bump CHANGELOG
78 | file_pattern: CHANGELOG.rst
79 | # Error if there are no changes.
80 | skip_dirty_check: true
81 |
82 | - name: Bump version and push tag
83 | id: tag_version
84 | uses: mathieudutour/github-tag-action@v6.2
85 | with:
86 | github_token: ${{ secrets.GITHUB_TOKEN }}
87 | custom_tag: ${{ steps.calver.outputs.release }}
88 | tag_prefix: ''
89 | commit_sha: ${{ steps.commit.outputs.commit_hash }}
90 |
91 | - name: Build a binary wheel and a source tarball
92 | id: build-wheel
93 | run: |
94 | sudo rm -rf dist/ build/
95 | git fetch --tags
96 | git checkout ${{ steps.tag_version.outputs.new_tag }}
97 | uv build --sdist --wheel --out-dir dist/
98 | WHEEL="$(ls dist/*.whl)"
99 | uv run --extra=release check-wheel-contents "${WHEEL}"
100 | echo "wheel_filename=${WHEEL}" >> "$GITHUB_OUTPUT"
101 |
102 | - name: Publish distribution 📦 to PyPI
103 | # We use PyPI trusted publishing rather than a PyPI API token.
104 | # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing.
105 | uses: pypa/gh-action-pypi-publish@release/v1
106 | with:
107 | verbose: true
108 |
109 | # We have a race condition.
110 | # In particular, we push to PyPI and then immediately try to install
111 | # the pushed version.
112 | # Here, we give PyPI time to propagate the package.
113 | - name: Install doccmd from PyPI
114 | uses: nick-fields/retry@v3
115 | with:
116 | timeout_seconds: 5
117 | max_attempts: 50
118 | command: uv pip install --refresh doccmd==${{ steps.calver.outputs.release }}
119 |
120 | - name: Set up Homebrew filename
121 | id: set-homebrew-filename
122 | run: |
123 | echo "filename=doccmd.rb" >> "$GITHUB_OUTPUT"
124 |
125 | # We still hit the race condition, so we have a retry here too.
126 | - name: Create a Homebrew recipe
127 | id: homebrew-create
128 | uses: nick-fields/retry@v3
129 | with:
130 | timeout_seconds: 5
131 | max_attempts: 50
132 | command: |
133 | uv run --no-cache --with="doccmd==${{ steps.calver.outputs.release }}" --extra=release poet --formula doccmd > ${{ steps.set-homebrew-filename.outputs.filename }}
134 |
135 | - name: Update Homebrew description
136 | uses: jacobtomlinson/gha-find-replace@v3
137 | with:
138 | find: desc "Shiny new formula"
139 | replace: desc "Run tools against code blocks in documentation"
140 | include: ${{ steps.set-homebrew-filename.outputs.filename }}
141 | regex: false
142 |
143 | - name: Push Homebrew Recipe
144 | uses: dmnemec/copy_file_to_another_repo_action@main
145 | env:
146 | # See https://github.com/marketplace/actions/github-action-to-push-subdirectories-to-another-repo#usage
147 | # for how to get this token.
148 | # I do not yet know how to set this up to work with a
149 | # "Fine-grained personal access token", only a "Token (classic)" with "repo" settings.
150 | API_TOKEN_GITHUB: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
151 | with:
152 | destination_branch: main
153 | source_file: ${{ steps.set-homebrew-filename.outputs.filename }}
154 | destination_repo: adamtheturtle/homebrew-doccmd
155 | user_email: adamdangoor@gmail.com
156 | user_name: adamtheturtle
157 | commit_message: Bump CLI Homebrew recipe
158 |
159 | - name: Update README versions
160 | uses: jacobtomlinson/gha-find-replace@v3
161 | with:
162 | find: ${{ steps.get_current_version.outputs.version }}
163 | replace: ${{ steps.calver.outputs.release }}
164 | include: README.rst
165 | regex: false
166 |
167 | - uses: stefanzweifel/git-auto-commit-action@v5
168 | id: commit-readme
169 | with:
170 | commit_message: Replace version in README
171 | branch: main
172 | file_pattern: README.rst
173 | # Error if there are no changes.
174 | skip_dirty_check: true
175 |
176 | - name: Create Linux binary
177 | uses: sayyid5416/pyinstaller@v1
178 | with:
179 | python_ver: '3.13'
180 | pyinstaller_ver: ==6.12.0
181 | spec: bin/doccmd-wrapper.py
182 | requirements: '`echo ${{ steps.build-wheel.outputs.wheel_filename }} > requirements.txt
183 | && echo requirements.txt`'
184 | options: --onefile, --name "doccmd-linux"
185 | upload_exe_with_name: doccmd-linux
186 | clean_checkout: false
187 |
188 | - name: Create a GitHub release
189 | uses: ncipollo/release-action@v1
190 | with:
191 | # Use a specific artifact name (not a glob) so that we get a clear
192 | # error if the artifact does not exist for some reason.
193 | artifacts: dist/doccmd-linux
194 | artifactErrorsFailBuild: true
195 | tag: ${{ steps.tag_version.outputs.new_tag }}
196 | makeLatest: true
197 | name: Release ${{ steps.tag_version.outputs.new_tag }}
198 | body: ${{ steps.tag_version.outputs.changelog }}
199 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 | .pytest_cache/
49 |
50 | # Translations
51 | *.mo
52 | *.pot
53 |
54 | # Django stuff:
55 | *.log
56 | local_settings.py
57 | db.sqlite3
58 |
59 | # Flask stuff:
60 | instance/
61 | .webassets-cache
62 |
63 | # Scrapy stuff:
64 | .scrapy
65 |
66 | # Sphinx documentation
67 | docs/_build/
68 |
69 | # PyBuilder
70 | target/
71 |
72 | # Jupyter Notebook
73 | .ipynb_checkpoints
74 |
75 | # pyenv
76 | .python-version
77 |
78 | # celery beat schedule file
79 | celerybeat-schedule
80 |
81 | # SageMath parsed files
82 | *.sage.py
83 |
84 | # Environments
85 | .env
86 | .venv
87 | env/
88 | venv/
89 | ENV/
90 | env.bak/
91 | venv.bak/
92 |
93 | # direnv file
94 | .envrc
95 |
96 | # IDEA ide
97 | .idea/
98 |
99 | # Spyder project settings
100 | .spyderproject
101 | .spyproject
102 |
103 | # Rope project settings
104 | .ropeproject
105 |
106 | # mkdocs documentation
107 | /site
108 |
109 | # mypy
110 | .mypy_cache/
111 |
112 | # setuptools_scm
113 | src/*/_setuptools_scm_version.txt
114 | src/*/_setuptools_scm_version.py
115 |
116 | # Ignore Mac DS_Store files
117 | .DS_Store
118 | **/.DS_Store
119 |
120 | uv.lock
121 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | fail_fast: true
3 |
4 | # See https://pre-commit.com for more information
5 | # See https://pre-commit.com/hooks.html for more hooks
6 |
7 | ci:
8 | # We use system Python, with required dependencies specified in pyproject.toml.
9 | # We therefore cannot use those dependencies in pre-commit CI.
10 | skip:
11 | - actionlint
12 | - check-manifest
13 | - deptry
14 | - doc8
15 | - docformatter
16 | - docs
17 | - interrogate
18 | - interrogate-docs
19 | - linkcheck
20 | - mypy
21 | - mypy-docs
22 | - pylint
23 | - pyproject-fmt-fix
24 | - pyright
25 | - pyright-docs
26 | - pyright-verifytypes
27 | - pyroma
28 | - ruff-check-fix
29 | - ruff-check-fix-docs
30 | - ruff-format-fix
31 | - ruff-format-fix-docs
32 | - shellcheck
33 | - shellcheck-docs
34 | - shfmt
35 | - shfmt-docs
36 | - spelling
37 | - sphinx-lint
38 | - vulture
39 | - vulture-docs
40 | - yamlfix
41 |
42 | default_install_hook_types: [pre-commit, pre-push, commit-msg]
43 |
44 | repos:
45 | - repo: meta
46 | hooks:
47 | - id: check-useless-excludes
48 | - repo: https://github.com/pre-commit/pre-commit-hooks
49 | rev: v5.0.0
50 | hooks:
51 | - id: check-added-large-files
52 | - id: check-case-conflict
53 | - id: check-executables-have-shebangs
54 | - id: check-merge-conflict
55 | - id: check-shebang-scripts-are-executable
56 | - id: check-symlinks
57 | - id: check-json
58 | - id: check-toml
59 | - id: check-vcs-permalinks
60 | - id: check-yaml
61 | - id: end-of-file-fixer
62 | - id: file-contents-sorter
63 | files: spelling_private_dict\.txt$
64 | - id: trailing-whitespace
65 | exclude: ^tests/test_doccmd/.*\.txt$
66 | - repo: https://github.com/pre-commit/pygrep-hooks
67 | rev: v1.10.0
68 | hooks:
69 | - id: rst-directive-colons
70 | - id: rst-inline-touching-normal
71 | - id: text-unicode-replacement-char
72 | - id: rst-backticks
73 | - repo: local
74 | hooks:
75 | - id: actionlint
76 | name: actionlint
77 | entry: uv run --extra=dev actionlint
78 | language: python
79 | pass_filenames: false
80 | types_or: [yaml]
81 | additional_dependencies: [uv==0.6.3]
82 |
83 | - id: docformatter
84 | name: docformatter
85 | entry: uv run --extra=dev -m docformatter --in-place
86 | language: python
87 | types_or: [python]
88 | additional_dependencies: [uv==0.6.3]
89 |
90 | - id: shellcheck
91 | name: shellcheck
92 | entry: uv run --extra=dev shellcheck --shell=bash
93 | language: python
94 | types_or: [shell]
95 | additional_dependencies: [uv==0.6.3]
96 |
97 | - id: shellcheck-docs
98 | name: shellcheck-docs
99 | entry: uv run --extra=dev doccmd --language=shell --language=console --command="shellcheck
100 | --shell=bash"
101 | language: python
102 | types_or: [markdown, rst]
103 | additional_dependencies: [uv==0.6.3]
104 |
105 | - id: shfmt
106 | name: shfmt
107 | entry: uv run --extra=dev shfmt --write --space-redirects --indent=4
108 | language: python
109 | types_or: [shell]
110 | additional_dependencies: [uv==0.6.3]
111 |
112 | - id: shfmt-docs
113 | name: shfmt-docs
114 | entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt
115 | --no-pad-file --command="shfmt --write --space-redirects --indent=4"
116 | language: python
117 | types_or: [markdown, rst]
118 | additional_dependencies: [uv==0.6.3]
119 |
120 | - id: mypy
121 | name: mypy
122 | stages: [pre-push]
123 | entry: uv run --extra=dev -m mypy
124 | language: python
125 | types_or: [python, toml]
126 | pass_filenames: false
127 | additional_dependencies: [uv==0.6.3]
128 |
129 | - id: mypy-docs
130 | name: mypy-docs
131 | stages: [pre-push]
132 | entry: uv run --extra=dev doccmd --language=python --command="mypy"
133 | language: python
134 | types_or: [markdown, rst]
135 | additional_dependencies: [uv==0.6.3]
136 |
137 | - id: check-manifest
138 | name: check-manifest
139 | stages: [pre-push]
140 | entry: uv run --extra=dev -m check_manifest
141 | language: python
142 | pass_filenames: false
143 | additional_dependencies: [uv==0.6.3]
144 |
145 | - id: pyright
146 | name: pyright
147 | stages: [pre-push]
148 | entry: uv run --extra=dev -m pyright .
149 | language: python
150 | types_or: [python, toml]
151 | pass_filenames: false
152 | additional_dependencies: [uv==0.6.3]
153 |
154 | - id: pyright-docs
155 | name: pyright-docs
156 | stages: [pre-push]
157 | entry: uv run --extra=dev doccmd --language=python --command="pyright"
158 | language: python
159 | types_or: [markdown, rst]
160 | additional_dependencies: [uv==0.6.3]
161 |
162 | - id: vulture
163 | name: vulture
164 | entry: uv run --extra=dev -m vulture .
165 | language: python
166 | types_or: [python]
167 | pass_filenames: false
168 | additional_dependencies: [uv==0.6.3]
169 |
170 | - id: vulture-docs
171 | name: vulture docs
172 | entry: uv run --extra=dev doccmd --language=python --command="vulture"
173 | language: python
174 | types_or: [markdown, rst]
175 | additional_dependencies: [uv==0.6.3]
176 |
177 | - id: pyroma
178 | name: pyroma
179 | entry: uv run --extra=dev -m pyroma --min 10 .
180 | language: python
181 | pass_filenames: false
182 | types_or: [toml]
183 | additional_dependencies: [uv==0.6.3]
184 |
185 | - id: deptry
186 | name: deptry
187 | entry: uv run --extra=dev -m deptry src/
188 | language: python
189 | pass_filenames: false
190 | additional_dependencies: [uv==0.6.3]
191 |
192 | - id: pylint
193 | name: pylint
194 | entry: uv run --extra=dev -m pylint src/ tests/
195 | language: python
196 | stages: [manual]
197 | pass_filenames: false
198 | additional_dependencies: [uv==0.6.3]
199 |
200 | - id: pylint-docs
201 | name: pylint-docs
202 | entry: uv run --extra=dev doccmd --language=python --command="pylint"
203 | language: python
204 | stages: [manual]
205 | types_or: [markdown, rst]
206 | additional_dependencies: [uv==0.6.3]
207 |
208 | - id: ruff-check-fix
209 | name: Ruff check fix
210 | entry: uv run --extra=dev -m ruff check --fix
211 | language: python
212 | types_or: [python]
213 | additional_dependencies: [uv==0.6.3]
214 |
215 | - id: ruff-check-fix-docs
216 | name: Ruff check fix docs
217 | entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix"
218 | language: python
219 | types_or: [markdown, rst]
220 | additional_dependencies: [uv==0.6.3]
221 |
222 | - id: ruff-format-fix
223 | name: Ruff format
224 | entry: uv run --extra=dev -m ruff format
225 | language: python
226 | types_or: [python]
227 | additional_dependencies: [uv==0.6.3]
228 |
229 | - id: ruff-format-fix-docs
230 | name: Ruff format docs
231 | entry: |
232 | uv run --extra=dev doccmd --language=python --no-pad-file --no-pad-groups --command="ruff format"
233 | language: python
234 | types_or: [markdown, rst]
235 | additional_dependencies: [uv==0.6.3]
236 |
237 | - id: doc8
238 | name: doc8
239 | entry: uv run --extra=dev -m doc8
240 | language: python
241 | types_or: [rst]
242 | additional_dependencies: [uv==0.6.3]
243 |
244 | - id: interrogate
245 | name: interrogate
246 | entry: uv run --extra=dev -m interrogate
247 | language: python
248 | types_or: [python]
249 | exclude_types: [executable]
250 | additional_dependencies: [uv==0.6.3]
251 |
252 | - id: interrogate-docs
253 | name: interrogate docs
254 | entry: uv run --extra=dev doccmd --language=python --command="interrogate"
255 | language: python
256 | types_or: [markdown, rst]
257 | additional_dependencies: [uv==0.6.3]
258 |
259 | - id: pyproject-fmt-fix
260 | name: pyproject-fmt
261 | entry: uv run --extra=dev pyproject-fmt
262 | language: python
263 | types_or: [toml]
264 | files: pyproject.toml
265 | additional_dependencies: [uv==0.6.3]
266 |
267 | - id: linkcheck
268 | name: linkcheck
269 | entry: make -C docs/ linkcheck SPHINXOPTS=-W
270 | language: python
271 | types_or: [rst]
272 | stages: [manual]
273 | pass_filenames: false
274 | additional_dependencies: [uv==0.6.3]
275 |
276 | - id: spelling
277 | name: spelling
278 | entry: make -C docs/ spelling SPHINXOPTS=-W
279 | language: python
280 | types_or: [rst]
281 | stages: [manual]
282 | pass_filenames: false
283 | additional_dependencies: [uv==0.6.3]
284 |
285 | - id: docs
286 | name: Build Documentation
287 | entry: make docs
288 | language: python
289 | stages: [manual]
290 | pass_filenames: false
291 | additional_dependencies: [uv==0.6.3]
292 |
293 | - id: pyright-verifytypes
294 | name: pyright-verifytypes
295 | stages: [pre-push]
296 | entry: uv run --extra=dev -m pyright --verifytypes doccmd
297 | language: python
298 | pass_filenames: false
299 | types_or: [python]
300 | additional_dependencies: [uv==0.6.3]
301 |
302 | - id: yamlfix
303 | name: yamlfix
304 | entry: uv run --extra=dev yamlfix
305 | language: python
306 | types_or: [yaml]
307 | additional_dependencies: [uv==0.6.3]
308 |
309 | - id: sphinx-lint
310 | name: sphinx-lint
311 | entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long
312 | --ignore=docs/build
313 | language: python
314 | types_or: [rst]
315 | additional_dependencies: [uv==0.6.3]
316 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "charliermarsh.ruff",
4 | "ms-python.python"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[python]": {
3 | "editor.codeActionsOnSave": {
4 | "source.fixAll": "explicit"
5 | },
6 | "editor.defaultFormatter": "charliermarsh.ruff",
7 | "editor.formatOnSave": true
8 | },
9 | "esbonio.sphinx.confDir": "",
10 | "python.testing.pytestArgs": [
11 | "."
12 | ],
13 | "python.testing.unittestEnabled": false,
14 | "python.testing.pytestEnabled": true
15 | }
16 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | Next
5 | ----
6 |
7 | 2025.04.08
8 | ----------
9 |
10 | * Fix ``IndexError`` when using a formatter which changed the number of lines in a code block.
11 |
12 | 2025.03.27
13 | ----------
14 |
15 | * Add a ``--sphinx-jinja2`` option to evaluate `sphinx-jinja2 `_ blocks.
16 |
17 | 2025.03.18
18 | ----------
19 |
20 | * With ``--verbose``, show the command that will be run before the command is run, not after.
21 |
22 | 2025.03.06
23 | ----------
24 |
25 | * Support files which are not UTF-8 encoded.
26 | * Support grouping code blocks with ``doccmd group[all]: start`` and ``doccmd group[all]: end`` comments.
27 |
28 | 2025.02.18
29 | ----------
30 |
31 | * Re-add support for Python 3.10.
32 |
33 | 2025.02.17
34 | ----------
35 |
36 | * Add support for Markdown (not MyST) files.
37 | * Add support for treating groups of code blocks as one.
38 | * Drop support for Python 3.10.
39 |
40 | 2025.01.11
41 | ----------
42 |
43 | 2024.12.26
44 | ----------
45 |
46 | 2024.11.14
47 | ----------
48 |
49 | * Skip files where we hit a lexing error.
50 | * Bump Sybil requirement to >= 9.0.0.
51 |
52 | 2024.11.06.1
53 | ------------
54 |
55 | * Add a ``--max-depth`` option for recursing into directories.
56 |
57 | 2024.11.06
58 | ----------
59 |
60 | * Add options to support given file extensions for source files.
61 | * Add support for passing in directories.
62 |
63 | 2024.11.05
64 | ----------
65 |
66 | * Error if files do not have a ``rst`` or ``md`` extension.
67 |
68 | 2024.11.04
69 | ----------
70 |
71 | * Add options to control whether a pseudo-terminal is used for running commands in.
72 | * Rename some options to make it clear that they apply to the temporary files created.
73 |
74 | 2024.10.14
75 | ----------
76 |
77 | * Add documentation and source links to PyPI.
78 |
79 | 2024.10.13.1
80 | ------------
81 |
82 | * Output in color (not yet on Windows).
83 |
84 | 2024.10.12
85 | ----------
86 |
87 | * Only log ``--verbose`` messages when the relevant example will be run and is not a skip directive.
88 |
89 | 2024.10.11
90 | ----------
91 |
92 | * Use line endings from the original file.
93 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.rst:
--------------------------------------------------------------------------------
1 | Contributor Covenant Code of Conduct
2 | ====================================
3 |
4 | Our Pledge
5 | ----------
6 |
7 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
8 |
9 | Our Standards
10 | -------------
11 |
12 | Examples of behavior that contributes to creating a positive environment include:
13 |
14 | Using welcoming and inclusive language
15 | Being respectful of differing viewpoints and experiences
16 | Gracefully accepting constructive criticism
17 | Focusing on what is best for the community
18 | Showing empathy towards other community members
19 | Examples of unacceptable behavior by participants include:
20 |
21 | The use of sexualized language or imagery and unwelcome sexual attention or advances
22 | Trolling, insulting/derogatory comments, and personal or political attacks
23 | Public or private harassment
24 | Publishing others' private information, such as a physical or electronic address, without explicit permission
25 | Other conduct which could reasonably be considered inappropriate in a professional setting
26 | Our Responsibilities
27 |
28 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
29 |
30 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
31 |
32 | Scope
33 | -----
34 |
35 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
36 |
37 | Enforcement
38 | -----------
39 |
40 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at adamdangoor@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
41 |
42 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
43 |
44 | Attribution
45 | -----------
46 |
47 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at http://contributor-covenant.org/version/1/4.
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | SHELL := /bin/bash -euxo pipefail
2 |
3 | # Treat Sphinx warnings as errors
4 | SPHINXOPTS := -W
5 |
6 | .PHONY: docs
7 | docs:
8 | make -C docs clean html SPHINXOPTS=$(SPHINXOPTS)
9 |
10 | .PHONY: open-docs
11 | open-docs:
12 | python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("docs/build/html/index.html"))'
13 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | |Build Status| |codecov| |PyPI|
2 |
3 | doccmd
4 | ======
5 |
6 | A command line tool for running commands against code blocks in documentation files.
7 | This allows you to run linters, formatters, and other tools against the code blocks in your documentation files.
8 |
9 | .. contents::
10 | :local:
11 |
12 | Installation
13 | ------------
14 |
15 | With ``pip``
16 | ^^^^^^^^^^^^
17 |
18 | Requires Python |minimum-python-version|\+.
19 |
20 | .. code-block:: shell
21 |
22 | $ pip install doccmd
23 |
24 | With Homebrew (macOS, Linux, WSL)
25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
26 |
27 | Requires `Homebrew`_.
28 |
29 | .. code-block:: shell
30 |
31 | $ brew tap adamtheturtle/doccmd
32 | $ brew install doccmd
33 |
34 | .. _Homebrew: https://docs.brew.sh/Installation
35 |
36 | Pre-built Linux (x86) binaries
37 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
38 |
39 | .. code-block:: console
40 |
41 | $ curl --fail -L https://github.com/adamtheturtle/doccmd/releases/download/2025.04.08/doccmd-linux -o /usr/local/bin/doccmd &&
42 | chmod +x /usr/local/bin/doccmd
43 |
44 | Using ``doccmd`` as a pre-commit hook
45 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
46 |
47 | To run ``doccmd`` with `pre-commit`_, add hooks like the following to your ``.pre-commit-config.yaml``:
48 |
49 | .. code-block:: yaml
50 |
51 | - repo: https://github.com/adamtheturtle/doccmd-pre-commit
52 | rev: v2025.04.08
53 | hooks:
54 | - id: doccmd
55 | args: ["--language", "shell", "--command", "shellcheck --shell=bash"]
56 | additional_dependencies: ["shellcheck-py"]
57 |
58 | .. _pre-commit: https://pre-commit.com
59 |
60 | Usage example
61 | -------------
62 |
63 | .. code-block:: shell
64 |
65 | # Run mypy against the Python code blocks in README.md and CHANGELOG.rst
66 | $ doccmd --language=python --command="mypy" README.md CHANGELOG.rst
67 |
68 | # Run gofmt against the Go code blocks in README.md
69 | # This will modify the README.md file in place
70 | $ doccmd --language=go --command="gofmt -w" README.md
71 |
72 | # or type less... and search for files in the docs directory
73 | $ doccmd -l python -c mypy README.md docs/
74 |
75 | # Run ruff format against the code blocks in a Markdown file
76 | # Don't "pad" the code blocks with newlines - the formatter wouldn't like that.
77 | # See the documentation about groups for more information.
78 | $ doccmd --language=python --no-pad-file --no-pad-groups --command="ruff format" README.md
79 |
80 | # Run j2lint against the sphinx-jinja2 code blocks in a MyST file
81 | $ doccmd --sphinx-jinja2 --no-pad-file --command="j2lint" README.md
82 |
83 | What does it work on?
84 | ---------------------
85 |
86 | * reStructuredText (``.rst``)
87 |
88 | .. code-block:: rst
89 |
90 | .. code-block:: shell
91 |
92 | echo "Hello, world!"
93 |
94 | .. code:: shell
95 |
96 | echo "Or this Hello, world!"
97 |
98 | * Markdown (``.md``)
99 |
100 | By default, ``.md`` files are treated as MyST files.
101 | To treat them as Markdown, use ``--myst-extension=. --markdown-extension=.md``.
102 |
103 | .. code-block:: markdown
104 |
105 | ```shell
106 | echo "Hello, world!"
107 | ```
108 |
109 | * MyST (``.md`` with MyST syntax)
110 |
111 | .. code-block:: markdown
112 |
113 | ```{code-block} shell
114 | echo "Hello, world!"
115 | ```
116 |
117 | ```{code} shell
118 | echo "Or this Hello, world!"
119 | ```
120 |
121 | * Want more? Open an issue!
122 |
123 | Formatters and padding
124 | ----------------------
125 |
126 | Running linters with ``doccmd`` gives you errors and warnings with line numbers that match the documentation file.
127 | It does this by adding padding to the code blocks before running the command.
128 |
129 | Some tools do not work well with this padding, and you can choose to obscure the line numbers in order to give the tool the original code block's content without padding, by using the ``--no-pad-file`` and ``--no-pad-groups`` flag.
130 | See using_groups_with_formatters_ for more information.
131 |
132 | File names and linter ignores
133 | -----------------------------
134 |
135 | ``doccmd`` creates temporary files for each code block in the documentation file.
136 | These files are created in the same directory as the documentation file, and are named with the documentation file name and the line number of the code block.
137 | Files are created with a prefix set to the given ``--temporary-file-name-prefix`` argument (default ``doccmd``).
138 |
139 | You can use this information to ignore files in your linter configuration.
140 |
141 | For example, to ignore a rule in all files created by ``doccmd`` in a ``ruff`` configuration in ``pyproject.toml``:
142 |
143 | .. code-block:: toml
144 |
145 | [tool.ruff]
146 |
147 | lint.per-file-ignores."doccmd_*.py" = [
148 | # Allow hardcoded secrets in documentation.
149 | "S105",
150 | ]
151 |
152 | Skipping code blocks
153 | --------------------
154 |
155 | Code blocks which come just after a comment matching ``skip doccmd[all]: next`` are skipped.
156 |
157 | To skip multiple code blocks in a row, use ``skip doccmd[all]: start`` and ``skip doccmd[all]: end`` comments surrounding the code blocks to skip.
158 |
159 | Use the ``--skip-marker`` option to set a marker for this particular command which will work as well as ``all``.
160 | For example, use ``--skip-marker="type-check"`` to skip code blocks which come just after a comment matching ``skip doccmd[type-check]: next``.
161 |
162 | To skip a code block for each of multiple markers, for example to skip a code block for the ``type-check`` and ``lint`` markers but not all markers, add multiple ``skip doccmd`` comments above the code block.
163 |
164 | The skip comment will skip the next code block which would otherwise be run.
165 | This means that if you run ``doccmd`` with ``--language=python``, the Python code block in the following example will be skipped:
166 |
167 | .. code-block:: markdown
168 |
169 | <-- skip doccmd[all]: next -->
170 |
171 | ```{code-block} shell
172 | echo "This will not run because the shell language was not selected"
173 | ```
174 |
175 | ```{code-block} python
176 | print("This will be skipped!")
177 | ```
178 |
179 | Therefore it is not recommended to use ``skip doccmd[all]`` and to instead use a more specific marker.
180 | For example, if we used ``doccmd`` with ``--language=shell`` and ``--skip-marker=echo`` the following examples show how to skip code blocks in different formats:
181 |
182 | * reStructuredText (``.rst``)
183 |
184 | .. code-block:: rst
185 |
186 | .. skip doccmd[echo]: next
187 |
188 | .. code-block:: shell
189 |
190 | echo "This will be skipped!"
191 |
192 | .. code-block:: shell
193 |
194 | echo "This will run"
195 |
196 | * Markdown (``.md``)
197 |
198 | .. code-block:: markdown
199 |
200 | <-- skip doccmd[echo]: next -->
201 |
202 | ```shell
203 | echo "This will be skipped!"
204 | ```
205 |
206 | ```shell
207 | echo "This will run"
208 | ```
209 |
210 | * MyST (``.md`` with MyST syntax)
211 |
212 | .. code-block:: markdown
213 |
214 | % skip doccmd[echo]: next
215 |
216 | ```{code-block} shell
217 | echo "This will be skipped!"
218 | ```
219 |
220 | ```{code-block} shell
221 | echo "This will run"
222 | ```
223 |
224 | Grouping code blocks
225 | --------------------
226 |
227 | You might have two code blocks like this:
228 |
229 | .. group doccmd[all]: start
230 |
231 | .. code-block:: python
232 |
233 | """Example function which is used in a future code block."""
234 |
235 |
236 | def my_function() -> None:
237 | """Do nothing."""
238 |
239 |
240 | .. code-block:: python
241 |
242 | my_function()
243 |
244 | .. group doccmd[all]: end
245 |
246 | and wish to type check the two code blocks as if they were one.
247 | By default, this will error as in the second code block, ``my_function`` is not defined.
248 |
249 | To treat code blocks as one, use ``group doccmd[all]: start`` and ``group doccmd[all]: end`` comments surrounding the code blocks to group.
250 | Grouped code blocks will not have their contents updated in the documentation file.
251 | Error messages for grouped code blocks may include lines which do not match the document, so code formatters will not work on them.
252 |
253 | Use the ``--group-marker`` option to set a marker for this particular command which will work as well as ``all``.
254 | For example, use ``--group-marker="type-check"`` to group code blocks which come between comments matching ``group doccmd[type-check]: start`` and ``group doccmd[type-check]: end``.
255 |
256 | .. _using_groups_with_formatters:
257 |
258 | Using groups with formatters
259 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
260 |
261 | By default, code blocks in groups will be separated by newlines in the temporary file created.
262 | This means that line numbers from the original document match the line numbers in the temporary file, and error messages will have correct line numbers.
263 | Some tools, such as formatters, may not work well with this separation.
264 | To have just one newline between code blocks in a group, use the ``--no-pad-groups`` option.
265 | If you then want to add extra padding to the code blocks in a group, add invisible code blocks to the document.
266 | Make sure that the language of the invisible code block is the same as the ``--language`` option given to ``doccmd``.
267 |
268 | For example:
269 |
270 | * reStructuredText (``.rst``)
271 |
272 | .. code-block:: rst
273 |
274 | .. invisible-code-block: java
275 |
276 | * Markdown (``.md``)
277 |
278 | .. code-block:: markdown
279 |
280 |
283 |
284 | Tools which change the code block content cannot change the content of code blocks inside groups.
285 | By default this will error.
286 | Use the ``--no-fail-on-group-write`` option to emit a warning but not error in this case.
287 |
288 | Full documentation
289 | ------------------
290 |
291 | See the `full documentation `__.
292 |
293 | .. |Build Status| image:: https://github.com/adamtheturtle/doccmd/actions/workflows/ci.yml/badge.svg?branch=main
294 | :target: https://github.com/adamtheturtle/doccmd/actions
295 | .. |codecov| image:: https://codecov.io/gh/adamtheturtle/doccmd/branch/main/graph/badge.svg
296 | :target: https://codecov.io/gh/adamtheturtle/doccmd
297 | .. |PyPI| image:: https://badge.fury.io/py/doccmd.svg
298 | :target: https://badge.fury.io/py/doccmd
299 | .. |minimum-python-version| replace:: 3.10
300 |
--------------------------------------------------------------------------------
/bin/doccmd-wrapper.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | """
4 | Run doccmd.
5 | """
6 |
7 | from doccmd import main
8 |
9 | main()
10 |
--------------------------------------------------------------------------------
/codecov.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | coverage:
3 | status:
4 | patch:
5 | default:
6 | # Require 100% test coverage.
7 | target: 100%
8 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SOURCEDIR = source
8 | BUILDDIR = build
9 |
10 | # Put it first so that "make" without argument is like "make help".
11 | help:
12 | @uv run --extra=dev $(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
13 |
14 | .PHONY: help Makefile
15 |
16 | # Catch-all target: route all unknown targets to Sphinx using the new
17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
18 | %: Makefile
19 | @uv run --extra=dev $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
20 |
--------------------------------------------------------------------------------
/docs/source/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Documentation for `doccmd`.
3 | """
4 |
--------------------------------------------------------------------------------
/docs/source/changelog.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../../CHANGELOG.rst
2 |
--------------------------------------------------------------------------------
/docs/source/commands.rst:
--------------------------------------------------------------------------------
1 | Commands
2 | ========
3 |
4 | .. click:: doccmd:main
5 | :prog: doccmd
6 | :show-nested:
7 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | """
2 | Configuration for Sphinx.
3 | """
4 |
5 | # pylint: disable=invalid-name
6 |
7 | import importlib.metadata
8 | from pathlib import Path
9 |
10 | from packaging.specifiers import SpecifierSet
11 | from packaging.version import Version
12 | from sphinx_pyproject import SphinxConfig
13 |
14 | _pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml"
15 | _pyproject_config = SphinxConfig(
16 | pyproject_file=_pyproject_file,
17 | config_overrides={"version": None},
18 | )
19 |
20 | project = _pyproject_config.name
21 | author = _pyproject_config.author
22 |
23 | extensions = [
24 | "sphinx_copybutton",
25 | "sphinxcontrib.spelling",
26 | "sphinx_click.ext",
27 | "sphinx_inline_tabs",
28 | "sphinx_substitution_extensions",
29 | ]
30 |
31 | templates_path = ["_templates"]
32 | source_suffix = ".rst"
33 | master_doc = "index"
34 |
35 | project_copyright = f"%Y, {author}"
36 |
37 | # Exclude the prompt from copied code with sphinx_copybutton.
38 | # https://sphinx-copybutton.readthedocs.io/en/latest/use.html#automatic-exclusion-of-prompts-from-the-copies.
39 | copybutton_exclude = ".linenos, .gp"
40 |
41 | # The version info for the project you're documenting, acts as replacement for
42 | # |release|, also used in various other places throughout the
43 | # built documents.
44 | #
45 | # Use ``importlib.metadata.version`` as per
46 | # https://setuptools-scm.readthedocs.io/en/latest/usage/#usage-from-sphinx.
47 | _version_string = importlib.metadata.version(distribution_name=project)
48 | _version = Version(version=_version_string)
49 | if _version.major == 0:
50 | msg = (
51 | f"The version is {_version_string}. "
52 | "This indicates that the version is not set correctly. "
53 | "This is likely because the project was built without having all "
54 | "Git tags available."
55 | )
56 | raise ValueError(msg)
57 |
58 | # GitHub release tags have the format YYYY.MM.DD, while Python requirement
59 | # versions may have the format YYYY.M.D for single digit months and days.
60 | _num_date_parts = 3
61 | release = ".".join(
62 | [
63 | f"{part:02d}" if index < _num_date_parts else str(object=part)
64 | for index, part in enumerate(iterable=_version.release)
65 | ]
66 | )
67 |
68 | project_metadata = importlib.metadata.metadata(distribution_name=project)
69 | requires_python = project_metadata["Requires-Python"]
70 | specifiers = SpecifierSet(specifiers=requires_python)
71 | (specifier,) = specifiers
72 | if specifier.operator != ">=":
73 | msg = (
74 | f"We only support '>=' for Requires-Python, got {specifier.operator}."
75 | )
76 | raise ValueError(msg)
77 | minimum_python_version = specifier.version
78 |
79 | language = "en"
80 |
81 | # The name of the syntax highlighting style to use.
82 | pygments_style = "sphinx"
83 |
84 | # Output file base name for HTML help builder.
85 | htmlhelp_basename = "doccmd"
86 | intersphinx_mapping = {
87 | "python": (f"https://docs.python.org/{minimum_python_version}", None),
88 | }
89 | nitpicky = True
90 | warning_is_error = True
91 |
92 | autoclass_content = "both"
93 |
94 | html_theme = "furo"
95 | html_title = project
96 | html_show_copyright = False
97 | html_show_sphinx = False
98 | html_show_sourcelink = False
99 | html_theme_options = {
100 | "source_edit_link": "https://github.com/adamtheturtle/doccmd/edit/main/docs/source/{filename}",
101 | "sidebar_hide_name": False,
102 | }
103 |
104 | # Retry link checking to avoid transient network errors.
105 | linkcheck_retries = 5
106 |
107 | spelling_word_list_filename = "../../spelling_private_dict.txt"
108 |
109 | autodoc_member_order = "bysource"
110 |
111 | rst_prolog = f"""
112 | .. |project| replace:: {project}
113 | .. |release| replace:: {release}
114 | .. |minimum-python-version| replace:: {minimum_python_version}
115 | .. |github-owner| replace:: adamtheturtle
116 | .. |github-repository| replace:: doccmd
117 | """
118 |
--------------------------------------------------------------------------------
/docs/source/contributing.rst:
--------------------------------------------------------------------------------
1 | Contributing to |project|
2 | =========================
3 |
4 | Contributions to this repository must pass tests and linting.
5 |
6 | CI is the canonical source of truth.
7 |
8 | Install contribution dependencies
9 | ---------------------------------
10 |
11 | Install Python dependencies in a virtual environment.
12 |
13 | .. code-block:: console
14 |
15 | $ pip install --editable '.[dev]'
16 |
17 | Spell checking requires ``enchant``.
18 | This can be installed on macOS, for example, with `Homebrew`_:
19 |
20 | .. code-block:: console
21 |
22 | $ brew install enchant
23 |
24 | and on Ubuntu with ``apt``:
25 |
26 | .. code-block:: console
27 |
28 | $ apt-get install -y enchant
29 |
30 | Install ``pre-commit`` hooks:
31 |
32 | .. code-block:: console
33 |
34 | $ pre-commit install
35 |
36 | Linting
37 | -------
38 |
39 | Run lint tools either by committing, or with:
40 |
41 | .. code-block:: console
42 |
43 | $ pre-commit run --all-files --hook-stage pre-commit --verbose
44 | $ pre-commit run --all-files --hook-stage pre-push --verbose
45 | $ pre-commit run --all-files --hook-stage manual --verbose
46 |
47 | .. _Homebrew: https://brew.sh
48 |
49 | Running tests
50 | -------------
51 |
52 | Run ``pytest``:
53 |
54 | .. code-block:: console
55 |
56 | $ pytest
57 |
58 | Documentation
59 | -------------
60 |
61 | Documentation is built on Read the Docs.
62 |
63 | Run the following commands to build and view documentation locally:
64 |
65 | .. code-block:: console
66 |
67 | $ make docs
68 | $ make open-docs
69 |
70 | Continuous integration
71 | ----------------------
72 |
73 | Tests are run on GitHub Actions.
74 | The configuration for this is in :file:`.github/workflows/`.
75 |
76 | Performing a release
77 | --------------------
78 |
79 | See :doc:`release-process`.
80 |
--------------------------------------------------------------------------------
/docs/source/file-names-and-linter-ignores.rst:
--------------------------------------------------------------------------------
1 | File names and linter ignores
2 | -----------------------------
3 |
4 | ``doccmd`` creates temporary files for each code block in the documentation file.
5 | These files are created in the same directory as the documentation file, and are named with the documentation file name and the line number of the code block.
6 | Files are created with a prefix set to the given :option:`doccmd --temporary-file-name-prefix` argument (default ``doccmd``).
7 |
8 | You can use this information to ignore files in your linter configuration.
9 |
10 | For example, to ignore a rule in all files created by ``doccmd`` in a ``ruff`` configuration in ``pyproject.toml``:
11 |
12 | .. code-block:: toml
13 |
14 | [tool.ruff]
15 |
16 | lint.per-file-ignores."*doccmd_*.py" = [
17 | # Allow hardcoded secrets in documentation.
18 | "S105",
19 | ]
20 |
21 | To ignore a rule in files created by ``doccmd`` when using ``pylint``, use `pylint-per-file-ignores `_, and a configuration like the following (if using ``pyproject.toml``):
22 |
23 | .. code-block:: toml
24 |
25 | [tool.pylint.'MESSAGES CONTROL']
26 |
27 | per-file-ignores = [
28 | "*doccmd_*.py:invalid-name",
29 | ]
30 |
--------------------------------------------------------------------------------
/docs/source/group-code-blocks.rst:
--------------------------------------------------------------------------------
1 | Grouping code blocks
2 | --------------------
3 |
4 | You might have two code blocks like this:
5 |
6 | .. group doccmd[all]: start
7 |
8 | .. code-block:: python
9 |
10 | """Example function which is used in a future code block."""
11 |
12 |
13 | def my_function() -> None:
14 | """Do nothing."""
15 |
16 |
17 | .. code-block:: python
18 |
19 | my_function()
20 |
21 | .. group doccmd[all]: end
22 |
23 | and wish to type check the two code blocks as if they were one.
24 | By default, this will error as in the second code block, ``my_function`` is not defined.
25 |
26 | To treat code blocks as one, use ``group doccmd[all]: start`` and ``group doccmd[all]: end`` comments surrounding the code blocks to group.
27 | Grouped code blocks will not have their contents updated in the documentation file.
28 | Error messages for grouped code blocks may include lines which do not match the document.
29 |
30 | Use the :option:`doccmd --group-marker` option to set a marker for this particular command which will work as well as ``all``.
31 | For example, set :option:`doccmd --group-marker` to ``"type-check"`` to group code blocks which come between comments matching ``group doccmd[type-check]: start`` and ``group doccmd[type-check]: end``.
32 |
33 | .. _using_groups_with_formatters:
34 |
35 | Using groups with formatters
36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
37 |
38 | By default, code blocks in groups will be separated by newlines in the temporary file created.
39 | This means that line numbers from the original document match the line numbers in the temporary file, and error messages will have correct line numbers.
40 | Some tools, such as formatters, may not work well with this separation.
41 | To have just one newline between code blocks in a group, use the :option:`doccmd --no-pad-groups` option.
42 | If you then want to add extra padding to the code blocks in a group, add invisible code blocks to the document.
43 | Make sure that the language of the invisible code block is the same as the :option:`doccmd --language` option given to ``doccmd``.
44 |
45 | For example:
46 |
47 | * reStructuredText (``.rst``)
48 |
49 | .. code-block:: rst
50 |
51 | .. invisible-code-block: java
52 |
53 | * Markdown (``.md``)
54 |
55 | .. code-block:: markdown
56 |
57 |
60 |
61 | Tools which change the code block content cannot change the content of code blocks inside groups.
62 | By default this will error.
63 | Use the :option:`doccmd --no-fail-on-group-write` option to emit a warning but not error in this case.
64 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | |project|
2 | =========
3 |
4 | A command line tool for running commands against code blocks in documentation files.
5 | This allows you to run linters, formatters, and other tools against the code blocks in your documentation files.
6 |
7 | .. include:: install.rst
8 |
9 | .. include:: usage-example.rst
10 |
11 | What does it work on?
12 | ---------------------
13 |
14 | * reStructuredText (``.rst``)
15 |
16 | .. code-block:: rst
17 |
18 | .. code-block:: shell
19 |
20 | echo "Hello, world!"
21 |
22 | * Markdown (``.md``)
23 |
24 | .. note::
25 |
26 | By default, ``.md`` files are treated as MyST files.
27 | To treat them as Markdown, set :option:`doccmd --myst-extension` to ``".""`` and :option:`doccmd --markdown-extension` to ``".md"``.
28 |
29 | .. code-block:: markdown
30 |
31 | ```shell
32 | echo "Hello, world!"
33 | ```
34 |
35 | * MyST (``.md`` with MyST syntax)
36 |
37 | .. code-block:: markdown
38 |
39 | ```{code-block} shell
40 | echo "Hello, world!"
41 | ```
42 |
43 | * Want more? Open an issue!
44 |
45 | Formatters and padding
46 | ----------------------
47 |
48 | Running linters with ``doccmd`` gives you errors and warnings with line numbers that match the documentation file.
49 | It does this by adding padding to the code blocks before running the command.
50 |
51 | Some tools do not work well with this padding, and you can choose to obscure the line numbers in order to give the tool the original code block's content without padding, by using the :option:`doccmd --no-pad-file` and :option:`doccmd --no-pad-groups` flags.
52 | See :ref:`using_groups_with_formatters` for more information.
53 |
54 | For example, to run ``ruff format`` against the code blocks in a Markdown file, use the following command:
55 |
56 | .. code-block:: shell
57 |
58 | $ doccmd --language=python --no-pad-file --no-pad-groups --command="ruff format"
59 |
60 | .. include:: file-names-and-linter-ignores.rst
61 |
62 | Reference
63 | ---------
64 |
65 | .. toctree::
66 | :maxdepth: 3
67 |
68 | install
69 | usage-example
70 | commands
71 | file-names-and-linter-ignores
72 | skip-code-blocks
73 | group-code-blocks
74 | contributing
75 | release-process
76 | changelog
77 |
--------------------------------------------------------------------------------
/docs/source/install.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ------------
3 |
4 | With ``pip``
5 | ~~~~~~~~~~~~
6 |
7 | Requires Python |minimum-python-version|\+.
8 |
9 | .. code-block:: console
10 |
11 | $ pip install doccmd
12 |
13 | With Homebrew (macOS, Linux, WSL)
14 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
15 |
16 | Requires `Homebrew`_.
17 |
18 | .. code-block:: console
19 |
20 | $ brew tap adamtheturtle/doccmd
21 | $ brew install doccmd
22 |
23 | .. _Homebrew: https://docs.brew.sh/Installation
24 |
25 | Pre-built Linux (x86) binaries
26 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
27 |
28 | .. code-block:: console
29 | :substitutions:
30 |
31 | $ curl --fail -L "https://github.com/|github-owner|/|github-repository|/releases/download/|release|/doccmd-linux" -o /usr/local/bin/doccmd &&
32 | chmod +x /usr/local/bin/doccmd
33 |
34 | Using ``doccmd`` as a pre-commit hook
35 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
36 |
37 | To run ``doccmd`` with `pre-commit`_, add hooks like the following to your ``.pre-commit-config.yaml``:
38 |
39 | .. code-block:: yaml
40 | :substitutions:
41 |
42 | - repo: https://github.com/adamtheturtle/doccmd-pre-commit
43 | rev: v|release|
44 | hooks:
45 | - id: doccmd
46 | args: ["--language", "shell", "--command", "shellcheck --shell=bash"]
47 | additional_dependencies: ["shellcheck-py"]
48 |
49 | .. _pre-commit: https://pre-commit.com
50 |
--------------------------------------------------------------------------------
/docs/source/release-process.rst:
--------------------------------------------------------------------------------
1 | Release process
2 | ===============
3 |
4 | Outcomes
5 | ~~~~~~~~
6 |
7 | * A new ``git`` tag available to install.
8 | * A new package on PyPI.
9 | * A new Homebrew recipe available to install.
10 |
11 | Perform a Release
12 | ~~~~~~~~~~~~~~~~~
13 |
14 | #. `Install GitHub CLI`_.
15 |
16 | #. Perform a release:
17 |
18 | .. code-block:: console
19 | :substitutions:
20 |
21 | $ gh workflow run release.yml --repo "|github-owner|/|github-repository|"
22 |
23 | .. _Install GitHub CLI: https://cli.github.com/
24 |
--------------------------------------------------------------------------------
/docs/source/skip-code-blocks.rst:
--------------------------------------------------------------------------------
1 | Skipping code blocks
2 | --------------------
3 |
4 | Code blocks which come just after a comment matching ``skip doccmd[all]: next`` are skipped.
5 |
6 | To skip multiple code blocks in a row, use ``skip doccmd[all]: start`` and ``skip doccmd[all]: end`` comments surrounding the code blocks to skip.
7 |
8 | Use the :option:`doccmd --skip-marker` option to set a marker for this particular command which will work as well as ``all``.
9 | For example, set :option:`doccmd --skip-marker` to ``"type-check"`` to skip code blocks which come just after a comment matching ``skip doccmd[type-check]: next``.
10 |
11 | To skip a code block for each of multiple markers, for example to skip a code block for the ``type-check`` and ``lint`` markers but not all markers, add multiple ``skip doccmd`` comments above the code block.
12 |
13 | The skip comment will skip the next code block which would otherwise be run.
14 | This means that if you set :option:`doccmd --language` to ``"python"``, the Python code block in the following example will be skipped:
15 |
16 | .. code-block:: markdown
17 |
18 | <-- skip doccmd[all]: next -->
19 |
20 | ```{code-block} shell
21 | echo "This will not run because the shell language was not selected"
22 | ```
23 |
24 | ```{code-block} python
25 | print("This will be skipped!")
26 | ```
27 |
28 | Therefore it is not recommended to use ``skip doccmd[all]`` and to instead use a more specific marker.
29 | For example, if we set :option:`doccmd --language` to ``"shell"`` and :option:`doccmd --skip-marker` ``"echo"`` the following examples show how to skip code blocks in different formats:
30 |
31 | * reStructuredText (``.rst``)
32 |
33 | .. code-block:: rst
34 |
35 | .. skip doccmd[echo]: next
36 |
37 | .. code-block:: shell
38 |
39 | echo "This will be skipped!"
40 |
41 | .. code-block:: shell
42 |
43 | echo "This will run"
44 |
45 | * Markdown (``.md``)
46 |
47 | .. code-block:: markdown
48 |
49 | <-- skip doccmd[echo]: next -->
50 |
51 | ```shell
52 | echo "This will be skipped!"
53 | ```
54 |
55 | ```shell
56 | echo "This will run"
57 | ```
58 |
59 | * MyST (``.md`` with MyST syntax)
60 |
61 | .. code-block:: markdown
62 |
63 | % skip doccmd[echo]: next
64 |
65 | ```{code-block} shell
66 | echo "This will be skipped!"
67 | ```
68 |
69 | ```{code-block} shell
70 | echo "This will run"
71 | ```
72 |
--------------------------------------------------------------------------------
/docs/source/usage-example.rst:
--------------------------------------------------------------------------------
1 | Usage example
2 | -------------
3 |
4 | .. code-block:: shell
5 |
6 | # Run mypy against the Python code blocks in README.md and CHANGELOG.rst
7 | $ doccmd --language=python --command="mypy" README.md CHANGELOG.rst
8 |
9 | # Run gofmt against the Go code blocks in README.md
10 | # This will modify the README.md file in place
11 | $ doccmd --language=go --command="gofmt -w" README.md
12 |
13 | # or type less... and search for files in the docs directory
14 | $ doccmd -l python -c mypy README.md docs/
15 |
16 | # Run ruff format against the code blocks in a Markdown file
17 | # Don't "pad" the code blocks with newlines - the formatter wouldn't like that.
18 | # See the documentation about groups for more information.
19 | $ doccmd --language=python --no-pad-file --no-pad-groups --command="ruff format" README.md
20 |
21 | # Run j2lint against the sphinx-jinja2 code blocks in a MyST file
22 | $ doccmd --sphinx-jinja2 --no-pad-file --no-pad-groups --command="j2lint" README.md
23 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "setuptools.build_meta"
3 | requires = [
4 | "setuptools",
5 | "setuptools-scm>=8.1.0",
6 | ]
7 |
8 | [project]
9 | name = "doccmd"
10 | description = "Run commands against code blocks in reStructuredText and Markdown files."
11 | readme = { file = "README.rst", content-type = "text/x-rst" }
12 | keywords = [
13 | "markdown",
14 | "rst",
15 | "sphinx",
16 | "testing",
17 | ]
18 | license = { file = "LICENSE" }
19 | authors = [
20 | { name = "Adam Dangoor", email = "adamdangoor@gmail.com" },
21 | ]
22 | requires-python = ">=3.10"
23 | classifiers = [
24 | "Development Status :: 5 - Production/Stable",
25 | "Environment :: Web Environment",
26 | "License :: OSI Approved :: MIT License",
27 | "Operating System :: Microsoft :: Windows",
28 | "Operating System :: POSIX",
29 | "Programming Language :: Python :: 3 :: Only",
30 | "Programming Language :: Python :: 3.10",
31 | "Programming Language :: Python :: 3.11",
32 | "Programming Language :: Python :: 3.12",
33 | "Programming Language :: Python :: 3.13",
34 | ]
35 | dynamic = [
36 | "version",
37 | ]
38 | dependencies = [
39 | "beartype>=0.19.0",
40 | "charset-normalizer>=3.4.1",
41 | "click>=8.2.0",
42 | "pygments>=2.18.0",
43 | "sybil>=9.1.0,<10.0.0",
44 | # Pin this dependency as we expect:
45 | # * It might have breaking changes
46 | # * It is not a direct dependency of the user
47 | "sybil-extras==2025.4.7",
48 | ]
49 | optional-dependencies.dev = [
50 | "actionlint-py==1.7.7.23",
51 | "ansi==0.3.7",
52 | "check-manifest==0.50",
53 | "deptry==0.23.0",
54 | "doc8==1.1.2",
55 | "docformatter==1.7.7",
56 | "furo==2024.8.6",
57 | "interrogate==1.7.0",
58 | "mypy[faster-cache]==1.16.0",
59 | "mypy-strict-kwargs==2025.4.3",
60 | "pre-commit==4.2.0",
61 | "pydocstyle==6.3",
62 | "pyenchant==3.3.0rc1",
63 | "pygments==2.19.1",
64 | "pylint==3.3.7",
65 | "pylint-per-file-ignores==1.4.0",
66 | "pyproject-fmt==2.6.0",
67 | "pyright==1.1.401",
68 | "pyroma==4.2",
69 | "pytest==8.4.0",
70 | "pytest-cov==6.1.1",
71 | "pytest-regressions==2.8.0",
72 | "pyyaml==6.0.2",
73 | "ruff==0.11.12",
74 | # We add shellcheck-py not only for shell scripts and shell code blocks,
75 | # but also because having it installed means that ``actionlint-py`` will
76 | # use it to lint shell commands in GitHub workflow files.
77 | "shellcheck-py==0.10.0.1",
78 | "shfmt-py==3.11.0.2",
79 | "sphinx>=8.1.3",
80 | "sphinx-click==6.0.0",
81 | "sphinx-copybutton==0.5.2",
82 | "sphinx-inline-tabs==2023.4.21",
83 | "sphinx-lint==1.0.0",
84 | "sphinx-pyproject==0.3.0",
85 | "sphinx-substitution-extensions==2025.4.3",
86 | "sphinxcontrib-spelling==8.0.1",
87 | "types-pygments==2.19.0.20250516",
88 | "vulture==2.14",
89 | "yamlfix==1.17.0",
90 | ]
91 | optional-dependencies.release = [
92 | "check-wheel-contents==0.6.2",
93 | "homebrew-pypi-poet==0.10",
94 | ]
95 | urls.Documentation = "https://adamtheturtle.github.io/doccmd/"
96 | urls.Source = "https://github.com/adamtheturtle/doccmd"
97 | scripts.doccmd = "doccmd:main"
98 |
99 | [tool.setuptools]
100 | zip-safe = false
101 |
102 | [tool.setuptools.packages.find]
103 | where = [
104 | "src",
105 | ]
106 |
107 | [tool.setuptools.package-data]
108 | doccmd = [
109 | "py.typed",
110 | ]
111 |
112 | [tool.distutils.bdist_wheel]
113 | universal = true
114 |
115 | [tool.setuptools_scm]
116 |
117 | # We write the version to a file so that we can import it.
118 | # We choose a ``.py`` file so that we can read it without
119 | # worrying about including the file in MANIFEST.in.
120 | write_to = "src/doccmd/_setuptools_scm_version.py"
121 | # This keeps the start of the version the same as the last release.
122 | # This is useful for our documentation to include e.g. binary links
123 | # to the latest released binary.
124 | #
125 | # Code to match this is in ``conf.py``.
126 | version_scheme = "post-release"
127 |
128 | [tool.ruff]
129 | line-length = 79
130 |
131 | lint.select = [
132 | "ALL",
133 | ]
134 | lint.ignore = [
135 | # We can manage our own complexity.
136 | "C901",
137 | # Ruff warns that this conflicts with the formatter.
138 | "COM812",
139 | # Allow our chosen docstring line-style - no one-line summary.
140 | "D200",
141 | "D205",
142 | "D212",
143 | # Ruff warns that this conflicts with the formatter.
144 | "ISC001",
145 | # Ignore "too-many-*" errors as they seem to get in the way more than
146 | # helping.
147 | "PLR0912",
148 | "PLR0913",
149 | "PLR0915",
150 | ]
151 |
152 | lint.per-file-ignores."tests/*.py" = [
153 | # Do not require tests to have a one-line summary.
154 | "S101",
155 | ]
156 |
157 | # Do not automatically remove commented out code.
158 | # We comment out code during development, and with VSCode auto-save, this code
159 | # is sometimes annoyingly removed.
160 | lint.unfixable = [
161 | "ERA001",
162 | ]
163 | lint.pydocstyle.convention = "google"
164 |
165 | [tool.pylint]
166 |
167 | [tool.pylint.'MASTER']
168 |
169 | # Pickle collected data for later comparisons.
170 | persistent = true
171 |
172 | # Use multiple processes to speed up Pylint.
173 | jobs = 0
174 |
175 | # List of plugins (as comma separated values of python modules names) to load,
176 | # usually to register additional checkers.
177 | # See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html.
178 | # We do not use the plugins:
179 | # - pylint.extensions.code_style
180 | # - pylint.extensions.magic_value
181 | # - pylint.extensions.while_used
182 | # as they seemed to get in the way.
183 | load-plugins = [
184 | 'pylint.extensions.bad_builtin',
185 | 'pylint.extensions.comparison_placement',
186 | 'pylint.extensions.consider_refactoring_into_while_condition',
187 | 'pylint.extensions.docparams',
188 | 'pylint.extensions.dunder',
189 | 'pylint.extensions.eq_without_hash',
190 | 'pylint.extensions.for_any_all',
191 | 'pylint.extensions.mccabe',
192 | 'pylint.extensions.no_self_use',
193 | 'pylint.extensions.overlapping_exceptions',
194 | 'pylint.extensions.private_import',
195 | 'pylint.extensions.redefined_loop_name',
196 | 'pylint.extensions.redefined_variable_type',
197 | 'pylint.extensions.set_membership',
198 | 'pylint.extensions.typing',
199 | "pylint_per_file_ignores",
200 | ]
201 |
202 | # Allow loading of arbitrary C extensions. Extensions are imported into the
203 | # active Python interpreter and may run arbitrary code.
204 | unsafe-load-any-extension = false
205 |
206 | ignore = [
207 | '_setuptools_scm_version.py',
208 | ]
209 |
210 | [tool.pylint.'MESSAGES CONTROL']
211 |
212 | # Enable the message, report, category or checker with the given id(s). You can
213 | # either give multiple identifier separated by comma (,) or put this option
214 | # multiple time (only on the command line, not in the configuration file where
215 | # it should appear only once). See also the "--disable" option for examples.
216 | enable = [
217 | 'bad-inline-option',
218 | 'deprecated-pragma',
219 | 'file-ignored',
220 | 'spelling',
221 | 'use-symbolic-message-instead',
222 | 'useless-suppression',
223 | ]
224 |
225 | # Disable the message, report, category or checker with the given id(s). You
226 | # can either give multiple identifiers separated by comma (,) or put this
227 | # option multiple times (only on the command line, not in the configuration
228 | # file where it should appear only once).You can also use "--disable=all" to
229 | # disable everything first and then reenable specific checks. For example, if
230 | # you want to run only the similarities checker, you can use "--disable=all
231 | # --enable=similarities". If you want to run only the classes checker, but have
232 | # no Warning level messages displayed, use"--disable=all --enable=classes
233 | # --disable=W"
234 |
235 | disable = [
236 | "too-many-branches",
237 | "too-many-statements",
238 | 'too-complex',
239 | 'too-few-public-methods',
240 | 'too-many-arguments',
241 | 'too-many-instance-attributes',
242 | 'too-many-lines',
243 | 'too-many-locals',
244 | 'too-many-return-statements',
245 | 'locally-disabled',
246 | # Let ruff handle long lines
247 | 'line-too-long',
248 | # Let ruff handle unused imports
249 | 'unused-import',
250 | # Let ruff deal with sorting
251 | 'ungrouped-imports',
252 | # We don't need everything to be documented because of mypy
253 | 'missing-type-doc',
254 | 'missing-return-type-doc',
255 | # Too difficult to please
256 | 'duplicate-code',
257 | # Let ruff handle imports
258 | 'wrong-import-order',
259 | # mypy does not want untyped parameters.
260 | 'useless-type-doc',
261 | ]
262 |
263 | per-file-ignores = [
264 | "doccmd_README_rst.*.py:invalid-name",
265 | ]
266 |
267 | [tool.pylint.'FORMAT']
268 |
269 | # Allow the body of an if to be on the same line as the test if there is no
270 | # else.
271 | single-line-if-stmt = false
272 |
273 | [tool.pylint.'SPELLING']
274 |
275 | # Spelling dictionary name. Available dictionaries: none. To make it working
276 | # install python-enchant package.
277 | spelling-dict = 'en_US'
278 |
279 | # A path to a file that contains private dictionary; one word per line.
280 | spelling-private-dict-file = 'spelling_private_dict.txt'
281 |
282 | # Tells whether to store unknown words to indicated private dictionary in
283 | # --spelling-private-dict-file option instead of raising a message.
284 | spelling-store-unknown-words = 'no'
285 |
286 | [tool.pylint.'TYPECHECK']
287 |
288 | signature-mutators = [
289 | "click.decorators.option",
290 | "click.decorators.argument",
291 | ]
292 |
293 | [tool.docformatter]
294 | make-summary-multi-line = true
295 |
296 | [tool.check-manifest]
297 |
298 | ignore = [
299 | ".checkmake-config.ini",
300 | ".yamlfmt",
301 | "*.enc",
302 | ".git_archival.txt",
303 | ".pre-commit-config.yaml",
304 | ".shellcheckrc",
305 | ".vscode/**",
306 | "CHANGELOG.rst",
307 | "CODE_OF_CONDUCT.rst",
308 | "CONTRIBUTING.rst",
309 | "LICENSE",
310 | "Makefile",
311 | "admin",
312 | "admin/**",
313 | "bin",
314 | "bin/*",
315 | "ci",
316 | "ci/**",
317 | "codecov.yaml",
318 | "conftest.py",
319 | "doc8.ini",
320 | "docs",
321 | "docs/**",
322 | "lint.mk",
323 |
324 | "spelling_private_dict.txt",
325 | "src/*/_setuptools_scm_version.py",
326 | "tests",
327 | "tests-pylintrc",
328 | "tests/**",
329 | ]
330 |
331 | [tool.deptry]
332 | pep621_dev_dependency_groups = [
333 | "dev",
334 | "packaging",
335 | "release",
336 | ]
337 |
338 | [tool.pyproject-fmt]
339 | indent = 4
340 | keep_full_version = true
341 | max_supported_python = "3.13"
342 |
343 | [tool.pytest.ini_options]
344 |
345 | xfail_strict = true
346 | log_cli = true
347 |
348 | [tool.coverage.run]
349 |
350 | branch = true
351 | omit = [
352 | 'src/*/_setuptools_scm_version.py',
353 | 'src/doccmd/__main__.py',
354 | ]
355 |
356 | [tool.coverage.report]
357 | exclude_also = [
358 | "if TYPE_CHECKING:",
359 | "class .*\\bProtocol\\):",
360 | "@overload",
361 | ]
362 |
363 | [tool.mypy]
364 |
365 | strict = true
366 | files = [ "." ]
367 | exclude = [ "build" ]
368 | follow_untyped_imports = true
369 | plugins = [
370 | "mypy_strict_kwargs",
371 | ]
372 |
373 | [tool.pyright]
374 |
375 | enableTypeIgnoreComments = false
376 | reportUnnecessaryTypeIgnoreComment = true
377 | typeCheckingMode = "strict"
378 |
379 | [tool.interrogate]
380 | fail-under = 100
381 | omit-covered-files = true
382 | ignore-overloaded-functions = true
383 | verbose = 2
384 | exclude = [
385 | "src/*/_setuptools_scm_version.py",
386 | ]
387 |
388 | [tool.doc8]
389 |
390 | max_line_length = 2000
391 | ignore_path = [
392 | "./.eggs",
393 | "./docs/build",
394 | "./docs/build/spelling/output.txt",
395 | "./node_modules",
396 | "./src/*.egg-info/",
397 | "./src/*/_setuptools_scm_version.txt",
398 | ]
399 |
400 | [tool.vulture]
401 | # Ideally we would limit the paths to the source code where we want to ignore names,
402 | # but Vulture does not enable this.
403 | ignore_names = [
404 | # pytest configuration
405 | "pytest_collect_file",
406 | "pytest_collection_modifyitems",
407 | "pytest_plugins",
408 | # pytest fixtures - we name fixtures like this for this purpose
409 | "fixture_*",
410 | # Sphinx
411 | "autoclass_content",
412 | "autoclass_content",
413 | "autodoc_member_order",
414 | "copybutton_exclude",
415 | "extensions",
416 | "html_show_copyright",
417 | "html_show_sourcelink",
418 | "html_show_sphinx",
419 | "html_theme",
420 | "html_theme_options",
421 | "html_title",
422 | "htmlhelp_basename",
423 | "intersphinx_mapping",
424 | "language",
425 | "linkcheck_ignore",
426 | "linkcheck_retries",
427 | "master_doc",
428 | "nitpicky",
429 | "project_copyright",
430 | "pygments_style",
431 | "rst_prolog",
432 | "source_suffix",
433 | "spelling_word_list_filename",
434 | "templates_path",
435 | "warning_is_error",
436 | # Ignore Protocol method arguments
437 | # see https://github.com/jendrikseipp/vulture/issues/309
438 | "directive",
439 | "pad_groups",
440 | ]
441 |
442 | exclude = [
443 | # Duplicate some of .gitignore
444 | ".venv",
445 | # We ignore the version file as it is generated by setuptools_scm.
446 | "_setuptools_scm_version.py",
447 | ]
448 |
449 | [tool.yamlfix]
450 | section_whitelines = 1
451 | whitelines = 1
452 |
--------------------------------------------------------------------------------
/spelling_private_dict.txt:
--------------------------------------------------------------------------------
1 | admin
2 | api
3 | args
4 | beartype
5 | changelog
6 | cli
7 | de
8 | doccmd
9 | dockerfile
10 | dockerfiles
11 | dulwich
12 | formatters
13 | gzip
14 | homebrew
15 | lexing
16 | linter
17 | linters
18 | linting
19 | linuxbrew
20 | login
21 | macOS
22 | metadata
23 | myst
24 | noqa
25 | parsers
26 | pragma
27 | pre
28 | py
29 | pyperclip
30 | pyright
31 | pytest
32 | reStructuredText
33 | reco
34 | recursing
35 | rst
36 | stderr
37 | txt
38 | typeshed
39 | ubuntu
40 | validator
41 | validators
42 | versioned
43 |
--------------------------------------------------------------------------------
/src/doccmd/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | CLI to run commands on the given files.
3 | """
4 |
5 | import difflib
6 | import platform
7 | import shlex
8 | import subprocess
9 | import sys
10 | import textwrap
11 | from collections.abc import Callable, Iterable, Mapping, Sequence
12 | from enum import Enum, auto, unique
13 | from importlib.metadata import PackageNotFoundError, version
14 | from pathlib import Path
15 | from typing import TypeVar, overload
16 |
17 | import charset_normalizer
18 | import click
19 | from beartype import beartype
20 | from pygments.lexers import get_all_lexers
21 | from sybil import Sybil
22 | from sybil.document import Document
23 | from sybil.example import Example
24 | from sybil.parsers.abstract.lexers import LexingException
25 | from sybil_extras.evaluators.multi import MultiEvaluator
26 | from sybil_extras.evaluators.shell_evaluator import ShellCommandEvaluator
27 |
28 | from ._languages import (
29 | Markdown,
30 | MarkupLanguage,
31 | MyST,
32 | ReStructuredText,
33 | )
34 |
35 | try:
36 | __version__ = version(distribution_name=__name__)
37 | except PackageNotFoundError: # pragma: no cover
38 | # When pkg_resources and git tags are not available,
39 | # for example in a PyInstaller binary,
40 | # we write the file ``_setuptools_scm_version.py`` on ``pip install``.
41 | from ._setuptools_scm_version import __version__
42 |
43 | T = TypeVar("T")
44 |
45 |
46 | @beartype
47 | class _LogCommandEvaluator:
48 | """
49 | Log a command before running it.
50 | """
51 |
52 | def __init__(
53 | self,
54 | *,
55 | args: Sequence[str | Path],
56 | ) -> None:
57 | """Initialize the evaluator.
58 |
59 | Args:
60 | args: The shell command to run.
61 | """
62 | self._args = args
63 |
64 | def __call__(self, example: Example) -> None:
65 | """
66 | Log the command before running it.
67 | """
68 | command_str = shlex.join(
69 | split_command=[str(object=item) for item in self._args],
70 | )
71 | running_command_message = (
72 | f"Running '{command_str}' on code block at "
73 | f"{example.path} line {example.line}"
74 | )
75 | _log_info(message=running_command_message)
76 |
77 |
78 | @beartype
79 | def _deduplicate(
80 | ctx: click.Context | None,
81 | param: click.Parameter | None,
82 | sequence: Sequence[T],
83 | ) -> Sequence[T]:
84 | """
85 | De-duplicate a sequence while keeping the order.
86 | """
87 | # We "use" the parameters to avoid vulture complaining.
88 | del ctx
89 | del param
90 |
91 | return tuple(dict.fromkeys(sequence).keys())
92 |
93 |
94 | @overload
95 | def _validate_file_extension(
96 | ctx: click.Context | None,
97 | param: click.Parameter | None,
98 | value: str,
99 | ) -> str: ...
100 |
101 |
102 | @overload
103 | def _validate_file_extension(
104 | ctx: click.Context | None,
105 | param: click.Parameter | None,
106 | value: None,
107 | ) -> None: ...
108 |
109 |
110 | @beartype
111 | def _validate_file_extension(
112 | ctx: click.Context | None,
113 | param: click.Parameter | None,
114 | value: str | None,
115 | ) -> str | None:
116 | """
117 | Validate that the input string starts with a dot.
118 | """
119 | if value is None:
120 | return value
121 |
122 | if not value.startswith("."):
123 | message = f"'{value}' does not start with a '.'."
124 | raise click.BadParameter(message=message, ctx=ctx, param=param)
125 | return value
126 |
127 |
128 | @beartype
129 | def _validate_given_files_have_known_suffixes(
130 | *,
131 | given_files: Iterable[Path],
132 | known_suffixes: Iterable[str],
133 | ) -> None:
134 | """
135 | Validate that the given files have known suffixes.
136 | """
137 | given_files_unknown_suffix = [
138 | document_path
139 | for document_path in given_files
140 | if document_path.suffix not in known_suffixes
141 | ]
142 |
143 | for given_file_unknown_suffix in given_files_unknown_suffix:
144 | message = f"Markup language not known for {given_file_unknown_suffix}."
145 | raise click.UsageError(message=message)
146 |
147 |
148 | @beartype
149 | def _validate_no_empty_string(
150 | ctx: click.Context | None,
151 | param: click.Parameter | None,
152 | value: str,
153 | ) -> str:
154 | """
155 | Validate that the input strings are not empty.
156 | """
157 | if not value:
158 | msg = "This value cannot be empty."
159 | raise click.BadParameter(message=msg, ctx=ctx, param=param)
160 | return value
161 |
162 |
163 | _ClickCallback = Callable[[click.Context | None, click.Parameter | None, T], T]
164 |
165 |
166 | @beartype
167 | def _sequence_validator(
168 | validator: _ClickCallback[T],
169 | ) -> _ClickCallback[Sequence[T]]:
170 | """
171 | Wrap a single-value validator to apply it to a sequence of values.
172 | """
173 |
174 | def callback(
175 | ctx: click.Context | None,
176 | param: click.Parameter | None,
177 | value: Sequence[T],
178 | ) -> Sequence[T]:
179 | """
180 | Apply the validators to the value.
181 | """
182 | return_values: tuple[T, ...] = ()
183 | for item in value:
184 | returned_value = validator(ctx, param, item)
185 | return_values = (*return_values, returned_value)
186 | return return_values
187 |
188 | return callback
189 |
190 |
191 | @beartype
192 | def _click_multi_callback(
193 | callbacks: Sequence[_ClickCallback[T]],
194 | ) -> _ClickCallback[T]:
195 | """
196 | Create a Click-compatible callback that applies a sequence of callbacks to
197 | an option value.
198 | """
199 |
200 | def callback(
201 | ctx: click.Context | None,
202 | param: click.Parameter | None,
203 | value: T,
204 | ) -> T:
205 | """
206 | Apply the validators to the value.
207 | """
208 | for callback in callbacks:
209 | value = callback(ctx, param, value)
210 | return value
211 |
212 | return callback
213 |
214 |
215 | _validate_file_extensions: _ClickCallback[Sequence[str]] = (
216 | _click_multi_callback(
217 | callbacks=[
218 | _deduplicate,
219 | _sequence_validator(validator=_validate_file_extension),
220 | ]
221 | )
222 | )
223 |
224 |
225 | @beartype
226 | def _get_file_paths(
227 | *,
228 | document_paths: Sequence[Path],
229 | file_suffixes: Iterable[str],
230 | max_depth: int,
231 | exclude_patterns: Iterable[str],
232 | ) -> Sequence[Path]:
233 | """
234 | Get the file paths from the given document paths (files and directories).
235 | """
236 | file_paths: dict[Path, bool] = {}
237 | for path in document_paths:
238 | if path.is_file():
239 | file_paths[path] = True
240 | else:
241 | for file_suffix in file_suffixes:
242 | new_file_paths = (
243 | path_part
244 | for path_part in path.rglob(pattern=f"*{file_suffix}")
245 | if len(path_part.relative_to(path).parts) <= max_depth
246 | )
247 | for new_file_path in new_file_paths:
248 | if new_file_path.is_file() and not any(
249 | new_file_path.match(path_pattern=pattern)
250 | for pattern in exclude_patterns
251 | ):
252 | file_paths[new_file_path] = True
253 | return tuple(file_paths.keys())
254 |
255 |
256 | @beartype
257 | def _validate_file_suffix_overlaps(
258 | *,
259 | suffix_groups: Mapping[MarkupLanguage, Iterable[str]],
260 | ) -> None:
261 | """
262 | Validate that the given file suffixes do not overlap.
263 | """
264 | for markup_language, suffixes in suffix_groups.items():
265 | for other_markup_language, other_suffixes in suffix_groups.items():
266 | if markup_language is other_markup_language:
267 | continue
268 | overlapping_suffixes = {*suffixes} & {*other_suffixes}
269 | # Allow the dot to overlap, as it is a common way to specify
270 | # "no extensions".
271 | overlapping_suffixes_ignoring_dot = overlapping_suffixes - {"."}
272 |
273 | if overlapping_suffixes_ignoring_dot:
274 | message = (
275 | f"Overlapping suffixes between {markup_language.name} and "
276 | f"{other_markup_language.name}: "
277 | f"{', '.join(sorted(overlapping_suffixes_ignoring_dot))}."
278 | )
279 | raise click.UsageError(message=message)
280 |
281 |
282 | @unique
283 | class _UsePty(Enum):
284 | """
285 | Choices for the use of a pseudo-terminal.
286 | """
287 |
288 | YES = auto()
289 | NO = auto()
290 | DETECT = auto()
291 |
292 | def use_pty(self) -> bool:
293 | """
294 | Whether to use a pseudo-terminal.
295 | """
296 | if self is _UsePty.DETECT:
297 | return sys.stdout.isatty() and platform.system() != "Windows"
298 | return self is _UsePty.YES
299 |
300 |
301 | @beartype
302 | def _log_info(message: str) -> None:
303 | """
304 | Log an info message.
305 | """
306 | styled_message = click.style(text=message, fg="green")
307 | click.echo(message=styled_message, err=True)
308 |
309 |
310 | @beartype
311 | def _log_warning(message: str) -> None:
312 | """
313 | Log an error message.
314 | """
315 | styled_message = click.style(text=message, fg="yellow")
316 | click.echo(message=styled_message, err=True)
317 |
318 |
319 | @beartype
320 | def _log_error(message: str) -> None:
321 | """
322 | Log an error message.
323 | """
324 | styled_message = click.style(text=message, fg="red")
325 | click.echo(message=styled_message, err=True)
326 |
327 |
328 | @beartype
329 | def _detect_newline(content_bytes: bytes) -> bytes | None:
330 | """
331 | Detect the newline character used in the content.
332 | """
333 | for newline in (b"\r\n", b"\n", b"\r"):
334 | if newline in content_bytes:
335 | return newline
336 | return None
337 |
338 |
339 | @beartype
340 | def _map_languages_to_suffix() -> dict[str, str]:
341 | """
342 | Map programming languages to their corresponding file extension.
343 | """
344 | language_extension_map: dict[str, str] = {}
345 |
346 | for lexer in get_all_lexers():
347 | language_name = lexer[0]
348 | file_extensions = lexer[2]
349 | if file_extensions:
350 | canonical_file_extension = file_extensions[0]
351 | if canonical_file_extension.startswith("*."):
352 | canonical_file_suffix = canonical_file_extension[1:]
353 | language_extension_map[language_name.lower()] = (
354 | canonical_file_suffix
355 | )
356 |
357 | return language_extension_map
358 |
359 |
360 | @beartype
361 | def _get_group_directives(markers: Iterable[str]) -> Sequence[str]:
362 | """
363 | Group directives based on the provided markers.
364 | """
365 | directives: Sequence[str] = []
366 |
367 | for marker in markers:
368 | directive = rf"group doccmd[{marker}]"
369 | directives = [*directives, directive]
370 | return directives
371 |
372 |
373 | @beartype
374 | def _get_skip_directives(markers: Iterable[str]) -> Iterable[str]:
375 | """
376 | Skip directives based on the provided markers.
377 | """
378 | directives: Sequence[str] = []
379 |
380 | for marker in markers:
381 | directive = rf"skip doccmd[{marker}]"
382 | directives = [*directives, directive]
383 | return directives
384 |
385 |
386 | @beartype
387 | def _get_temporary_file_extension(
388 | language: str,
389 | given_file_extension: str | None,
390 | ) -> str:
391 | """
392 | Get the file suffix, either from input or based on the language.
393 | """
394 | if given_file_extension is None:
395 | language_to_suffix = _map_languages_to_suffix()
396 | given_file_extension = language_to_suffix.get(language.lower(), ".txt")
397 |
398 | return given_file_extension
399 |
400 |
401 | @beartype
402 | def _evaluate_document(
403 | *,
404 | document: Document,
405 | args: Sequence[str | Path],
406 | ) -> None:
407 | """Evaluate the document.
408 |
409 | Raises:
410 | _EvaluateError: An example in the document could not be evaluated.
411 | """
412 | try:
413 | for example in document.examples():
414 | example.evaluate()
415 | except ValueError as exc:
416 | raise _EvaluateError(
417 | command_args=args,
418 | reason=str(object=exc),
419 | exit_code=1,
420 | ) from exc
421 | except subprocess.CalledProcessError as exc:
422 | raise _EvaluateError(
423 | command_args=args,
424 | reason=None,
425 | exit_code=exc.returncode,
426 | ) from exc
427 | except OSError as exc:
428 | raise _EvaluateError(
429 | command_args=args,
430 | reason=str(object=exc),
431 | exit_code=exc.errno,
432 | ) from exc
433 |
434 |
435 | @beartype
436 | class _EvaluateError(Exception):
437 | """
438 | Error raised when an example could not be evaluated.
439 | """
440 |
441 | @beartype
442 | def __init__(
443 | self,
444 | command_args: Sequence[str | Path],
445 | reason: str | None,
446 | exit_code: int | None,
447 | ) -> None:
448 | """
449 | Initialize the error.
450 | """
451 | self.exit_code = exit_code
452 | self.reason = reason
453 | self.command_args = command_args
454 | super().__init__()
455 |
456 |
457 | @beartype
458 | class _GroupModifiedError(Exception):
459 | """
460 | Error raised when there was an attempt to modify a code block in a group.
461 | """
462 |
463 | def __init__(
464 | self,
465 | *,
466 | example: Example,
467 | modified_example_content: str,
468 | ) -> None:
469 | """
470 | Initialize the error.
471 | """
472 | self._example = example
473 | self._modified_example_content = modified_example_content
474 |
475 | def __str__(self) -> str:
476 | """
477 | Get the string representation of the error.
478 | """
479 | unified_diff = difflib.unified_diff(
480 | a=str(object=self._example.parsed).lstrip().splitlines(),
481 | b=self._modified_example_content.lstrip().splitlines(),
482 | fromfile="original",
483 | tofile="modified",
484 | )
485 | message = textwrap.dedent(
486 | text=f"""\
487 | Writing to a group is not supported.
488 |
489 | A command modified the contents of examples in the group ending on line {self._example.line} in {Path(self._example.path).as_posix()}.
490 |
491 | Diff:
492 |
493 | """, # noqa: E501
494 | )
495 |
496 | message += "\n".join(unified_diff)
497 | return message
498 |
499 |
500 | @beartype
501 | def _raise_group_modified(
502 | *,
503 | example: Example,
504 | modified_example_content: str,
505 | ) -> None:
506 | """
507 | Raise an error when there was an attempt to modify a code block in a group.
508 | """
509 | raise _GroupModifiedError(
510 | example=example,
511 | modified_example_content=modified_example_content,
512 | )
513 |
514 |
515 | @beartype
516 | def _get_encoding(*, document_path: Path) -> str | None:
517 | """
518 | Get the encoding of the file.
519 | """
520 | content_bytes = document_path.read_bytes()
521 | charset_matches = charset_normalizer.from_bytes(sequences=content_bytes)
522 | best_match = charset_matches.best()
523 | if best_match is None:
524 | return None
525 | return best_match.encoding
526 |
527 |
528 | @beartype
529 | def _get_sybil(
530 | *,
531 | encoding: str,
532 | args: Sequence[str | Path],
533 | code_block_languages: Sequence[str],
534 | temporary_file_extension: str,
535 | temporary_file_name_prefix: str,
536 | pad_temporary_file: bool,
537 | pad_groups: bool,
538 | skip_directives: Iterable[str],
539 | group_directives: Iterable[str],
540 | use_pty: bool,
541 | markup_language: MarkupLanguage,
542 | log_command_evaluators: Sequence[_LogCommandEvaluator],
543 | newline: str | None,
544 | parse_sphinx_jinja2: bool,
545 | ) -> Sybil:
546 | """
547 | Get a Sybil for running commands on the given file.
548 | """
549 | tempfile_suffixes = (temporary_file_extension,)
550 |
551 | shell_command_evaluator = ShellCommandEvaluator(
552 | args=args,
553 | tempfile_suffixes=tempfile_suffixes,
554 | pad_file=pad_temporary_file,
555 | write_to_file=True,
556 | tempfile_name_prefix=temporary_file_name_prefix,
557 | newline=newline,
558 | use_pty=use_pty,
559 | encoding=encoding,
560 | )
561 |
562 | shell_command_group_evaluator = ShellCommandEvaluator(
563 | args=args,
564 | tempfile_suffixes=tempfile_suffixes,
565 | pad_file=pad_temporary_file,
566 | # We do not write to file for grouped code blocks.
567 | write_to_file=False,
568 | tempfile_name_prefix=temporary_file_name_prefix,
569 | newline=newline,
570 | use_pty=use_pty,
571 | encoding=encoding,
572 | on_modify=_raise_group_modified,
573 | )
574 |
575 | evaluator = MultiEvaluator(
576 | evaluators=[*log_command_evaluators, shell_command_evaluator],
577 | )
578 | group_evaluator = MultiEvaluator(
579 | evaluators=[*log_command_evaluators, shell_command_group_evaluator],
580 | )
581 |
582 | skip_parsers = [
583 | markup_language.skip_parser_cls(
584 | directive=skip_directive,
585 | )
586 | for skip_directive in skip_directives
587 | ]
588 | code_block_parsers = [
589 | markup_language.code_block_parser_cls(
590 | language=code_block_language,
591 | evaluator=evaluator,
592 | )
593 | for code_block_language in code_block_languages
594 | ]
595 |
596 | group_parsers = [
597 | markup_language.group_parser_cls(
598 | directive=group_directive,
599 | evaluator=group_evaluator,
600 | pad_groups=pad_groups,
601 | )
602 | for group_directive in group_directives
603 | ]
604 |
605 | sphinx_jinja2_parsers = (
606 | [
607 | markup_language.sphinx_jinja_parser_cls(
608 | evaluator=evaluator,
609 | )
610 | ]
611 | if markup_language.sphinx_jinja_parser_cls and parse_sphinx_jinja2
612 | else []
613 | )
614 |
615 | return Sybil(
616 | parsers=(
617 | *code_block_parsers,
618 | *sphinx_jinja2_parsers,
619 | *skip_parsers,
620 | *group_parsers,
621 | ),
622 | encoding=encoding,
623 | )
624 |
625 |
626 | @click.command(name="doccmd")
627 | @click.option(
628 | "languages",
629 | "-l",
630 | "--language",
631 | type=str,
632 | required=False,
633 | help=(
634 | "Run `command` against code blocks for this language. "
635 | "Give multiple times for multiple languages. "
636 | "If this is not given, no code blocks are run, unless "
637 | "`--sphinx-jinja2` is given."
638 | ),
639 | multiple=True,
640 | callback=_click_multi_callback(
641 | callbacks=[
642 | _deduplicate,
643 | _sequence_validator(validator=_validate_no_empty_string),
644 | ]
645 | ),
646 | )
647 | @click.option("command", "-c", "--command", type=str, required=True)
648 | @click.option(
649 | "temporary_file_extension",
650 | "--temporary-file-extension",
651 | type=str,
652 | required=False,
653 | help=(
654 | "The file extension to give to the temporary file made from the code "
655 | "block. By default, the file extension is inferred from the language, "
656 | "or it is '.txt' if the language is not recognized."
657 | ),
658 | callback=_validate_file_extension,
659 | )
660 | @click.option(
661 | "temporary_file_name_prefix",
662 | "--temporary-file-name-prefix",
663 | type=str,
664 | default="doccmd",
665 | show_default=True,
666 | required=True,
667 | help=(
668 | "The prefix to give to the temporary file made from the code block. "
669 | "This is useful for distinguishing files created by this tool "
670 | "from other files, e.g. for ignoring in linter configurations."
671 | ),
672 | )
673 | @click.option(
674 | "skip_markers",
675 | "--skip-marker",
676 | type=str,
677 | default=None,
678 | show_default=True,
679 | required=False,
680 | help=(
681 | """\
682 | The marker used to identify code blocks to be skipped.
683 |
684 | By default, code blocks which come just after a comment matching 'skip
685 | doccmd[all]: next' are skipped (e.g. `.. skip doccmd[all]: next` in
686 | reStructuredText, `` in Markdown, or
687 | `% skip doccmd[all]: next` in MyST).
688 |
689 | When using this option, those, and code blocks which come just after a
690 | comment including the given marker are ignored. For example, if the
691 | given marker is 'type-check', code blocks which come just after a
692 | comment matching 'skip doccmd[type-check]: next' are also skipped.
693 |
694 | To skip a code block for each of multiple markers, for example to skip
695 | a code block for the ``type-check`` and ``lint`` markers but not all
696 | markers, add multiple ``skip doccmd`` comments above the code block.
697 | """
698 | ),
699 | multiple=True,
700 | callback=_deduplicate,
701 | )
702 | @click.option(
703 | "group_markers",
704 | "--group-marker",
705 | type=str,
706 | default=None,
707 | show_default=True,
708 | required=False,
709 | help=(
710 | """\
711 | The marker used to identify code blocks to be grouped.
712 |
713 | By default, code blocks which come just between comments matching
714 | 'group doccmd[all]: start' and 'group doccmd[all]: end' are grouped
715 | (e.g. `.. group doccmd[all]: start` in reStructuredText, `` in Markdown, or `% group doccmd[all]: start` in
717 | MyST).
718 |
719 | When using this option, those, and code blocks which are grouped by
720 | a comment including the given marker are ignored. For example, if the
721 | given marker is 'type-check', code blocks which come within comments
722 | matching 'group doccmd[type-check]: start' and
723 | 'group doccmd[type-check]: end' are also skipped.
724 |
725 | Error messages for grouped code blocks may include lines which do not
726 | match the document, so code formatters will not work on them.
727 | """
728 | ),
729 | multiple=True,
730 | callback=_deduplicate,
731 | )
732 | @click.option(
733 | "--pad-file/--no-pad-file",
734 | is_flag=True,
735 | default=True,
736 | show_default=True,
737 | help=(
738 | "Run the command against a temporary file padded with newlines. "
739 | "This is useful for matching line numbers from the output to "
740 | "the relevant location in the document. "
741 | "Use --no-pad-file for formatters - "
742 | "they generally need to look at the file without padding."
743 | ),
744 | )
745 | @click.option(
746 | "--pad-groups/--no-pad-groups",
747 | is_flag=True,
748 | default=True,
749 | show_default=True,
750 | help=(
751 | "Maintain line spacing between groups from the source file in the "
752 | "temporary file. "
753 | "This is useful for matching line numbers from the output to "
754 | "the relevant location in the document. "
755 | "Use --no-pad-groups for formatters - "
756 | "they generally need to look at the file without padding."
757 | ),
758 | )
759 | @click.argument(
760 | "document_paths",
761 | type=click.Path(exists=True, path_type=Path, dir_okay=True),
762 | nargs=-1,
763 | callback=_deduplicate,
764 | )
765 | @click.version_option(version=__version__)
766 | @click.option(
767 | "--verbose",
768 | "-v",
769 | is_flag=True,
770 | default=False,
771 | help="Enable verbose output.",
772 | )
773 | @click.option(
774 | "--use-pty",
775 | "use_pty_option",
776 | is_flag=True,
777 | type=_UsePty,
778 | flag_value=_UsePty.YES,
779 | default=False,
780 | show_default="--detect-use-pty",
781 | help=(
782 | "Use a pseudo-terminal for running commands. "
783 | "This can be useful e.g. to get color output, but can also break "
784 | "in some environments. "
785 | "Not supported on Windows."
786 | ),
787 | )
788 | @click.option(
789 | "--no-use-pty",
790 | "use_pty_option",
791 | is_flag=True,
792 | type=_UsePty,
793 | flag_value=_UsePty.NO,
794 | default=False,
795 | show_default="--detect-use-pty",
796 | help=(
797 | "Do not use a pseudo-terminal for running commands. "
798 | "This is useful when ``doccmd`` detects that it is running in a "
799 | "TTY outside of Windows but the environment does not support PTYs."
800 | ),
801 | )
802 | @click.option(
803 | "--detect-use-pty",
804 | "use_pty_option",
805 | is_flag=True,
806 | type=_UsePty,
807 | flag_value=_UsePty.DETECT,
808 | default=True,
809 | show_default="True",
810 | help=(
811 | "Automatically determine whether to use a pseudo-terminal for running "
812 | "commands."
813 | ),
814 | )
815 | @click.option(
816 | "--rst-extension",
817 | "rst_suffixes",
818 | type=str,
819 | help=(
820 | "Treat files with this extension (suffix) as reStructuredText. "
821 | "Give this multiple times to look for multiple extensions. "
822 | "To avoid considering any files, "
823 | "including the default, "
824 | "as reStructuredText files, use `--rst-extension=.`."
825 | ),
826 | multiple=True,
827 | default=(".rst",),
828 | show_default=True,
829 | callback=_validate_file_extensions,
830 | )
831 | @click.option(
832 | "--myst-extension",
833 | "myst_suffixes",
834 | type=str,
835 | help=(
836 | "Treat files with this extension (suffix) as MyST. "
837 | "Give this multiple times to look for multiple extensions. "
838 | "To avoid considering any files, "
839 | "including the default, "
840 | "as MyST files, use `--myst-extension=.`."
841 | ),
842 | multiple=True,
843 | default=(".md",),
844 | show_default=True,
845 | callback=_validate_file_extensions,
846 | )
847 | @click.option(
848 | "--markdown-extension",
849 | "markdown_suffixes",
850 | type=str,
851 | help=(
852 | "Files with this extension (suffix) to treat as Markdown. "
853 | "Give this multiple times to look for multiple extensions. "
854 | "By default, `.md` is treated as MyST, not Markdown."
855 | ),
856 | multiple=True,
857 | show_default=True,
858 | callback=_validate_file_extensions,
859 | )
860 | @click.option(
861 | "--max-depth",
862 | type=click.IntRange(min=1),
863 | default=sys.maxsize,
864 | show_default=False,
865 | help="Maximum depth to search for files in directories.",
866 | )
867 | @click.option(
868 | "--exclude",
869 | "exclude_patterns",
870 | type=str,
871 | multiple=True,
872 | help=(
873 | "A glob-style pattern that matches file paths to ignore while "
874 | "recursively discovering files in directories. "
875 | "This option can be used multiple times. "
876 | "Use forward slashes on all platforms."
877 | ),
878 | )
879 | @click.option(
880 | "--fail-on-parse-error/--no-fail-on-parse-error",
881 | "fail_on_parse_error",
882 | default=False,
883 | show_default=True,
884 | type=bool,
885 | help=(
886 | "Whether to fail (with exit code 1) if a given file cannot be parsed."
887 | ),
888 | )
889 | @click.option(
890 | "--fail-on-group-write/--no-fail-on-group-write",
891 | "fail_on_group_write",
892 | default=True,
893 | show_default=True,
894 | type=bool,
895 | help=(
896 | "Whether to fail (with exit code 1) if a command (e.g. a formatter) "
897 | "tries to change code within a grouped code block. "
898 | "``doccmd`` does not support writing to grouped code blocks."
899 | ),
900 | )
901 | @click.option(
902 | "--sphinx-jinja2/--no-sphinx-jinja2",
903 | "sphinx_jinja2",
904 | default=False,
905 | show_default=True,
906 | help=(
907 | "Whether to parse `sphinx-jinja2` blocks. "
908 | "This is useful for evaluating code blocks with Jinja2 "
909 | "templates used in Sphinx documentation. "
910 | "This is supported for MyST and reStructuredText files only."
911 | ),
912 | )
913 | @beartype
914 | def main(
915 | *,
916 | languages: Sequence[str],
917 | command: str,
918 | document_paths: Sequence[Path],
919 | temporary_file_extension: str | None,
920 | temporary_file_name_prefix: str,
921 | pad_file: bool,
922 | pad_groups: bool,
923 | verbose: bool,
924 | skip_markers: Iterable[str],
925 | group_markers: Iterable[str],
926 | use_pty_option: _UsePty,
927 | rst_suffixes: Sequence[str],
928 | myst_suffixes: Sequence[str],
929 | markdown_suffixes: Sequence[str],
930 | max_depth: int,
931 | exclude_patterns: Sequence[str],
932 | fail_on_parse_error: bool,
933 | fail_on_group_write: bool,
934 | sphinx_jinja2: bool,
935 | ) -> None:
936 | """Run commands against code blocks in the given documentation files.
937 |
938 | This works with Markdown and reStructuredText files.
939 | """
940 | args = shlex.split(s=command)
941 | use_pty = use_pty_option.use_pty()
942 |
943 | suffix_groups: Mapping[MarkupLanguage, Sequence[str]] = {
944 | MyST: myst_suffixes,
945 | ReStructuredText: rst_suffixes,
946 | Markdown: markdown_suffixes,
947 | }
948 |
949 | _validate_file_suffix_overlaps(suffix_groups=suffix_groups)
950 |
951 | suffix_map = {
952 | value: key for key, values in suffix_groups.items() for value in values
953 | }
954 |
955 | _validate_given_files_have_known_suffixes(
956 | given_files=[
957 | document_path
958 | for document_path in document_paths
959 | if document_path.is_file()
960 | ],
961 | known_suffixes=suffix_map.keys(),
962 | )
963 |
964 | file_paths = _get_file_paths(
965 | document_paths=document_paths,
966 | file_suffixes=suffix_map.keys(),
967 | max_depth=max_depth,
968 | exclude_patterns=exclude_patterns,
969 | )
970 |
971 | log_command_evaluators = []
972 | if verbose:
973 | _log_info(
974 | message="Using PTY for running commands."
975 | if use_pty
976 | else "Not using PTY for running commands."
977 | )
978 | log_command_evaluators = [_LogCommandEvaluator(args=args)]
979 |
980 | skip_markers = {*skip_markers, "all"}
981 | skip_directives = _get_skip_directives(markers=skip_markers)
982 |
983 | group_markers = {*group_markers, "all"}
984 | group_directives = _get_group_directives(markers=group_markers)
985 |
986 | given_temporary_file_extension = temporary_file_extension
987 |
988 | for file_path in file_paths:
989 | markup_language = suffix_map[file_path.suffix]
990 | encoding = _get_encoding(document_path=file_path)
991 | if encoding is None:
992 | _log_error(
993 | message=f"Could not determine encoding for {file_path}."
994 | )
995 | if fail_on_parse_error:
996 | sys.exit(1)
997 | continue
998 |
999 | content_bytes = file_path.read_bytes()
1000 | newline_bytes = _detect_newline(content_bytes=content_bytes)
1001 | newline = (
1002 | newline_bytes.decode(encoding=encoding) if newline_bytes else None
1003 | )
1004 | sybils: Sequence[Sybil] = []
1005 | for code_block_language in languages:
1006 | temporary_file_extension = _get_temporary_file_extension(
1007 | language=code_block_language,
1008 | given_file_extension=given_temporary_file_extension,
1009 | )
1010 | sybil = _get_sybil(
1011 | args=args,
1012 | code_block_languages=[code_block_language],
1013 | pad_temporary_file=pad_file,
1014 | pad_groups=pad_groups,
1015 | temporary_file_extension=temporary_file_extension,
1016 | temporary_file_name_prefix=temporary_file_name_prefix,
1017 | skip_directives=skip_directives,
1018 | group_directives=group_directives,
1019 | use_pty=use_pty,
1020 | markup_language=markup_language,
1021 | encoding=encoding,
1022 | log_command_evaluators=log_command_evaluators,
1023 | newline=newline,
1024 | parse_sphinx_jinja2=False,
1025 | )
1026 | sybils = [*sybils, sybil]
1027 |
1028 | if sphinx_jinja2:
1029 | temporary_file_extension = (
1030 | given_temporary_file_extension or ".jinja"
1031 | )
1032 | sybil = _get_sybil(
1033 | args=args,
1034 | code_block_languages=[],
1035 | pad_temporary_file=pad_file,
1036 | pad_groups=pad_groups,
1037 | temporary_file_extension=temporary_file_extension,
1038 | temporary_file_name_prefix=temporary_file_name_prefix,
1039 | skip_directives=skip_directives,
1040 | group_directives=group_directives,
1041 | use_pty=use_pty,
1042 | markup_language=markup_language,
1043 | encoding=encoding,
1044 | log_command_evaluators=log_command_evaluators,
1045 | newline=newline,
1046 | parse_sphinx_jinja2=True,
1047 | )
1048 | sybils = [*sybils, sybil]
1049 |
1050 | for sybil in sybils:
1051 | try:
1052 | document = sybil.parse(path=file_path)
1053 | except (LexingException, ValueError) as exc:
1054 | message = f"Could not parse {file_path}: {exc}"
1055 | _log_error(message=message)
1056 | if fail_on_parse_error:
1057 | sys.exit(1)
1058 | continue
1059 |
1060 | try:
1061 | _evaluate_document(document=document, args=args)
1062 | except _GroupModifiedError as exc:
1063 | if fail_on_group_write:
1064 | _log_error(message=str(object=exc))
1065 | sys.exit(1)
1066 | _log_warning(message=str(object=exc))
1067 | except _EvaluateError as exc:
1068 | if exc.reason:
1069 | message = (
1070 | f"Error running command '{exc.command_args[0]}': "
1071 | f"{exc.reason}"
1072 | )
1073 | _log_error(message=message)
1074 | sys.exit(exc.exit_code)
1075 |
--------------------------------------------------------------------------------
/src/doccmd/__main__.py:
--------------------------------------------------------------------------------
1 | """
2 | Enable running `doccmd` with `python -m doccmd`.
3 | """
4 |
5 | from doccmd import main
6 |
7 | if __name__ == "__main__":
8 | main()
9 |
--------------------------------------------------------------------------------
/src/doccmd/_languages.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for managing markup languages.
3 | """
4 |
5 | from collections.abc import Iterable
6 | from dataclasses import dataclass
7 | from typing import Protocol, runtime_checkable
8 |
9 | import sybil.parsers.markdown
10 | import sybil.parsers.myst
11 | import sybil.parsers.rest
12 | import sybil_extras.parsers.markdown.custom_directive_skip
13 | import sybil_extras.parsers.markdown.grouped_source
14 | import sybil_extras.parsers.myst.custom_directive_skip
15 | import sybil_extras.parsers.myst.grouped_source
16 | import sybil_extras.parsers.myst.sphinx_jinja2
17 | import sybil_extras.parsers.rest.custom_directive_skip
18 | import sybil_extras.parsers.rest.grouped_source
19 | import sybil_extras.parsers.rest.sphinx_jinja2
20 | from beartype import beartype
21 | from sybil import Document, Region
22 | from sybil.typing import Evaluator
23 |
24 |
25 | @runtime_checkable
26 | class _SphinxJinja2Parser(Protocol):
27 | """
28 | A parser for sphinx-jinja2 blocks.
29 | """
30 |
31 | def __init__(self, *, evaluator: Evaluator) -> None:
32 | """
33 | Construct a sphinx-jinja2 parser.
34 | """
35 | # We disable a pylint warning here because the ellipsis is required
36 | # for pyright to recognize this as a protocol.
37 | ... # pylint: disable=unnecessary-ellipsis
38 |
39 | def __call__(self, document: Document) -> Iterable[Region]:
40 | """
41 | Call the sphinx-jinja2 parser.
42 | """
43 | # We disable a pylint warning here because the ellipsis is required
44 | # for pyright to recognize this as a protocol.
45 | ... # pylint: disable=unnecessary-ellipsis
46 |
47 |
48 | @runtime_checkable
49 | class _SkipParser(Protocol):
50 | """
51 | A parser for skipping custom directives.
52 | """
53 |
54 | def __init__(self, directive: str) -> None:
55 | """
56 | Construct a skip parser.
57 | """
58 | # We disable a pylint warning here because the ellipsis is required
59 | # for pyright to recognize this as a protocol.
60 | ... # pylint: disable=unnecessary-ellipsis
61 |
62 | def __call__(self, document: Document) -> Iterable[Region]:
63 | """
64 | Call the skip parser.
65 | """
66 | # We disable a pylint warning here because the ellipsis is required
67 | # for pyright to recognize this as a protocol.
68 | ... # pylint: disable=unnecessary-ellipsis
69 |
70 |
71 | @runtime_checkable
72 | class _GroupedSourceParser(Protocol):
73 | """
74 | A parser for grouping code blocks.
75 | """
76 |
77 | def __init__(
78 | self,
79 | *,
80 | directive: str,
81 | evaluator: Evaluator,
82 | pad_groups: bool,
83 | ) -> None:
84 | """
85 | Construct a grouped code block parser.
86 | """
87 | # We disable a pylint warning here because the ellipsis is required
88 | # for pyright to recognize this as a protocol.
89 | ... # pylint: disable=unnecessary-ellipsis
90 |
91 | def __call__(self, document: Document) -> Iterable[Region]:
92 | """
93 | Call the grouped code block parser.
94 | """
95 | # We disable a pylint warning here because the ellipsis is required
96 | # for pyright to recognize this as a protocol.
97 | ... # pylint: disable=unnecessary-ellipsis
98 |
99 |
100 | @runtime_checkable
101 | class _CodeBlockParser(Protocol):
102 | """
103 | A parser for code blocks.
104 | """
105 |
106 | def __init__(
107 | self,
108 | language: str | None = None,
109 | evaluator: Evaluator | None = None,
110 | ) -> None:
111 | """
112 | Construct a code block parser.
113 | """
114 | # We disable a pylint warning here because the ellipsis is required
115 | # for pyright to recognize this as a protocol.
116 | ... # pylint: disable=unnecessary-ellipsis
117 |
118 | def __call__(self, document: Document) -> Iterable[Region]:
119 | """
120 | Call the code block parser.
121 | """
122 | # We disable a pylint warning here because the ellipsis is required
123 | # for pyright to recognize this as a protocol.
124 | ... # pylint: disable=unnecessary-ellipsis
125 |
126 |
127 | @beartype
128 | @dataclass(frozen=True)
129 | class MarkupLanguage:
130 | """
131 | A markup language.
132 | """
133 |
134 | name: str
135 | skip_parser_cls: type[_SkipParser]
136 | code_block_parser_cls: type[_CodeBlockParser]
137 | group_parser_cls: type[_GroupedSourceParser]
138 | sphinx_jinja_parser_cls: type[_SphinxJinja2Parser] | None
139 |
140 |
141 | MyST = MarkupLanguage(
142 | name="MyST",
143 | skip_parser_cls=(
144 | sybil_extras.parsers.myst.custom_directive_skip.CustomDirectiveSkipParser
145 | ),
146 | code_block_parser_cls=sybil.parsers.myst.CodeBlockParser,
147 | group_parser_cls=sybil_extras.parsers.myst.grouped_source.GroupedSourceParser,
148 | sphinx_jinja_parser_cls=sybil_extras.parsers.myst.sphinx_jinja2.SphinxJinja2Parser,
149 | )
150 |
151 | ReStructuredText = MarkupLanguage(
152 | name="reStructuredText",
153 | skip_parser_cls=sybil_extras.parsers.rest.custom_directive_skip.CustomDirectiveSkipParser,
154 | code_block_parser_cls=sybil.parsers.rest.CodeBlockParser,
155 | group_parser_cls=sybil_extras.parsers.rest.grouped_source.GroupedSourceParser,
156 | sphinx_jinja_parser_cls=sybil_extras.parsers.rest.sphinx_jinja2.SphinxJinja2Parser,
157 | )
158 |
159 | Markdown = MarkupLanguage(
160 | name="Markdown",
161 | skip_parser_cls=sybil_extras.parsers.markdown.custom_directive_skip.CustomDirectiveSkipParser,
162 | code_block_parser_cls=sybil.parsers.markdown.CodeBlockParser,
163 | group_parser_cls=sybil_extras.parsers.markdown.grouped_source.GroupedSourceParser,
164 | sphinx_jinja_parser_cls=None,
165 | )
166 |
--------------------------------------------------------------------------------
/src/doccmd/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adamtheturtle/doccmd/8e759f8f61685638a23da9c0a05a6548be920d6c/src/doccmd/py.typed
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for `doccmd`.
3 | """
4 |
--------------------------------------------------------------------------------
/tests/test_doccmd.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for `doccmd`.
3 | """
4 |
5 | import stat
6 | import subprocess
7 | import sys
8 | import textwrap
9 | import uuid
10 | from collections.abc import Sequence
11 | from pathlib import Path
12 |
13 | import pytest
14 | from ansi.colour import fg
15 | from ansi.colour.base import Graphic
16 | from ansi.colour.fx import reset
17 | from click.testing import CliRunner
18 | from pytest_regressions.file_regression import FileRegressionFixture
19 |
20 | from doccmd import main
21 |
22 |
23 | def test_help(file_regression: FileRegressionFixture) -> None:
24 | """Expected help text is shown.
25 |
26 | This help text is defined in files.
27 | To update these files, run ``pytest`` with the ``--regen-all`` flag.
28 | """
29 | runner = CliRunner()
30 | arguments = ["--help"]
31 | result = runner.invoke(
32 | cli=main,
33 | args=arguments,
34 | catch_exceptions=False,
35 | color=True,
36 | )
37 | assert result.exit_code == 0, (result.stdout, result.stderr)
38 | file_regression.check(contents=result.output)
39 |
40 |
41 | def test_run_command(tmp_path: Path) -> None:
42 | """
43 | It is possible to run a command against a code block in a document.
44 | """
45 | runner = CliRunner()
46 | rst_file = tmp_path / "example.rst"
47 | content = textwrap.dedent(
48 | text="""\
49 | .. code-block:: python
50 |
51 | x = 2 + 2
52 | assert x == 4
53 | """,
54 | )
55 | rst_file.write_text(data=content, encoding="utf-8")
56 | arguments = [
57 | "--language",
58 | "python",
59 | "--command",
60 | "cat",
61 | str(object=rst_file),
62 | ]
63 | result = runner.invoke(
64 | cli=main,
65 | args=arguments,
66 | catch_exceptions=False,
67 | color=True,
68 | )
69 | assert result.exit_code == 0, (result.stdout, result.stderr)
70 | expected_output = textwrap.dedent(
71 | # The file is padded so that any error messages relate to the correct
72 | # line number in the original file.
73 | text="""\
74 |
75 |
76 | x = 2 + 2
77 | assert x == 4
78 | """,
79 | )
80 |
81 | assert result.stdout == expected_output
82 | assert result.stderr == ""
83 |
84 |
85 | def test_double_language(tmp_path: Path) -> None:
86 | """
87 | Giving the same language twice does not run the command twice.
88 | """
89 | runner = CliRunner()
90 | rst_file = tmp_path / "example.rst"
91 | content = textwrap.dedent(
92 | text="""\
93 | .. code-block:: python
94 |
95 | x = 2 + 2
96 | assert x == 4
97 | """,
98 | )
99 | rst_file.write_text(data=content, encoding="utf-8")
100 | arguments = [
101 | "--language",
102 | "python",
103 | "--language",
104 | "python",
105 | "--command",
106 | "cat",
107 | str(object=rst_file),
108 | ]
109 | result = runner.invoke(
110 | cli=main,
111 | args=arguments,
112 | catch_exceptions=False,
113 | color=True,
114 | )
115 | assert result.exit_code == 0, (result.stdout, result.stderr)
116 | expected_output = textwrap.dedent(
117 | # The file is padded so that any error messages relate to the correct
118 | # line number in the original file.
119 | text="""\
120 |
121 |
122 | x = 2 + 2
123 | assert x == 4
124 | """,
125 | )
126 |
127 | assert result.stdout == expected_output
128 | assert result.stderr == ""
129 |
130 |
131 | def test_file_does_not_exist() -> None:
132 | """
133 | An error is shown when a file does not exist.
134 | """
135 | runner = CliRunner()
136 | arguments = [
137 | "--language",
138 | "python",
139 | "--command",
140 | "cat",
141 | "non_existent_file.rst",
142 | ]
143 | result = runner.invoke(
144 | cli=main,
145 | args=arguments,
146 | catch_exceptions=False,
147 | color=True,
148 | )
149 | assert result.exit_code != 0
150 | assert "Path 'non_existent_file.rst' does not exist" in result.stderr
151 |
152 |
153 | def test_not_utf_8_file_given(tmp_path: Path) -> None:
154 | """
155 | No error is given if a file is passed in which is not UTF-8.
156 | """
157 | runner = CliRunner()
158 | rst_file = tmp_path / "example.rst"
159 | content = textwrap.dedent(
160 | text="""\
161 | .. code-block:: python
162 |
163 | print("\xc0\x80")
164 | """,
165 | )
166 | rst_file.write_text(data=content, encoding="latin1")
167 | arguments = [
168 | "--language",
169 | "python",
170 | "--command",
171 | "cat",
172 | str(object=rst_file),
173 | ]
174 | result = runner.invoke(
175 | cli=main,
176 | args=arguments,
177 | catch_exceptions=False,
178 | color=True,
179 | )
180 | assert result.exit_code == 0, (result.stdout, result.stderr)
181 | expected_output_bytes = b'print("\xc0\x80")'
182 | expected_stderr = ""
183 | assert result.stdout_bytes.strip() == expected_output_bytes
184 | assert result.stderr == expected_stderr
185 |
186 |
187 | @pytest.mark.parametrize(
188 | argnames=("fail_on_parse_error_options", "expected_exit_code"),
189 | argvalues=[
190 | ([], 0),
191 | (["--fail-on-parse-error"], 1),
192 | ],
193 | )
194 | def test_unknown_encoding(
195 | tmp_path: Path,
196 | fail_on_parse_error_options: Sequence[str],
197 | expected_exit_code: int,
198 | ) -> None:
199 | """
200 | An error is shown when a file cannot be decoded.
201 | """
202 | runner = CliRunner()
203 | rst_file = tmp_path / "example.rst"
204 | rst_file.write_bytes(data=Path(sys.executable).read_bytes())
205 | arguments = [
206 | *fail_on_parse_error_options,
207 | "--language",
208 | "python",
209 | "--command",
210 | "cat",
211 | str(object=rst_file),
212 | ]
213 | result = runner.invoke(
214 | cli=main,
215 | args=arguments,
216 | catch_exceptions=False,
217 | color=True,
218 | )
219 | expected_stderr = (
220 | f"{fg.red}Could not determine encoding for {rst_file}.{reset}\n"
221 | )
222 | assert result.exit_code == expected_exit_code
223 | assert result.stdout == ""
224 | assert result.stderr == expected_stderr
225 |
226 |
227 | def test_multiple_code_blocks(tmp_path: Path) -> None:
228 | """
229 | It is possible to run a command against multiple code blocks in a document.
230 | """
231 | runner = CliRunner()
232 | rst_file = tmp_path / "example.rst"
233 | content = textwrap.dedent(
234 | text="""\
235 | .. code-block:: python
236 |
237 | x = 2 + 2
238 | assert x == 4
239 |
240 | .. code-block:: python
241 |
242 | y = 3 + 3
243 | assert y == 6
244 | """,
245 | )
246 | rst_file.write_text(data=content, encoding="utf-8")
247 | arguments = [
248 | "--language",
249 | "python",
250 | "--command",
251 | "cat",
252 | str(object=rst_file),
253 | ]
254 | result = runner.invoke(
255 | cli=main,
256 | args=arguments,
257 | catch_exceptions=False,
258 | color=True,
259 | )
260 | assert result.exit_code == 0, (result.stdout, result.stderr)
261 | expected_output = textwrap.dedent(
262 | text="""\
263 |
264 |
265 | x = 2 + 2
266 | assert x == 4
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 | y = 3 + 3
275 | assert y == 6
276 | """,
277 | )
278 |
279 | assert result.stdout == expected_output
280 | assert result.stderr == ""
281 |
282 |
283 | def test_language_filters(tmp_path: Path) -> None:
284 | """
285 | Languages not specified are not run.
286 | """
287 | runner = CliRunner()
288 | rst_file = tmp_path / "example.rst"
289 | content = textwrap.dedent(
290 | text="""\
291 | .. code-block:: python
292 |
293 | x = 2 + 2
294 | assert x == 4
295 |
296 | .. code-block:: javascript
297 |
298 | var y = 3 + 3;
299 | console.assert(y === 6);
300 | """,
301 | )
302 | rst_file.write_text(data=content, encoding="utf-8")
303 | arguments = [
304 | "--language",
305 | "python",
306 | "--command",
307 | "cat",
308 | str(object=rst_file),
309 | ]
310 | result = runner.invoke(
311 | cli=main,
312 | args=arguments,
313 | catch_exceptions=False,
314 | color=True,
315 | )
316 | assert result.exit_code == 0, (result.stdout, result.stderr)
317 | expected_output = textwrap.dedent(
318 | text="""\
319 |
320 |
321 | x = 2 + 2
322 | assert x == 4
323 | """,
324 | )
325 |
326 | assert result.stdout == expected_output
327 | assert result.stderr == ""
328 |
329 |
330 | def test_run_command_no_pad_file(tmp_path: Path) -> None:
331 | """
332 | It is possible to not pad the file.
333 | """
334 | runner = CliRunner()
335 | rst_file = tmp_path / "example.rst"
336 | content = textwrap.dedent(
337 | text="""\
338 | .. code-block:: python
339 |
340 | x = 2 + 2
341 | assert x == 4
342 | """,
343 | )
344 | rst_file.write_text(data=content, encoding="utf-8")
345 | arguments = [
346 | "--language",
347 | "python",
348 | "--command",
349 | "cat",
350 | "--no-pad-file",
351 | str(object=rst_file),
352 | ]
353 | result = runner.invoke(
354 | cli=main,
355 | args=arguments,
356 | catch_exceptions=False,
357 | color=True,
358 | )
359 | assert result.exit_code == 0, (result.stdout, result.stderr)
360 | expected_output = textwrap.dedent(
361 | text="""\
362 | x = 2 + 2
363 | assert x == 4
364 | """,
365 | )
366 |
367 | assert result.stdout == expected_output
368 | assert result.stderr == ""
369 |
370 |
371 | def test_multiple_files(tmp_path: Path) -> None:
372 | """
373 | It is possible to run a command against multiple files.
374 | """
375 | runner = CliRunner()
376 | rst_file1 = tmp_path / "example1.rst"
377 | rst_file2 = tmp_path / "example2.rst"
378 | content1 = """\
379 | .. code-block:: python
380 |
381 | x = 2 + 2
382 | assert x == 4
383 | """
384 | content2 = """\
385 | .. code-block:: python
386 |
387 | y = 3 + 3
388 | assert y == 6
389 | """
390 | rst_file1.write_text(data=content1, encoding="utf-8")
391 | rst_file2.write_text(data=content2, encoding="utf-8")
392 | arguments = [
393 | "--language",
394 | "python",
395 | "--command",
396 | "cat",
397 | str(object=rst_file1),
398 | str(object=rst_file2),
399 | ]
400 | result = runner.invoke(
401 | cli=main,
402 | args=arguments,
403 | catch_exceptions=False,
404 | color=True,
405 | )
406 | assert result.exit_code == 0, (result.stdout, result.stderr)
407 | expected_output = textwrap.dedent(
408 | text="""\
409 |
410 |
411 | x = 2 + 2
412 | assert x == 4
413 |
414 |
415 | y = 3 + 3
416 | assert y == 6
417 | """,
418 | )
419 |
420 | assert result.stdout == expected_output
421 | assert result.stderr == ""
422 |
423 |
424 | def test_multiple_files_multiple_types(tmp_path: Path) -> None:
425 | """
426 | It is possible to run a command against multiple files of multiple types
427 | (Markdown and rST).
428 | """
429 | runner = CliRunner()
430 | rst_file = tmp_path / "example.rst"
431 | md_file = tmp_path / "example.md"
432 | rst_content = textwrap.dedent(
433 | text="""\
434 | .. code-block:: python
435 |
436 | print("In reStructuredText code-block")
437 |
438 | .. code:: python
439 |
440 | print("In reStructuredText code")
441 | """,
442 | )
443 | md_content = textwrap.dedent(
444 | text="""\
445 | ```python
446 | print("In simple markdown code block")
447 | ```
448 |
449 | ```{code-block} python
450 | print("In MyST code-block")
451 | ```
452 |
453 | ```{code} python
454 | print("In MyST code")
455 | ```
456 | """,
457 | )
458 | rst_file.write_text(data=rst_content, encoding="utf-8")
459 | md_file.write_text(data=md_content, encoding="utf-8")
460 | arguments = [
461 | "--language",
462 | "python",
463 | "--command",
464 | "cat",
465 | "--no-pad-file",
466 | str(object=rst_file),
467 | str(object=md_file),
468 | ]
469 | result = runner.invoke(
470 | cli=main,
471 | args=arguments,
472 | catch_exceptions=False,
473 | color=True,
474 | )
475 | assert result.exit_code == 0, (result.stdout, result.stderr)
476 | expected_output = textwrap.dedent(
477 | text="""\
478 | print("In reStructuredText code-block")
479 | print("In reStructuredText code")
480 | print("In simple markdown code block")
481 | print("In MyST code-block")
482 | print("In MyST code")
483 | """,
484 | )
485 |
486 | assert result.stdout == expected_output
487 | assert result.stderr == ""
488 |
489 |
490 | def test_modify_file(tmp_path: Path) -> None:
491 | """
492 | Commands (outside of groups) can modify files.
493 | """
494 | runner = CliRunner()
495 | rst_file = tmp_path / "example.rst"
496 | content = textwrap.dedent(
497 | text="""\
498 | .. code-block:: python
499 |
500 | a = 1
501 | b = 1
502 | c = 1
503 | """,
504 | )
505 | rst_file.write_text(data=content, encoding="utf-8")
506 | modify_code_script = textwrap.dedent(
507 | text="""\
508 | #!/usr/bin/env python
509 |
510 | import sys
511 |
512 | with open(sys.argv[1], "w") as file:
513 | file.write("foobar")
514 | """,
515 | )
516 | modify_code_file = tmp_path / "modify_code.py"
517 | modify_code_file.write_text(data=modify_code_script, encoding="utf-8")
518 | arguments = [
519 | "--language",
520 | "python",
521 | "--command",
522 | f"python {modify_code_file.as_posix()}",
523 | str(object=rst_file),
524 | ]
525 | result = runner.invoke(
526 | cli=main,
527 | args=arguments,
528 | catch_exceptions=False,
529 | color=True,
530 | )
531 | assert result.exit_code == 0, (result.stdout, result.stderr)
532 | modified_content = rst_file.read_text(encoding="utf-8")
533 | expected_modified_content = textwrap.dedent(
534 | text="""\
535 | .. code-block:: python
536 |
537 | foobar
538 | """,
539 | )
540 | assert modified_content == expected_modified_content
541 |
542 |
543 | def test_exit_code(tmp_path: Path) -> None:
544 | """
545 | The exit code of the first failure is propagated.
546 | """
547 | runner = CliRunner()
548 | rst_file = tmp_path / "example.rst"
549 | exit_code = 25
550 | content = textwrap.dedent(
551 | text=f"""\
552 | .. code-block:: python
553 |
554 | import sys
555 | sys.exit({exit_code})
556 | """,
557 | )
558 | rst_file.write_text(data=content, encoding="utf-8")
559 | arguments = [
560 | "--language",
561 | "python",
562 | "--command",
563 | Path(sys.executable).as_posix(),
564 | str(object=rst_file),
565 | ]
566 | result = runner.invoke(
567 | cli=main,
568 | args=arguments,
569 | catch_exceptions=False,
570 | color=True,
571 | )
572 | assert result.exit_code == exit_code, (result.stdout, result.stderr)
573 | assert result.stdout == ""
574 | assert result.stderr == ""
575 |
576 |
577 | @pytest.mark.parametrize(
578 | argnames=("language", "expected_extension"),
579 | argvalues=[
580 | ("python", ".py"),
581 | ("javascript", ".js"),
582 | ],
583 | )
584 | def test_file_extension(
585 | tmp_path: Path,
586 | language: str,
587 | expected_extension: str,
588 | ) -> None:
589 | """
590 | The file extension of the temporary file is appropriate for the language.
591 | """
592 | runner = CliRunner()
593 | rst_file = tmp_path / "example.rst"
594 | content = textwrap.dedent(
595 | text=f"""\
596 | .. code-block:: {language}
597 |
598 | x = 2 + 2
599 | assert x == 4
600 | """,
601 | )
602 | rst_file.write_text(data=content, encoding="utf-8")
603 | arguments = [
604 | "--language",
605 | language,
606 | "--command",
607 | "echo",
608 | str(object=rst_file),
609 | ]
610 | result = runner.invoke(
611 | cli=main,
612 | args=arguments,
613 | catch_exceptions=False,
614 | color=True,
615 | )
616 | assert result.exit_code == 0, (result.stdout, result.stderr)
617 | output = result.stdout
618 | output_path = Path(output.strip())
619 | assert output_path.suffix == expected_extension
620 |
621 |
622 | def test_given_temporary_file_extension(tmp_path: Path) -> None:
623 | """
624 | It is possible to specify the file extension for created temporary files.
625 | """
626 | runner = CliRunner()
627 | rst_file = tmp_path / "example.rst"
628 | content = textwrap.dedent(
629 | text="""\
630 | .. code-block:: python
631 |
632 | x = 2 + 2
633 | assert x == 4
634 | """,
635 | )
636 | rst_file.write_text(data=content, encoding="utf-8")
637 | arguments = [
638 | "--language",
639 | "python",
640 | "--temporary-file-extension",
641 | ".foobar",
642 | "--command",
643 | "echo",
644 | str(object=rst_file),
645 | ]
646 | result = runner.invoke(
647 | cli=main,
648 | args=arguments,
649 | catch_exceptions=False,
650 | color=True,
651 | )
652 | assert result.exit_code == 0, (result.stdout, result.stderr)
653 | output = result.stdout
654 | output_path = Path(output.strip())
655 | assert output_path.suffixes == [".foobar"]
656 |
657 |
658 | def test_given_temporary_file_extension_no_leading_period(
659 | tmp_path: Path,
660 | ) -> None:
661 | """
662 | An error is shown when a given temporary file extension is given with no
663 | leading period.
664 | """
665 | runner = CliRunner()
666 | rst_file = tmp_path / "example.rst"
667 | content = textwrap.dedent(
668 | text="""\
669 | .. code-block:: python
670 |
671 | x = 2 + 2
672 | assert x == 4
673 | """,
674 | )
675 | rst_file.write_text(data=content, encoding="utf-8")
676 | arguments = [
677 | "--language",
678 | "python",
679 | "--temporary-file-extension",
680 | "foobar",
681 | "--command",
682 | "echo",
683 | str(object=rst_file),
684 | ]
685 | result = runner.invoke(
686 | cli=main,
687 | args=arguments,
688 | catch_exceptions=False,
689 | color=True,
690 | )
691 | assert result.exit_code != 0, (result.stdout, result.stderr)
692 | assert result.stdout == ""
693 | expected_stderr = textwrap.dedent(
694 | text="""\
695 | Usage: doccmd [OPTIONS] [DOCUMENT_PATHS]...
696 | Try 'doccmd --help' for help.
697 |
698 | Error: Invalid value for '--temporary-file-extension': 'foobar' does not start with a '.'.
699 | """, # noqa: E501
700 | )
701 | assert result.stderr == expected_stderr
702 |
703 |
704 | def test_given_prefix(tmp_path: Path) -> None:
705 | """
706 | It is possible to specify a prefix for the temporary file.
707 | """
708 | runner = CliRunner()
709 | rst_file = tmp_path / "example.rst"
710 | content = textwrap.dedent(
711 | text="""\
712 | .. code-block:: python
713 |
714 | x = 2 + 2
715 | assert x == 4
716 | """,
717 | )
718 | rst_file.write_text(data=content, encoding="utf-8")
719 | arguments = [
720 | "--language",
721 | "python",
722 | "--temporary-file-name-prefix",
723 | "myprefix",
724 | "--command",
725 | "echo",
726 | str(object=rst_file),
727 | ]
728 | result = runner.invoke(
729 | cli=main,
730 | args=arguments,
731 | catch_exceptions=False,
732 | color=True,
733 | )
734 | assert result.exit_code == 0, (result.stdout, result.stderr)
735 | output = result.stdout
736 | output_path = Path(output.strip())
737 | assert output_path.name.startswith("myprefix_")
738 |
739 |
740 | def test_file_extension_unknown_language(tmp_path: Path) -> None:
741 | """
742 | The file extension of the temporary file is `.txt` for any unknown
743 | language.
744 | """
745 | runner = CliRunner()
746 | rst_file = tmp_path / "example.rst"
747 | content = textwrap.dedent(
748 | text="""\
749 | .. code-block:: unknown
750 |
751 | x = 2 + 2
752 | assert x == 4
753 | """,
754 | )
755 | rst_file.write_text(data=content, encoding="utf-8")
756 | arguments = [
757 | "--language",
758 | "unknown",
759 | "--command",
760 | "echo",
761 | str(object=rst_file),
762 | ]
763 | result = runner.invoke(
764 | cli=main,
765 | args=arguments,
766 | catch_exceptions=False,
767 | color=True,
768 | )
769 | assert result.exit_code == 0, (result.stdout, result.stderr)
770 | output = result.stdout
771 | output_path = Path(output.strip())
772 | assert output_path.suffix == ".txt"
773 |
774 |
775 | def test_file_given_multiple_times(tmp_path: Path) -> None:
776 | """
777 | Files given multiple times are de-duplicated.
778 | """
779 | runner = CliRunner()
780 | rst_file = tmp_path / "example.rst"
781 | other_rst_file = tmp_path / "other_example.rst"
782 | content = textwrap.dedent(
783 | text="""\
784 | .. code-block:: python
785 |
786 | block
787 | """,
788 | )
789 | other_content = textwrap.dedent(
790 | text="""\
791 | .. code-block:: python
792 |
793 | other_block
794 | """,
795 | )
796 | rst_file.write_text(data=content, encoding="utf-8")
797 | other_rst_file.write_text(data=other_content, encoding="utf-8")
798 | arguments = [
799 | "--language",
800 | "python",
801 | "--command",
802 | "cat",
803 | str(object=rst_file),
804 | str(object=other_rst_file),
805 | str(object=rst_file),
806 | ]
807 | result = runner.invoke(
808 | cli=main,
809 | args=arguments,
810 | catch_exceptions=False,
811 | color=True,
812 | )
813 | assert result.exit_code == 0, (result.stdout, result.stderr)
814 | expected_output = textwrap.dedent(
815 | text="""\
816 |
817 |
818 | block
819 |
820 |
821 | other_block
822 | """,
823 | )
824 |
825 | assert result.stdout == expected_output
826 | assert result.stderr == ""
827 |
828 |
829 | def test_verbose_running(tmp_path: Path) -> None:
830 | """
831 | ``--verbose`` shows what is running.
832 | """
833 | runner = CliRunner()
834 | rst_file = tmp_path / "example.rst"
835 | content = textwrap.dedent(
836 | text="""\
837 | .. code-block:: python
838 |
839 | x = 2 + 2
840 | assert x == 4
841 |
842 | .. skip doccmd[all]: next
843 |
844 | .. code-block:: python
845 |
846 | x = 3 + 3
847 | assert x == 6
848 |
849 | .. code-block:: shell
850 |
851 | echo 1
852 | """,
853 | )
854 | rst_file.write_text(data=content, encoding="utf-8")
855 | arguments = [
856 | "--language",
857 | "python",
858 | "--command",
859 | "cat",
860 | "--verbose",
861 | str(object=rst_file),
862 | ]
863 | result = runner.invoke(
864 | cli=main,
865 | args=arguments,
866 | catch_exceptions=False,
867 | color=True,
868 | )
869 | assert result.exit_code == 0, (result.stdout, result.stderr)
870 | expected_output = textwrap.dedent(
871 | text="""\
872 |
873 |
874 | x = 2 + 2
875 | assert x == 4
876 | """,
877 | )
878 | expected_stderr = textwrap.dedent(
879 | text=f"""\
880 | {fg.green}Not using PTY for running commands.{reset}
881 | {fg.green}Running 'cat' on code block at {rst_file} line 1{reset}
882 | """,
883 | )
884 | assert result.stdout == expected_output
885 | assert result.stderr == expected_stderr
886 |
887 |
888 | def test_verbose_running_with_stderr(tmp_path: Path) -> None:
889 | """
890 | ``--verbose`` shows what is running before any stderr output.
891 | """
892 | runner = CliRunner()
893 | rst_file = tmp_path / "example.rst"
894 | # We include a group as well to ensure that the verbose output is shown
895 | # in the right place for groups.
896 | content = textwrap.dedent(
897 | text="""\
898 | .. code-block:: python
899 |
900 | x = 2 + 2
901 | assert x == 4
902 |
903 | .. skip doccmd[all]: next
904 |
905 | .. code-block:: python
906 |
907 | x = 3 + 3
908 | assert x == 6
909 |
910 | .. code-block:: shell
911 |
912 | echo 1
913 |
914 | .. group doccmd[all]: start
915 |
916 | .. code-block:: python
917 |
918 | block_group_1
919 |
920 | .. group doccmd[all]: end
921 | """,
922 | )
923 | command = (
924 | f"{Path(sys.executable).as_posix()} -c "
925 | "'import sys; sys.stderr.write(\"error\\n\")'"
926 | )
927 | rst_file.write_text(data=content, encoding="utf-8")
928 | arguments = [
929 | "--language",
930 | "python",
931 | "--command",
932 | command,
933 | "--verbose",
934 | str(object=rst_file),
935 | ]
936 | result = runner.invoke(
937 | cli=main,
938 | args=arguments,
939 | catch_exceptions=False,
940 | color=True,
941 | )
942 | assert result.exit_code == 0, (result.stdout, result.stderr)
943 | expected_output = ""
944 | expected_stderr = textwrap.dedent(
945 | text=f"""\
946 | {fg.green}Not using PTY for running commands.{reset}
947 | {fg.green}Running '{command}' on code block at {rst_file} line 1{reset}
948 | error
949 | {fg.green}Running '{command}' on code block at {rst_file} line 19{reset}
950 | error
951 | """, # noqa: E501
952 | )
953 | assert result.stdout == expected_output
954 | assert result.stderr == expected_stderr
955 |
956 |
957 | def test_main_entry_point() -> None:
958 | """
959 | It is possible to run the main entry point.
960 | """
961 | result = subprocess.run(
962 | args=[sys.executable, "-m", "doccmd"],
963 | capture_output=True,
964 | text=True,
965 | check=False,
966 | )
967 | assert "Usage:" in result.stderr
968 |
969 |
970 | def test_command_not_found(tmp_path: Path) -> None:
971 | """
972 | An error is shown when the command is not found.
973 | """
974 | runner = CliRunner()
975 | rst_file = tmp_path / "example.rst"
976 | non_existent_command = uuid.uuid4().hex
977 | non_existent_command_with_args = f"{non_existent_command} --help"
978 | content = textwrap.dedent(
979 | text="""\
980 | .. code-block:: python
981 |
982 | x = 2 + 2
983 | assert x == 4
984 | """,
985 | )
986 | rst_file.write_text(data=content, encoding="utf-8")
987 | arguments = [
988 | "--language",
989 | "python",
990 | "--command",
991 | non_existent_command_with_args,
992 | str(object=rst_file),
993 | ]
994 | result = runner.invoke(
995 | cli=main,
996 | args=arguments,
997 | catch_exceptions=False,
998 | color=True,
999 | )
1000 | assert result.exit_code != 0
1001 | red_style_start = "\x1b[31m"
1002 | expected_stderr = (
1003 | f"{red_style_start}Error running command '{non_existent_command}':"
1004 | )
1005 | assert result.stderr.startswith(expected_stderr)
1006 |
1007 |
1008 | def test_not_executable(tmp_path: Path) -> None:
1009 | """
1010 | An error is shown when the command is a non-executable file.
1011 | """
1012 | runner = CliRunner()
1013 | rst_file = tmp_path / "example.rst"
1014 | not_executable_command = tmp_path / "non_executable"
1015 | not_executable_command.touch()
1016 | not_executable_command_with_args = (
1017 | f"{not_executable_command.as_posix()} --help"
1018 | )
1019 | content = textwrap.dedent(
1020 | text="""\
1021 | .. code-block:: python
1022 |
1023 | x = 2 + 2
1024 | assert x == 4
1025 | """,
1026 | )
1027 | rst_file.write_text(data=content, encoding="utf-8")
1028 | arguments = [
1029 | "--language",
1030 | "python",
1031 | "--command",
1032 | not_executable_command_with_args,
1033 | str(object=rst_file),
1034 | ]
1035 | result = runner.invoke(
1036 | cli=main,
1037 | args=arguments,
1038 | catch_exceptions=False,
1039 | color=True,
1040 | )
1041 | assert result.exit_code != 0
1042 | expected_stderr = (
1043 | f"{fg.red}Error running command '{not_executable_command.as_posix()}':"
1044 | )
1045 | assert result.stderr.startswith(expected_stderr)
1046 |
1047 |
1048 | def test_multiple_languages(tmp_path: Path) -> None:
1049 | """
1050 | It is possible to run a command against multiple code blocks in a document
1051 | with different languages.
1052 | """
1053 | runner = CliRunner()
1054 | rst_file = tmp_path / "example.rst"
1055 | content = textwrap.dedent(
1056 | text="""\
1057 | .. code-block:: python
1058 |
1059 | x = 2 + 2
1060 | assert x == 4
1061 |
1062 | .. code-block:: javascript
1063 |
1064 | var y = 3 + 3;
1065 | console.assert(y === 6);
1066 | """,
1067 | )
1068 | rst_file.write_text(data=content, encoding="utf-8")
1069 | arguments = [
1070 | "--no-pad-file",
1071 | "--language",
1072 | "python",
1073 | "--language",
1074 | "javascript",
1075 | "--command",
1076 | "cat",
1077 | str(object=rst_file),
1078 | ]
1079 | result = runner.invoke(
1080 | cli=main,
1081 | args=arguments,
1082 | catch_exceptions=False,
1083 | color=True,
1084 | )
1085 | assert result.exit_code == 0, (result.stdout, result.stderr)
1086 | expected_output = textwrap.dedent(
1087 | text="""\
1088 | x = 2 + 2
1089 | assert x == 4
1090 | var y = 3 + 3;
1091 | console.assert(y === 6);
1092 | """,
1093 | )
1094 |
1095 | assert result.stdout == expected_output
1096 | assert result.stderr == ""
1097 |
1098 |
1099 | def test_default_skip_rst(tmp_path: Path) -> None:
1100 | """
1101 | By default, the next code block after a 'skip doccmd: next' comment in a
1102 | rST document is not run.
1103 | """
1104 | runner = CliRunner()
1105 | rst_file = tmp_path / "example.rst"
1106 | content = textwrap.dedent(
1107 | text="""\
1108 | .. code-block:: python
1109 |
1110 | block_1
1111 |
1112 | .. skip doccmd[all]: next
1113 |
1114 | .. code-block:: python
1115 |
1116 | block_2
1117 |
1118 | .. code-block:: python
1119 |
1120 | block_3
1121 | """,
1122 | )
1123 | rst_file.write_text(data=content, encoding="utf-8")
1124 | arguments = [
1125 | "--no-pad-file",
1126 | "--language",
1127 | "python",
1128 | "--command",
1129 | "cat",
1130 | str(object=rst_file),
1131 | ]
1132 | result = runner.invoke(
1133 | cli=main,
1134 | args=arguments,
1135 | catch_exceptions=False,
1136 | color=True,
1137 | )
1138 | assert result.exit_code == 0, (result.stdout, result.stderr)
1139 | expected_output = textwrap.dedent(
1140 | text="""\
1141 | block_1
1142 | block_3
1143 | """,
1144 | )
1145 |
1146 | assert result.stdout == expected_output
1147 | assert result.stderr == ""
1148 |
1149 |
1150 | @pytest.mark.parametrize(
1151 | argnames=("fail_on_parse_error_options", "expected_exit_code"),
1152 | argvalues=[
1153 | ([], 0),
1154 | (["--fail-on-parse-error"], 1),
1155 | ],
1156 | )
1157 | def test_skip_no_arguments(
1158 | tmp_path: Path,
1159 | fail_on_parse_error_options: Sequence[str],
1160 | expected_exit_code: int,
1161 | ) -> None:
1162 | """
1163 | An error is shown if a skip is given with no arguments.
1164 | """
1165 | runner = CliRunner()
1166 | rst_file = tmp_path / "example.rst"
1167 | content = textwrap.dedent(
1168 | text="""\
1169 | .. skip doccmd[all]:
1170 |
1171 | .. code-block:: python
1172 |
1173 | block_2
1174 | """,
1175 | )
1176 | rst_file.write_text(data=content, encoding="utf-8")
1177 | arguments = [
1178 | *fail_on_parse_error_options,
1179 | "--no-pad-file",
1180 | "--language",
1181 | "python",
1182 | "--command",
1183 | "cat",
1184 | str(object=rst_file),
1185 | ]
1186 | result = runner.invoke(
1187 | cli=main,
1188 | args=arguments,
1189 | catch_exceptions=False,
1190 | color=True,
1191 | )
1192 | assert result.exit_code == expected_exit_code, (
1193 | result.stdout,
1194 | result.stderr,
1195 | )
1196 | expected_stderr = textwrap.dedent(
1197 | text=f"""\
1198 | {fg.red}Could not parse {rst_file}: missing arguments to skip doccmd[all]{reset}
1199 | """, # noqa: E501
1200 | )
1201 |
1202 | assert result.stdout == ""
1203 | assert result.stderr == expected_stderr
1204 |
1205 |
1206 | @pytest.mark.parametrize(
1207 | argnames=("fail_on_parse_error_options", "expected_exit_code"),
1208 | argvalues=[
1209 | ([], 0),
1210 | (["--fail-on-parse-error"], 1),
1211 | ],
1212 | )
1213 | def test_skip_bad_arguments(
1214 | tmp_path: Path,
1215 | fail_on_parse_error_options: Sequence[str],
1216 | expected_exit_code: int,
1217 | ) -> None:
1218 | """
1219 | An error is shown if a skip is given with bad arguments.
1220 | """
1221 | runner = CliRunner()
1222 | rst_file = tmp_path / "example.rst"
1223 | content = textwrap.dedent(
1224 | text="""\
1225 | .. skip doccmd[all]: !!!
1226 |
1227 | .. code-block:: python
1228 |
1229 | block_2
1230 | """,
1231 | )
1232 | rst_file.write_text(data=content, encoding="utf-8")
1233 | arguments = [
1234 | *fail_on_parse_error_options,
1235 | "--no-pad-file",
1236 | "--language",
1237 | "python",
1238 | "--command",
1239 | "cat",
1240 | str(object=rst_file),
1241 | ]
1242 | result = runner.invoke(
1243 | cli=main,
1244 | args=arguments,
1245 | catch_exceptions=False,
1246 | color=True,
1247 | )
1248 | assert result.exit_code == expected_exit_code, (
1249 | result.stdout,
1250 | result.stderr,
1251 | )
1252 | expected_stderr = textwrap.dedent(
1253 | text=f"""\
1254 | {fg.red}Could not parse {rst_file}: malformed arguments to skip doccmd[all]: '!!!'{reset}
1255 | """, # noqa: E501
1256 | )
1257 |
1258 | assert result.stdout == ""
1259 | assert result.stderr == expected_stderr
1260 |
1261 |
1262 | def test_custom_skip_markers_rst(tmp_path: Path) -> None:
1263 | """
1264 | The next code block after a custom skip marker comment in a rST document is
1265 | not run.
1266 | """
1267 | runner = CliRunner()
1268 | rst_file = tmp_path / "example.rst"
1269 | skip_marker = uuid.uuid4().hex
1270 | content = textwrap.dedent(
1271 | text=f"""\
1272 | .. code-block:: python
1273 |
1274 | block_1
1275 |
1276 | .. skip doccmd[{skip_marker}]: next
1277 |
1278 | .. code-block:: python
1279 |
1280 | block_2
1281 |
1282 | .. code-block:: python
1283 |
1284 | block_3
1285 | """,
1286 | )
1287 | rst_file.write_text(data=content, encoding="utf-8")
1288 | arguments = [
1289 | "--no-pad-file",
1290 | "--language",
1291 | "python",
1292 | "--skip-marker",
1293 | skip_marker,
1294 | "--command",
1295 | "cat",
1296 | str(object=rst_file),
1297 | ]
1298 | result = runner.invoke(
1299 | cli=main,
1300 | args=arguments,
1301 | catch_exceptions=False,
1302 | color=True,
1303 | )
1304 | assert result.exit_code == 0, (result.stdout, result.stderr)
1305 | expected_output = textwrap.dedent(
1306 | text="""\
1307 | block_1
1308 | block_3
1309 | """,
1310 | )
1311 |
1312 | assert result.stdout == expected_output
1313 | assert result.stderr == ""
1314 |
1315 |
1316 | def test_default_skip_myst(tmp_path: Path) -> None:
1317 | """
1318 | By default, the next code block after a 'skip doccmd: next' comment in a
1319 | MyST document is not run.
1320 | """
1321 | runner = CliRunner()
1322 | myst_file = tmp_path / "example.md"
1323 | content = textwrap.dedent(
1324 | text="""\
1325 | Example
1326 |
1327 | ```python
1328 | block_1
1329 | ```
1330 |
1331 |
1332 |
1333 | ```python
1334 | block_2
1335 | ```
1336 |
1337 | ```python
1338 | block_3
1339 | ```
1340 |
1341 | % skip doccmd[all]: next
1342 |
1343 | ```python
1344 | block_4
1345 | ```
1346 | """,
1347 | )
1348 | myst_file.write_text(data=content, encoding="utf-8")
1349 | arguments = [
1350 | "--no-pad-file",
1351 | "--language",
1352 | "python",
1353 | "--command",
1354 | "cat",
1355 | str(object=myst_file),
1356 | ]
1357 | result = runner.invoke(
1358 | cli=main,
1359 | args=arguments,
1360 | catch_exceptions=False,
1361 | color=True,
1362 | )
1363 | assert result.exit_code == 0, (result.stdout, result.stderr)
1364 | expected_output = textwrap.dedent(
1365 | text="""\
1366 | block_1
1367 | block_3
1368 | """,
1369 | )
1370 |
1371 | assert result.stdout == expected_output
1372 | assert result.stderr == ""
1373 |
1374 |
1375 | def test_custom_skip_markers_myst(tmp_path: Path) -> None:
1376 | """
1377 | The next code block after a custom skip marker comment in a MyST document
1378 | is not run.
1379 | """
1380 | runner = CliRunner()
1381 | myst_file = tmp_path / "example.md"
1382 | skip_marker = uuid.uuid4().hex
1383 | content = textwrap.dedent(
1384 | text=f"""\
1385 | Example
1386 |
1387 | ```python
1388 | block_1
1389 | ```
1390 |
1391 |
1392 |
1393 | ```python
1394 | block_2
1395 | ```
1396 |
1397 | ```python
1398 | block_3
1399 | ```
1400 |
1401 | % skip doccmd[{skip_marker}]: next
1402 |
1403 | ```python
1404 | block_4
1405 | ```
1406 | """,
1407 | )
1408 | myst_file.write_text(data=content, encoding="utf-8")
1409 | arguments = [
1410 | "--no-pad-file",
1411 | "--language",
1412 | "python",
1413 | "--skip-marker",
1414 | skip_marker,
1415 | "--command",
1416 | "cat",
1417 | str(object=myst_file),
1418 | ]
1419 | result = runner.invoke(
1420 | cli=main,
1421 | args=arguments,
1422 | catch_exceptions=False,
1423 | color=True,
1424 | )
1425 | assert result.exit_code == 0, (result.stdout, result.stderr)
1426 | expected_output = textwrap.dedent(
1427 | text="""\
1428 | block_1
1429 | block_3
1430 | """,
1431 | )
1432 |
1433 | assert result.stdout == expected_output
1434 | assert result.stderr == ""
1435 |
1436 |
1437 | def test_multiple_skip_markers(tmp_path: Path) -> None:
1438 | """
1439 | All given skip markers, including the default one, are respected.
1440 | """
1441 | runner = CliRunner()
1442 | rst_file = tmp_path / "example.rst"
1443 | skip_marker_1 = uuid.uuid4().hex
1444 | skip_marker_2 = uuid.uuid4().hex
1445 | content = textwrap.dedent(
1446 | text=f"""\
1447 | .. code-block:: python
1448 |
1449 | block_1
1450 |
1451 | .. skip doccmd[{skip_marker_1}]: next
1452 |
1453 | .. code-block:: python
1454 |
1455 | block_2
1456 |
1457 | .. skip doccmd[{skip_marker_2}]: next
1458 |
1459 | .. code-block:: python
1460 |
1461 | block_3
1462 |
1463 | .. skip doccmd[all]: next
1464 |
1465 | .. code-block:: python
1466 |
1467 | block_4
1468 | """,
1469 | )
1470 | rst_file.write_text(data=content, encoding="utf-8")
1471 | arguments = [
1472 | "--no-pad-file",
1473 | "--language",
1474 | "python",
1475 | "--skip-marker",
1476 | skip_marker_1,
1477 | "--skip-marker",
1478 | skip_marker_2,
1479 | "--command",
1480 | "cat",
1481 | str(object=rst_file),
1482 | ]
1483 | result = runner.invoke(
1484 | cli=main,
1485 | args=arguments,
1486 | catch_exceptions=False,
1487 | color=True,
1488 | )
1489 | assert result.exit_code == 0, (result.stdout, result.stderr)
1490 | expected_output = textwrap.dedent(
1491 | text="""\
1492 | block_1
1493 | """,
1494 | )
1495 |
1496 | assert result.stdout == expected_output
1497 | assert result.stderr == ""
1498 |
1499 |
1500 | def test_skip_start_end(tmp_path: Path) -> None:
1501 | """
1502 | Skip start and end markers are respected.
1503 | """
1504 | runner = CliRunner()
1505 | rst_file = tmp_path / "example.rst"
1506 | skip_marker_1 = uuid.uuid4().hex
1507 | skip_marker_2 = uuid.uuid4().hex
1508 | content = textwrap.dedent(
1509 | text="""\
1510 | .. code-block:: python
1511 |
1512 | block_1
1513 |
1514 | .. skip doccmd[all]: start
1515 |
1516 | .. code-block:: python
1517 |
1518 | block_2
1519 |
1520 | .. code-block:: python
1521 |
1522 | block_3
1523 |
1524 | .. skip doccmd[all]: end
1525 |
1526 | .. code-block:: python
1527 |
1528 | block_4
1529 | """,
1530 | )
1531 | rst_file.write_text(data=content, encoding="utf-8")
1532 | arguments = [
1533 | "--no-pad-file",
1534 | "--language",
1535 | "python",
1536 | "--skip-marker",
1537 | skip_marker_1,
1538 | "--skip-marker",
1539 | skip_marker_2,
1540 | "--command",
1541 | "cat",
1542 | str(object=rst_file),
1543 | ]
1544 | result = runner.invoke(
1545 | cli=main,
1546 | args=arguments,
1547 | catch_exceptions=False,
1548 | color=True,
1549 | )
1550 | assert result.exit_code == 0, (result.stdout, result.stderr)
1551 | expected_output = textwrap.dedent(
1552 | text="""\
1553 | block_1
1554 | block_4
1555 | """,
1556 | )
1557 |
1558 | assert result.stdout == expected_output
1559 | assert result.stderr == ""
1560 |
1561 |
1562 | def test_duplicate_skip_marker(tmp_path: Path) -> None:
1563 | """
1564 | Duplicate skip markers are respected.
1565 | """
1566 | runner = CliRunner()
1567 | rst_file = tmp_path / "example.rst"
1568 | skip_marker = uuid.uuid4().hex
1569 | content = textwrap.dedent(
1570 | text=f"""\
1571 | .. code-block:: python
1572 |
1573 | block_1
1574 |
1575 | .. skip doccmd[{skip_marker}]: next
1576 |
1577 | .. code-block:: python
1578 |
1579 | block_2
1580 |
1581 | .. skip doccmd[{skip_marker}]: next
1582 |
1583 | .. code-block:: python
1584 |
1585 | block_3
1586 | """,
1587 | )
1588 | rst_file.write_text(data=content, encoding="utf-8")
1589 | arguments = [
1590 | "--no-pad-file",
1591 | "--language",
1592 | "python",
1593 | "--skip-marker",
1594 | skip_marker,
1595 | "--skip-marker",
1596 | skip_marker,
1597 | "--command",
1598 | "cat",
1599 | str(object=rst_file),
1600 | ]
1601 | result = runner.invoke(
1602 | cli=main,
1603 | args=arguments,
1604 | catch_exceptions=False,
1605 | color=True,
1606 | )
1607 | assert result.exit_code == 0, (result.stdout, result.stderr)
1608 | expected_output = textwrap.dedent(
1609 | text="""\
1610 | block_1
1611 | """,
1612 | )
1613 |
1614 | assert result.stdout == expected_output
1615 | assert result.stderr == ""
1616 |
1617 |
1618 | def test_default_skip_marker_given(tmp_path: Path) -> None:
1619 | """
1620 | No error is shown when the default skip marker is given.
1621 | """
1622 | runner = CliRunner()
1623 | rst_file = tmp_path / "example.rst"
1624 | skip_marker = "all"
1625 | content = textwrap.dedent(
1626 | text=f"""\
1627 | .. code-block:: python
1628 |
1629 | block_1
1630 |
1631 | .. skip doccmd[{skip_marker}]: next
1632 |
1633 | .. code-block:: python
1634 |
1635 | block_2
1636 |
1637 | .. skip doccmd[{skip_marker}]: next
1638 |
1639 | .. code-block:: python
1640 |
1641 | block_3
1642 | """,
1643 | )
1644 | rst_file.write_text(data=content, encoding="utf-8")
1645 | arguments = [
1646 | "--no-pad-file",
1647 | "--language",
1648 | "python",
1649 | "--skip-marker",
1650 | skip_marker,
1651 | "--command",
1652 | "cat",
1653 | str(object=rst_file),
1654 | ]
1655 | result = runner.invoke(
1656 | cli=main,
1657 | args=arguments,
1658 | catch_exceptions=False,
1659 | color=True,
1660 | )
1661 | assert result.exit_code == 0, (result.stdout, result.stderr)
1662 | expected_output = textwrap.dedent(
1663 | text="""\
1664 | block_1
1665 | """,
1666 | )
1667 |
1668 | assert result.stdout == expected_output
1669 | assert result.stderr == ""
1670 |
1671 |
1672 | def test_skip_multiple(tmp_path: Path) -> None:
1673 | """
1674 | It is possible to mark a code block as to be skipped by multiple markers.
1675 | """
1676 | runner = CliRunner()
1677 | rst_file = tmp_path / "example.rst"
1678 | skip_marker_1 = uuid.uuid4().hex
1679 | skip_marker_2 = uuid.uuid4().hex
1680 | content = textwrap.dedent(
1681 | text=f"""\
1682 | .. code-block:: python
1683 |
1684 | block_1
1685 |
1686 | .. skip doccmd[{skip_marker_1}]: next
1687 | .. skip doccmd[{skip_marker_2}]: next
1688 |
1689 | .. code-block:: python
1690 |
1691 | block_2
1692 | """,
1693 | )
1694 | rst_file.write_text(data=content, encoding="utf-8")
1695 | arguments = [
1696 | "--no-pad-file",
1697 | "--language",
1698 | "python",
1699 | "--skip-marker",
1700 | skip_marker_1,
1701 | "--command",
1702 | "cat",
1703 | str(object=rst_file),
1704 | ]
1705 | result = runner.invoke(
1706 | cli=main,
1707 | args=arguments,
1708 | catch_exceptions=False,
1709 | color=True,
1710 | )
1711 | assert result.exit_code == 0, (result.stdout, result.stderr)
1712 | expected_output = textwrap.dedent(
1713 | text="""\
1714 | block_1
1715 | """,
1716 | )
1717 |
1718 | assert result.stdout == expected_output
1719 | assert result.stderr == ""
1720 |
1721 | arguments = [
1722 | "--no-pad-file",
1723 | "--language",
1724 | "python",
1725 | "--skip-marker",
1726 | skip_marker_2,
1727 | "--command",
1728 | "cat",
1729 | str(object=rst_file),
1730 | ]
1731 | result = runner.invoke(
1732 | cli=main,
1733 | args=arguments,
1734 | catch_exceptions=False,
1735 | color=True,
1736 | )
1737 | assert result.exit_code == 0, (result.stdout, result.stderr)
1738 | expected_output = textwrap.dedent(
1739 | text="""\
1740 | block_1
1741 | """,
1742 | )
1743 |
1744 | assert result.stdout == expected_output
1745 | assert result.stderr == ""
1746 |
1747 |
1748 | def test_bad_skips(tmp_path: Path) -> None:
1749 | """
1750 | Bad skip orders are flagged.
1751 | """
1752 | runner = CliRunner()
1753 | rst_file = tmp_path / "example.rst"
1754 | skip_marker_1 = uuid.uuid4().hex
1755 | content = textwrap.dedent(
1756 | text=f"""\
1757 | .. skip doccmd[{skip_marker_1}]: end
1758 |
1759 | .. code-block:: python
1760 |
1761 | block_2
1762 | """,
1763 | )
1764 | rst_file.write_text(data=content, encoding="utf-8")
1765 | arguments = [
1766 | "--no-pad-file",
1767 | "--language",
1768 | "python",
1769 | "--skip-marker",
1770 | skip_marker_1,
1771 | "--command",
1772 | "cat",
1773 | str(object=rst_file),
1774 | ]
1775 | result = runner.invoke(
1776 | cli=main,
1777 | args=arguments,
1778 | catch_exceptions=False,
1779 | color=True,
1780 | )
1781 | assert result.exit_code != 0, (result.stdout, result.stderr)
1782 | expected_stderr = textwrap.dedent(
1783 | text=f"""\
1784 | {fg.red}Error running command 'cat': 'skip doccmd[{skip_marker_1}]: end' must follow 'skip doccmd[{skip_marker_1}]: start'{reset}
1785 | """, # noqa: E501
1786 | )
1787 |
1788 | assert result.stdout == ""
1789 | assert result.stderr == expected_stderr
1790 |
1791 |
1792 | def test_empty_file(tmp_path: Path) -> None:
1793 | """
1794 | No error is shown when an empty file is given.
1795 | """
1796 | runner = CliRunner()
1797 | rst_file = tmp_path / "example.rst"
1798 | rst_file.touch()
1799 | arguments = [
1800 | "--no-pad-file",
1801 | "--language",
1802 | "python",
1803 | "--command",
1804 | "cat",
1805 | str(object=rst_file),
1806 | ]
1807 | result = runner.invoke(
1808 | cli=main,
1809 | args=arguments,
1810 | catch_exceptions=False,
1811 | color=True,
1812 | )
1813 | assert result.exit_code == 0, (result.stdout, result.stderr)
1814 | assert result.stdout == ""
1815 | assert result.stderr == ""
1816 |
1817 |
1818 | @pytest.mark.parametrize(
1819 | argnames=("source_newline", "expect_crlf", "expect_cr", "expect_lf"),
1820 | argvalues=[
1821 | ("\n", False, False, True),
1822 | ("\r\n", True, True, True),
1823 | ("\r", False, True, False),
1824 | ],
1825 | )
1826 | def test_detect_line_endings(
1827 | *,
1828 | tmp_path: Path,
1829 | source_newline: str,
1830 | expect_crlf: bool,
1831 | expect_cr: bool,
1832 | expect_lf: bool,
1833 | ) -> None:
1834 | """
1835 | The line endings of the original file are used in the new file.
1836 | """
1837 | runner = CliRunner()
1838 | rst_file = tmp_path / "example.rst"
1839 | content = textwrap.dedent(
1840 | text="""\
1841 | .. code-block:: python
1842 |
1843 | block_1
1844 | """,
1845 | )
1846 | rst_file.write_text(data=content, encoding="utf-8", newline=source_newline)
1847 | arguments = [
1848 | "--no-pad-file",
1849 | "--language",
1850 | "python",
1851 | "--command",
1852 | "cat",
1853 | str(object=rst_file),
1854 | ]
1855 | result = runner.invoke(
1856 | cli=main,
1857 | args=arguments,
1858 | catch_exceptions=False,
1859 | color=True,
1860 | )
1861 | assert result.exit_code == 0, (result.stdout, result.stderr)
1862 | assert result.stderr == ""
1863 | assert bool(b"\r\n" in result.stdout_bytes) == expect_crlf
1864 | assert bool(b"\r" in result.stdout_bytes) == expect_cr
1865 | assert bool(b"\n" in result.stdout_bytes) == expect_lf
1866 |
1867 |
1868 | def test_one_supported_markup_in_another_extension(tmp_path: Path) -> None:
1869 | """
1870 | Code blocks in a supported markup language in a file with an extension
1871 | which matches another extension are not run.
1872 | """
1873 | runner = CliRunner()
1874 | rst_file = tmp_path / "example.rst"
1875 | content = textwrap.dedent(
1876 | text="""\
1877 | ```python
1878 | print("In simple markdown code block")
1879 | ```
1880 |
1881 | ```{code-block} python
1882 | print("In MyST code-block")
1883 | ```
1884 | """,
1885 | )
1886 | rst_file.write_text(data=content, encoding="utf-8")
1887 | arguments = [
1888 | "--language",
1889 | "python",
1890 | "--command",
1891 | "cat",
1892 | str(object=rst_file),
1893 | ]
1894 | result = runner.invoke(
1895 | cli=main,
1896 | args=arguments,
1897 | catch_exceptions=False,
1898 | color=True,
1899 | )
1900 | assert result.exit_code == 0, (result.stdout, result.stderr)
1901 | # Empty because the Markdown-style code block is not run in.
1902 | expected_output = ""
1903 | assert result.stdout == expected_output
1904 | assert result.stderr == ""
1905 |
1906 |
1907 | @pytest.mark.parametrize(argnames="extension", argvalues=[".unknown", ""])
1908 | def test_unknown_file_suffix(extension: str, tmp_path: Path) -> None:
1909 | """
1910 | An error is shown when the file suffix is not known.
1911 | """
1912 | runner = CliRunner()
1913 | document_file = tmp_path / ("example" + extension)
1914 | content = textwrap.dedent(
1915 | text="""\
1916 | .. code-block:: python
1917 |
1918 | x = 2 + 2
1919 | assert x == 4
1920 | """,
1921 | )
1922 | document_file.write_text(data=content, encoding="utf-8")
1923 | arguments = [
1924 | "--language",
1925 | "python",
1926 | "--command",
1927 | "cat",
1928 | str(object=document_file),
1929 | ]
1930 | result = runner.invoke(
1931 | cli=main,
1932 | args=arguments,
1933 | catch_exceptions=False,
1934 | color=True,
1935 | )
1936 | assert result.exit_code != 0, (result.stdout, result.stderr)
1937 | expected_stderr = textwrap.dedent(
1938 | text=f"""\
1939 | Usage: doccmd [OPTIONS] [DOCUMENT_PATHS]...
1940 | Try 'doccmd --help' for help.
1941 |
1942 | Error: Markup language not known for {document_file}.
1943 | """,
1944 | )
1945 |
1946 | assert result.stdout == ""
1947 | assert result.stderr == expected_stderr
1948 |
1949 |
1950 | def test_custom_rst_file_suffixes(tmp_path: Path) -> None:
1951 | """
1952 | ReStructuredText files with custom suffixes are recognized.
1953 | """
1954 | runner = CliRunner()
1955 | rst_file = tmp_path / "example.customrst"
1956 | content = textwrap.dedent(
1957 | text="""\
1958 | .. code-block:: python
1959 |
1960 | x = 1
1961 | """,
1962 | )
1963 | rst_file.write_text(data=content, encoding="utf-8")
1964 | rst_file_2 = tmp_path / "example.customrst2"
1965 | content_2 = """\
1966 | .. code-block:: python
1967 |
1968 | x = 2
1969 | """
1970 | rst_file_2.write_text(data=content_2, encoding="utf-8")
1971 | arguments = [
1972 | "--no-pad-file",
1973 | "--language",
1974 | "python",
1975 | "--command",
1976 | "cat",
1977 | "--rst-extension",
1978 | ".customrst",
1979 | "--rst-extension",
1980 | ".customrst2",
1981 | str(object=rst_file),
1982 | str(object=rst_file_2),
1983 | ]
1984 | result = runner.invoke(
1985 | cli=main,
1986 | args=arguments,
1987 | catch_exceptions=False,
1988 | color=True,
1989 | )
1990 | expected_output = textwrap.dedent(
1991 | text="""\
1992 | x = 1
1993 | x = 2
1994 | """,
1995 | )
1996 | assert result.exit_code == 0, (result.stdout, result.stderr)
1997 | assert result.stdout == expected_output
1998 | assert result.stderr == ""
1999 |
2000 |
2001 | def test_custom_myst_file_suffixes(tmp_path: Path) -> None:
2002 | """
2003 | MyST files with custom suffixes are recognized.
2004 | """
2005 | runner = CliRunner()
2006 | myst_file = tmp_path / "example.custommyst"
2007 | content = textwrap.dedent(
2008 | text="""\
2009 | ```python
2010 | x = 1
2011 | ```
2012 | """,
2013 | )
2014 | myst_file.write_text(data=content, encoding="utf-8")
2015 | myst_file_2 = tmp_path / "example.custommyst2"
2016 | content_2 = """\
2017 | ```python
2018 | x = 2
2019 | ```
2020 | """
2021 | myst_file_2.write_text(data=content_2, encoding="utf-8")
2022 | arguments = [
2023 | "--no-pad-file",
2024 | "--language",
2025 | "python",
2026 | "--command",
2027 | "cat",
2028 | "--myst-extension",
2029 | ".custommyst",
2030 | "--myst-extension",
2031 | ".custommyst2",
2032 | str(object=myst_file),
2033 | str(object=myst_file_2),
2034 | ]
2035 | result = runner.invoke(
2036 | cli=main,
2037 | args=arguments,
2038 | catch_exceptions=False,
2039 | color=True,
2040 | )
2041 | expected_output = textwrap.dedent(
2042 | text="""\
2043 | x = 1
2044 | x = 2
2045 | """,
2046 | )
2047 | assert result.exit_code == 0, (result.stdout, result.stderr)
2048 | assert result.stdout == expected_output
2049 | assert result.stderr == ""
2050 |
2051 |
2052 | @pytest.mark.parametrize(
2053 | argnames=("options", "expected_output"),
2054 | argvalues=[
2055 | # We cannot test the actual behavior of using a pseudo-terminal,
2056 | # as CI (e.g. GitHub Actions) does not support it.
2057 | # Therefore we do not test the `--use-pty` option.
2058 | (["--no-use-pty"], "stdout is not a terminal."),
2059 | # We are not really testing the detection mechanism.
2060 | (["--detect-use-pty"], "stdout is not a terminal."),
2061 | ],
2062 | ids=["no-use-pty", "detect-use-pty"],
2063 | )
2064 | def test_pty(
2065 | tmp_path: Path,
2066 | options: Sequence[str],
2067 | expected_output: str,
2068 | ) -> None:
2069 | """
2070 | Test options for using pseudo-terminal.
2071 | """
2072 | runner = CliRunner()
2073 | rst_file = tmp_path / "example.rst"
2074 | tty_test = textwrap.dedent(
2075 | text="""\
2076 | import sys
2077 |
2078 | if sys.stdout.isatty():
2079 | print("stdout is a terminal.")
2080 | else:
2081 | print("stdout is not a terminal.")
2082 | """,
2083 | )
2084 | script = tmp_path / "my_script.py"
2085 | script.write_text(data=tty_test)
2086 | script.chmod(mode=stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
2087 | content = textwrap.dedent(
2088 | text="""\
2089 | .. code-block:: python
2090 |
2091 | block_1
2092 | """,
2093 | )
2094 | rst_file.write_text(data=content, encoding="utf-8")
2095 | arguments = [
2096 | *options,
2097 | "--no-pad-file",
2098 | "--language",
2099 | "python",
2100 | "--command",
2101 | f"{Path(sys.executable).as_posix()} {script.as_posix()}",
2102 | str(object=rst_file),
2103 | ]
2104 | result = runner.invoke(
2105 | cli=main,
2106 | args=arguments,
2107 | catch_exceptions=False,
2108 | color=True,
2109 | )
2110 | assert result.exit_code == 0, (result.stdout, result.stderr)
2111 | assert result.stderr == ""
2112 | assert result.stdout.strip() == expected_output
2113 |
2114 |
2115 | @pytest.mark.parametrize(
2116 | argnames="option",
2117 | argvalues=["--rst-extension", "--myst-extension"],
2118 | )
2119 | def test_source_given_extension_no_leading_period(
2120 | tmp_path: Path,
2121 | option: str,
2122 | ) -> None:
2123 | """
2124 | An error is shown when a given source file extension is given with no
2125 | leading period.
2126 | """
2127 | runner = CliRunner()
2128 | source_file = tmp_path / "example.rst"
2129 | content = "Hello world"
2130 | source_file.write_text(data=content, encoding="utf-8")
2131 | arguments = [
2132 | "--language",
2133 | "python",
2134 | "--command",
2135 | "cat",
2136 | option,
2137 | "customrst",
2138 | str(object=source_file),
2139 | ]
2140 | result = runner.invoke(
2141 | cli=main,
2142 | args=arguments,
2143 | catch_exceptions=False,
2144 | color=True,
2145 | )
2146 | assert result.exit_code != 0, (result.stdout, result.stderr)
2147 | expected_stderr = textwrap.dedent(
2148 | text=f"""\
2149 | Usage: doccmd [OPTIONS] [DOCUMENT_PATHS]...
2150 | Try 'doccmd --help' for help.
2151 |
2152 | Error: Invalid value for '{option}': 'customrst' does not start with a '.'.
2153 | """, # noqa: E501
2154 | )
2155 | assert result.stdout == ""
2156 | assert result.stderr == expected_stderr
2157 |
2158 |
2159 | def test_overlapping_extensions(tmp_path: Path) -> None:
2160 | """
2161 | An error is shown if there are overlapping extensions between --rst-
2162 | extension and --myst-extension.
2163 | """
2164 | runner = CliRunner()
2165 | source_file = tmp_path / "example.custom"
2166 | content = textwrap.dedent(
2167 | text="""\
2168 | .. code-block:: python
2169 |
2170 | x = 1
2171 | """,
2172 | )
2173 | source_file.write_text(data=content, encoding="utf-8")
2174 | arguments = [
2175 | "--language",
2176 | "python",
2177 | "--command",
2178 | "cat",
2179 | "--rst-extension",
2180 | ".custom",
2181 | "--myst-extension",
2182 | ".custom",
2183 | "--rst-extension",
2184 | ".custom2",
2185 | "--myst-extension",
2186 | ".custom2",
2187 | str(object=source_file),
2188 | ]
2189 | result = runner.invoke(
2190 | cli=main,
2191 | args=arguments,
2192 | catch_exceptions=False,
2193 | color=True,
2194 | )
2195 | assert result.exit_code != 0, (result.stdout, result.stderr)
2196 | expected_stderr = textwrap.dedent(
2197 | text="""\
2198 | Usage: doccmd [OPTIONS] [DOCUMENT_PATHS]...
2199 | Try 'doccmd --help' for help.
2200 |
2201 | Error: Overlapping suffixes between MyST and reStructuredText: .custom, .custom2.
2202 | """, # noqa: E501
2203 | )
2204 | assert result.stdout == ""
2205 | assert result.stderr == expected_stderr
2206 |
2207 |
2208 | def test_overlapping_extensions_dot(tmp_path: Path) -> None:
2209 | """
2210 | No error is shown if multiple extension types are '.'.
2211 | """
2212 | runner = CliRunner()
2213 | source_file = tmp_path / "example.custom"
2214 | content = textwrap.dedent(
2215 | text="""\
2216 | .. code-block:: python
2217 |
2218 | x = 1
2219 | """,
2220 | )
2221 | source_file.write_text(data=content, encoding="utf-8")
2222 | arguments = [
2223 | "--language",
2224 | "python",
2225 | "--no-pad-file",
2226 | "--command",
2227 | "cat",
2228 | "--rst-extension",
2229 | ".",
2230 | "--myst-extension",
2231 | ".",
2232 | "--rst-extension",
2233 | ".custom",
2234 | str(object=source_file),
2235 | ]
2236 | result = runner.invoke(
2237 | cli=main,
2238 | args=arguments,
2239 | catch_exceptions=False,
2240 | color=True,
2241 | )
2242 | assert result.exit_code == 0, (result.stdout, result.stderr)
2243 | expected_output = textwrap.dedent(
2244 | text="""\
2245 | x = 1
2246 | """,
2247 | )
2248 | assert result.stdout == expected_output
2249 | assert result.stderr == ""
2250 |
2251 |
2252 | def test_markdown(tmp_path: Path) -> None:
2253 | """
2254 | It is possible to run a command against a Markdown file.
2255 | """
2256 | runner = CliRunner()
2257 | source_file = tmp_path / "example.md"
2258 | content = textwrap.dedent(
2259 | text="""\
2260 | % skip doccmd[all]: next
2261 |
2262 | ```python
2263 | x = 1
2264 | ```
2265 |
2266 |
2267 |
2268 | ```python
2269 | x = 2
2270 | ```
2271 |
2272 | ```python
2273 | x = 3
2274 | ```
2275 | """,
2276 | )
2277 | source_file.write_text(data=content, encoding="utf-8")
2278 | arguments = [
2279 | "--language",
2280 | "python",
2281 | "--no-pad-file",
2282 | "--command",
2283 | "cat",
2284 | "--rst-extension",
2285 | ".",
2286 | "--myst-extension",
2287 | ".",
2288 | "--markdown-extension",
2289 | ".md",
2290 | str(object=source_file),
2291 | ]
2292 | result = runner.invoke(
2293 | cli=main,
2294 | args=arguments,
2295 | catch_exceptions=False,
2296 | color=True,
2297 | )
2298 | assert result.exit_code == 0, (result.stdout, result.stderr)
2299 | expected_output = textwrap.dedent(
2300 | text="""\
2301 | x = 1
2302 | x = 3
2303 | """,
2304 | )
2305 | # The first skip directive is not run as "%" is not a valid comment in
2306 | # Markdown.
2307 | #
2308 | # The second skip directive is run as `` is a valid comment in Markdown.
2310 | #
2311 | # The code block after the second skip directive is run as it is
2312 | # a valid Markdown code block.
2313 | assert result.stdout == expected_output
2314 | assert result.stderr == ""
2315 |
2316 |
2317 | def test_directory(tmp_path: Path) -> None:
2318 | """
2319 | All source files in a given directory are worked on.
2320 | """
2321 | runner = CliRunner()
2322 | rst_file = tmp_path / "example.rst"
2323 | rst_content = textwrap.dedent(
2324 | text="""\
2325 | .. code-block:: python
2326 |
2327 | rst_1_block
2328 | """,
2329 | )
2330 | rst_file.write_text(data=rst_content, encoding="utf-8")
2331 | md_file = tmp_path / "example.md"
2332 | md_content = textwrap.dedent(
2333 | text="""\
2334 | ```python
2335 | md_1_block
2336 | ```
2337 | """,
2338 | )
2339 | md_file.write_text(data=md_content, encoding="utf-8")
2340 | sub_directory = tmp_path / "subdir"
2341 | sub_directory.mkdir()
2342 | rst_file_in_sub_directory = sub_directory / "subdir_example.rst"
2343 | subdir_rst_content = textwrap.dedent(
2344 | text="""\
2345 | .. code-block:: python
2346 |
2347 | rst_subdir_1_block
2348 | """,
2349 | )
2350 | rst_file_in_sub_directory.write_text(
2351 | data=subdir_rst_content,
2352 | encoding="utf-8",
2353 | )
2354 |
2355 | sub_directory_with_known_file_extension = sub_directory / "subdir.rst"
2356 | sub_directory_with_known_file_extension.mkdir()
2357 |
2358 | arguments = [
2359 | "--language",
2360 | "python",
2361 | "--no-pad-file",
2362 | "--command",
2363 | "cat",
2364 | str(object=tmp_path),
2365 | ]
2366 | result = runner.invoke(
2367 | cli=main,
2368 | args=arguments,
2369 | catch_exceptions=False,
2370 | color=True,
2371 | )
2372 | assert result.exit_code == 0, result.stderr
2373 | expected_output = textwrap.dedent(
2374 | text="""\
2375 | md_1_block
2376 | rst_1_block
2377 | rst_subdir_1_block
2378 | """,
2379 | )
2380 |
2381 | assert result.stdout == expected_output
2382 | assert result.stderr == ""
2383 |
2384 |
2385 | def test_de_duplication_source_files_and_dirs(tmp_path: Path) -> None:
2386 | """
2387 | If a file is given which is within a directory that is also given, the file
2388 | is de-duplicated.
2389 | """
2390 | runner = CliRunner()
2391 | rst_file = tmp_path / "example.rst"
2392 | rst_content = textwrap.dedent(
2393 | text="""\
2394 | .. code-block:: python
2395 |
2396 | rst_1_block
2397 | """,
2398 | )
2399 | rst_file.write_text(data=rst_content, encoding="utf-8")
2400 | sub_directory = tmp_path / "subdir"
2401 | sub_directory.mkdir()
2402 | rst_file_in_sub_directory = sub_directory / "subdir_example.rst"
2403 | subdir_rst_content = textwrap.dedent(
2404 | text="""\
2405 | .. code-block:: python
2406 |
2407 | rst_subdir_1_block
2408 | """,
2409 | )
2410 | rst_file_in_sub_directory.write_text(
2411 | data=subdir_rst_content,
2412 | encoding="utf-8",
2413 | )
2414 |
2415 | arguments = [
2416 | "--language",
2417 | "python",
2418 | "--no-pad-file",
2419 | "--command",
2420 | "cat",
2421 | str(object=tmp_path),
2422 | str(object=sub_directory),
2423 | str(object=rst_file_in_sub_directory),
2424 | ]
2425 | result = runner.invoke(
2426 | cli=main,
2427 | args=arguments,
2428 | catch_exceptions=False,
2429 | color=True,
2430 | )
2431 | assert result.exit_code == 0, result.stderr
2432 | expected_output = textwrap.dedent(
2433 | text="""\
2434 | rst_1_block
2435 | rst_subdir_1_block
2436 | """,
2437 | )
2438 |
2439 | assert result.stdout == expected_output
2440 | assert result.stderr == ""
2441 |
2442 |
2443 | def test_max_depth(tmp_path: Path) -> None:
2444 | """
2445 | The --max-depth option limits the depth of directories to search for files.
2446 | """
2447 | runner = CliRunner()
2448 | rst_file = tmp_path / "example.rst"
2449 | rst_content = textwrap.dedent(
2450 | text="""\
2451 | .. code-block:: python
2452 |
2453 | rst_1_block
2454 | """,
2455 | )
2456 | rst_file.write_text(data=rst_content, encoding="utf-8")
2457 |
2458 | sub_directory = tmp_path / "subdir"
2459 | sub_directory.mkdir()
2460 | rst_file_in_sub_directory = sub_directory / "subdir_example.rst"
2461 | subdir_rst_content = textwrap.dedent(
2462 | text="""\
2463 | .. code-block:: python
2464 |
2465 | rst_subdir_1_block
2466 | """,
2467 | )
2468 | rst_file_in_sub_directory.write_text(
2469 | data=subdir_rst_content,
2470 | encoding="utf-8",
2471 | )
2472 |
2473 | sub_sub_directory = sub_directory / "subsubdir"
2474 | sub_sub_directory.mkdir()
2475 | rst_file_in_sub_sub_directory = sub_sub_directory / "subsubdir_example.rst"
2476 | subsubdir_rst_content = textwrap.dedent(
2477 | text="""\
2478 | .. code-block:: python
2479 |
2480 | rst_subsubdir_1_block
2481 | """,
2482 | )
2483 | rst_file_in_sub_sub_directory.write_text(
2484 | data=subsubdir_rst_content,
2485 | encoding="utf-8",
2486 | )
2487 |
2488 | arguments = [
2489 | "--language",
2490 | "python",
2491 | "--no-pad-file",
2492 | "--command",
2493 | "cat",
2494 | "--max-depth",
2495 | "1",
2496 | str(object=tmp_path),
2497 | ]
2498 | result = runner.invoke(
2499 | cli=main,
2500 | args=arguments,
2501 | catch_exceptions=False,
2502 | color=True,
2503 | )
2504 | assert result.exit_code == 0, result.stderr
2505 | expected_output = textwrap.dedent(
2506 | text="""\
2507 | rst_1_block
2508 | """,
2509 | )
2510 |
2511 | assert result.stdout == expected_output
2512 | assert result.stderr == ""
2513 |
2514 | arguments = [
2515 | "--language",
2516 | "python",
2517 | "--no-pad-file",
2518 | "--command",
2519 | "cat",
2520 | "--max-depth",
2521 | "2",
2522 | str(object=tmp_path),
2523 | ]
2524 | result = runner.invoke(
2525 | cli=main,
2526 | args=arguments,
2527 | catch_exceptions=False,
2528 | color=True,
2529 | )
2530 | assert result.exit_code == 0, result.stderr
2531 | expected_output = textwrap.dedent(
2532 | text="""\
2533 | rst_1_block
2534 | rst_subdir_1_block
2535 | """,
2536 | )
2537 |
2538 | assert result.stdout == expected_output
2539 | assert result.stderr == ""
2540 |
2541 | arguments = [
2542 | "--language",
2543 | "python",
2544 | "--no-pad-file",
2545 | "--command",
2546 | "cat",
2547 | "--max-depth",
2548 | "3",
2549 | str(object=tmp_path),
2550 | ]
2551 | result = runner.invoke(
2552 | cli=main,
2553 | args=arguments,
2554 | catch_exceptions=False,
2555 | color=True,
2556 | )
2557 | assert result.exit_code == 0, result.stderr
2558 | expected_output = textwrap.dedent(
2559 | text="""\
2560 | rst_1_block
2561 | rst_subdir_1_block
2562 | rst_subsubdir_1_block
2563 | """,
2564 | )
2565 |
2566 | assert result.stdout == expected_output
2567 | assert result.stderr == ""
2568 |
2569 |
2570 | def test_exclude_files_from_recursed_directories(tmp_path: Path) -> None:
2571 | """
2572 | Files with names matching the exclude pattern are not processed when
2573 | recursing directories.
2574 | """
2575 | runner = CliRunner()
2576 | rst_file = tmp_path / "example.rst"
2577 | rst_content = textwrap.dedent(
2578 | text="""\
2579 | .. code-block:: python
2580 |
2581 | rst_1_block
2582 | """,
2583 | )
2584 | rst_file.write_text(data=rst_content, encoding="utf-8")
2585 |
2586 | sub_directory = tmp_path / "subdir"
2587 | sub_directory.mkdir()
2588 | rst_file_in_sub_directory = sub_directory / "subdir_example.rst"
2589 | subdir_rst_content = textwrap.dedent(
2590 | text="""\
2591 | .. code-block:: python
2592 |
2593 | rst_subdir_1_block
2594 | """,
2595 | )
2596 | rst_file_in_sub_directory.write_text(
2597 | data=subdir_rst_content,
2598 | encoding="utf-8",
2599 | )
2600 |
2601 | excluded_file = sub_directory / "exclude_me.rst"
2602 | excluded_content = textwrap.dedent(
2603 | text="""\
2604 | .. code-block:: python
2605 |
2606 | excluded_block
2607 | """,
2608 | )
2609 | excluded_file.write_text(data=excluded_content, encoding="utf-8")
2610 |
2611 | arguments = [
2612 | "--language",
2613 | "python",
2614 | "--no-pad-file",
2615 | "--command",
2616 | "cat",
2617 | "--exclude",
2618 | "exclude_*e.*",
2619 | str(object=tmp_path),
2620 | ]
2621 | result = runner.invoke(
2622 | cli=main,
2623 | args=arguments,
2624 | catch_exceptions=False,
2625 | color=True,
2626 | )
2627 | assert result.exit_code == 0, result.stderr
2628 | expected_output = textwrap.dedent(
2629 | text="""\
2630 | rst_1_block
2631 | rst_subdir_1_block
2632 | """,
2633 | )
2634 |
2635 | assert result.stdout == expected_output
2636 | assert result.stderr == ""
2637 |
2638 |
2639 | def test_multiple_exclude_patterns(tmp_path: Path) -> None:
2640 | """
2641 | Files matching any of the exclude patterns are not processed when recursing
2642 | directories.
2643 | """
2644 | runner = CliRunner()
2645 | rst_file = tmp_path / "example.rst"
2646 | rst_content = textwrap.dedent(
2647 | text="""\
2648 | .. code-block:: python
2649 |
2650 | rst_1_block
2651 | """,
2652 | )
2653 | rst_file.write_text(data=rst_content, encoding="utf-8")
2654 |
2655 | sub_directory = tmp_path / "subdir"
2656 | sub_directory.mkdir()
2657 | rst_file_in_sub_directory = sub_directory / "subdir_example.rst"
2658 | subdir_rst_content = textwrap.dedent(
2659 | text="""\
2660 | .. code-block:: python
2661 |
2662 | rst_subdir_1_block
2663 | """,
2664 | )
2665 | rst_file_in_sub_directory.write_text(
2666 | data=subdir_rst_content,
2667 | encoding="utf-8",
2668 | )
2669 |
2670 | excluded_file_1 = sub_directory / "exclude_me.rst"
2671 | excluded_content_1 = """\
2672 | .. code-block:: python
2673 |
2674 | excluded_block_1
2675 | """
2676 | excluded_file_1.write_text(data=excluded_content_1, encoding="utf-8")
2677 |
2678 | excluded_file_2 = sub_directory / "ignore_me.rst"
2679 | excluded_content_2 = """\
2680 | .. code-block:: python
2681 |
2682 | excluded_block_2
2683 | """
2684 | excluded_file_2.write_text(data=excluded_content_2, encoding="utf-8")
2685 |
2686 | arguments = [
2687 | "--language",
2688 | "python",
2689 | "--no-pad-file",
2690 | "--command",
2691 | "cat",
2692 | "--exclude",
2693 | "exclude_*e.*",
2694 | "--exclude",
2695 | "ignore_*e.*",
2696 | str(object=tmp_path),
2697 | ]
2698 | result = runner.invoke(
2699 | cli=main,
2700 | args=arguments,
2701 | catch_exceptions=False,
2702 | color=True,
2703 | )
2704 | assert result.exit_code == 0, result.stderr
2705 | expected_output = textwrap.dedent(
2706 | text="""\
2707 | rst_1_block
2708 | rst_subdir_1_block
2709 | """,
2710 | )
2711 |
2712 | assert result.stdout == expected_output
2713 | assert result.stderr == ""
2714 |
2715 |
2716 | @pytest.mark.parametrize(
2717 | argnames=("fail_on_parse_error_options", "expected_exit_code"),
2718 | argvalues=[
2719 | ([], 0),
2720 | (["--fail-on-parse-error"], 1),
2721 | ],
2722 | )
2723 | def test_lexing_exception(
2724 | tmp_path: Path,
2725 | fail_on_parse_error_options: Sequence[str],
2726 | expected_exit_code: int,
2727 | ) -> None:
2728 | """
2729 | Lexing exceptions are handled when an invalid source file is given.
2730 | """
2731 | runner = CliRunner()
2732 | source_file = tmp_path / "invalid_example.md"
2733 | # Lexing error as there is a hyphen in the comment
2734 | # or... because of the word code!
2735 | invalid_content = textwrap.dedent(
2736 | text="""\
2737 |
2738 | """,
2739 | )
2740 | source_file.write_text(data=invalid_content, encoding="utf-8")
2741 | arguments = [
2742 | *fail_on_parse_error_options,
2743 | "--language",
2744 | "python",
2745 | "--command",
2746 | "cat",
2747 | str(object=source_file),
2748 | ]
2749 | result = runner.invoke(
2750 | cli=main,
2751 | args=arguments,
2752 | catch_exceptions=False,
2753 | color=True,
2754 | )
2755 | assert result.exit_code == expected_exit_code, (
2756 | result.stdout,
2757 | result.stderr,
2758 | )
2759 | expected_stderr = textwrap.dedent(
2760 | text=f"""\
2761 | {fg.red}Could not parse {source_file}: Could not find end of '\\n', starting at line 1, column 1, looking for '(?:(?<=\\n))?--+>' in {source_file}:
2762 | ''{reset}
2763 | """, # noqa: E501
2764 | )
2765 | assert result.stderr == expected_stderr
2766 |
2767 |
2768 | @pytest.mark.parametrize(
2769 | argnames="file_padding_options",
2770 | argvalues=[
2771 | [],
2772 | ["--no-pad-file"],
2773 | ],
2774 | )
2775 | @pytest.mark.parametrize(
2776 | argnames=("group_marker", "group_marker_options"),
2777 | argvalues=[
2778 | ("all", []),
2779 | ("custom-marker", ["--group-marker", "custom-marker"]),
2780 | ],
2781 | )
2782 | @pytest.mark.parametrize(
2783 | argnames=("group_padding_options", "expect_padding"),
2784 | argvalues=[
2785 | ([], True),
2786 | (["--no-pad-groups"], False),
2787 | ],
2788 | )
2789 | def test_group_blocks(
2790 | *,
2791 | tmp_path: Path,
2792 | file_padding_options: Sequence[str],
2793 | group_marker: str,
2794 | group_marker_options: Sequence[str],
2795 | group_padding_options: Sequence[str],
2796 | expect_padding: bool,
2797 | ) -> None:
2798 | """It is possible to group some blocks together.
2799 |
2800 | Code blocks between a group start and end marker are concatenated
2801 | and passed as a single input to the command.
2802 | """
2803 | runner = CliRunner()
2804 | rst_file = tmp_path / "example.rst"
2805 | script = tmp_path / "print_underlined.py"
2806 | content = textwrap.dedent(
2807 | text=f"""\
2808 | .. code-block:: python
2809 |
2810 | block_1
2811 |
2812 | .. group doccmd[{group_marker}]: start
2813 |
2814 | .. code-block:: python
2815 |
2816 | block_group_1
2817 |
2818 | .. code-block:: python
2819 |
2820 | block_group_2
2821 |
2822 | .. group doccmd[{group_marker}]: end
2823 |
2824 | .. code-block:: python
2825 |
2826 | block_3
2827 | """,
2828 | )
2829 | rst_file.write_text(data=content, encoding="utf-8")
2830 |
2831 | print_underlined_script = textwrap.dedent(
2832 | text="""\
2833 | import sys
2834 | import pathlib
2835 |
2836 | # We strip here so that we don't have to worry about
2837 | # the file padding.
2838 | print(pathlib.Path(sys.argv[1]).read_text().strip())
2839 | print("-------")
2840 | """,
2841 | )
2842 | script.write_text(data=print_underlined_script, encoding="utf-8")
2843 |
2844 | arguments = [
2845 | *file_padding_options,
2846 | *group_padding_options,
2847 | *group_marker_options,
2848 | "--language",
2849 | "python",
2850 | "--command",
2851 | f"{Path(sys.executable).as_posix()} {script.as_posix()}",
2852 | str(object=rst_file),
2853 | ]
2854 | result = runner.invoke(
2855 | cli=main,
2856 | args=arguments,
2857 | catch_exceptions=False,
2858 | )
2859 | # The expected output is that the content outside the group remains
2860 | # unchanged, while the contents inside the group are merged.
2861 | if expect_padding:
2862 | expected_output = textwrap.dedent(
2863 | text="""\
2864 | block_1
2865 | -------
2866 | block_group_1
2867 |
2868 |
2869 |
2870 | block_group_2
2871 | -------
2872 | block_3
2873 | -------
2874 | """,
2875 | )
2876 | else:
2877 | expected_output = textwrap.dedent(
2878 | text="""\
2879 | block_1
2880 | -------
2881 | block_group_1
2882 |
2883 | block_group_2
2884 | -------
2885 | block_3
2886 | -------
2887 | """,
2888 | )
2889 | assert result.exit_code == 0, (result.stdout, result.stderr)
2890 | assert result.stdout == expected_output
2891 | assert result.stderr == ""
2892 |
2893 |
2894 | @pytest.mark.parametrize(
2895 | argnames=(
2896 | "fail_on_group_write_options",
2897 | "expected_exit_code",
2898 | "message_colour",
2899 | ),
2900 | argvalues=[
2901 | ([], 1, fg.red),
2902 | (["--fail-on-group-write"], 1, fg.red),
2903 | (["--no-fail-on-group-write"], 0, fg.yellow),
2904 | ],
2905 | )
2906 | def test_modify_file_single_group_block(
2907 | *,
2908 | tmp_path: Path,
2909 | fail_on_group_write_options: Sequence[str],
2910 | expected_exit_code: int,
2911 | message_colour: Graphic,
2912 | ) -> None:
2913 | """
2914 | Commands in groups cannot modify files in single grouped blocks.
2915 | """
2916 | runner = CliRunner()
2917 | rst_file = tmp_path / "example.rst"
2918 | content = textwrap.dedent(
2919 | text="""\
2920 | .. group doccmd[all]: start
2921 |
2922 | .. code-block:: python
2923 |
2924 | a = 1
2925 | b = 1
2926 | c = 1
2927 |
2928 | .. group doccmd[all]: end
2929 | """,
2930 | )
2931 | rst_file.write_text(data=content, encoding="utf-8")
2932 | modify_code_script = textwrap.dedent(
2933 | text="""\
2934 | #!/usr/bin/env python
2935 |
2936 | import sys
2937 |
2938 | with open(sys.argv[1], "w") as file:
2939 | file.write("foobar")
2940 | """,
2941 | )
2942 | modify_code_file = tmp_path / "modify_code.py"
2943 | modify_code_file.write_text(data=modify_code_script, encoding="utf-8")
2944 | arguments = [
2945 | *fail_on_group_write_options,
2946 | "--language",
2947 | "python",
2948 | "--command",
2949 | f"{Path(sys.executable).as_posix()} {modify_code_file.as_posix()}",
2950 | str(object=rst_file),
2951 | ]
2952 | result = runner.invoke(
2953 | cli=main,
2954 | args=arguments,
2955 | catch_exceptions=False,
2956 | color=True,
2957 | )
2958 | assert result.exit_code == expected_exit_code, (
2959 | result.stdout,
2960 | result.stderr,
2961 | )
2962 | new_content = rst_file.read_text(encoding="utf-8")
2963 | expected_content = content
2964 | assert new_content == expected_content
2965 |
2966 | expected_stderr = textwrap.dedent(
2967 | text=f"""\
2968 | {message_colour}Writing to a group is not supported.
2969 |
2970 | A command modified the contents of examples in the group ending on line 3 in {rst_file.as_posix()}.
2971 |
2972 | Diff:
2973 |
2974 | --- original
2975 |
2976 | +++ modified
2977 |
2978 | @@ -1,3 +1 @@
2979 |
2980 | -a = 1
2981 | -b = 1
2982 | -c = 1
2983 | +foobar{reset}
2984 | """, # noqa: E501
2985 | )
2986 | assert result.stderr == expected_stderr
2987 |
2988 |
2989 | @pytest.mark.parametrize(
2990 | argnames=(
2991 | "fail_on_group_write_options",
2992 | "expected_exit_code",
2993 | "message_colour",
2994 | ),
2995 | argvalues=[
2996 | ([], 1, fg.red),
2997 | (["--fail-on-group-write"], 1, fg.red),
2998 | (["--no-fail-on-group-write"], 0, fg.yellow),
2999 | ],
3000 | )
3001 | def test_modify_file_multiple_group_blocks(
3002 | *,
3003 | tmp_path: Path,
3004 | fail_on_group_write_options: Sequence[str],
3005 | expected_exit_code: int,
3006 | message_colour: Graphic,
3007 | ) -> None:
3008 | """
3009 | Commands in groups cannot modify files in multiple grouped commands.
3010 | """
3011 | runner = CliRunner()
3012 | rst_file = tmp_path / "example.rst"
3013 | content = textwrap.dedent(
3014 | text="""\
3015 | .. group doccmd[all]: start
3016 |
3017 | .. code-block:: python
3018 |
3019 | a = 1
3020 | b = 1
3021 |
3022 | .. code-block:: python
3023 |
3024 | c = 1
3025 |
3026 | .. group doccmd[all]: end
3027 | """,
3028 | )
3029 | rst_file.write_text(data=content, encoding="utf-8")
3030 | modify_code_script = textwrap.dedent(
3031 | text="""\
3032 | #!/usr/bin/env python
3033 |
3034 | import sys
3035 |
3036 | with open(sys.argv[1], "w") as file:
3037 | file.write("foobar")
3038 | """,
3039 | )
3040 | modify_code_file = tmp_path / "modify_code.py"
3041 | modify_code_file.write_text(data=modify_code_script, encoding="utf-8")
3042 | arguments = [
3043 | *fail_on_group_write_options,
3044 | "--language",
3045 | "python",
3046 | "--command",
3047 | f"{Path(sys.executable).as_posix()} {modify_code_file.as_posix()}",
3048 | str(object=rst_file),
3049 | ]
3050 | result = runner.invoke(
3051 | cli=main,
3052 | args=arguments,
3053 | catch_exceptions=False,
3054 | color=True,
3055 | )
3056 | assert result.exit_code == expected_exit_code, (
3057 | result.stdout,
3058 | result.stderr,
3059 | )
3060 | new_content = rst_file.read_text(encoding="utf-8")
3061 | expected_content = content
3062 | assert new_content == expected_content
3063 |
3064 | expected_stderr = textwrap.dedent(
3065 | text=f"""\
3066 | {message_colour}Writing to a group is not supported.
3067 |
3068 | A command modified the contents of examples in the group ending on line 3 in {rst_file.as_posix()}.
3069 |
3070 | Diff:
3071 |
3072 | --- original
3073 |
3074 | +++ modified
3075 |
3076 | @@ -1,6 +1 @@
3077 |
3078 | -a = 1
3079 | -b = 1
3080 | -
3081 | -
3082 | -
3083 | -c = 1
3084 | +foobar{reset}
3085 | """, # noqa: E501
3086 | )
3087 | assert result.stderr == expected_stderr
3088 |
3089 |
3090 | def test_jinja2(*, tmp_path: Path) -> None:
3091 | """
3092 | It is possible to run commands against sphinx-jinja2 blocks.
3093 | """
3094 | runner = CliRunner()
3095 | source_file = tmp_path / "example.rst"
3096 | content = textwrap.dedent(
3097 | text="""\
3098 | .. jinja::
3099 |
3100 | {% set x = 1 %}
3101 | {{ x }}
3102 |
3103 | .. Nested code block
3104 |
3105 | .. code-block:: python
3106 |
3107 | x = 2
3108 | print(x)
3109 | """,
3110 | )
3111 | source_file.write_text(data=content, encoding="utf-8")
3112 | arguments = [
3113 | "--sphinx-jinja2",
3114 | "--command",
3115 | "cat",
3116 | str(object=source_file),
3117 | ]
3118 | result = runner.invoke(
3119 | cli=main,
3120 | args=arguments,
3121 | catch_exceptions=False,
3122 | color=True,
3123 | )
3124 | assert result.exit_code == 0, (result.stdout, result.stderr)
3125 | expected_output = textwrap.dedent(
3126 | text="""\
3127 |
3128 |
3129 | {% set x = 1 %}
3130 | {{ x }}
3131 |
3132 | .. Nested code block
3133 |
3134 | .. code-block:: python
3135 |
3136 | x = 2
3137 | print(x)
3138 | """
3139 | )
3140 | assert result.stdout == expected_output
3141 | assert result.stderr == ""
3142 |
3143 |
3144 | def test_empty_language_given(*, tmp_path: Path) -> None:
3145 | """
3146 | An error is shown when an empty language is given.
3147 | """
3148 | runner = CliRunner()
3149 | source_file = tmp_path / "example.rst"
3150 | content = ""
3151 | source_file.write_text(data=content, encoding="utf-8")
3152 | arguments = [
3153 | "--command",
3154 | "cat",
3155 | "--language",
3156 | "",
3157 | str(object=source_file),
3158 | ]
3159 | result = runner.invoke(
3160 | cli=main,
3161 | args=arguments,
3162 | catch_exceptions=False,
3163 | color=True,
3164 | )
3165 | assert result.exit_code != 0, (result.stdout, result.stderr)
3166 | expected_stderr = textwrap.dedent(
3167 | text="""\
3168 | Usage: doccmd [OPTIONS] [DOCUMENT_PATHS]...
3169 | Try 'doccmd --help' for help.
3170 |
3171 | Error: Invalid value for '-l' / '--language': This value cannot be empty.
3172 | """, # noqa: E501
3173 | )
3174 | assert result.stdout == ""
3175 | assert result.stderr == expected_stderr
3176 |
--------------------------------------------------------------------------------
/tests/test_doccmd/test_help.txt:
--------------------------------------------------------------------------------
1 | Usage: doccmd [OPTIONS] [DOCUMENT_PATHS]...
2 |
3 | Run commands against code blocks in the given documentation files.
4 |
5 | This works with Markdown and reStructuredText files.
6 |
7 | Options:
8 | -l, --language TEXT Run `command` against code blocks for this
9 | language. Give multiple times for multiple
10 | languages. If this is not given, no code
11 | blocks are run, unless `--sphinx-jinja2` is
12 | given.
13 | -c, --command TEXT [required]
14 | --temporary-file-extension TEXT
15 | The file extension to give to the temporary
16 | file made from the code block. By default, the
17 | file extension is inferred from the language,
18 | or it is '.txt' if the language is not
19 | recognized.
20 | --temporary-file-name-prefix TEXT
21 | The prefix to give to the temporary file made
22 | from the code block. This is useful for
23 | distinguishing files created by this tool from
24 | other files, e.g. for ignoring in linter
25 | configurations. [default: doccmd; required]
26 | --skip-marker TEXT The marker used to identify code blocks to be
27 | skipped.
28 |
29 | By default, code blocks which come just after
30 | a comment matching 'skip doccmd[all]: next'
31 | are skipped (e.g. `.. skip doccmd[all]: next`
32 | in reStructuredText, `` in Markdown, or `% skip doccmd[all]:
34 | next` in MyST).
35 |
36 | When using this option, those, and code blocks
37 | which come just after a comment including the
38 | given marker are ignored. For example, if the
39 | given marker is 'type-check', code blocks
40 | which come just after a comment matching 'skip
41 | doccmd[type-check]: next' are also skipped.
42 |
43 | To skip a code block for each of multiple
44 | markers, for example to skip a code block for
45 | the ``type-check`` and ``lint`` markers but
46 | not all markers, add multiple ``skip doccmd``
47 | comments above the code block.
48 | --group-marker TEXT The marker used to identify code blocks to be
49 | grouped.
50 |
51 | By default, code blocks which come just
52 | between comments matching 'group doccmd[all]:
53 | start' and 'group doccmd[all]: end' are
54 | grouped (e.g. `.. group doccmd[all]: start` in
55 | reStructuredText, `` in Markdown, or `% group
57 | doccmd[all]: start` in MyST).
58 |
59 | When using this option, those, and code blocks
60 | which are grouped by a comment including the
61 | given marker are ignored. For example, if the
62 | given marker is 'type-check', code blocks
63 | which come within comments matching 'group
64 | doccmd[type-check]: start' and 'group
65 | doccmd[type-check]: end' are also skipped.
66 |
67 | Error messages for grouped code blocks may
68 | include lines which do not match the document,
69 | so code formatters will not work on them.
70 | --pad-file / --no-pad-file Run the command against a temporary file
71 | padded with newlines. This is useful for
72 | matching line numbers from the output to the
73 | relevant location in the document. Use --no-
74 | pad-file for formatters - they generally need
75 | to look at the file without padding.
76 | [default: pad-file]
77 | --pad-groups / --no-pad-groups Maintain line spacing between groups from the
78 | source file in the temporary file. This is
79 | useful for matching line numbers from the
80 | output to the relevant location in the
81 | document. Use --no-pad-groups for formatters -
82 | they generally need to look at the file
83 | without padding. [default: pad-groups]
84 | --version Show the version and exit.
85 | -v, --verbose Enable verbose output.
86 | --use-pty Use a pseudo-terminal for running commands.
87 | This can be useful e.g. to get color output,
88 | but can also break in some environments. Not
89 | supported on Windows. [default: (--detect-
90 | use-pty)]
91 | --no-use-pty Do not use a pseudo-terminal for running
92 | commands. This is useful when ``doccmd``
93 | detects that it is running in a TTY outside of
94 | Windows but the environment does not support
95 | PTYs. [default: (--detect-use-pty)]
96 | --detect-use-pty Automatically determine whether to use a
97 | pseudo-terminal for running commands.
98 | [default: (True)]
99 | --rst-extension TEXT Treat files with this extension (suffix) as
100 | reStructuredText. Give this multiple times to
101 | look for multiple extensions. To avoid
102 | considering any files, including the default,
103 | as reStructuredText files, use `--rst-
104 | extension=.`. [default: .rst]
105 | --myst-extension TEXT Treat files with this extension (suffix) as
106 | MyST. Give this multiple times to look for
107 | multiple extensions. To avoid considering any
108 | files, including the default, as MyST files,
109 | use `--myst-extension=.`. [default: .md]
110 | --markdown-extension TEXT Files with this extension (suffix) to treat as
111 | Markdown. Give this multiple times to look for
112 | multiple extensions. By default, `.md` is
113 | treated as MyST, not Markdown.
114 | --max-depth INTEGER RANGE Maximum depth to search for files in
115 | directories. [x>=1]
116 | --exclude TEXT A glob-style pattern that matches file paths
117 | to ignore while recursively discovering files
118 | in directories. This option can be used
119 | multiple times. Use forward slashes on all
120 | platforms.
121 | --fail-on-parse-error / --no-fail-on-parse-error
122 | Whether to fail (with exit code 1) if a given
123 | file cannot be parsed. [default: no-fail-on-
124 | parse-error]
125 | --fail-on-group-write / --no-fail-on-group-write
126 | Whether to fail (with exit code 1) if a
127 | command (e.g. a formatter) tries to change
128 | code within a grouped code block. ``doccmd``
129 | does not support writing to grouped code
130 | blocks. [default: fail-on-group-write]
131 | --sphinx-jinja2 / --no-sphinx-jinja2
132 | Whether to parse `sphinx-jinja2` blocks. This
133 | is useful for evaluating code blocks with
134 | Jinja2 templates used in Sphinx documentation.
135 | This is supported for MyST and
136 | reStructuredText files only. [default: no-
137 | sphinx-jinja2]
138 | --help Show this message and exit.
139 |
--------------------------------------------------------------------------------