pax_global_header00006660000000000000000000000064150206524530014514gustar00rootroot0000000000000052 comment=f2f24508d62365a68782d9cb4b1d8bff81a0aed6 sphinx-substitution-extensions-2025.06.06/000077500000000000000000000000001502065245300203765ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/.git_archival.txt000066400000000000000000000000511502065245300236450ustar00rootroot00000000000000ref-names: HEAD -> main, tag: 2025.06.06 sphinx-substitution-extensions-2025.06.06/.gitattributes000066400000000000000000000000631502065245300232700ustar00rootroot00000000000000.git_archival.txt export-subst * text=auto eol=lf sphinx-substitution-extensions-2025.06.06/.github/000077500000000000000000000000001502065245300217365ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/.github/dependabot.yml000066400000000000000000000003451502065245300245700ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: pip directory: / schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: / schedule: interval: daily sphinx-substitution-extensions-2025.06.06/.github/workflows/000077500000000000000000000000001502065245300237735ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/.github/workflows/ci.yml000066400000000000000000000045031502065245300251130ustar00rootroot00000000000000--- name: CI on: push: branches: [main] pull_request: branches: [main] schedule: # * is a special character in YAML so you have to quote this string # Run at 1:00 every day - cron: 0 1 * * * jobs: build: strategy: matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] uv-resolution: [highest, lowest-direct] platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v6 with: enable-cache: true cache-dependency-glob: '**/pyproject.toml' - name: Lint run: | uv run --extra=dev pre-commit run --all-files --hook-stage pre-commit --verbose uv run --extra=dev pre-commit run --all-files --hook-stage pre-push --verbose uv run --extra=dev pre-commit run --all-files --hook-stage manual --verbose env: UV_PYTHON: ${{ matrix.python-version }} UV_RESOLUTION: ${{ matrix.uv-resolution }} - name: Freeze for debugging run: | uv pip freeze - name: Build sample run: | make build-sample env: UV_PYTHON: ${{ matrix.python-version }} UV_RESOLUTION: ${{ matrix.uv-resolution }} - name: Build sample parallel run: | make build-sample-parallel env: UV_PYTHON: ${{ matrix.python-version }} UV_RESOLUTION: ${{ matrix.uv-resolution }} - name: Run tests run: | uv run --extra=dev pytest -s -vvv --cov-fail-under 100 --cov=src/ --cov=tests . --cov-report=xml env: UV_PYTHON: ${{ matrix.python-version }} UV_RESOLUTION: ${{ matrix.uv-resolution }} - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} - uses: pre-commit-ci/lite-action@v1.1.0 if: always() completion-ci: needs: build runs-on: ubuntu-latest if: always() # Run even if one matrix job fails steps: - name: Check matrix job status run: |- if ! ${{ needs.build.result == 'success' }}; then echo "One or more matrix jobs failed" exit 1 fi sphinx-substitution-extensions-2025.06.06/.github/workflows/dependabot-merge.yml000066400000000000000000000011141502065245300277150ustar00rootroot00000000000000--- name: Dependabot auto-merge on: pull_request permissions: contents: write pull-requests: write jobs: dependabot: runs-on: ubuntu-latest if: github.actor == 'dependabot[bot]' steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --merge "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GH_TOKEN: ${{secrets.GITHUB_TOKEN}} sphinx-substitution-extensions-2025.06.06/.github/workflows/release.yml000066400000000000000000000066751502065245300261540ustar00rootroot00000000000000--- name: Release on: workflow_dispatch jobs: build: name: Publish a release runs-on: ubuntu-latest # Specifying an environment is strongly recommended by PyPI. # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. environment: release permissions: # This is needed for PyPI publishing. # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. id-token: write # This is needed for https://github.com/stefanzweifel/git-auto-commit-action. contents: write steps: - uses: actions/checkout@v4 with: # See # https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#push-to-protected-branches token: ${{ secrets.RELEASE_PAT }} # Fetch all history including tags. # Needed to find the latest tag. # # Also, avoids # https://github.com/stefanzweifel/git-auto-commit-action/issues/99. fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v6 with: enable-cache: true cache-dependency-glob: '**/pyproject.toml' - name: Calver calculate version uses: StephaneBour/actions-calver@master id: calver with: date_format: '%Y.%m.%d' release: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Get the changelog underline id: changelog_underline run: | underline="$(echo "${{ steps.calver.outputs.release }}" | tr -c '\n' '-')" echo "underline=${underline}" >> "$GITHUB_OUTPUT" - name: Update changelog uses: jacobtomlinson/gha-find-replace@v3 with: find: "Next\n----" replace: "Next\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ \ }}" include: CHANGELOG.rst regex: false - uses: stefanzweifel/git-auto-commit-action@v5 id: commit with: commit_message: Bump CHANGELOG file_pattern: CHANGELOG.rst # Error if there are no changes. skip_dirty_check: true - name: Bump version and push tag id: tag_version uses: mathieudutour/github-tag-action@v6.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} custom_tag: ${{ steps.calver.outputs.release }} tag_prefix: '' commit_sha: ${{ steps.commit.outputs.commit_hash }} - name: Create a GitHub release uses: ncipollo/release-action@v1 with: tag: ${{ steps.tag_version.outputs.new_tag }} makeLatest: true name: Release ${{ steps.tag_version.outputs.new_tag }} body: ${{ steps.tag_version.outputs.changelog }} - name: Build a binary wheel and a source tarball run: | git fetch --tags git checkout ${{ steps.tag_version.outputs.new_tag }} uv build --sdist --wheel --out-dir dist/ uv run --extra=release check-wheel-contents dist/*.whl # We use PyPI trusted publishing rather than a PyPI API token. # See https://github.com/pypa/gh-action-pypi-publish/tree/release/v1/?tab=readme-ov-file#trusted-publishing. - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: verbose: true sphinx-substitution-extensions-2025.06.06/.gitignore000066400000000000000000000025211502065245300223660ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # direnv file .envrc # IDEA ide .idea/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # setuptools_scm src/*/_setuptools_scm_version.txt uv.lock # Ignore Mac DS_Store files .DS_Store **/.DS_Store sphinx-substitution-extensions-2025.06.06/.pre-commit-config.yaml000066400000000000000000000174621502065245300246710ustar00rootroot00000000000000--- fail_fast: true # We use system Python, with required dependencies specified in pyproject.toml. # We therefore cannot use those dependencies in pre-commit CI. ci: skip: - actionlint - sphinx-lint - check-manifest - deptry - doc8 - interrogate - interrogate-docs - mypy - mypy-docs - pylint - pyproject-fmt-fix - pyright - pyright-docs - pyright-verifytypes - pyroma - ruff-check-fix - ruff-check-fix-docs - ruff-format-fix - ruff-format-fix-docs - docformatter - shellcheck - shellcheck-docs - shfmt - shfmt-docs - vulture - vulture-docs - yamlfix # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks default_install_hook_types: [pre-commit, pre-push, commit-msg] repos: - repo: meta hooks: - id: check-useless-excludes - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-shebang-scripts-are-executable - id: check-symlinks - id: check-json - id: check-toml - id: check-vcs-permalinks - id: check-yaml - id: end-of-file-fixer - id: file-contents-sorter files: spelling_private_dict\.txt$ - id: trailing-whitespace - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: rst-directive-colons - id: rst-inline-touching-normal - id: text-unicode-replacement-char - id: rst-backticks - repo: local hooks: - id: actionlint name: actionlint entry: uv run --extra=dev actionlint language: python pass_filenames: false types_or: [yaml] additional_dependencies: [uv==0.6.3] - id: docformatter name: docformatter entry: uv run --extra=dev -m docformatter --in-place language: python types_or: [python] additional_dependencies: [uv==0.6.3] - id: shellcheck name: shellcheck entry: uv run --extra=dev shellcheck --shell=bash language: python types_or: [shell] additional_dependencies: [uv==0.6.3] - id: shellcheck-docs name: shellcheck-docs entry: uv run --extra=dev doccmd --language=shell --language=console --command="shellcheck --shell=bash" language: python types_or: [markdown, rst] additional_dependencies: [uv==0.6.3] - id: shfmt name: shfmt entry: shfmt --write --space-redirects --indent=4 language: python types_or: [shell] additional_dependencies: [uv==0.6.3] - id: shfmt-docs name: shfmt-docs entry: uv run --extra=dev doccmd --language=shell --language=console --skip-marker=shfmt --no-pad-file --command="shfmt --write --space-redirects --indent=4" language: python types_or: [markdown, rst] additional_dependencies: [uv==0.6.3] - id: mypy name: mypy stages: [pre-push] entry: uv run --extra=dev -m mypy language: python types_or: [python, toml] pass_filenames: false additional_dependencies: [uv==0.6.3] - id: mypy-docs name: mypy-docs stages: [pre-push] entry: uv run --extra=dev doccmd --language=python --command="mypy" language: python types_or: [markdown, rst] - id: check-manifest name: check-manifest stages: [pre-push] entry: uv run --extra=dev -m check_manifest language: python pass_filenames: false additional_dependencies: [uv==0.6.3] - id: pyright name: pyright stages: [pre-push] entry: uv run --extra=dev -m pyright . language: python types_or: [python, toml] pass_filenames: false additional_dependencies: [uv==0.6.3] - id: pyright-docs name: pyright-docs stages: [pre-push] entry: uv run --extra=dev doccmd --language=python --command="pyright" language: python types_or: [markdown, rst] - id: pyright-verifytypes name: pyright-verifytypes stages: [pre-push] # Use `--ignoreexternal` because we expose parts of the Sphinx API and Sphinx is not # thoroughly typed enough. entry: uv run --extra=dev -m pyright --ignoreexternal --verifytypes sphinx_substitution_extensions language: python pass_filenames: false types_or: [python] additional_dependencies: [uv==0.6.3] - id: vulture name: vulture entry: uv run --extra=dev -m vulture . language: python types_or: [python] pass_filenames: false additional_dependencies: [uv==0.6.3] - id: vulture-docs name: vulture docs entry: uv run --extra=dev doccmd --language=python --command="vulture" language: python types_or: [markdown, rst] additional_dependencies: [uv==0.6.3] - id: pyroma name: pyroma entry: uv run --extra=dev -m pyroma --min 10 . language: python pass_filenames: false types_or: [toml] additional_dependencies: [uv==0.6.3] - id: deptry name: deptry entry: uv run --extra=dev -m deptry src/ language: python pass_filenames: false additional_dependencies: [uv==0.6.3] - id: pylint name: pylint entry: uv run --extra=dev -m pylint src/ tests/ language: python stages: [manual] pass_filenames: false additional_dependencies: [uv==0.6.3] - id: ruff-check-fix name: Ruff check fix entry: uv run --extra=dev -m ruff check --fix language: python types_or: [python] additional_dependencies: [uv==0.6.3] - id: ruff-check-fix-docs name: Ruff check fix docs entry: uv run --extra=dev doccmd --language=python --command="ruff check --fix" language: python types_or: [markdown, rst] additional_dependencies: [uv==0.6.3] - id: ruff-format-fix name: Ruff format entry: uv run --extra=dev -m ruff format language: python types_or: [python] additional_dependencies: [uv==0.6.3] - id: ruff-format-fix-docs name: Ruff format docs entry: uv run --extra=dev doccmd --language=python --no-pad-file --command="ruff format" language: python types_or: [markdown, rst] additional_dependencies: [uv==0.6.3] - id: doc8 name: doc8 entry: uv run --extra=dev -m doc8 language: python types_or: [rst] additional_dependencies: [uv==0.6.3] - id: interrogate name: interrogate entry: uv run --extra=dev -m interrogate language: python types_or: [python] additional_dependencies: [uv==0.6.3] - id: interrogate-docs name: interrogate docs entry: uv run --extra=dev doccmd --language=python --command="interrogate" language: python types_or: [markdown, rst] additional_dependencies: [uv==0.6.3] - id: pyproject-fmt-fix name: pyproject-fmt entry: uv run --extra=dev pyproject-fmt language: python types_or: [toml] files: pyproject.toml - id: yamlfix name: yamlfix entry: uv run --extra=dev yamlfix language: python types_or: [yaml] additional_dependencies: [uv==0.6.3] - id: sphinx-lint name: sphinx-lint entry: uv run --extra=dev sphinx-lint --enable=all --disable=line-too-long language: python types_or: [rst] additional_dependencies: [uv==0.6.3] sphinx-substitution-extensions-2025.06.06/.vscode/000077500000000000000000000000001502065245300217375ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/.vscode/extensions.json000066400000000000000000000001341502065245300250270ustar00rootroot00000000000000{ "recommendations": [ "charliermarsh.ruff", "ms-python.python" ] } sphinx-substitution-extensions-2025.06.06/.vscode/settings.json000066400000000000000000000005371502065245300244770ustar00rootroot00000000000000{ "[python]": { "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true }, "python.testing.pytestArgs": [ "." ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true } sphinx-substitution-extensions-2025.06.06/CHANGELOG.rst000066400000000000000000000054721502065245300224270ustar00rootroot00000000000000Changelog ========= .. contents:: Next ---- 2025.06.06 ---------- 2025.04.03 ---------- 2025.03.03 ---------- - Add support for Python 3.10. 2025.02.19 ---------- - Support the ``substitution-code`` role in MyST documents. - Support the ``substitution-download`` role in MyST documents. - Drop support for Python 3.10. 2025.01.02 ---------- - Supports situations where there is no source file name available to the extension, such as when using ``sphinx_toolbox.rest_example``. 2024.10.17 ---------- - Support Python 3.13. - In MyST documents, support the ``myst_sub_delimiters`` option. This means you can use the ``{{replace-me}}`` syntax in MyST documents. 2024.08.06 ------------ - Bump the minimum supported version of Sphinx to 7.3.5. - Remove support for ``sphinx-prompt``. Please create a GitHub issue if you have a use case for this extension which is not covered by the built-in Sphinx functionality. 2024.02.25 ------------ - Add ``substitution-download`` role. 2024.02.24.1 ------------ - Add support for MyST. Thanks to Václav Votípka (@eNcacz) for the contribution. 2024.02.24 ------------ - Bump the minimum supported version of Sphinx to 7.2.0. - Bump the minimum supported version of docutils to 0.19. - ``sphinx-prompt`` is no longer an optional dependency, meaning you can remove the ``[prompt]`` extras dependency specification. - Remove the need to specify the ``sphinx-prompt`` extension in ``conf.py`` in order to use the ``prompt`` directive. - Support Python 3.12 - Drop support for Python 3.9 2022.02.16 ------------ - Breaking change: The required Sphinx version is at least 4.0. - ``sphinx-prompt`` is now an optional dependency. Thanks go to @dgarcia360 for this change. 2020.09.30.0 ------------ 2020.07.04.1 ------------ - Ensure non-lower-case replacements can also be substituted in the inline substitution code role. 2020.07.04.0 ------------ - Ensure non-lower-case replacements can also be substituted. Thanks go to @Julian for this change. 2020.05.30.0 ------------ 2020.05.27.0 ------------ - Breaking change: Use ``:substitutions:`` option on ``code-block`` or ``prompt`` rather than new directives. 2020.05.23.0 ------------ - Breaking change: Use the default Sphinx replacements, rather than a custom variable. Thanks go to @sbaudoin for the original code for this change. Please make a GitHub issue if you have a use case which this does not suit. 2020.04.05.0 ------------ 2020.02.21.0 ------------ 2019.12.28.1 ------------ 2019.12.28.0 ------------ 2019.06.15.0 ------------ 2019.04.04.1 ------------ 2019.04.04.0 ------------ - Support Sphinx 2.0.0. 2018.11.12.3 ------------ - Make ``substitution`` a list, not a tuple. 2018.11.12.2 ------------ - Add ``substitution-code-block`` directive. 2018.11.12.0 ------------ - Initial release with ``substitution-prompt``. sphinx-substitution-extensions-2025.06.06/CONTRIBUTING.rst000066400000000000000000000027751502065245300230520ustar00rootroot00000000000000Contributing ============ Contributions to this repository must pass tests and linting. CI is the canonical source of truth. Install contribution dependencies --------------------------------- Install Python dependencies in a virtual environment. .. code-block:: shell pip install --editable '.[dev]' Spell checking requires ``enchant``. This can be installed on macOS, for example, with `Homebrew`_: .. code-block:: shell brew install enchant and on Ubuntu with ``apt``: .. code-block:: shell apt-get install -y enchant Install ``pre-commit`` hooks: .. code-block:: shell pre-commit install Linting ------- Run lint tools either by committing, or with: .. code-block:: shell pre-commit run --all-files --hook-stage pre-commit --verbose pre-commit run --all-files --hook-stage pre-push --verbose pre-commit run --all-files --hook-stage manual --verbose .. _Homebrew: https://brew.sh Running tests ------------- Run ``pytest``: .. code-block:: shell pytest Continuous integration ---------------------- Tests are run on GitHub Actions. The configuration for this is in ```.github/workflows/``. Release Process --------------- Outcomes ~~~~~~~~ * A new ``git`` tag available to install. * A new package on PyPI. Perform a Release ~~~~~~~~~~~~~~~~~ #. `Install GitHub CLI`_. #. Perform a release: .. code-block:: shell $ gh workflow run release.yml --repo adamtheturtle/sphinx-substitution-extensions .. _Install GitHub CLI: https://cli.github.com/manual/installation sphinx-substitution-extensions-2025.06.06/LICENSE000066400000000000000000000020201502065245300213750ustar00rootroot00000000000000The MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sphinx-substitution-extensions-2025.06.06/Makefile000066400000000000000000000007701502065245300220420ustar00rootroot00000000000000SHELL := /bin/bash -euxo pipefail .PHONY: build-sample build-sample: rm -rf sample/build uv run --extra=dev sphinx-build -W -b html sample/source sample/build .PHONY: build-sample-parallel build-sample-parallel: rm -rf sample/build uv run --extra=dev sphinx-build -j 2 -W -b html sample/source sample/build .PHONY: open-sample open-sample: python -c 'import os, webbrowser; webbrowser.open("file://" + os.path.abspath("sample/build/index.html"))' .PHONY: sample sample: build-sample open-sample sphinx-substitution-extensions-2025.06.06/README.rst000066400000000000000000000077311502065245300220750ustar00rootroot00000000000000|Build Status| |codecov| |PyPI| Sphinx Substitution Extensions ============================== Extensions for Sphinx which allow substitutions within code blocks. .. contents:: Installation ------------ Sphinx Substitution Extensions is compatible with Sphinx 8.2.0+ using Python |minimum-python-version|\+. .. code-block:: console $ pip install Sphinx-Substitution-Extensions rST setup --------- 1. Add the following to ``conf.py`` to enable the extension: .. code-block:: python """Configuration for Sphinx.""" extensions = ["sphinxcontrib.spelling"] # Example existing extensions extensions += ["sphinx_substitution_extensions"] 2. Set the following variable in ``conf.py`` to define substitutions: .. code-block:: python """Configuration for Sphinx.""" rst_prolog = """ .. |release| replace:: 0.1 .. |author| replace:: Eleanor """ This will replace ``|release|`` in the new directives with ``0.1``, and ``|author|`` with ``Eleanor``. Using substitutions in rST documents ------------------------------------ ``code-block`` ~~~~~~~~~~~~~~ This adds a ``:substitutions:`` option to Sphinx's built-in `code-block`_ directive. .. code-block:: rst .. code-block:: shell :substitutions: echo "|author| released version |release|" Inline ``:substitution-code:`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: rst :substitution-code:`echo "|author| released version |release|"` ``substitution-download`` ~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: rst :substitution-download:`|author|'s manuscript <|author|_manuscript.txt>` MyST Markdown setup ------------------- 1. Add ``sphinx_substitution_extensions`` to ``extensions`` in ``conf.py`` to enable the extension: .. code-block:: python """Configuration for Sphinx.""" extensions = ["myst_parser"] # Example existing extensions extensions += ["sphinx_substitution_extensions"] 2. Set the following variables in ``conf.py`` to define substitutions: .. code-block:: python """Configuration for Sphinx.""" myst_enable_extensions = ["substitution"] myst_substitutions = { "release": "0.1", "author": "Eleanor", } This will replace ``|release|`` in the new directives with ``0.1``, and ``|author|`` with ``Eleanor``. Using substitutions in MyST Markdown ------------------------------------ ``code-block`` ~~~~~~~~~~~~~~ This adds a ``:substitutions:`` option to Sphinx's built-in `code-block`_ directive. .. code-block:: markdown ```{code-block} bash :substitutions: echo "|author| released version |release|" ``` As well as using ``|author|``, you can also use ``{{author}}``. This will respect the value of ``myst_sub_delimiters`` as set in ``conf.py``. Inline ``:substitution-code:`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: rst {substitution-code}`echo "|author| released version |release|"` ``substitution-download`` ~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: rst {substitution-download}`|author|'s manuscript <|author|_manuscript.txt>` Credits ------- ClusterHQ Developers ~~~~~~~~~~~~~~~~~~~~ This package is largely inspired by code written for Flocker by ClusterHQ. Developers of the relevant code include, at least, Jon Giddy and Tom Prince. Contributing ------------ See `CONTRIBUTING.rst <./CONTRIBUTING.rst>`_. .. |Build Status| image:: https://github.com/adamtheturtle/sphinx-substitution-extensions/actions/workflows/ci.yml/badge.svg?branch=main :target: https://github.com/adamtheturtle/sphinx-substitution-extensions/actions .. _code-block: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block .. |codecov| image:: https://codecov.io/gh/adamtheturtle/sphinx-substitution-extensions/branch/main/graph/badge.svg :target: https://codecov.io/gh/adamtheturtle/sphinx-substitution-extensions .. |PyPI| image:: https://badge.fury.io/py/Sphinx-Substitution-Extensions.svg :target: https://badge.fury.io/py/Sphinx-Substitution-Extensions .. |minimum-python-version| replace:: 3.10 sphinx-substitution-extensions-2025.06.06/codecov.yaml000066400000000000000000000003041502065245300227010ustar00rootroot00000000000000--- coverage: status: patch: default: # Require 100% test coverage. target: 100% project: default: # Require 100% test coverage. target: 100% sphinx-substitution-extensions-2025.06.06/pyproject.toml000066400000000000000000000230271502065245300233160ustar00rootroot00000000000000[build-system] build-backend = "setuptools.build_meta" requires = [ "setuptools", "setuptools-scm>=8.1.0", ] [project] name = "sphinx-substitution-extensions" description = "Extensions for Sphinx which allow for substitutions." readme = { file = "README.rst", content-type = "text/x-rst" } keywords = [ "documentation", "rst", "sphinx", ] license = { file = "LICENSE" } authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, ] requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Pytest", "License :: OSI Approved :: MIT License", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dynamic = [ "version", ] dependencies = [ "beartype>=0.18.5", "docutils>=0.19", "myst-parser>=4.0.0", "sphinx>=8.1.0", ] optional-dependencies.dev = [ "actionlint-py==1.7.7.23", "check-manifest==0.50", "deptry==0.23.0", "doc8==1.1.2", "doccmd==2025.4.8", "docformatter==1.7.7", "interrogate==1.7.0", "mypy[faster-cache]==1.16.0", "mypy-strict-kwargs==2025.4.3", "pre-commit==4.2.0", "pyenchant==3.3.0rc1", "pylint==3.3.7", "pyproject-fmt==2.6.0", "pyright==1.1.401", "pyroma==4.2", "pytest==8.4.0", "pytest-cov==6.1.1", "ruff==0.11.13", # We add shellcheck-py not only for shell scripts and shell code blocks, # but also because having it installed means that ``actionlint-py`` will # use it to lint shell commands in GitHub workflow files. "shellcheck-py==0.10.0.1", "shfmt-py==3.11.0.2", "sphinx-lint==1.0.0", "sphinx-toolbox==4.0.0", "types-docutils==0.21.0.20250604", "vulture==2.14", "yamlfix==1.17.0", ] optional-dependencies.release = [ "check-wheel-contents==0.6.2" ] urls.Source = "https://github.com/adamtheturtle/sphinx-substitution-extensions" [tool.setuptools] zip-safe = false [tool.setuptools.packages.find] where = [ "src", ] [tool.setuptools.package-data] sphinx_substitution_extensions = [ "py.typed", ] [tool.distutils.bdist_wheel] universal = true [tool.setuptools_scm] # This keeps the start of the version the same as the last release. # This is useful for our documentation to include e.g. binary links # to the latest released binary. # # Code to match this is in ``conf.py``. version_scheme = "post-release" [tool.ruff] line-length = 79 lint.select = [ "ALL", ] lint.ignore = [ # Ruff warns that this conflicts with the formatter. "COM812", # Allow our chosen docstring line-style - no one-line summary. "D200", "D205", "D212", "D415", # Ruff warns that this conflicts with the formatter. "ISC001", # Ignore "too-many-*" errors as they seem to get in the way more than # helping. "PLR0913", # Allow 'assert' as we use it for tests. "S101", ] # Do not automatically remove commented out code. # We comment out code during development, and with VSCode auto-save, this code # is sometimes annoyingly removed. lint.unfixable = [ "ERA001", ] lint.pydocstyle.convention = "google" [tool.pylint] [tool.pylint.'MASTER'] # Pickle collected data for later comparisons. persistent = true # Use multiple processes to speed up Pylint. jobs = 0 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. # See https://chezsoi.org/lucas/blog/pylint-strict-base-configuration.html. # We do not use the plugins: # - pylint.extensions.code_style # - pylint.extensions.magic_value # - pylint.extensions.while_used # as they seemed to get in the way. load-plugins = [ 'pylint.extensions.bad_builtin', 'pylint.extensions.comparison_placement', 'pylint.extensions.consider_refactoring_into_while_condition', 'pylint.extensions.docparams', 'pylint.extensions.dunder', 'pylint.extensions.eq_without_hash', 'pylint.extensions.for_any_all', 'pylint.extensions.mccabe', 'pylint.extensions.no_self_use', 'pylint.extensions.overlapping_exceptions', 'pylint.extensions.private_import', 'pylint.extensions.redefined_loop_name', 'pylint.extensions.redefined_variable_type', 'pylint.extensions.set_membership', 'pylint.extensions.typing', ] # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension = false [tool.pylint.'MESSAGES CONTROL'] # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable = [ 'bad-inline-option', 'deprecated-pragma', 'file-ignored', 'spelling', 'use-symbolic-message-instead', 'useless-suppression', ] # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once).You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" disable = [ 'too-few-public-methods', 'too-many-positional-arguments', 'too-many-locals', 'too-many-arguments', 'too-many-instance-attributes', 'too-many-return-statements', 'too-many-lines', 'locally-disabled', # Let flake8 handle long lines 'line-too-long', # Let ruff handle unused imports 'unused-import', # Let ruff deal with sorting 'ungrouped-imports', # We don't need everything to be documented because of mypy 'missing-type-doc', 'missing-return-type-doc', # Too difficult to please 'duplicate-code', # Let ruff handle imports 'wrong-import-order', # Let ruff find protected member access. 'protected-access', ] [tool.pylint.'FORMAT'] # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt = false [tool.pylint.'SPELLING'] # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package. spelling-dict = 'en_US' # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file = 'spelling_private_dict.txt' # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words = 'no' [tool.docformatter] make-summary-multi-line = true [tool.check-manifest] ignore = [ ".checkmake-config.ini", ".yamlfmt", "*.enc", ".pre-commit-config.yaml", "readthedocs.yaml", "CHANGELOG.rst", "CODE_OF_CONDUCT.rst", "CONTRIBUTING.rst", "LICENSE", "Makefile", "ci", "ci/**", "codecov.yaml", "docs", "docs/**", ".git_archival.txt", "sample", "sample/**", "spelling_private_dict.txt", "tests", "tests-pylintrc", "tests/**", "lint.mk", ] [tool.deptry] pep621_dev_dependency_groups = [ "dev", "release", ] [tool.pyproject-fmt] indent = 4 keep_full_version = true max_supported_python = "3.13" [tool.pytest.ini_options] xfail_strict = true log_cli = true [tool.coverage.run] branch = true [tool.coverage.report] exclude_also = [ "if TYPE_CHECKING:", ] [tool.mypy] strict = true files = [ "." ] exclude = [ "build" ] follow_untyped_imports = true plugins = [ "mypy_strict_kwargs", ] [tool.pyright] enableTypeIgnoreComments = false reportUnnecessaryTypeIgnoreComment = true typeCheckingMode = "strict" [tool.interrogate] fail-under = 100 omit-covered-files = true verbose = 2 [tool.doc8] max_line_length = 2000 ignore_path = [ "./.eggs", "./docs/build", "./docs/build/spelling/output.txt", "./node_modules", "./sample/build", "./src/*.egg-info/", "./src/*/_setuptools_scm_version.txt", ] [tool.vulture] # Ideally we would limit the paths to the source code where we want to ignore names, # but Vulture does not enable this. ignore_names = [ # pytest configuration "pytest_collect_file", "pytest_collection_modifyitems", "pytest_plugins", # pytest fixtures - we name fixtures like this for this purpose "fixture_*", # Sphinx "autoclass_content", "autoclass_content", "autodoc_member_order", "copybutton_exclude", "extensions", "html_show_copyright", "html_show_sourcelink", "html_show_sphinx", "html_theme", "html_theme_options", "html_title", "htmlhelp_basename", "intersphinx_mapping", "language", "linkcheck_ignore", "linkcheck_retries", "master_doc", "myst_enable_extensions", "myst_substitutions", "nitpicky", "project_copyright", "pygments_style", "rst_prolog", "setup", "source_suffix", "spelling_word_list_filename", "templates_path", "warning_is_error", ] # Duplicate some of .gitignore exclude = [ ".venv" ] [tool.yamlfix] section_whitelines = 1 whitelines = 1 sphinx-substitution-extensions-2025.06.06/sample/000077500000000000000000000000001502065245300216575ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/sample/source/000077500000000000000000000000001502065245300231575ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/sample/source/Eleanor.txt000066400000000000000000000000461502065245300253050ustar00rootroot00000000000000This is a downloadable text document. sphinx-substitution-extensions-2025.06.06/sample/source/__init__.py000066400000000000000000000000271502065245300252670ustar00rootroot00000000000000""" Documentation. """ sphinx-substitution-extensions-2025.06.06/sample/source/conf.py000066400000000000000000000005171502065245300244610ustar00rootroot00000000000000""" Sample ``conf.py``. """ extensions = [ "myst_parser", "sphinx_substitution_extensions", "sphinx_toolbox.rest_example", ] rst_prolog = """ .. |author| replace:: Eleanor .. |MixedCaseReplacement| replace:: UnusedReplacement """ myst_enable_extensions = ["substitution"] myst_substitutions = { "author": "Talya", } sphinx-substitution-extensions-2025.06.06/sample/source/five.rst000066400000000000000000000000261502065245300246400ustar00rootroot00000000000000======= Five ======= sphinx-substitution-extensions-2025.06.06/sample/source/four.rst000066400000000000000000000000261502065245300246620ustar00rootroot00000000000000======= Four ======= sphinx-substitution-extensions-2025.06.06/sample/source/index.rst000066400000000000000000000021361502065245300250220ustar00rootroot00000000000000Samples for substitution directives and roles ============================================= Configuration ------------- .. literalinclude:: conf.py :language: python ``code-block`` -------------- .. rest-example:: .. code-block:: shell echo "The author is |author|" .. code-block:: shell :substitutions: echo "The author is |author|" Inline ``:code:`` ----------------- .. rest-example:: :code:`echo "The author is |author|"` :substitution-code:`echo "The author is |author|"` Inline ``:download:`` --------------------- .. rest-example:: .. We cannot use the substitution in the download target, because the download directive will error if the file does not exist. :download:`Script by |author| <../source/Eleanor.txt>`. :substitution-download:`Script by |author| <../source/|author|.txt>`. .. This is a test of parallel document builds. You need at least 5 documents. See: https://github.com/adamtheturtle/sphinx-substitution-extensions/pull/173 .. toctree:: :hidden: one two three four five .. toctree:: markdown_sample sphinx-substitution-extensions-2025.06.06/sample/source/markdown_sample.md000066400000000000000000000025571502065245300266750ustar00rootroot00000000000000Samples for substitution directives in Markdown =============================================== Configuration ------------- ```{literalinclude} conf.py :language: python ``` ``code-block`` -------------- ```{code-block} markdown ```{code-block} markdown echo "The author is |author|" ``` ```{code-block} markdown :substitutions: echo "The author is |author|" ``` or, with the value of the `myst_sub_delimiters` `conf.py` setting: ```{code-block} markdown echo "The author is {{author}}" ``` ```{code-block} markdown :substitutions: echo "The author is {{author}}" ``` ``` => ```{code-block} markdown echo "The author is |author|" ``` ```{code-block} markdown :substitutions: echo "The author is |author|" ``` ```{code-block} markdown echo "The author is {{author}}" ``` ```{code-block} markdown :substitutions: echo "The author is {{author}}" ``` Inline ``:substitution-code:`` ------------------------------ ```{code-block} markdown {substitution-code}`The author is {{author}}` ``` => {substitution-code}`The author is {{author}}` ``substitution-download`` ------------------------- ```{code-block} markdown {substitution-download}`Script by {{author}} <../source/Eleanor.txt>` ``` => {substitution-download}`Script by {{author}} <../source/Eleanor.txt>` sphinx-substitution-extensions-2025.06.06/sample/source/one.rst000066400000000000000000000000211502065245300244630ustar00rootroot00000000000000===== One ===== sphinx-substitution-extensions-2025.06.06/sample/source/three.rst000066400000000000000000000000271502065245300250170ustar00rootroot00000000000000======= Three ======= sphinx-substitution-extensions-2025.06.06/sample/source/two.rst000066400000000000000000000000211502065245300245130ustar00rootroot00000000000000===== Two ===== sphinx-substitution-extensions-2025.06.06/spelling_private_dict.txt000066400000000000000000000002571502065245300255150ustar00rootroot00000000000000admin beartype changelog conf hardcoded inline linters py pyright pytest reportUnknownMemberType reportUnknownParameterType reportUnknownVariableType rst str tuple whitespace sphinx-substitution-extensions-2025.06.06/src/000077500000000000000000000000001502065245300211655ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/src/sphinx_substitution_extensions/000077500000000000000000000000001502065245300276115ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/src/sphinx_substitution_extensions/__init__.py000066400000000000000000000214441502065245300317270ustar00rootroot00000000000000""" Custom Sphinx extensions. """ from typing import Any, ClassVar from beartype import beartype from docutils.nodes import ( Element, Node, substitution_definition, system_message, ) from docutils.parsers.rst import directives from docutils.parsers.rst.roles import code_role from docutils.parsers.rst.states import Inliner from docutils.statemachine import StringList from myst_parser.mocking import MockInliner from sphinx import addnodes from sphinx.application import Sphinx from sphinx.config import Config from sphinx.directives.code import CodeBlock from sphinx.environment import BuildEnvironment from sphinx.roles import XRefRole from sphinx.util.typing import ExtensionMetadata, OptionSpec from sphinx_substitution_extensions.shared import ( SUBSTITUTION_OPTION_NAME, ) def _get_delimiter_pairs( env: BuildEnvironment, config: Config, ) -> set[tuple[str, str]]: """ Get the delimiter pairs for substitution. """ markdown_suffixes = { key.lstrip(".") for key, value in config.source_suffix.items() if value == "markdown" } # Use `| |` on reST as it is the default substitution syntax. # Use `| |` on MyST for backwards compatibility as this is what we # originally shipped with. delimiter_pairs = {("|", "|")} parser_supported_formats = set(env.parser.supported) if parser_supported_formats.intersection(markdown_suffixes): opening_delimiter, closing_delimiter = config.myst_sub_delimiters new_delimiter_pair = ( opening_delimiter + opening_delimiter, closing_delimiter + closing_delimiter, ) delimiter_pairs = {*delimiter_pairs, new_delimiter_pair} return delimiter_pairs def _get_substitution_defs( env: BuildEnvironment, config: Config, substitution_defs: dict[str, substitution_definition], ) -> dict[str, str]: """ Get the substitution definitions from the environment. """ markdown_suffixes = { key.lstrip(".") for key, value in config.source_suffix.items() if value == "markdown" } parser_supported_formats = set(env.parser.supported) if parser_supported_formats.intersection(markdown_suffixes): if "substitution" in config.myst_enable_extensions: return dict(config.myst_substitutions) else: return { key: value.astext() for key, value in substitution_defs.items() } return {} @beartype class SubstitutionCodeBlock(CodeBlock): """ Similar to CodeBlock but replaces placeholders with variables. """ option_spec: ClassVar[OptionSpec] = CodeBlock.option_spec option_spec["substitutions"] = directives.flag def run(self) -> list[Node]: """ Replace placeholders with given variables. """ new_content = StringList() existing_content = self.content substitution_defs = _get_substitution_defs( env=self.env, config=self.config, substitution_defs=self.state.document.substitution_defs, ) delimiter_pairs = _get_delimiter_pairs( env=self.env, config=self.config, ) for item in existing_content: new_item = item for name, replacement in substitution_defs.items(): if SUBSTITUTION_OPTION_NAME in self.options: for delimiter_pair in delimiter_pairs: opening_delimiter, closing_delimiter = delimiter_pair new_item = new_item.replace( f"{opening_delimiter}{name}{closing_delimiter}", replacement, ) new_item_string_list = StringList(initlist=[new_item]) new_content.extend(other=new_item_string_list) self.content = new_content return super().run() @beartype class SubstitutionCodeRole: """ Custom role for substitution code. """ options: ClassVar[dict[str, Any]] = { "class": directives.class_option, "language": directives.unchanged, } def __call__( # pylint: disable=dangerous-default-value self, typ: str, rawtext: str, text: str, lineno: int, inliner: Inliner | MockInliner, # We allow mutable defaults as the Sphinx implementation requires it. options: dict[Any, Any] = {}, # noqa: B006 content: list[str] = [], # noqa: B006 ) -> tuple[list[Node], list[system_message]]: """ Replace placeholders with given variables. """ settings = inliner.document.settings env = settings.env substitution_defs = _get_substitution_defs( env=env, config=env.config, substitution_defs=inliner.document.substitution_defs, ) delimiter_pairs = _get_delimiter_pairs( env=env, config=env.config, ) for name, value in substitution_defs.items(): for delimiter_pair in delimiter_pairs: opening_delimiter, closing_delimiter = delimiter_pair text = text.replace( f"{opening_delimiter}{name}{closing_delimiter}", value, ) rawtext = text.replace( f"{opening_delimiter}{name}{closing_delimiter}", value, ) rawtext = rawtext.replace(name, value) # ``types-docutils`` says that ``code_role`` requires an ``Inliner`` # for ``inliner``. # # We can remove this when # https://github.com/executablebooks/MyST-Parser/issues/1017 # is resolved by typing ``inliner`` as ``Inliner``. if isinstance(inliner, MockInliner): new_inliner = Inliner() new_inliner.document = inliner.document inliner = new_inliner return code_role( role=typ, rawtext=rawtext, text=text, lineno=lineno, inliner=inliner, options=options, content=content, ) @beartype class SubstitutionXRefRole(XRefRole): """ Custom role for XRefs. """ def create_xref_node(self) -> tuple[list[Node], list[system_message]]: """Override parent method to set classes. This is a bit of a hack because it assumes that the role name will be `substitution-` and that we want to remove the `substitution-`. """ for index, class_name in enumerate(iterable=self.classes): self.classes[index] = class_name.replace("substitution-", "") return super().create_xref_node() def process_link( self, env: BuildEnvironment, refnode: Element, # We allow a boolean-typed positional argument as we are matching the # method signature of the parent class. has_explicit_title: bool, # noqa: FBT001 title: str, target: str, ) -> tuple[str, str]: """ Override parent method to replace placeholders with given variables. """ assert isinstance(env, BuildEnvironment) substitution_defs = _get_substitution_defs( env=env, config=env.config, substitution_defs=self.inliner.document.substitution_defs, ) delimiter_pairs = _get_delimiter_pairs( env=env, config=env.config, ) for name, value in substitution_defs.items(): for delimiter_pair in delimiter_pairs: opening_delimiter, closing_delimiter = delimiter_pair title = title.replace( f"{opening_delimiter}{name}{closing_delimiter}", value, ) target = target.replace( f"{opening_delimiter}{name}{closing_delimiter}", value, ) # Use the default implementation to process the link # as it handles whitespace in target text. return super().process_link( env=env, refnode=refnode, has_explicit_title=has_explicit_title, title=title, target=target, ) @beartype def setup(app: Sphinx) -> ExtensionMetadata: """ Add the custom directives to Sphinx. """ app.add_config_value(name="substitutions", default=[], rebuild="html") directives.register_directive( name="code-block", directive=SubstitutionCodeBlock, ) app.add_role(name="substitution-code", role=SubstitutionCodeRole()) substitution_download_role = SubstitutionXRefRole( nodeclass=addnodes.download_reference, ) app.add_role(name="substitution-download", role=substitution_download_role) return {"parallel_read_safe": True} sphinx-substitution-extensions-2025.06.06/src/sphinx_substitution_extensions/py.typed000066400000000000000000000000001502065245300312760ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/src/sphinx_substitution_extensions/shared.py000066400000000000000000000003671502065245300314370ustar00rootroot00000000000000""" Constants and functions shared between modules. """ # This is hardcoded in doc8 as a valid option so be wary that changing this # may break doc8 linting. # See https://github.com/PyCQA/doc8/pull/34. SUBSTITUTION_OPTION_NAME = "substitutions" spelling_private_dict.txt000066400000000000000000000000001502065245300346330ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/src/sphinx_substitution_extensionssphinx-substitution-extensions-2025.06.06/tests/000077500000000000000000000000001502065245300215405ustar00rootroot00000000000000sphinx-substitution-extensions-2025.06.06/tests/__init__.py000066400000000000000000000000561502065245300236520ustar00rootroot00000000000000""" Tests for the substitution extension. """ sphinx-substitution-extensions-2025.06.06/tests/conftest.py000066400000000000000000000007251502065245300237430ustar00rootroot00000000000000""" Configuration for pytest. """ import pytest from beartype import beartype pytest_plugins = "sphinx.testing.fixtures" # pylint: disable=invalid-name def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: """ Apply the beartype decorator to all collected test functions. """ for item in items: # All our tests are functions, for now assert isinstance(item, pytest.Function) item.obj = beartype(obj=item.obj) sphinx-substitution-extensions-2025.06.06/tests/test_substitution_extensions.py000066400000000000000000000714541502065245300302170ustar00rootroot00000000000000""" Tests for Sphinx extensions. """ from collections.abc import Callable from pathlib import Path from textwrap import dedent from sphinx.testing.util import SphinxTestApp def test_no_substitution_code_block( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``code-block`` directive does not replace placeholders. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. code-block:: shell $ PRE-|a|-POST """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() app_expected = make_app( srcdir=source_directory, exception_on_warning=True, freshenv=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = (app_expected.outdir / "index.html").read_text() assert content_html == expected_content_html def test_substitution_code_block( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``code-block`` directive replaces the placeholders defined in ``conf.py`` as specified. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. code-block:: shell :substitutions: $ PRE-|a|-POST """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ .. code-block:: shell $ PRE-example_substitution-POST """, ) source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = (app_expected.outdir / "index.html").read_text() assert content_html == expected_content_html def test_substitution_code_block_case_preserving( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``code-block`` directive respects the original case of replacements. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file_content = dedent( text="""\ .. |aBcD_eFgH| replace:: example_substitution .. code-block:: shell :substitutions: $ PRE-|aBcD_eFgH|-POST """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ .. code-block:: shell $ PRE-example_substitution-POST """, ) source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = (app_expected.outdir / "index.html").read_text() assert content_html == expected_content_html def test_substitution_inline( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``substitution-code`` role replaces the placeholders defined in ``conf.py`` as specified. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file_content = dedent( text="""\ .. |a| replace:: example_substitution Example :substitution-code:`PRE-|a|-POST` """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ Example :code:`PRE-example_substitution-POST` """, ) source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = (app_expected.outdir / "index.html").read_text() assert content_html == expected_content_html def test_substitution_inline_case_preserving( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``substitution-code`` role respects the original case of replacements. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file_content = dedent( text="""\ .. |aBcD_eFgH| replace:: example_substitution Example :substitution-code:`PRE-|aBcD_eFgH|-POST` """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ Example :code:`PRE-example_substitution-POST` """, ) source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = (app_expected.outdir / "index.html").read_text() assert content_html == expected_content_html def test_substitution_download( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``substitution-download`` role replaces the placeholders defined in ``conf.py`` as specified in both the download text and the download target. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() # Importantly we have a non-space whitespace character in the target name. downloadable_file = ( source_directory / "tgt_pre-example_substitution-tgt_post .py" ) downloadable_file.write_text(data="Sample") source_file_content = dedent( # Importantly we have a substitution in the download text and the # target. text="""\ .. |a| replace:: example_substitution :substitution-download:`txt_pre-|a|-txt_post ` """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ :download:`txt_pre-example_substitution-txt_post ` """, # noqa: E501 ) source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = (app_expected.outdir / "index.html").read_text() assert content_html == expected_content_html class TestMyst: """ Tests for MyST documents. """ @staticmethod def test_myst_substitutions_ignored_given_rst_definition( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ MyST substitutions are ignored in rST documents with a rST substitution definition. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. |a| replace:: rst_prolog_substitution .. code-block:: shell :substitutions: $ PRE-|a|-POST """, ) source_file.write_text(data=index_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "myst_substitution", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ .. code-block:: shell $ PRE-rst_prolog_substitution-POST """, ) source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "index.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_myst_substitutions_ignored_without_rst_definition( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ MyST substitutions are ignored in rST documents without a rST substitution definition. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file_content = dedent( text="""\ .. code-block:: shell :substitutions: $ PRE-|a|-POST """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "myst_substitution", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ .. code-block:: shell $ PRE-|a|-POST """, ) source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "index.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_myst_substitutions( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ MyST substitutions are respected in MyST documents. """ source_directory = tmp_path / "source" source_directory.mkdir() index_source_file = source_directory / "index.rst" markdown_source_file = source_directory / "markdown_document.md" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{code-block} :substitutions: $ PRE-|a|-POST ``` """, ) index_source_file.write_text(data=index_source_file_content) markdown_source_file.write_text(data=markdown_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "example_substitution", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{code-block} $ PRE-example_substitution-POST ``` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "markdown_document.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_myst_substitutions_not_enabled( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ MyST substitutions are not respected in MyST documents when ``myst_enable_extensions`` does not contain ``substitutions``. """ source_directory = tmp_path / "source" source_directory.mkdir() index_source_file = source_directory / "index.rst" markdown_source_file = source_directory / "markdown_document.md" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{code-block} :substitutions: $ PRE-|a|-POST ``` """, ) index_source_file.write_text(data=index_source_file_content) markdown_source_file.write_text(data=markdown_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_substitutions": { "a": "example_substitution", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{code-block} $ PRE-|a|-POST ``` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "markdown_document.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_myst_substitutions_custom_markdown_suffix( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ Custom markdown suffixes are respected in MyST documents. """ source_directory = tmp_path / "source" source_directory.mkdir() index_source_file = source_directory / "index.rst" markdown_source_file = source_directory / "markdown_document.txt" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{code-block} :substitutions: $ PRE-|a|-POST ``` """, ) index_source_file.write_text(data=index_source_file_content) markdown_source_file.write_text(data=markdown_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "example_substitution", }, "source_suffix": { ".rst": "restructuredtext", ".txt": "markdown", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{code-block} $ PRE-example_substitution-POST ``` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["myst_parser"], "source_suffix": { ".rst": "restructuredtext", ".txt": "markdown", }, }, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "markdown_document.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_default_myst_sub_delimiters_code_block( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The default MyST substitution delimiters are respected. """ source_directory = tmp_path / "source" source_directory.mkdir() index_source_file = source_directory / "index.rst" markdown_source_file = source_directory / "markdown_document.md" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{code-block} :substitutions: $ PRE-{{a}}-POST ``` """, ) index_source_file.write_text(data=index_source_file_content) markdown_source_file.write_text(data=markdown_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "example_substitution", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{code-block} $ PRE-example_substitution-POST ``` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "markdown_document.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_custom_myst_sub_delimiters_code_block( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ Custom MyST substitution delimiters are respected. """ source_directory = tmp_path / "source" source_directory.mkdir() index_source_file = source_directory / "index.rst" markdown_source_file = source_directory / "markdown_document.md" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{code-block} :substitutions: $ PRE-[[a]]-POST ``` """, ) index_source_file.write_text(data=index_source_file_content) markdown_source_file.write_text(data=markdown_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "example_substitution", }, "myst_sub_delimiters": ("[", "]"), }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{code-block} $ PRE-example_substitution-POST ``` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "markdown_document.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_substitution_code_role( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``substitution-code`` role replaces the placeholders defined in ``conf.py`` as specified. """ source_directory = tmp_path / "source" source_directory.mkdir() index_source_file = source_directory / "index.rst" markdown_source_file = source_directory / "markdown_document.md" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title Example {substitution-code}`PRE-|a|-POST` """, ) index_source_file.write_text(data=index_source_file_content) markdown_source_file.write_text(data=markdown_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "example_substitution", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title Example {code}`PRE-example_substitution-POST` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "markdown_document.html" ).read_text() assert content_html == expected_content_html @staticmethod def test_substitution_download( tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``substitution-download`` role replaces the placeholders defined in ``conf.py`` as specified. """ source_directory = tmp_path / "source" source_directory.mkdir() index_source_file = source_directory / "index.rst" markdown_source_file = source_directory / "markdown_document.md" (source_directory / "conf.py").touch() index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title {substitution-download}`txt_pre-|a|-txt_post ` """, ) # Importantly we have a non-space whitespace character in the target # name. downloadable_file = ( source_directory / "tgt_pre-example_substitution-tgt_post .py" ) downloadable_file.write_text(data="Sample") index_source_file.write_text(data=index_source_file_content) markdown_source_file.write_text(data=markdown_source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "a": "example_substitution", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title {download}`txt_pre-example_substitution-txt_post ` """, # noqa: E501 ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, ) app_expected.build() assert app_expected.statuscode == 0 expected_content_html = ( app_expected.outdir / "markdown_document.html" ).read_text() assert content_html == expected_content_html