├── .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 | --------------------------------------------------------------------------------