pax_global_header00006660000000000000000000000064152346252540014522gustar00rootroot0000000000000052 comment=8087a67c1ed0e09cb150ba8d59b2d58b11153c61 adamtheturtle-sphinx-substitution-extensions-8087a67/000077500000000000000000000000001523462525400231315ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/.git_archival.txt000066400000000000000000000000331523462525400264000ustar00rootroot00000000000000ref-names: tag: 2026.08.05 adamtheturtle-sphinx-substitution-extensions-8087a67/.gitattributes000066400000000000000000000000631523462525400260230ustar00rootroot00000000000000.git_archival.txt export-subst * text=auto eol=lf adamtheturtle-sphinx-substitution-extensions-8087a67/.github/000077500000000000000000000000001523462525400244715ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/.github/dependabot.yml000066400000000000000000000003451523462525400273230ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: pip directory: / schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: / schedule: interval: daily adamtheturtle-sphinx-substitution-extensions-8087a67/.github/workflows/000077500000000000000000000000001523462525400265265ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/.github/workflows/ci.yml000066400000000000000000000034451523462525400276520ustar00rootroot00000000000000--- 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 * * * permissions: {} jobs: build: strategy: matrix: python-version: ['3.11', '3.12', '3.13', '3.14'] uv-resolution: [highest, lowest-direct] platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: '**/pyproject.toml' - name: Freeze for debugging run: | uv pip freeze - name: Build sample run: | uv run --extra=dev sphinx-build -W -b html sample/source sample/build env: UV_PYTHON: ${{ matrix.python-version }} UV_RESOLUTION: ${{ matrix.uv-resolution }} - name: Build sample parallel run: | uv run --extra=dev sphinx-build -j 2 -W -b html sample/source sample/build 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 . env: UV_PYTHON: ${{ matrix.python-version }} UV_RESOLUTION: ${{ matrix.uv-resolution }} completion-ci: needs: build runs-on: ubuntu-latest if: always() steps: - name: Check matrix job status run: |- if ! ${{ needs.build.result == 'success' }}; then echo "One or more matrix jobs failed" exit 1 fi adamtheturtle-sphinx-substitution-extensions-8087a67/.github/workflows/dependabot-merge.yml000066400000000000000000000007001523462525400324500ustar00rootroot00000000000000--- name: Dependabot auto-merge on: pull_request permissions: contents: write pull-requests: write jobs: dependabot: runs-on: ubuntu-latest if: github.event.pull_request.user.login == 'dependabot[bot]' steps: - 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}} adamtheturtle-sphinx-substitution-extensions-8087a67/.github/workflows/lint.yml000066400000000000000000000033531523462525400302230ustar00rootroot00000000000000--- name: Lint 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 * * * workflow_dispatch: {} permissions: {} jobs: build: strategy: matrix: python-version: ['3.13'] platform: [ubuntu-latest, windows-latest] hook-stage: [pre-commit, pre-push, manual] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: '**/pyproject.toml' - name: Lint # Use bash to ensure the step fails if any command fails. # PowerShell does not fail on intermediate command failures by default. shell: bash run: | # pre-commit is the only hook stage uv run --extra=dev prek run --all-files --hook-stage ${{ matrix.hook-stage }} --verbose env: UV_PYTHON: ${{ matrix.python-version }} # UV_RESOLUTION is intentionally not set here. prek uses uv to # install hooks, and uv inherits UV_RESOLUTION. With lowest-direct, # this causes hook dependencies to resolve to ancient versions that # don't build on modern Python. - uses: pre-commit-ci/lite-action@v1.1.0 if: always() completion-lint: needs: build runs-on: ubuntu-latest if: always() steps: - name: Check matrix job status run: |- if ! ${{ needs.build.result == 'success' }}; then echo "One or more matrix jobs failed" exit 1 fi adamtheturtle-sphinx-substitution-extensions-8087a67/.github/workflows/release.yml000066400000000000000000000072621523462525400307000ustar00rootroot00000000000000--- 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@v7 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@v9.0.0 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 }} # towncrier writes the rendered notes to stdout (informational # chatter goes to stderr), so this is the curated release body for # this version, not github-tag-action's commit-derived changelog. - name: Generate the GitHub release notes env: RELEASE: ${{ steps.calver.outputs.release }} run: uv run --extra=release towncrier build --draft --version "$RELEASE" > release-notes.md # Assemble the same fragments into CHANGELOG.rst under a new # ``$RELEASE`` section and delete the consumed fragment files. - name: Update the changelog env: RELEASE: ${{ steps.calver.outputs.release }} run: uv run --extra=release towncrier build --yes --version "$RELEASE" - uses: stefanzweifel/git-auto-commit-action@v7 id: commit with: commit_message: Bump CHANGELOG file_pattern: CHANGELOG.rst newsfragments # 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 }} bodyFile: release-notes.md - name: Build a binary wheel and a source tarball env: NEW_TAG: ${{ steps.tag_version.outputs.new_tag }} run: | git fetch --tags git checkout "$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 adamtheturtle-sphinx-substitution-extensions-8087a67/.gitignore000066400000000000000000000025211523462525400251210ustar00rootroot00000000000000# 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 adamtheturtle-sphinx-substitution-extensions-8087a67/.pre-commit-config.yaml000066400000000000000000000264631523462525400274250ustar00rootroot00000000000000--- fail_fast: true .uv_version: &uv_version uv==0.11.7 # 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 - strict-kwargs-fix - check-manifest - deptry - doc8 - interrogate - interrogate-docs - mypy - mypy-docs - pylint - pyproject-fmt-fix - pyright - pyright-docs - pyright-verifytypes - ty - ty-docs - pyroma - ruff-check-fix - ruff-check-fix-docs - ruff-format-fix - ruff-format-fix-docs - pydocstringformatter - shellcheck - shellcheck-docs - shfmt - shfmt-docs - vulture - vulture-docs - yamlfix - zizmor - pyrefly - pyrefly-docs # 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] repos: - repo: meta hooks: - id: check-useless-excludes stages: [pre-commit] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files stages: [pre-commit] - id: check-case-conflict stages: [pre-commit] - id: check-executables-have-shebangs stages: [pre-commit] - id: check-merge-conflict stages: [pre-commit] - id: check-shebang-scripts-are-executable stages: [pre-commit] - id: check-symlinks stages: [pre-commit] - id: check-json stages: [pre-commit] - id: check-toml stages: [pre-commit] - id: check-vcs-permalinks stages: [pre-commit] - id: check-yaml stages: [pre-commit] - id: end-of-file-fixer stages: [pre-commit] - id: file-contents-sorter files: spelling_private_dict\.txt$ stages: [pre-commit] - id: trailing-whitespace stages: [pre-commit] - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: rst-directive-colons stages: [pre-commit] - id: rst-inline-touching-normal stages: [pre-commit] - id: text-unicode-replacement-char stages: [pre-commit] - id: rst-backticks stages: [pre-commit] - repo: local hooks: - id: actionlint name: actionlint entry: uv run --extra=dev actionlint language: python pass_filenames: false types_or: [yaml] additional_dependencies: - *uv_version stages: [pre-commit] - id: pydocstringformatter name: pydocstringformatter entry: uv run --extra=dev pydocstringformatter language: python types_or: [python] additional_dependencies: - *uv_version stages: [pre-commit] - id: shellcheck name: shellcheck entry: uv run --extra=dev shellcheck --shell=bash language: python types_or: [shell] additional_dependencies: - *uv_version stages: [pre-commit] - id: shellcheck-docs name: shellcheck-docs entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=shell --language=console --command="shellcheck --shell=bash" language: python types_or: [markdown, rst] additional_dependencies: - *uv_version stages: [pre-commit] - id: shfmt name: shfmt entry: shfmt --write --space-redirects --indent=4 language: python types_or: [shell] additional_dependencies: - *uv_version stages: [pre-commit] - 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_version stages: [pre-commit] - id: mypy name: mypy stages: [pre-push] entry: uv run --extra=dev -m mypy --num-workers=4 language: python types_or: [python, toml] pass_filenames: false additional_dependencies: - *uv_version # We do not use --example-workers 0 due to https://github.com/python/mypy/issues/18283 - id: mypy-docs name: mypy-docs stages: [pre-push] entry: uv run --extra=dev doccmd --no-write-to-file --language=python --command="mypy --num-workers=4" 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_version - 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_version - id: pyright-docs name: pyright-docs stages: [pre-push] entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --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_version - id: ty name: ty stages: [pre-push] entry: uv run --extra=dev ty check language: python types_or: [python, toml] pass_filenames: false additional_dependencies: - *uv_version - id: ty-docs name: ty-docs stages: [pre-push] entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python --command="ty check" language: python types_or: [markdown, rst] additional_dependencies: - *uv_version - id: vulture name: vulture entry: uv run --extra=dev -m vulture . language: python types_or: [python] pass_filenames: false additional_dependencies: - *uv_version stages: [pre-commit] - id: vulture-docs name: vulture docs entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python --command="vulture" language: python types_or: [markdown, rst] additional_dependencies: - *uv_version stages: [pre-commit] - id: pyroma name: pyroma entry: uv run --extra=dev -m pyroma --min 10 . language: python pass_filenames: false types_or: [toml] additional_dependencies: - *uv_version stages: [pre-commit] - id: deptry name: deptry entry: uv run --extra=dev -m deptry src/ language: python pass_filenames: false additional_dependencies: - *uv_version stages: [pre-commit] - id: pylint name: pylint entry: uv run --extra=dev -m pylint src/ tests/ language: python stages: [manual] pass_filenames: false additional_dependencies: - *uv_version - 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_version stages: [pre-commit] - 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_version stages: [pre-commit] - id: ruff-format-fix name: Ruff format entry: uv run --extra=dev -m ruff format language: python types_or: [python] additional_dependencies: - *uv_version stages: [pre-commit] - 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_version stages: [pre-commit] - id: strict-kwargs-fix name: strict-kwargs entry: uv run --extra=dev strict-kwargs check --diff language: python types_or: [python] additional_dependencies: - *uv_version stages: [pre-commit] require_serial: true - id: doc8 name: doc8 entry: uv run --extra=dev -m doc8 language: python types_or: [rst] additional_dependencies: - *uv_version stages: [pre-commit] - id: interrogate name: interrogate entry: uv run --extra=dev -m interrogate language: python types_or: [python] additional_dependencies: - *uv_version stages: [pre-commit] - id: interrogate-docs name: interrogate docs entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python --command="interrogate" language: python types_or: [markdown, rst] additional_dependencies: - *uv_version stages: [pre-commit] - id: pyproject-fmt-fix name: pyproject-fmt entry: uv run --extra=dev pyproject-fmt language: python types_or: [toml] files: pyproject.toml stages: [pre-commit] - id: yamlfix name: yamlfix entry: uv run --extra=dev yamlfix language: python types_or: [yaml] additional_dependencies: - *uv_version stages: [pre-commit] - id: zizmor name: zizmor entry: uv run --extra=dev zizmor --strict-collection .github language: python pass_filenames: false types_or: [yaml] additional_dependencies: - *uv_version stages: [pre-commit] - 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_version stages: [pre-commit] - id: pyrefly name: pyrefly stages: [pre-push] entry: uv run --extra=dev pyrefly check language: python types_or: [python, toml] pass_filenames: false additional_dependencies: - *uv_version - id: pyrefly-docs name: pyrefly-docs stages: [pre-push] entry: uv run --extra=dev doccmd --no-write-to-file --example-workers 0 --language=python --command="pyrefly check" language: python types_or: [markdown, rst] additional_dependencies: - *uv_version adamtheturtle-sphinx-substitution-extensions-8087a67/.prettierrc000066400000000000000000000002331523462525400253130ustar00rootroot00000000000000{ "overrides": [ { "files": ["*.yaml", "*.yml"], "options": { "singleQuote": true, "printWidth": 100 } } ] } adamtheturtle-sphinx-substitution-extensions-8087a67/.vscode/000077500000000000000000000000001523462525400244725ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/.vscode/extensions.json000066400000000000000000000001341523462525400275620ustar00rootroot00000000000000{ "recommendations": [ "charliermarsh.ruff", "ms-python.python" ] } adamtheturtle-sphinx-substitution-extensions-8087a67/.vscode/settings.json000066400000000000000000000005371523462525400272320ustar00rootroot00000000000000{ "[python]": { "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true }, "python.testing.pytestArgs": [ "." ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true } adamtheturtle-sphinx-substitution-extensions-8087a67/CHANGELOG.rst000066400000000000000000000115071523462525400251560ustar00rootroot00000000000000Changelog ========= .. contents:: .. towncrier release notes start 2026.08.05 ---------- No significant changes. 2026.08.02 ---------- - Add content substitutions to the ``include`` directive with ``:content-substitutions:`` and ``:nocontent-substitutions:`` options. - Add an option to apply substitutions to external hyperlink targets. 2026.06.17 ---------- - Drop Python 3.10 support (requires Python >=3.11). - Pin Sphinx to <9 in dev dependencies for sphinx_toolbox compatibility in sample project. 2026.01.12 ---------- - Fix bug where ``SubstitutionXRefRole`` removed all occurrences of "substitution-" from CSS class names instead of only the prefix. 2025.12.15 ---------- 2025.11.17 ---------- - Give version in extension metadata. - ``literalinclude`` directive now supports the following options: - ``:content-substitutions:`` - Performs substitutions on the included file content. - ``:path-substitutions:`` - Performs substitutions on the file path. - ``image`` directive now supports the following option: - ``:path-substitutions:`` - Performs substitutions on the image file path. - Add ``substitutions_default_enabled`` configuration option to enable substitutions by default. When set to ``True`` in ``conf.py``: - Substitutions are applied to all ``code-block`` directives without requiring the ``:substitutions:`` flag. Use the ``:nosubstitutions:`` flag on individual code blocks to disable substitutions when the default is enabled. - Substitutions are applied to all ``literalinclude`` directives (both content and path) without requiring the ``:content-substitutions:`` or ``:path-substitutions:`` flags. Use the ``:nocontent-substitutions:`` or ``:nopath-substitutions:`` flags on individual literalinclude directives to disable substitutions when the default is enabled. - Substitutions are applied to all ``image`` directives (path) without requiring the ``:path-substitutions:`` flag. Use the ``:nopath-substitutions:`` flag on individual image directives to disable substitutions when the default is enabled. 2025.10.24 ---------- 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``. adamtheturtle-sphinx-substitution-extensions-8087a67/CONTRIBUTING.rst000066400000000000000000000043721523462525400256000ustar00rootroot00000000000000Contributing ============ 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 prek install Linting ------- Run lint tools either by committing, or with: .. code-block:: shell prek run --all-files --hook-stage pre-commit --verbose prek run --all-files --hook-stage pre-push --verbose prek run --all-files --hook-stage manual --verbose The development dependencies include `doc8-custom-ignores`_. The plugin is discovered automatically by doc8 and reads additional ignores from ``pyproject.toml``: .. code-block:: toml [tool.doc8] ignore-messages = [ """Error in "include" directive: unknown option: "content-substitutions".""", """Error in "include" directive: unknown option: "path-substitutions".""", ] This ignores diagnostics for ``include``'s ``:content-substitutions:`` and ``:path-substitutions:`` options. Plain docutils does not know about those options, but this extension adds them when Sphinx builds the documentation. Keep these as exact-message ignores so other ``D000`` diagnostics remain visible. .. _Homebrew: https://brew.sh .. _doc8-custom-ignores: https://adamtheturtle.github.io/doc8-custom-ignores/ 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 adamtheturtle-sphinx-substitution-extensions-8087a67/LICENSE000066400000000000000000000020551523462525400241400ustar00rootroot00000000000000MIT License Copyright (c) 2025 Adam Dangoor 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. adamtheturtle-sphinx-substitution-extensions-8087a67/README.rst000066400000000000000000000257511523462525400246320ustar00rootroot00000000000000|Build Status| |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>` External hyperlinks ~~~~~~~~~~~~~~~~~~~ Enable substitutions in external hyperlink targets in ``conf.py``: .. code-block:: python """Configuration for Sphinx.""" substitutions_hyperlink_targets_enabled = True Then substitutions are applied to hyperlink targets: .. code-block:: rst Download version |release| from the tarball_. .. _tarball: https://example.com/releases/v|release|.tar.gz The setting enables hyperlink-target substitutions throughout the project, but only targets containing a defined substitution are changed. To limit a substitution to one page, define it in that page instead of in ``rst_prolog``: .. code-block:: rst .. |tarball-release| replace:: 0.8.5 Download the tarball_. .. _tarball: https://example.com/releases/v|tarball-release|.tar.gz To limit the substitution to one link on that page, use a unique substitution name, such as ``tarball-release`` above, only in that link's target. Other hyperlink targets are left unchanged. ``literalinclude`` ~~~~~~~~~~~~~~~~~~ This adds ``:content-substitutions:`` and ``:path-substitutions:`` options to Sphinx's built-in `literalinclude`_ directive. Replace substitutions in the content of the included file: .. code-block:: rst .. literalinclude:: path/to/file.txt :content-substitutions: Replace substitutions in the file path: .. code-block:: rst .. literalinclude:: path/to/|author|_file.txt :path-substitutions: ``include`` ~~~~~~~~~~~ This adds ``:content-substitutions:`` and ``:path-substitutions:`` options to docutils' built-in `include`_ directive. Replace substitutions in the included source content before it is parsed: .. code-block:: rst .. include:: path/to/file.rst :content-substitutions: Replace substitutions in the file path: .. code-block:: rst .. include:: path/to/|author|_file.txt :path-substitutions: ``image`` ~~~~~~~~~ This adds a ``:path-substitutions:`` option to Sphinx's built-in `image`_ directive. Replace substitutions in the image path: .. code-block:: rst .. image:: path/to/|author|_diagram.png :path-substitutions: :alt: Diagram 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``. Substitutions can also be defined or overridden for an individual Markdown document in its frontmatter: .. code-block:: markdown --- myst: substitutions: release: "0.2" author: name: Talya --- ```{code-block} shell :substitutions: echo "|author.name| released version |release|" ``` Enabling substitutions by default ---------------------------------- By default, you need to explicitly add the ``:substitutions:`` flag to ``code-block`` directives, ``:content-substitutions:`` or ``:path-substitutions:`` flags to ``literalinclude`` and ``include`` directives, and ``:path-substitutions:`` to ``image`` directives. If you want substitutions to be applied by default without needing these flags, you can set the following in ``conf.py``: .. code-block:: python """Configuration for Sphinx.""" substitutions_default_enabled = True When this is enabled: - All ``code-block`` directives will have substitutions applied automatically - All ``literalinclude`` directives will have both content and path substitutions applied automatically - All ``include`` directives will have both content and path substitutions applied automatically - All ``image`` directives will have path substitutions applied automatically You can disable substitutions for specific directives when the default is enabled: .. code-block:: rst .. code-block:: shell :nosubstitutions: echo "This |will| not be substituted" .. literalinclude:: path/to/file.txt :nocontent-substitutions: .. literalinclude:: path/to/|literal|_file.txt :nopath-substitutions: .. include:: path/to/|literal|_file.txt :nocontent-substitutions: :nopath-substitutions: .. image:: path/to/|literal|_diagram.png :nopath-substitutions: 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>` ``literalinclude`` ~~~~~~~~~~~~~~~~~~ This adds ``:content-substitutions:`` and ``:path-substitutions:`` options to Sphinx's built-in `literalinclude`_ directive. Replace substitutions in the content of the included file: .. code-block:: markdown ```{literalinclude} path/to/file.txt :content-substitutions: ``` Replace substitutions in the file path: .. code-block:: markdown ```{literalinclude} path/to/|author|_file.txt :path-substitutions: ``` ``include`` ~~~~~~~~~~~ This adds a ``:path-substitutions:`` option to docutils' built-in `include`_ directive. Replace substitutions in the file path: .. code-block:: markdown ```{include} path/to/|author|_file.txt :path-substitutions: ``` ``image`` ~~~~~~~~~ This adds a ``:path-substitutions:`` option to Sphinx's built-in `image`_ directive. Replace substitutions in the image path: .. code-block:: markdown ```{image} path/to/|author|_diagram.png :path-substitutions: :alt: Diagram ``` Nested substitutions ~~~~~~~~~~~~~~~~~~~~~ ``myst_substitutions`` supports nested dictionaries and lists, which are flattened using dot notation. **Important:** Substitution keys cannot contain dots (``.``), as dots are reserved for nested access notation. For example, ``{"key.with.dots": "value"}`` raises an exception. Nested dictionaries: .. code-block:: python """Configuration for Sphinx.""" myst_substitutions = { "app": { "name": "MyApp", "version": "1.0.0", }, } Usage in Markdown: .. code-block:: markdown ```{code-block} bash :substitutions: echo "Application: |app.name| version |app.version|" ``` Lists with index access: .. code-block:: python """Configuration for Sphinx.""" myst_substitutions = { "platforms": ["Linux", "Windows", "macOS"], } Usage: .. code-block:: markdown ```{code-block} bash :substitutions: echo "First platform: |platforms.0|" echo "Second platform: |platforms.1|" ``` Nested lists of dictionaries: .. code-block:: python """Configuration for Sphinx.""" myst_substitutions = { "releases": [ {"version": "1.0", "codename": "Alpha"}, {"version": "2.0", "codename": "Beta"}, ], } Usage: .. code-block:: markdown ```{code-block} bash :substitutions: echo "First release: |releases.0.version| (|releases.0.codename|)" echo "Second release: |releases.1.version| (|releases.1.codename|)" ``` Complex nested structures: .. code-block:: python """Configuration for Sphinx.""" myst_substitutions = { "project": { "name": "MyProject", "contributors": [ {"name": "Alice", "role": "dev"}, {"name": "Bob", "role": "docs"}, ], }, } Usage: .. code-block:: markdown ```{code-block} bash :substitutions: echo "Project: |project.name|" echo "Developer: |project.contributors.0.name|" echo "Documentation: |project.contributors.1.name|" ``` 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 .. _literalinclude: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-literalinclude .. _include: https://docutils.sourceforge.io/docs/ref/rst/directives.html#including-an-external-document-fragment .. _image: http://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-image .. |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.11 adamtheturtle-sphinx-substitution-extensions-8087a67/newsfragments/000077500000000000000000000000001523462525400260145ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/newsfragments/.gitkeep000066400000000000000000000000001523462525400274330ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/newsfragments/1554.bugfix.rst000066400000000000000000000000741523462525400304300ustar00rootroot00000000000000Support MyST substitutions defined in document frontmatter. adamtheturtle-sphinx-substitution-extensions-8087a67/pyproject.toml000066400000000000000000000301501523462525400260440ustar00rootroot00000000000000[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 = "MIT" authors = [ { name = "Adam Dangoor", email = "adamdangoor@gmail.com" }, ] requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Pytest", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] dynamic = [ "version", ] dependencies = [ "beartype>=0.22.9", "docutils>=0.19", "myst-parser>=4.0.0", "sphinx>=8.1.0", ] optional-dependencies.dev = [ "actionlint-py==1.7.12.24", "check-manifest==0.51", "deptry==0.25.1", "doc8==2.0.0", "doc8-custom-ignores==2026.7.29.1", "doccmd==2026.7.19", "interrogate==1.7.0", "mypy[faster-cache]==2.3.0", "mypy-strict-kwargs==2026.7.19.1", "prek==0.4.11", "pydocstringformatter==1.0.0", "pylint[spelling]==4.0.6", "pyproject-fmt==2.26.0", "pyrefly==1.2.0", "pyright==1.1.411", "pyroma==5.0.1", "pytest==9.1.1", "pytest-beartype-tests==2026.4.26", "pytest-cov==7.1.0", "ruff==0.16.1", # 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.11.0.1", "shfmt-py==4.0.0", "sphinx>=9,<10", "sphinx-lint==1.0.2", "sphinx-toolbox==4.3.0", "strict-kwargs==2026.7.24", "towncrier==25.8.0", "ty==0.0.65", "types-docutils==0.22.3.20260724", "vulture==2.16", "yamlfix==1.19.1", "zizmor==1.29.0", ] optional-dependencies.release = [ "check-wheel-contents==0.6.3", "towncrier==25.8.0" ] urls.Source = "https://github.com/adamtheturtle/sphinx-substitution-extensions" [dependency-groups] dev = [] [tool.setuptools] packages.find.where = [ "src", ] package-data.sphinx_substitution_extensions = [ "py.typed", ] zip-safe = false [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", # This project does not use per-file copyright notices. "CPY001", # Allow our chosen docstring line-style - pydocstringformatter handles formatting # but doesn't enforce D205 (blank line after summary) or D212 (summary on first line). "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", ] lint.per-file-ignores."doccmd_*.py" = [ # Allow our chosen docstring line-style - pydocstringformatter handles # formatting but docstrings in docs may not match this style. "D200", ] # 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.flake8-tidy-imports.banned-api."typing.cast".msg = "typing.cast is banned: use explicit type narrowing or a typed variable instead." lint.pydocstyle.convention = "google" [tool.pylint] # 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" "MESSAGES CONTROL".disable = [ # Too difficult to please "duplicate-code", # Let flake8 handle long lines "line-too-long", "locally-disabled", "missing-return-type-doc", # We don't need everything to be documented because of mypy "missing-type-doc", # Let ruff find protected member access. "protected-access", "too-few-public-methods", "too-many-arguments", "too-many-instance-attributes", "too-many-lines", "too-many-locals", "too-many-positional-arguments", "too-many-return-statements", # Let ruff deal with sorting "ungrouped-imports", # Let ruff handle unused imports "unused-import", # Let ruff handle imports "wrong-import-order", ] # 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. "MESSAGES CONTROL".enable = [ "bad-inline-option", "deprecated-pragma", "file-ignored", "spelling", "use-symbolic-message-instead", "useless-suppression", ] DEPRECATED_BUILTINS.bad-functions = [ # Use Pylint until Ruff can ban bare builtin calls, or until custom rules # make this removable: # https://github.com/astral-sh/ruff/issues/10079 # https://github.com/astral-sh/ruff/issues/970 "filter", "getattr", "hasattr", "map", "setattr", ] # Allow the body of an if to be on the same line as the test if there is no # else. FORMAT.single-line-if-stmt = false # Return non-zero exit code if useless-suppression is emitted. MAIN.fail-on = [ "useless-suppression", ] # Use multiple processes to speed up Pylint. MASTER.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. MASTER.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", ] # Pickle collected data for later comparisons. MASTER.persistent = true # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. MASTER.unsafe-load-any-extension = false # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package. SPELLING.spelling-dict = "en_US" # A path to a file that contains private dictionary; one word per line. SPELLING.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.spelling-store-unknown-words = "no" [tool.interrogate] fail-under = 100 verbose = 2 omit-covered-files = true [tool.check-manifest] ignore = [ "*.enc", ".checkmake-config.ini", ".git_archival.txt", ".pre-commit-config.yaml", ".prettierrc", ".yamlfmt", "CHANGELOG.rst", "ci", "ci/**", "CODE_OF_CONDUCT.rst", "CONTRIBUTING.rst", "docs", "docs/**", "LICENSE", "lint.mk", "Makefile", "newsfragments", "newsfragments/**", "readthedocs.yaml", "sample", "sample/**", "spelling_private_dict.txt", "tests", "tests-pylintrc", "tests/**", "zizmor.yml", ] [tool.deptry] optional_dependencies_dev_groups = [ "dev", "release", ] [tool.vulture] # Duplicate some of .gitignore exclude = [ ".venv" ] # Ideally we would limit the paths to the source code where we want to ignore names, # but Vulture does not enable this. ignore_names = [ # Sphinx "autoclass_content", "autoclass_content", "autodoc_member_order", "copybutton_exclude", "extensions", # pytest fixtures - we name fixtures like this for this purpose "fixture_*", "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", # pytest configuration "pytest_collect_file", "pytest_plugins", "rst_prolog", "setup", "source_suffix", "spelling_word_list_filename", "substitutions_default_enabled", "substitutions_hyperlink_targets_enabled", "templates_path", "warning_is_error", ] [tool.pyproject-fmt] indent = 4 keep_full_version = true max_supported_python = "3.14" [tool.mypy] files = [ "." ] exclude = [ "build" ] follow_untyped_imports = true strict = true plugins = [ "mypy_strict_kwargs", ] [tool.pyrefly] errors.non-exhaustive-match = "error" [tool.pyright] typeCheckingMode = "strict" enableTypeIgnoreComments = false reportUnnecessaryTypeIgnoreComment = true [tool.ty] terminal.error-on-warning = true analysis.respect-type-ignore-comments = false [tool.pytest] log_cli = true xfail_strict = true [tool.coverage] run.branch = true report.exclude_also = [ "if TYPE_CHECKING:", ] report.show_missing = true [tool.towncrier] # The changelog and the per-release GitHub release notes are both built # from news fragments under ``newsfragments/``. The release workflow # runs ``towncrier build`` to assemble them; contributors add one # fragment file per user-facing change. directory = "newsfragments" filename = "CHANGELOG.rst" # Custom template so an assembled version reproduces the historical # style exactly: a bare ```` heading (no project name, no # date) followed by a flat bullet list with no per-type sub-headings. template = "towncrier_template.rst.jinja" title_format = "{version}" issue_format = "#{issue}" # ``title_format`` underline first, then any nested headings. A bare # version such as ``2026.05.18`` underlined with ``-`` matches every # pre-towncrier entry in CHANGELOG.rst. underlines = [ "-", "~", "^" ] type = [ # A single, unnamed fragment type keeps the assembled output as one # flat bullet list, matching the historical changelog (which never # grouped entries under "Features"/"Bugfixes"/... sub-headings). { directory = "change", name = "", showcontent = true }, ] [tool.pydocstringformatter] write = true split-summary-body = false max-line-length = 75 linewrap-full-docstring = true [tool.doc8] max_line_length = 2000 ignore-messages = [ '''Error in "include" directive: unknown option: "content-substitutions".''', '''Error in "include" directive: unknown option: "path-substitutions".''', ] ignore_path = [ "./.eggs", "./docs/build", "./docs/build/spelling/output.txt", "./node_modules", "./sample/build", "./src/*.egg-info/", "./src/*/_setuptools_scm_version.txt", ] [tool.yamlfix] section_whitelines = 1 whitelines = 1 adamtheturtle-sphinx-substitution-extensions-8087a67/sample/000077500000000000000000000000001523462525400244125ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/000077500000000000000000000000001523462525400257125ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/Eleanor.txt000066400000000000000000000000461523462525400300400ustar00rootroot00000000000000This is a downloadable text document. adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/Eleanor_diagram.png000066400000000000000000000102161523462525400314710ustar00rootroot00000000000000‰PNG  IHDR,bÕr•UIDATxœíÝ{xSåÀñ÷$iÚR¤nŽíATP¹ ¡ˆP)"ÃY´Tª°y)ÒQZŸú ¢>*¬ Üª °¢S[ ¨¨Âä& <ʘ”)åoˆâ…zKšd#KzIišœß9ßÏ_iH““÷¼ç›7iéÑ< ,‘ÞÁ Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á †Mé’¦i‘ÞÀì<ÒVXÄÐé Ë땒ÑÞÀŒþÜå|¥K¬°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆ¡ë?‘ŒPÉHìxÑ%½¼—û\5xèèÛ3ût)ÞUÒì¨i5NGÊM·vó—ö¼ëk2F5߃Âð–)Ø¢ìÓ‹_‰È#VWVÌ“Û"ùÚ¡í.ìÎm€ñ,S;QVúìŒúþ»§sÔøÉõèõåýOO_^Vö§¿d ÍÊUJeöé’:"kßû;ËËJo¾ë¤”Ôcß÷äÄû*+Êc[ÄÝ9ëñsóÛÌ>]’®N½ [÷´Ì¿‡ˆŽm‘9î¡Â‡'%_;Ô»¬ó{ˆc?}ê¡ÊËJ[·m·û­7–¼ó‘÷A½wØ£ßÛsyJêG»Þ¹>gLÉ{;Kv¿;dd¶÷Ÿ`x|†ejË>ddöÔ¢—îóÄÓ“Ç)¥6,/yßęϭ^óìSÞÛÔ8çüê×3ŠWýí‰ÂÂY“”RKò§]‘vÃÌ嫯H»aé#Ó•RNGõi×ÖÊ+¡s×#Ÿò}é÷K™ÞÈõ3Ÿ{µßà´ªŠrïm|w¸=ÎêªÁ7gN_¶rÑ´ C2s¦/[éû'+,S¨q:&gþÙ{ùÖû'vNìí½¼gû6_Jª**Ü.W渇¶¯[óî¶Í'Ž{¯÷¸ÝW ¿E)õ»vÊŸ¼ò£;îœ5O)•œš¾ü±‡O¾îY¬=“¯¬ëÑ]5.kÔ/3Íï!>ڹ㎙)¥zºÚb±zoã»ÃÀíÑ,–NÝ{Z¬V[TÔ…Ý{jKuUeó t‡`™ú3,WkÒâ磢£=n÷¾÷vZ¬Ö¹÷ä^>8mÈÈœM/,ó}o\«Vgž‘;ð|ÀV›M³Ô¹Zÿdïîwõ}é÷5N§÷zÛãQ¿;¬u{,Ö“]‹ŠŽ©çAaHìoSëriŸÿlÙ ”zÿ­7V=³@)õé?èŸ:Ôé¨v:ª½· ŒB÷¤äozM)õïM¯ý¡orýq¢¬´xîÃ7Üv‡ï¿‡èÜë²][7*¥þ³yCà¹Ñ·fÆ Ëto /îÕ{ä}Ût¸`Õ3 FO˜öôäq›V,³ZmcfÌQJ];"kBFzBçnq­âG”Ýxo?ùÞ÷ú‹Å1±-îœõx}¨i®ç°Ûî<³k~1:ojAÞ=ëŸ+êœØ;&¶…ßý³=0-ð5M¼o=8U½,È»7}ôí:w=°wÏ’G¦Í\¾:Ò[å=U½ãÀ 6$3{ÑŒ‰ö蘧3wÊìHot1+,>t Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á †®ÿâ(€òè/¬°ˆ¡Ó$ò­‹=Ï™—#ºQ0‚…&Ï¡ºÛD¶Z M˜=A/£(B‚`á¬æÍY½ã#[h"‚…FΘ&8E¶pÖ‚›(¡þlá,,44Ešùç}” Á#X¨{r„ñWÈ‚A°PÛ´ˆÐoQ‘-Ô`áŒÙ ßöÔÉf@‡ôÛ}n"ˆ`™þ£ ÿ-DØ,ó’Y[‹fB°LGô‘/zãÑtËDŒt´é¹ xËŒzxõy¡.ËàÌpH›á9‹`“ a>e"XFÃqËÁ2TFÃð–*FÆ$–`tŠ2‚%©bÜ̉` CªC3#Xb*ÆKïèà ‚¥_¤ŠÑ†‚¥G¤Š‘Gí‡ÿAWH•°t‹`…à?¯Õ%øŽjÊN áÜÀ™V“Ô3/ƒœ‘¤JÿÎn5}n ­–ë¤J o_´SÈMd±ÂjòÖöBZÿK(©’«Qûî,æêÇ +|è”°ÚŠ,VX¡Äÿ! | %UFÕàžmpn QXa5/Rel,¸ÂŒVs½Ò’*³©k3BˆVˆ1;M‹ÕV°Â ÝPrÔ14-4t:Ž þ¢0BEŸ Ì 3Ï K¤7 ñÖüçGzŒìÞ_ß«Ä:6uj¤7ÁÈÎÕëð²Â Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á J^B^ÓÇzËü-M¿èÁs»w\¸0eñâ ¾°gO=·œ·}{£î¹±·7‚>˶8P¼{÷ÚQ£6ßvÛÚQ£–¾ÿþ¶ƒëº1Á -‚Õ8y yëf®[pÝ‚G<úákú®\;ymÁ‚‚´‚>ÿÁoEæ½¼!ƒ£ÜñÔð§B»ÿ~;vLOI‰‰QJÅÇÄLKI™jYÔ>?ßwïåÙo¾Yîp +.ö^3éõׯ-,L-*úü§Ÿ¼=jE°ÇåpÅ7öµ±9Å9«&¬:}eµ«]¯vw¯¿;yTò«½Zë7¦æ¥ÚãìcViäBwö=Ú£Mß—=Û´)9z´Ö[N4(În_™©”rÔÔô:ÿüÙÙY½{OÜ´©ÁÛ£V«q<OÒˆ$¥Ôy çU•U¾VS=Òz(¥¯O<´óÿ·¸uw6J„Ò©;û]ç<©¦]×µ«Rê†nÝv}õU÷GmVãX£¬±ñ±§¿øy–jM³žþÂm;3R•¥•.§«‘]ëÒºõß|ãûrÏ7ßtmÝúÌè”VU9]þ;ýÔ9=IìVkƒ·G­Vãhÿ×Ò“3ÏåÞ·yßɹ»fÏE.RJŶŠ=RrD)õÞËïi?‡Íãö°Ú2€»û÷Ÿ²ysYÕÉõuiUÕÔ-[îéß_)Õ*:ºä»ï”R/}ø¡o–œÚç'Ãär»7ò‰RêÕ?pÁ ÞòÎü,…-ÚöÁ??غ`kl|lFA†Rjxþð%£—´lݲå¬Ñ'_N•RúuZ4bQîŠÜHo/šäªN—•]·ti´Õêp¹nOJر£RêÑÔÔ¬—_þM\\ï¶mí¶ÓGVrûö/¼ðâˆÑ6Ûš}ûþ¾cG|LÌéé ÞžT+ͣ˜{?rªú¼„¼üC¿üÄGÖ©êõ9œâNUß>?ÿ‹¼ü6_8OU¯Ã¹Á[Bb¬¸¼B˜ Z^éÁ Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á †®ÿ¦;Â@Ÿ Ì 3Ï VXÄÐé K"ß+?C æC3!X¡P²enL€fE°šmd)—ɰÇÀ`5óø’-`/‡ Á Ë(“-ƒbφÁ ãX“-£`WF ÁŠÄ S.±Øw‘E°"7ôdKö—¬Hï²¥{ì#ý XºÀ!¡Cì"XúÂA¢ìÝ"XzÄÃÈ£VK¿È£ ?KïÈà ‚%åbÔEo<šŽ`™—¬ƒ_ÖÖ¢™,³Óô¿…‚ýFAŸ[…"XM |ß[—àï“N¡. !+W=Í ò~HêgkàßaJÞ¾h§„ç©B0Xa!”«­ZYõ#©BðXa!2«-:…³À !^pù-²oFªpÖXa!| .R…&b……Я¶¯'U ‚…&Ï¡ºóäÃo~"$š¥\,©Ð– þ9B…• ÎY"½,~J(†Çs(Ò›`dš–éM@ÃXaƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ?‘Œ ´hÑ¥oßžš¦95óæMêÓ§'‡ðã¬9ü|ŽåHþM÷sÏ½äØ±½J©½{K²³ÇïÚµVñoºsÖc……ƹä’.Ÿ}ö¥7aÆ]Ó«×nºéºììq'NT´lÙ¢°pŽÍfÍÍðãÇìvûòåó£¢lcÇN9rä¨Ãáœ;÷Á¾}{.X°¤°ð%MÓòóóöïÿÔwyðàì Ô`¡q¶nýWbb7¥Tuµã–[Ò¯¹æÊ[o½'##=3sxqñªxØjµÞxcZFFzQÑËS¦<îtÖŒ›•””øÅ‡ÓÓsöìÙ0cFÁ§Ÿ¾ýõ×GfÍzrãÆm¾Ë â-¡zxKèý ËãñÄÇ·*(˜šðû–-»••ý×b±´m›tðàÛÑÑöêjGÇŽ4M;xðm»=Êår8QѽûàNÚ{ïä믿-)Ùš“3¾´ôøwd¦¤ Èʺßw9‚ÏŽ·„R°ÂBPìö¨mÛ^ü¿©c³Z,–ÀÏ}\.—÷«ÕNMMÍÆËbb¢Ýn÷öíïZ­Ö%K{ë­óç?ûüókμ\T4—úñk hªAƒú­\¹^)µråú?þñò¾}׬y])µxñŠ éßÿ²Õ«7)¥6lØ6{ö“¥¥Ç¼©_¿K‹‹ç­[÷†ïòúõo²'Ð Þ  «Ÿ^søð·99ãËË+ãâb çTTTæäŒ÷¾y\¶ìñãÇËssó***m6Û¢Eù;¶Ÿ3gáŠÿt»Ý997WVVù.ßuרÈ=?~J(Á@Á2<~­AÞƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ¿é.æoº# 8U½Î±Â +,b°Â Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á ¤ø:Qá71šþIEND®B`‚adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/Talya.txt000066400000000000000000000000371523462525400275250ustar00rootroot00000000000000This is a text file for Talya. adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/Talya_diagram.png000066400000000000000000000102161523462525400311560ustar00rootroot00000000000000‰PNG  IHDR,bÕr•UIDATxœíÝ{xSåÀñ÷$iÚR¤nŽíATP¹ ¡ˆP)"ÃY´Tª°y)ÒQZŸú ¢>*¬ Üª °¢S[ ¨¨Âä& <ʘ”)åoˆâ…zKšd#KzIišœß9ßÏ_iH““÷¼ç›7iéÑ< ,‘ÞÁ Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á †Mé’¦i‘ÞÀì<ÒVXÄÐé Ë땒ÑÞÀŒþÜå|¥K¬°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆA°ˆ¡ë?‘ŒPÉHìxÑ%½¼—û\5xèèÛ3ût)ÞUÒì¨i5NGÊM·vó—ö¼ëk2F5߃Âð–)Ø¢ìÓ‹_‰È#VWVÌ“Û"ùÚ¡í.ìÎm€ñ,S;QVúìŒúþ»§sÔøÉõèõåýOO_^Vö§¿d ÍÊUJeöé’:"kßû;ËËJo¾ë¤”Ôcß÷äÄû*+Êc[ÄÝ9ëñsóÛÌ>]’®N½ [÷´Ì¿‡ˆŽm‘9î¡Â‡'%_;Ô»¬ó{ˆc?}ê¡ÊËJ[·m·û­7–¼ó‘÷A½wØ£ßÛsyJêG»Þ¹>gLÉ{;Kv¿;dd¶÷Ÿ`x|†ejË>ddöÔ¢—îóÄÓ“Ç)¥6,/yßęϭ^óìSÞÛÔ8çüê×3ŠWýí‰ÂÂY“”RKò§]‘vÃÌ嫯H»aé#Ó•RNGõi×ÖÊ+¡s×#Ÿò}é÷K™ÞÈõ3Ÿ{µßà´ªŠrïm|w¸=ÎêªÁ7gN_¶rÑ´ C2s¦/[éû'+,S¨q:&gþÙ{ùÖû'vNìí½¼gû6_Jª**Ü.W渇¶¯[óî¶Í'Ž{¯÷¸ÝW ¿E)õ»vÊŸ¼ò£;îœ5O)•œš¾ü±‡O¾îY¬=“¯¬ëÑ]5.kÔ/3Íï!>ڹ㎙)¥zºÚb±zoã»ÃÀíÑ,–NÝ{Z¬V[TÔ…Ý{jKuUeó t‡`™ú3,WkÒâ磢£=n÷¾÷vZ¬Ö¹÷ä^>8mÈÈœM/,ó}o\«Vgž‘;ð|ÀV›M³Ô¹Zÿdïîwõ}é÷5N§÷zÛãQ¿;¬u{,Ö“]‹ŠŽ©çAaHìoSëriŸÿlÙ ”zÿ­7V=³@)õé?èŸ:Ôé¨v:ª½· ŒB÷¤äozM)õïM¯ý¡orýq¢¬´xîÃ7Üv‡ï¿‡èÜë²][7*¥þ³yCà¹Ñ·fÆ Ëto /îÕ{ä}Ût¸`Õ3 FO˜öôäq›V,³ZmcfÌQJ];"kBFzBçnq­âG”Ýxo?ùÞ÷ú‹Å1±-îœõx}¨i®ç°Ûî<³k~1:ojAÞ=ëŸ+êœØ;&¶…ßý³=0-ð5M¼o=8U½,È»7}ôí:w=°wÏ’G¦Í\¾:Ò[å=U½ãÀ 6$3{ÑŒ‰ö蘧3wÊìHot1+,>t Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á †®ÿâ(€òè/¬°ˆ¡Ó$ò­‹=Ï™—#ºQ0‚…&Ï¡ºÛD¶Z M˜=A/£(B‚`á¬æÍY½ã#[h"‚…FΘ&8E¶pÖ‚›(¡þlá,,44Ešùç}” Á#X¨{r„ñWÈ‚A°PÛ´ˆÐoQ‘-Ô`áŒÙ ßöÔÉf@‡ôÛ}n"ˆ`™þ£ ÿ-DØ,ó’Y[‹fB°LGô‘/zãÑtËDŒt´é¹ xËŒzxõy¡.ËàÌpH›á9‹`“ a>e"XFÃqËÁ2TFÃð–*FÆ$–`tŠ2‚%©bÜ̉` CªC3#Xb*ÆKïèà ‚¥_¤ŠÑ†‚¥G¤Š‘Gí‡ÿAWH•°t‹`…à?¯Õ%øŽjÊN áÜÀ™V“Ô3/ƒœ‘¤JÿÎn5}n ­–ë¤J o_´SÈMd±ÂjòÖöBZÿK(©’«Qûî,æêÇ +|è”°ÚŠ,VX¡Äÿ! | %UFÕàžmpn QXa5/Rel,¸ÂŒVs½Ò’*³©k3BˆVˆ1;M‹ÕV°Â ÝPrÔ14-4t:Ž þ¢0BEŸ Ì 3Ï K¤7 ñÖüçGzŒìÞ_ß«Ä:6uj¤7ÁÈÎÕëð²Â Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á J^B^ÓÇzËü-M¿èÁs»w\¸0eñâ ¾°gO=·œ·}{£î¹±·7‚>˶8P¼{÷ÚQ£6ßvÛÚQ£–¾ÿþ¶ƒëº1Á -‚Õ8y yëf®[pÝ‚G<úákú®\;ymÁ‚‚´‚>ÿÁoEæ½¼!ƒ£ÜñÔð§B»ÿ~;vLOI‰‰QJÅÇÄLKI™jYÔ>?ßwïåÙo¾Yîp +.ö^3éõׯ-,L-*úü§Ÿ¼=jE°ÇåpÅ7öµ±9Å9«&¬:}eµ«]¯vw¯¿;yTò«½Zë7¦æ¥ÚãìcViäBwö=Ú£Mß—=Û´)9z´Ö[N4(În_™©”rÔÔô:ÿüÙÙY½{OÜ´©ÁÛ£V«q<OÒˆ$¥Ôy çU•U¾VS=Òz(¥¯O<´óÿ·¸uw6J„Ò©;û]ç<©¦]×µ«Rê†nÝv}õU÷GmVãX£¬±ñ±§¿øy–jM³žþÂm;3R•¥•.§«‘]ëÒºõß|ãûrÏ7ßtmÝúÌè”VU9]þ;ýÔ9=IìVkƒ·G­Vãhÿ×Ò“3ÏåÞ·yßɹ»fÏE.RJŶŠ=RrD)õÞËïi?‡Íãö°Ú2€»û÷Ÿ²ysYÕÉõuiUÕÔ-[îéß_)Õ*:ºä»ï”R/}ø¡o–œÚç'Ãär»7ò‰RêÕ?pÁ ÞòÎü,…-ÚöÁ??غ`kl|lFA†Rjxþð%£—´lݲå¬Ñ'_N•RúuZ4bQîŠÜHo/šäªN—•]·ti´Õêp¹nOJر£RêÑÔÔ¬—_þM\\ï¶mí¶ÓGVrûö/¼ðâˆÑ6Ûš}ûþ¾cG|LÌéé ÞžT+ͣ˜{?rªú¼„¼üC¿üÄGÖ©êõ9œâNUß>?ÿ‹¼ü6_8OU¯Ã¹Á[Bb¬¸¼B˜ Z^éÁ Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á †®ÿ¦;Â@Ÿ Ì 3Ï VXÄÐé K"ß+?C æC3!X¡P²enL€fE°šmd)—ɰÇÀ`5óø’-`/‡ Á Ë(“-ƒbφÁ ãX“-£`WF ÁŠÄ S.±Øw‘E°"7ôdKö—¬Hï²¥{ì#ý XºÀ!¡Cì"XúÂA¢ìÝ"XzÄÃÈ£VK¿È£ ?KïÈà ‚%åbÔEo<šŽ`™—¬ƒ_ÖÖ¢™,³Óô¿…‚ýFAŸ[…"XM |ß[—àï“N¡. !+W=Í ò~HêgkàßaJÞ¾h§„ç©B0Xa!”«­ZYõ#©BðXa!2«-:…³À !^pù-²oFªpÖXa!| .R…&b……Я¶¯'U ‚…&Ï¡ºóäÃo~"$š¥\,©Ð– þ9B…• ÎY"½,~J(†Çs(Ò›`dš–éM@ÃXaƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ?‘Œ ´hÑ¥oßžš¦95óæMêÓ§'‡ðã¬9ü|ŽåHþM÷sÏ½äØ±½J©½{K²³ÇïÚµVñoºsÖc……ƹä’.Ÿ}ö¥7aÆ]Ó«×nºéºììq'NT´lÙ¢°pŽÍfÍÍðãÇìvûòåó£¢lcÇN9rä¨Ãáœ;÷Á¾}{.X°¤°ð%MÓòóóöïÿÔwyðàì Ô`¡q¶nýWbb7¥Tuµã–[Ò¯¹æÊ[o½'##=3sxqñªxØjµÞxcZFFzQÑËS¦<îtÖŒ›•””øÅ‡ÓÓsöìÙ0cFÁ§Ÿ¾ýõ×GfÍzrãÆm¾Ë â-¡zxKèý ËãñÄÇ·*(˜šðû–-»••ý×b±´m›tðàÛÑÑöêjGÇŽ4M;xðm»=Êår8QѽûàNÚ{ïä믿-)Ùš“3¾´ôøwd¦¤ Èʺßw9‚ÏŽ·„R°ÂBPìö¨mÛ^ü¿©c³Z,–ÀÏ}\.—÷«ÕNMMÍÆËbb¢Ýn÷öíïZ­Ö%K{ë­óç?ûüókμ\T4—úñk hªAƒú­\¹^)µråú?þñò¾}׬y])µxñŠ éßÿ²Õ«7)¥6lØ6{ö“¥¥Ç¼©_¿K‹‹ç­[÷†ïòúõo²'Ð Þ  «Ÿ^søð·99ãËË+ãâb çTTTæäŒ÷¾y\¶ìñãÇËssó***m6Û¢Eù;¶Ÿ3gáŠÿt»Ý997WVVù.ßuרÈ=?~J(Á@Á2<~­AÞƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ`ƒ¿é.æoº# 8U½Î±Â +,b°Â Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á Á ¤ø:Qá71šþIEND®B`‚adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/__init__.py000066400000000000000000000000251523462525400300200ustar00rootroot00000000000000"""Documentation.""" adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/conf.py000066400000000000000000000005151523462525400272120ustar00rootroot00000000000000"""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", } adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/five.rst000066400000000000000000000000261523462525400273730ustar00rootroot00000000000000======= Five ======= adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/four.rst000066400000000000000000000000261523462525400274150ustar00rootroot00000000000000======= Four ======= adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/index.rst000066400000000000000000000035101523462525400275520ustar00rootroot00000000000000Samples 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>`. ``literalinclude`` ------------------ Content substitutions ~~~~~~~~~~~~~~~~~~~~~ .. rest-example:: .. literalinclude:: sample_include.txt .. literalinclude:: sample_include.txt :content-substitutions: Path substitutions ~~~~~~~~~~~~~~~~~~ .. rest-example:: .. literalinclude:: |author|.txt :path-substitutions: ``include`` ----------- Content substitutions ~~~~~~~~~~~~~~~~~~~~~ .. include:: sample_content_include.txt :content-substitutions: Path substitutions ~~~~~~~~~~~~~~~~~~ .. include:: |author|.txt :path-substitutions: ``image`` --------- Path substitutions ~~~~~~~~~~~~~~~~~~ .. rest-example:: .. image:: |author|_diagram.png :path-substitutions: :alt: Diagram for |author| .. 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 adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/markdown_sample.md000066400000000000000000000050021523462525400314140ustar00rootroot00000000000000Samples 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>` ``literalinclude`` ------------------ ### Content substitutions ```{code-block} markdown ```{literalinclude} sample_include.txt ``` ```{literalinclude} sample_include.txt :content-substitutions: ``` ``` => ```{literalinclude} sample_include.txt ``` ```{literalinclude} sample_include.txt :content-substitutions: ``` ### Path substitutions ```{code-block} markdown ```{literalinclude} {{author}}.txt :path-substitutions: ``` ``` => ```{literalinclude} {{author}}.txt :path-substitutions: ``` ``include`` ----------- ### Path substitutions ```{code-block} markdown ```{include} {{author}}.txt :path-substitutions: ``` ``` ``image`` --------- ### Path substitutions ```{code-block} markdown ```{image} {{author}}_diagram.png :path-substitutions: :alt: Diagram for {{author}} ``` ``` => ```{image} {{author}}_diagram.png :path-substitutions: :alt: Diagram for {{author}} ``` adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/one.rst000066400000000000000000000000211523462525400272160ustar00rootroot00000000000000===== One ===== adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/sample_content_include.txt000066400000000000000000000000611523462525400331660ustar00rootroot00000000000000.. code-block:: text The author is |author|. adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/sample_image.png000066400000000000000000000001031523462525400310350ustar00rootroot00000000000000‰PNG  IHDRĉ IDATxœc -´IEND®B`‚adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/sample_include.txt000066400000000000000000000001201523462525400314300ustar00rootroot00000000000000This is a sample file for demonstrating literalinclude. The author is |author|. adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/three.rst000066400000000000000000000000271523462525400275520ustar00rootroot00000000000000======= Three ======= adamtheturtle-sphinx-substitution-extensions-8087a67/sample/source/two.rst000066400000000000000000000000211523462525400272460ustar00rootroot00000000000000===== Two ===== adamtheturtle-sphinx-substitution-extensions-8087a67/spelling_private_dict.txt000066400000000000000000000002571523462525400302500ustar00rootroot00000000000000admin beartype changelog conf hardcoded inline linters py pyright pytest reportUnknownMemberType reportUnknownParameterType reportUnknownVariableType rst str tuple whitespace adamtheturtle-sphinx-substitution-extensions-8087a67/src/000077500000000000000000000000001523462525400237205ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/src/sphinx_substitution_extensions/000077500000000000000000000000001523462525400323445ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/src/sphinx_substitution_extensions/__init__.py000066400000000000000000000612221523462525400344600ustar00rootroot00000000000000"""Custom Sphinx extensions.""" from importlib.metadata import version from typing import Any, ClassVar, TypeAlias from unittest.mock import patch from beartype import beartype from docutils.nodes import ( Element, Node, Text, document, reference, substitution_definition, system_message, ) from docutils.nodes import target as target_node from docutils.parsers.rst import directives from docutils.parsers.rst.directives.images import Image from docutils.parsers.rst.directives.misc import Include from docutils.parsers.rst.roles import code_role from docutils.parsers.rst.states import Inliner from docutils.statemachine import StringList from myst_parser.config.main import MdParserConfig from myst_parser.mdit_to_docutils.base import DocutilsRenderer from myst_parser.mocking import MockInliner, MockState from sphinx import addnodes from sphinx.application import Sphinx from sphinx.config import Config from sphinx.directives.code import CodeBlock, LiteralInclude from sphinx.environment import BuildEnvironment from sphinx.errors import SphinxError from sphinx.roles import XRefRole from sphinx.util.typing import ExtensionMetadata, OptionSpec from sphinx_substitution_extensions.shared import ( CONTENT_SUBSTITUTION_OPTION_NAME, NO_CONTENT_SUBSTITUTION_OPTION_NAME, NO_PATH_SUBSTITUTION_OPTION_NAME, NO_SUBSTITUTION_OPTION_NAME, PATH_SUBSTITUTION_OPTION_NAME, SUBSTITUTION_OPTION_NAME, ) SubstitutionValue: TypeAlias = ( str | int | float | list["SubstitutionValue"] | dict[str, "SubstitutionValue"] ) Substitutions: TypeAlias = dict[str, SubstitutionValue] @beartype def _get_myst_config(*, context: object) -> MdParserConfig | None: """Get the effective MyST configuration from a parsing context.""" if isinstance(context, (MockInliner, MockState)): # MyST merges front matter into a document-local configuration before # it creates these parsing contexts. The Sphinx configuration only # contains the global ``conf.py`` values. # https://github.com/executablebooks/MyST-Parser/issues/680 tracks # substitutions inside directives, but its proposed PR registers them # only after directives have run: # https://github.com/executablebooks/MyST-Parser/pull/966 renderer = vars(context)["_renderer"] assert isinstance(renderer, DocutilsRenderer) return renderer.md_config return None @beartype def _validate_substitution_key(*, key: str) -> None: """Validate that a substitution key does not contain dots. Dots are reserved for nested access notation in flattened keys (e.g., |a.b.c| for nested dictionaries or |items.0| for lists). Allowing dots in user-defined keys would create ambiguity between a literal key name and a path to a nested value. A :class:`~sphinx.errors.SphinxError` is raised if the key contains a dot. """ if "." in key: message = ( f"Substitution key {key!r} contains a dot ('.'). " "Dots are reserved for nested access notation " "(e.g., |a.b.c| for nested dictionaries or |items.0| for lists)." ) raise SphinxError(message) # NOTE: beartype is not used here # because it throws `beartype.roar.BeartypeCallHintForwardRefException` # for recursive type `Substitutions` def _flatten_substitutions( *, substitutions: Substitutions, ) -> dict[str, str]: """Flatten nested substitutions dictionary using dot notation. Recursively processes nested dictionaries and lists, converting them to a flat dictionary where keys represent the path to each value using dot notation. For example:: {'a': {'b': {'c': 'value'}}} -> {'a.b.c': 'value'} {'items': [{'name': 'a'}]} -> {'items.0.name': 'a'} A :class:`~sphinx.errors.SphinxError` is raised if any key in the nested structure contains a dot. """ result: dict[str, str] = {} stack: list[tuple[str, SubstitutionValue]] = [("", substitutions)] while stack: current_key, current_value = stack.pop() match current_value: case dict(): for key, value in current_value.items(): _validate_substitution_key(key=key) new_key = f"{current_key}.{key}" if current_key else key stack.append((new_key, value)) case list(): for idx, item in enumerate(iterable=current_value): new_key = ( f"{current_key}.{idx}" if current_key else str(object=idx) ) stack.append((new_key, item)) case _: result[current_key] = str(object=current_value) return result @beartype def _get_delimiter_pairs( *, env: BuildEnvironment, config: Config, myst_config: MdParserConfig | None, ) -> 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): if myst_config is None: opening_delimiter, closing_delimiter = config.myst_sub_delimiters else: opening_delimiter, closing_delimiter = myst_config.sub_delimiters new_delimiter_pair = ( opening_delimiter + opening_delimiter, closing_delimiter + closing_delimiter, ) delimiter_pairs = {*delimiter_pairs, new_delimiter_pair} return delimiter_pairs @beartype def _get_substitution_defs( *, env: BuildEnvironment, config: Config, substitution_defs: dict[str, substitution_definition], myst_config: MdParserConfig | None, ) -> 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 myst_config is None: enable_extensions = config.myst_enable_extensions substitutions = dict(config.myst_substitutions) else: enable_extensions = myst_config.enable_extensions substitutions = dict(myst_config.substitutions) if "substitution" in enable_extensions: return _flatten_substitutions( substitutions=substitutions, ) else: return { key: value.astext() for key, value in substitution_defs.items() } return {} @beartype def _apply_substitutions( *, text: str, substitution_defs: dict[str, str], delimiter_pairs: set[tuple[str, str]], ) -> str: """Apply substitutions to text using the given delimiter pairs.""" new_text = text for name, replacement in substitution_defs.items(): for delimiter_pair in delimiter_pairs: opening_delimiter, closing_delimiter = delimiter_pair new_text = new_text.replace( f"{opening_delimiter}{name}{closing_delimiter}", replacement, ) return new_text @beartype def _should_apply_substitutions( *, options: dict[str, Any], config: Config, yes_flag: str, no_flag: str, ) -> bool: """ Whether substitutions should be applied based on flags and configuration. """ if no_flag in options: return False if yes_flag in options: return True return bool(config.substitutions_default_enabled) @beartype def _process_node( *, node: Node, substitution_defs: dict[str, str], delimiter_pairs: set[tuple[str, str]], ) -> None: """Recursively process nodes to apply substitutions.""" if isinstance(node, Element): new_text = _apply_substitutions( text=node.rawsource, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) node.rawsource = new_text if node.children: first_child = node.children[0] if isinstance(first_child, Text): node.replace(old=first_child, new=Text(data=new_text)) for child in node.children: _process_node( node=child, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) @beartype def _substitute_hyperlink_targets( app: Sphinx, doctree: document, ) -> None: """Replace placeholders in hyperlink targets.""" if not app.config.substitutions_hyperlink_targets_enabled: return substitution_defs = _get_substitution_defs( env=app.env, config=app.config, substitution_defs=doctree.substitution_defs, myst_config=None, ) delimiter_pairs = _get_delimiter_pairs( env=app.env, config=app.config, myst_config=None, ) for node in doctree.findall(): if not isinstance(node, (reference, target_node)): continue refuri = node.attributes.get("refuri") if isinstance(refuri, str): node["refuri"] = _apply_substitutions( text=refuri, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) @beartype class SubstitutionCodeBlock(CodeBlock): """Similar to CodeBlock but replaces placeholders with variables.""" option_spec: ClassVar[OptionSpec] = ( CodeBlock.option_spec.copy() if CodeBlock.option_spec else {} ) option_spec[SUBSTITUTION_OPTION_NAME] = directives.flag option_spec[NO_SUBSTITUTION_OPTION_NAME] = directives.flag def run(self) -> list[Node]: """Replace placeholders with given variables.""" new_content = StringList() existing_content = self.content myst_config = _get_myst_config(context=self.state) substitution_defs = _get_substitution_defs( env=self.env, config=self.config, substitution_defs=self.state.document.substitution_defs, myst_config=myst_config, ) delimiter_pairs = _get_delimiter_pairs( env=self.env, config=self.config, myst_config=myst_config, ) should_apply_substitutions = _should_apply_substitutions( options=self.options, config=self.config, yes_flag=SUBSTITUTION_OPTION_NAME, no_flag=NO_SUBSTITUTION_OPTION_NAME, ) for item in existing_content: new_item = item if should_apply_substitutions: new_item = _apply_substitutions( text=item, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) 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 myst_config = _get_myst_config(context=inliner) substitution_defs = _get_substitution_defs( env=env, config=env.config, substitution_defs=inliner.document.substitution_defs, myst_config=myst_config, ) delimiter_pairs = _get_delimiter_pairs( env=env, config=env.config, myst_config=myst_config, ) text = _apply_substitutions( text=text, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) rawtext = _apply_substitutions( text=rawtext, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) # ``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 SubstitutionLiteralInclude(LiteralInclude): """ Similar to LiteralInclude but replaces placeholders with variables. """ option_spec: ClassVar[OptionSpec] = ( LiteralInclude.option_spec.copy() if LiteralInclude.option_spec else {} ) option_spec[CONTENT_SUBSTITUTION_OPTION_NAME] = directives.flag option_spec[PATH_SUBSTITUTION_OPTION_NAME] = directives.flag option_spec[NO_CONTENT_SUBSTITUTION_OPTION_NAME] = directives.flag option_spec[NO_PATH_SUBSTITUTION_OPTION_NAME] = directives.flag def run(self) -> list[Node]: """ Replace placeholders with given variables in the file path and/or included file content. """ myst_config = _get_myst_config(context=self.state) should_apply_path_substitutions = _should_apply_substitutions( options=self.options, config=self.config, yes_flag=PATH_SUBSTITUTION_OPTION_NAME, no_flag=NO_PATH_SUBSTITUTION_OPTION_NAME, ) if should_apply_path_substitutions: substitution_defs = _get_substitution_defs( env=self.env, config=self.config, substitution_defs=self.state.document.substitution_defs, myst_config=myst_config, ) delimiter_pairs = _get_delimiter_pairs( env=self.env, config=self.config, myst_config=myst_config, ) for argument_index, argument in enumerate(iterable=self.arguments): self.arguments[argument_index] = _apply_substitutions( text=argument, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) nodes_list = super().run() should_apply_content_substitutions = _should_apply_substitutions( options=self.options, config=self.config, yes_flag=CONTENT_SUBSTITUTION_OPTION_NAME, no_flag=NO_CONTENT_SUBSTITUTION_OPTION_NAME, ) if should_apply_content_substitutions: substitution_defs = _get_substitution_defs( env=self.env, config=self.config, substitution_defs=self.state.document.substitution_defs, myst_config=myst_config, ) delimiter_pairs = _get_delimiter_pairs( env=self.env, config=self.config, myst_config=myst_config, ) for node in nodes_list: _process_node( node=node, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) return nodes_list @beartype class SubstitutionInclude(Include): """ Similar to Include but replaces placeholders with variables in the path. """ option_spec: ClassVar[OptionSpec | None] = { **(Include.option_spec or {}), CONTENT_SUBSTITUTION_OPTION_NAME: directives.flag, PATH_SUBSTITUTION_OPTION_NAME: directives.flag, NO_CONTENT_SUBSTITUTION_OPTION_NAME: directives.flag, NO_PATH_SUBSTITUTION_OPTION_NAME: directives.flag, } def run(self) -> list[Node]: """Replace placeholders in the path and/or included content.""" env = self.state.document.settings.env if env is None: return list(super().run()) config = env.config myst_config = _get_myst_config(context=self.state) should_apply_path_substitutions = _should_apply_substitutions( options=self.options, config=config, yes_flag=PATH_SUBSTITUTION_OPTION_NAME, no_flag=NO_PATH_SUBSTITUTION_OPTION_NAME, ) should_apply_content_substitutions = _should_apply_substitutions( options=self.options, config=config, yes_flag=CONTENT_SUBSTITUTION_OPTION_NAME, no_flag=NO_CONTENT_SUBSTITUTION_OPTION_NAME, ) if not ( should_apply_path_substitutions or should_apply_content_substitutions ): return list(super().run()) substitution_defs = _get_substitution_defs( env=env, config=config, substitution_defs=self.state.document.substitution_defs, myst_config=myst_config, ) delimiter_pairs = _get_delimiter_pairs( env=env, config=config, myst_config=myst_config, ) if should_apply_path_substitutions: for argument_index, argument in enumerate(iterable=self.arguments): self.arguments[argument_index] = _apply_substitutions( text=argument, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) if not should_apply_content_substitutions: return list(super().run()) original_insert_input = self.state_machine.insert_input def insert_substituted_input( input_lines: list[str], source: str, ) -> None: """Insert included lines after applying substitutions.""" substituted_lines = [ _apply_substitutions( text=line, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) for line in input_lines ] original_insert_input( input_lines=substituted_lines, source=source, ) # ``Include.run`` only queues these lines. Nested includes are parsed # after it returns, when this patch has already been removed. with patch.object( target=self.state_machine, attribute="insert_input", new=insert_substituted_input, ): nodes_list = list(super().run()) for node in nodes_list: _process_node( node=node, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) return nodes_list @beartype class SubstitutionImage(Image): """ Similar to Image but replaces placeholders with variables in the path. """ option_spec: ClassVar[OptionSpec | None] = { **(Image.option_spec or {}), PATH_SUBSTITUTION_OPTION_NAME: directives.flag, NO_PATH_SUBSTITUTION_OPTION_NAME: directives.flag, } def run(self) -> list[Node]: """Replace placeholders with given variables in the image path.""" env = self.state.document.settings.env config = env.config myst_config = _get_myst_config(context=self.state) should_apply_path_substitutions = _should_apply_substitutions( options=self.options, config=config, yes_flag=PATH_SUBSTITUTION_OPTION_NAME, no_flag=NO_PATH_SUBSTITUTION_OPTION_NAME, ) if should_apply_path_substitutions: substitution_defs = _get_substitution_defs( env=env, config=config, substitution_defs=self.state.document.substitution_defs, myst_config=myst_config, ) delimiter_pairs = _get_delimiter_pairs( env=env, config=config, myst_config=myst_config, ) for argument_index, argument in enumerate(iterable=self.arguments): self.arguments[argument_index] = _apply_substitutions( text=argument, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) return list(super().run()) @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.removeprefix("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) myst_config = _get_myst_config(context=self.inliner) substitution_defs = _get_substitution_defs( env=env, config=env.config, substitution_defs=self.inliner.document.substitution_defs, myst_config=myst_config, ) delimiter_pairs = _get_delimiter_pairs( env=env, config=env.config, myst_config=myst_config, ) title = _apply_substitutions( text=title, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) target = _apply_substitutions( text=target, substitution_defs=substitution_defs, delimiter_pairs=delimiter_pairs, ) # 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") app.add_config_value( name="substitutions_hyperlink_targets_enabled", default=False, rebuild="html", ) app.add_config_value( name="substitutions_default_enabled", default=False, rebuild="html", ) directives.register_directive( name="code-block", directive=SubstitutionCodeBlock, ) directives.register_directive( name="literalinclude", directive=SubstitutionLiteralInclude, ) directives.register_directive( name="include", directive=SubstitutionInclude, ) directives.register_directive( name="image", directive=SubstitutionImage, ) 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) app.connect( event="doctree-read", callback=_substitute_hyperlink_targets, ) return { "parallel_read_safe": True, "version": version(distribution_name="sphinx-substitution-extensions"), } adamtheturtle-sphinx-substitution-extensions-8087a67/src/sphinx_substitution_extensions/py.typed000066400000000000000000000000001523462525400340310ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/src/sphinx_substitution_extensions/shared.py000066400000000000000000000010201523462525400341550ustar00rootroot00000000000000"""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" CONTENT_SUBSTITUTION_OPTION_NAME = "content-substitutions" PATH_SUBSTITUTION_OPTION_NAME = "path-substitutions" NO_SUBSTITUTION_OPTION_NAME = "nosubstitutions" NO_CONTENT_SUBSTITUTION_OPTION_NAME = "nocontent-substitutions" NO_PATH_SUBSTITUTION_OPTION_NAME = "nopath-substitutions" spelling_private_dict.txt000066400000000000000000000000001523462525400373660ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/src/sphinx_substitution_extensionsadamtheturtle-sphinx-substitution-extensions-8087a67/tests/000077500000000000000000000000001523462525400242735ustar00rootroot00000000000000adamtheturtle-sphinx-substitution-extensions-8087a67/tests/__init__.py000066400000000000000000000000541523462525400264030ustar00rootroot00000000000000"""Tests for the substitution extension.""" adamtheturtle-sphinx-substitution-extensions-8087a67/tests/conftest.py000066400000000000000000000001541523462525400264720ustar00rootroot00000000000000"""Configuration for pytest.""" pytest_plugins = "sphinx.testing.fixtures" # pylint: disable=invalid-name adamtheturtle-sphinx-substitution-extensions-8087a67/tests/test_substitution_extensions.py000066400000000000000000003535701523462525400327540ustar00rootroot00000000000000"""Tests for Sphinx extensions.""" from collections.abc import Callable from importlib.metadata import version from pathlib import Path from textwrap import dedent import pytest from docutils import core, nodes from docutils.parsers.rst import directives from sphinx.errors import SphinxError from sphinx.testing.util import SphinxTestApp import sphinx_substitution_extensions def test_setup( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Test that the setup function returns the expected metadata.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() app = make_app( srcdir=source_directory, ) setup_result = sphinx_substitution_extensions.setup(app=app) pkg_version = version(distribution_name="sphinx-substitution-extensions") assert setup_result == { "parallel_read_safe": True, "version": pkg_version, } 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_default_substitutions_enabled( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ When ``substitutions_default_enabled`` is set to True in conf.py, code blocks should apply substitutions by default without needing the ``:substitutions:`` flag. """ 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"], "substitutions_default_enabled": True, }, ) 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_default_substitutions_disabled_with_flag( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ When ``substitutions_default_enabled`` is True but a code block has the ``:nosubstitutions:`` flag, substitutions should not be applied. """ 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 :nosubstitutions: $ 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"], "substitutions_default_enabled": True, }, ) 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, 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_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 def test_no_substitution_hyperlink_target( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Leave placeholders in hyperlink targets unchanged by default.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file.write_text( data=dedent( text="""\ .. |ver| replace:: 0.8.5 Download the tarball_ .. _tarball: https://example.com/releases/v|ver|.tar.gz """, ), ) 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, ) 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_hyperlink_target( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace placeholders in external hyperlink targets.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file.write_text( data=dedent( text="""\ Download the tarball_ .. _tarball: https://example.com/releases/v|ver|.tar.gz See the `internal section`_. Internal section ---------------- """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "rst_prolog": ".. |ver| replace:: 0.8.5", "substitutions_hyperlink_targets_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ Download the tarball_ .. _tarball: https://example.com/releases/v0.8.5.tar.gz See the `internal section`_. Internal section ---------------- """, ) 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_myst_substitution_hyperlink_target( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace global MyST substitutions in external hyperlink targets.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.md" (source_directory / "conf.py").touch() source_file.write_text( data=dedent( text="""\ ```{eval-rst} Download the tarball_ .. _tarball: https://example.com/releases/v|ver|.tar.gz ``` """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": {"ver": "0.8.5"}, "substitutions_hyperlink_targets_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() source_file.write_text( data=dedent( text="""\ ```{eval-rst} Download the tarball_ .. _tarball: https://example.com/releases/v0.8.5.tar.gz ``` """, ), ) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, freshenv=True, confoverrides={"extensions": ["myst_parser"]}, ) 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_no_substitution_literal_include( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """The ``literalinclude`` directive does not replace placeholders.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text(data="Content with |a| placeholder") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: example.txt """, ) 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_literal_include( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``literalinclude`` directive replaces the placeholders defined in ``conf.py`` as specified when the `:content-substitutions:` flag is set. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text(data="Content with |a| placeholder") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: example.txt :content-substitutions: """, ) 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() include_file.write_text( data="Content with example_substitution placeholder" ) equivalent_source = dedent( text="""\ .. literalinclude:: example.txt """, ) 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_literal_include_empty_file( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``literalinclude`` directive handles empty files without crashing. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() # Create an empty file include_file = source_directory / "empty.txt" include_file.write_text(data="") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: empty.txt :content-substitutions: """, ) 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="""\ .. literalinclude:: empty.txt """, ) 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_literal_include_multiple( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """The ``literalinclude`` directive replaces multiple placeholders.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text(data="PRE-|a|-MID-|b|-POST") source_file_content = dedent( text="""\ .. |a| replace:: first_substitution .. |b| replace:: second_substitution .. literalinclude:: example.txt :content-substitutions: """, ) 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() include_file.write_text( data="PRE-first_substitution-MID-second_substitution-POST", ) equivalent_source = dedent( text="""\ .. literalinclude:: example.txt """, ) 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_literal_include_with_caption( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """The ``literalinclude`` directive works with captions.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text(data="Content with |a| placeholder") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: example.txt :caption: Example caption :content-substitutions: """, ) 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() include_file.write_text( data="Content with example_substitution placeholder" ) equivalent_source = dedent( text="""\ .. literalinclude:: example.txt :caption: Example caption """, ) 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_literal_include_in_rest_example( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """The ``literalinclude`` directive works inside rest-example.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text(data="Content with |a| placeholder") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. rest-example:: .. literalinclude:: example.txt :content-substitutions: """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, warningiserror=True, confoverrides={ "extensions": [ "sphinx_substitution_extensions", "sphinx_toolbox.rest_example", ], }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() assert "example_substitution" in content_html def test_substitution_literal_include_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``literalinclude`` directive replaces placeholders in the file path when the `:path-substitutions:` flag is set. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() # Create a file with substitution in the name include_file = source_directory / "example_substitution.txt" include_file.write_text(data="File content") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: |a|.txt :path-substitutions: """, ) 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() # Compare with directly using the filename equivalent_source = dedent( text="""\ .. literalinclude:: example_substitution.txt """, ) 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_literal_include_both_path_and_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``literalinclude`` directive can use both path and content substitutions at the same time. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() # Create a file with substitution in the name and content include_file = source_directory / "example_substitution.txt" include_file.write_text(data="Content with |b| placeholder") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. |b| replace:: test_value .. literalinclude:: |a|.txt :path-substitutions: :content-substitutions: """, ) 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() # Create equivalent file with substituted content include_file.write_text(data="Content with test_value placeholder") equivalent_source = dedent( text="""\ .. literalinclude:: example_substitution.txt """, ) 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 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_default_substitutions_literal_include_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ When ``substitutions_default_enabled`` is True, ``literalinclude`` should apply content substitutions by default without requiring the ``:content- substitutions:`` flag. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text(data="Content with |a| placeholder") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: example.txt """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() include_file.write_text( data="Content with example_substitution placeholder" ) equivalent_source = dedent( text="""\ .. literalinclude:: example.txt """, ) 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_default_substitutions_literal_include_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ When ``substitutions_default_enabled`` is True, ``literalinclude`` should apply path substitutions by default without requiring the ``:path- substitutions:`` flag. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example_substitution.txt" include_file.write_text(data="File content") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: |a|.txt """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ .. literalinclude:: example_substitution.txt """, ) 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_default_substitutions_literal_include_disabled_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ When ``substitutions_default_enabled`` is True but ``literalinclude`` has the ``:nocontent-substitutions:`` flag, content substitutions should not be applied. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text(data="Content with |a| placeholder") source_file_content = dedent( text="""\ .. |a| replace:: example_substitution .. literalinclude:: example.txt :nocontent-substitutions: """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ .. literalinclude:: example.txt """, ) source_file.write_text(data=equivalent_source) 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_default_substitutions_literal_include_disabled_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """When ``substitutions_default_enabled`` is True but ``literalinclude`` has the ``:nopath-substitutions:`` flag, path substitutions should not be applied. Note: This test uses MyST format with custom delimiters because the `|` character cannot be used in Windows file paths. """ 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() # Use custom delimiters [[a]] instead of |a| because | is not allowed # in Windows file paths include_file = source_directory / "[[a]].txt" include_file.write_text(data="File content") index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{literalinclude} [[a]].txt :nopath-substitutions: ``` """, ) 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": ("[", "]"), "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{literalinclude} [[a]].txt ``` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, freshenv=True, ) 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 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_frontmatter_substitutions( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST front matter substitutions are respected in code blocks. """ 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.write_text( data=dedent( text="""\ .. toctree:: markdown_document """, ), ) markdown_source_file.write_text( data=dedent( text="""\ --- myst: substitutions: local: nested: local_value --- # Title ```{code-block} shell :substitutions: $ PRE-|global|-|local.nested|-POST ``` """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "global": "global_value", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() markdown_source_file.write_text( data=dedent( text="""\ # Title ```{code-block} shell $ PRE-global_value-local_value-POST ``` """, ), ) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, freshenv=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_nested_substitutions( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST nested substitutions are flattened and applied correctly. """ 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 ``` ```{code-block} :substitutions: $ PRE-|b.c|-POST ``` ```{code-block} :substitutions: $ PRE-|b.d.e|-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": "value_a", "b": { "c": "value_b_c", "d": { "e": "value_b_d_e", }, }, }, }, ) 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-value_a-POST ``` ```{code-block} $ PRE-value_b_c-POST ``` ```{code-block} $ PRE-value_b_d_e-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_nested_substitutions_with_lists( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST nested substitutions with lists are flattened and applied correctly. """ 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-|items.0|-POST ``` ```{code-block} :substitutions: $ PRE-|items.1|-POST ``` ```{code-block} :substitutions: $ PRE-|nested.0.name|-POST ``` ```{code-block} :substitutions: $ PRE-|nested.1.value|-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": { "items": ["first", "second"], "nested": [ {"name": "a", "value": "1"}, {"name": "b", "value": "2"}, ], }, }, ) 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-first-POST ``` ```{code-block} $ PRE-second-POST ``` ```{code-block} $ PRE-a-POST ``` ```{code-block} $ PRE-2-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_deep_nesting( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST deeply nested substitutions (5 levels) are flattened correctly. """ 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-|level1.level2.level3.level4.level5|-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": { "level1": { "level2": { "level3": { "level4": { "level5": "deep_value", }, }, }, }, }, }, ) 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-deep_value-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_empty_containers( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST empty containers (lists and dictionaries) do not create keys and do not break the build. """ 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-|normal|-POST ``` ```{code-block} :substitutions: $ PRE-|mixed.normal|-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": { "empty_list": [], "empty_dict": {}, "mixed": { "empty_nested_list": [], "empty_nested_dict": {}, "normal": "value", }, "normal": "normal_value", }, }, ) 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-normal_value-POST ``` ```{code-block} $ PRE-value-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_mixed_types_in_list( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST mixed types in lists are converted to strings correctly. """ 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-|mixed.0|-POST ``` ```{code-block} :substitutions: $ PRE-|mixed.1|-POST ``` ```{code-block} :substitutions: $ PRE-|mixed.2|-POST ``` ```{code-block} :substitutions: $ PRE-|mixed.3|-POST ``` ```{code-block} :substitutions: $ PRE-|mixed.4|-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": { "mixed": [1, "text", 3.14, -42, 0], }, }, ) 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-1-POST ``` ```{code-block} $ PRE-text-POST ``` ```{code-block} $ PRE-3.14-POST ``` ```{code-block} $ PRE--42-POST ``` ```{code-block} $ PRE-0-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_nested_lists( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST nested lists are flattened with multi-index notation.""" 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-|matrix.0.0|-POST ``` ```{code-block} :substitutions: $ PRE-|matrix.0.1|-POST ``` ```{code-block} :substitutions: $ PRE-|matrix.1.0|-POST ``` ```{code-block} :substitutions: $ PRE-|matrix.1.1|-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": { "matrix": [ [1, 2], [3, 4], ], }, }, ) 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-1-POST ``` ```{code-block} $ PRE-2-POST ``` ```{code-block} $ PRE-3-POST ``` ```{code-block} $ PRE-4-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_invalid_substitution_access( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST invalid substitution access does not break the build.""" 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-|items.99|-POST ``` ```{code-block} :substitutions: $ PRE-|nonexistent.key|-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=False, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_substitutions": { "items": ["a", "b"], }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() expected_text_in_html = [ "$ PRE-|items.99|-POST", "$ PRE-|nonexistent.key|-POST", ] for text in expected_text_in_html: assert text in content_html @staticmethod def test_myst_substitution_key_with_dot_raises_error( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST substitution keys containing dots raise SphinxError. Dots are reserved for nested access notation. """ 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: |key.with.dots| ``` """, ) 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": { "key.with.dots": "value", }, }, ) with pytest.raises( expected_exception=SphinxError, match=r"Substitution key 'key\.with\.dots' contains a dot", ): app.build() @staticmethod def test_myst_nested_substitution_key_with_dot_raises_error( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """MyST nested substitution keys containing dots raise SphinxError. Dots are reserved for nested access notation. """ 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: |parent.key.with.dots| ``` """, ) 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": { "parent": { "key.with.dots": "value", }, }, }, ) with pytest.raises( expected_exception=SphinxError, match=r"Substitution key 'key\.with\.dots' contains a dot", ): app.build() @staticmethod def test_rst_substitution_key_with_dot_does_not_raise_error( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Substitution names with dots do not raise SphinxError in reST. The dot validation applies only to flattened MyST substitutions, so a refactoring that leaked it into the reST path would be caught here. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() source_file_content = dedent( text="""\ .. |key.with.dots| replace:: example_substitution .. code-block:: shell :substitutions: $ PRE-|key.with.dots|-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, 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_xref_role_class_prefix_removal( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``SubstitutionXRefRole`` should only remove the "substitution-" prefix from CSS classes, not all occurrences of "substitution-" in the class name. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" conf_file = source_directory / "conf.py" downloadable_file = source_directory / "example.py" downloadable_file.write_text(data="Sample") conf_file.write_text( data=dedent( text="""\ from sphinx import addnodes from sphinx_substitution_extensions import SubstitutionXRefRole def setup(app): role = SubstitutionXRefRole( nodeclass=addnodes.download_reference, ) app.add_role("substitution-my-substitution-download", role) """, ), ) source_file_content = dedent( text="""\ :substitution-my-substitution-download:`Download ` """, ) 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() conf_file.write_text( data=dedent( text="""\ from sphinx import addnodes from sphinx.roles import XRefRole def setup(app): role = XRefRole(nodeclass=addnodes.download_reference) app.add_role("my-substitution-download", role) """, ), ) equivalent_source = dedent( text="""\ :my-substitution-download:`Download ` """, ) 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_no_substitution_include( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Leave an ``include`` path unchanged by default.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() (source_directory / "example.txt").write_text(data="Included content") (source_directory / "index.rst").write_text( data=".. include:: example.txt\n", ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() assert app.statuscode == 0 assert "Included content" in (app.outdir / "index.html").read_text() def test_include_without_sphinx_environment(tmp_path: Path) -> None: """Support documents which have no Sphinx environment.""" source_file = tmp_path / "index.rst" source_file.write_text(data=".. include:: included.rst\n") (tmp_path / "included.rst").write_text(data="Included content") directives.register_directive( name="include", directive=sphinx_substitution_extensions.SubstitutionInclude, ) publish_doctree: Callable[..., nodes.document] = core.publish_doctree # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] document = publish_doctree( source=source_file.read_text(), source_path=source_file.as_posix(), settings_overrides={"env": None}, ) assert "Included content" in document.astext() def test_substitution_include_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace placeholders in an ``include`` path when requested.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() (source_directory / "example.txt").write_text(data="Included content") (source_directory / "index.rst").write_text( data=dedent( text="""\ .. |name| replace:: example .. include:: |name|.txt :path-substitutions: """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["sphinx_substitution_extensions"]}, ) app.build() assert app.statuscode == 0 assert "Included content" in (app.outdir / "index.html").read_text() def test_substitution_include_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace placeholders in included source content when requested.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() include_file = source_directory / "upgrade-guide.txt" include_file.write_text( data=dedent( text="""\ .. code-block:: shell upgrade --from |SRC_VERSION| --to |NEW_VERSION| """, ), ) source_file = source_directory / "index.rst" source_file.write_text( data=dedent( text="""\ .. |SRC_VERSION| replace:: 4.0 .. |NEW_VERSION| replace:: 4.1 .. include:: upgrade-guide.txt :content-substitutions: """, ), ) 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() include_file.write_text( data=dedent( text="""\ .. code-block:: shell upgrade --from 4.0 --to 4.1 """, ), ) source_file.write_text(data=".. include:: upgrade-guide.txt\n") 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_include_content_does_not_leak_to_nested_includes( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Apply content substitutions only to the include that requests them. """ source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() (source_directory / "nested-default.txt").write_text( data=dedent( text="""\ .. code-block:: text Default nested content with |name| placeholder """, ), ) (source_directory / "nested-disabled.txt").write_text( data=dedent( text="""\ .. code-block:: text Disabled nested content with |name| placeholder """, ), ) outer_file = source_directory / "outer.txt" outer_file.write_text( data=dedent( text="""\ .. include:: nested-default.txt .. include:: nested-disabled.txt :nocontent-substitutions: """, ), ) source_file = source_directory / "index.rst" source_file.write_text( data=dedent( text="""\ .. |name| replace:: example .. include:: outer.txt :content-substitutions: """, ), ) 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() outer_file.write_text( data=dedent( text="""\ .. include:: nested-default.txt .. include:: nested-disabled.txt """, ), ) source_file.write_text(data=".. include:: outer.txt\n") 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_include_path_and_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace placeholders in an include path and its source content.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() include_file = source_directory / "upgrade-guide.txt" include_file.write_text( data=dedent( text="""\ .. code-block:: text Upgrade to |version|. """, ), ) source_file = source_directory / "index.rst" source_file.write_text( data=dedent( text="""\ .. |document| replace:: upgrade-guide .. |version| replace:: 4.1 .. include:: |document|.txt :path-substitutions: :content-substitutions: """, ), ) 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() include_file.write_text( data=dedent( text="""\ .. code-block:: text Upgrade to 4.1. """, ), ) source_file.write_text(data=".. include:: upgrade-guide.txt\n") 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_include_literal_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace placeholders when ``include`` returns a literal node.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text( data="Content with |name| placeholder", ) source_file = source_directory / "index.rst" source_file.write_text( data=dedent( text="""\ .. |name| replace:: example .. include:: example.txt :literal: :content-substitutions: """, ), ) 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() include_file.write_text(data="Content with example placeholder") source_file.write_text( data=dedent( text="""\ .. include:: example.txt :literal: """, ), ) 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_default_substitution_include_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace ``include`` path placeholders when defaults are enabled.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() (source_directory / "example.txt").write_text(data="Included content") (source_directory / "index.rst").write_text( data=dedent( text="""\ .. |name| replace:: example .. include:: |name|.txt """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 assert "Included content" in (app.outdir / "index.html").read_text() def test_default_substitution_include_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Replace include content when substitutions are enabled by default. """ source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text( data=dedent( text="""\ Included literal content:: Content with |name| placeholder """, ), ) source_file = source_directory / "index.rst" source_file.write_text( data=dedent( text="""\ .. |name| replace:: example .. include:: example.txt """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() include_file.write_text( data=dedent( text="""\ Included literal content:: Content with example placeholder """, ), ) source_file.write_text(data=".. include:: example.txt\n") 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_default_substitution_include_disabled_content( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Respect ``:nocontent-substitutions:`` when defaults are enabled.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() include_file = source_directory / "example.txt" include_file.write_text( data=dedent( text="""\ Included literal content:: Content with |name| placeholder """, ), ) source_file = source_directory / "index.rst" source_file.write_text( data=dedent( text="""\ .. |name| replace:: example .. include:: example.txt :nocontent-substitutions: """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() source_file.write_text(data=".. include:: example.txt\n") 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_default_substitution_include_disabled( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """Respect ``:nopath-substitutions:`` when defaults are enabled.""" source_directory = tmp_path / "source" source_directory.mkdir() (source_directory / "conf.py").touch() (source_directory / "[[name]].txt").write_text(data="Included content") (source_directory / "index.rst").write_text( data=dedent( text="""\ .. toctree:: document """, ), ) (source_directory / "document.md").write_text( data=dedent( text="""\ # Document ```{include} [[name]].txt :nopath-substitutions: ``` """, ), ) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": [ "myst_parser", "sphinx_substitution_extensions", ], "myst_enable_extensions": ["substitution"], "myst_sub_delimiters": ("[", "]"), "myst_substitutions": {"name": "example"}, "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 assert "Included content" in (app.outdir / "document.html").read_text() def test_no_substitution_image( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """The ``image`` directive does not replace custom placeholders by default. Note: reST by default processes |substitutions| in image paths, but our extension adds the ability to use custom delimiters like {{var}}. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() image_file = source_directory / "test_image.png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) source_file_content = dedent( text="""\ .. |a| replace:: test_image .. image:: test_image.png """, ) 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() # The behavior should be the same with or without our extension # when not using :path-substitutions: assert content_html == expected_content_html def test_substitution_image_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``image`` directive replaces placeholders in the file path when the ``:path-substitutions:`` flag is set. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() # Create a simple image file with substitution in the name image_file = source_directory / "test_image.png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) source_file_content = dedent( text="""\ .. |a| replace:: test_image .. image:: |a|.png :path-substitutions: """, ) 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() # Compare with directly using the filename equivalent_source = dedent( text="""\ .. image:: test_image.png """, ) 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_image_path_multiple( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The ``image`` directive replaces multiple placeholders in the file path. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() # Create an image file with multiple substitutions in the name image_file = source_directory / "pre_test_mid_image_post.png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) source_file_content = dedent( text="""\ .. |a| replace:: test .. |b| replace:: image .. image:: pre_|a|_mid_|b|_post.png :path-substitutions: """, ) 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() # Compare with directly using the filename equivalent_source = dedent( text="""\ .. image:: pre_test_mid_image_post.png """, ) 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_image_with_options( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """The ``image`` directive works with standard image options.""" source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() image_file = source_directory / "test_image.png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) source_file_content = dedent( text="""\ .. |a| replace:: test_image .. image:: |a|.png :path-substitutions: :alt: Test image alt text :width: 100px """, ) 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="""\ .. image:: test_image.png :alt: Test image alt text :width: 100px """, ) 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_default_substitutions_image_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ When ``substitutions_default_enabled`` is True, ``image`` should apply path substitutions by default without requiring the ``:path-substitutions:`` flag. """ source_directory = tmp_path / "source" source_directory.mkdir() source_file = source_directory / "index.rst" (source_directory / "conf.py").touch() image_file = source_directory / "test_image.png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) source_file_content = dedent( text="""\ .. |a| replace:: test_image .. image:: |a|.png """, ) source_file.write_text(data=source_file_content) app = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={ "extensions": ["sphinx_substitution_extensions"], "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "index.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ .. image:: test_image.png """, ) 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_default_substitutions_image_disabled_path( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """When ``substitutions_default_enabled`` is True but ``image`` has the ``:nopath-substitutions:`` flag, path substitutions should not be applied. Note: This test uses MyST format with custom delimiters because the `|` character cannot be used in Windows file paths. """ 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() # Create an image file with the literal [[a]] in the filename image_file = source_directory / "[[a]].png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{image} [[a]].png :nopath-substitutions: ``` """, ) 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": ("[", "]"), "substitutions_default_enabled": True, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{image} [[a]].png ``` """, ) markdown_source_file.write_text(data=equivalent_source) app_expected = make_app( srcdir=source_directory, exception_on_warning=True, confoverrides={"extensions": ["myst_parser"]}, freshenv=True, ) 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 class TestImageMyst: """Tests for image directive with MyST documents.""" @staticmethod def test_myst_substitutions_image( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ MyST substitutions are respected in image paths 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() # Create an image file image_file = source_directory / "test_image.png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{image} |a|.png :path-substitutions: ``` """, ) 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": "test_image", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{image} test_image.png ``` """, ) 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_image_default_delimiters( *, tmp_path: Path, make_app: Callable[..., SphinxTestApp], ) -> None: """ The default MyST substitution delimiters {{}} are respected for images. """ 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() image_file = source_directory / "test_image.png" png_data = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx" b"\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) image_file.write_bytes(data=png_data) index_source_file_content = dedent( text="""\ .. toctree:: markdown_document """, ) markdown_source_file_content = dedent( text="""\ # Title ```{image} {{a}}.png :path-substitutions: ``` """, ) 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": "test_image", }, }, ) app.build() assert app.statuscode == 0 content_html = (app.outdir / "markdown_document.html").read_text() app.cleanup() equivalent_source = dedent( text="""\ # Title ```{image} test_image.png ``` """, ) 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 adamtheturtle-sphinx-substitution-extensions-8087a67/towncrier_template.rst.jinja000066400000000000000000000003771523462525400306730ustar00rootroot00000000000000 {% for section_name, section in sections.items() %} {% if section %} {% for category, entries in section.items() %} {% for text, _ in entries.items() %} - {{ text }} {% endfor %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} adamtheturtle-sphinx-substitution-extensions-8087a67/zizmor.yml000066400000000000000000000003051523462525400252040ustar00rootroot00000000000000--- rules: unpinned-uses: disable: true cache-poisoning: disable: true dependabot-cooldown: disable: true superfluous-actions: disable: true artipacked: disable: true