├── .copier-answers.yml ├── .envrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-bug.md │ ├── 2-feature.md │ ├── 3-docs.md │ ├── 4-change.md │ └── config.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.json ├── LICENSE ├── Makefile ├── README.md ├── config ├── coverage.ini ├── git-changelog.toml ├── mypy.ini ├── pytest.ini ├── ruff.toml └── vscode │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── docs ├── .overrides │ ├── main.html │ └── partials │ │ ├── comments.html │ │ └── path-item.html ├── changelog.md ├── code_of_conduct.md ├── contributing.md ├── credits.md ├── css │ ├── insiders.css │ ├── material.css │ └── mkdocstrings.css ├── gallery.md ├── index.md ├── insiders │ ├── changelog.md │ ├── goals.yml │ ├── index.md │ └── installation.md ├── js │ ├── feedback.js │ └── insiders.js ├── license.md ├── reference │ └── api.md ├── schema.json ├── snippets │ ├── gallery │ │ ├── ansi.sh │ │ ├── argparse.py │ │ ├── argparse_format.py │ │ ├── chalk.py │ │ ├── d2.py │ │ ├── diagrams.py │ │ ├── drawsvg.py │ │ ├── expandable_filetree.py │ │ ├── hyperbolic.py │ │ ├── matplotlib.py │ │ ├── plotly.py │ │ ├── pydeps.py │ │ ├── qrcode.py │ │ ├── rich.py │ │ ├── rich_terminal.py │ │ ├── runpy.py │ │ └── textual.py │ └── usage │ │ ├── boolean_matrix.py │ │ ├── hide.py │ │ ├── multiple.pycon │ │ ├── platform_html.py │ │ ├── platform_md.py │ │ ├── source.py │ │ └── source.pycon └── usage │ ├── index.md │ ├── pyodide.md │ ├── python.md │ ├── shell.md │ └── tree.md ├── duties.py ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── gen_credits.py ├── get_version.py ├── insiders.py ├── make └── make.py ├── src └── markdown_exec │ ├── __init__.py │ ├── _internal │ ├── __init__.py │ ├── debug.py │ ├── formatters │ │ ├── __init__.py │ │ ├── _exec_python.py │ │ ├── base.py │ │ ├── bash.py │ │ ├── console.py │ │ ├── markdown.py │ │ ├── pycon.py │ │ ├── pyodide.py │ │ ├── python.py │ │ ├── sh.py │ │ └── tree.py │ ├── logger.py │ ├── main.py │ ├── mkdocs_plugin.py │ ├── processors.py │ └── rendering.py │ ├── assets │ ├── ansi.css │ ├── pyodide.css │ └── pyodide.js │ ├── formatters │ ├── __init__.py │ ├── base.py │ ├── bash.py │ ├── console.py │ ├── markdown.py │ ├── pycon.py │ ├── pyodide.py │ ├── python.py │ ├── sh.py │ └── tree.py │ ├── logger.py │ ├── mkdocs_plugin.py │ ├── processors.py │ ├── py.typed │ └── rendering.py └── tests ├── __init__.py ├── conftest.py ├── test_api.py ├── test_base_formatter.py ├── test_converter.py ├── test_headings.py ├── test_python.py ├── test_shell.py ├── test_toc.py ├── test_tree.py └── test_validator.py /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier. 2 | _commit: 1.8.1 3 | _src_path: gh:pawamoy/copier-uv 4 | author_email: dev@pawamoy.fr 5 | author_fullname: Timothée Mazzucotelli 6 | author_username: pawamoy 7 | copyright_date: '2022' 8 | copyright_holder: Timothée Mazzucotelli 9 | copyright_holder_email: dev@pawamoy.fr 10 | copyright_license: ISC 11 | insiders: true 12 | insiders_email: insiders@pawamoy.fr 13 | insiders_repository_name: markdown-exec 14 | project_description: Utilities to execute code blocks in Markdown files. 15 | project_name: Markdown Exec 16 | public_release: true 17 | python_package_command_line_name: '' 18 | python_package_distribution_name: markdown-exec 19 | python_package_import_name: markdown_exec 20 | repository_name: markdown-exec 21 | repository_namespace: pawamoy 22 | repository_provider: github.com 23 | 24 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | PATH_add scripts 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pawamoy 2 | polar: pawamoy 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve. 4 | title: "bug: " 5 | labels: unconfirmed 6 | assignees: [pawamoy] 7 | --- 8 | 9 | ### Description of the bug 10 | 11 | 12 | ### To Reproduce 13 | 28 | 29 | ``` 30 | WRITE MRE / INSTRUCTIONS HERE 31 | ``` 32 | 33 | ### Full traceback 34 | 36 | 37 |
Full traceback 38 | 39 | ```python 40 | PASTE TRACEBACK HERE 41 | ``` 42 | 43 |
44 | 45 | ### Expected behavior 46 | 47 | 48 | ### Environment information 49 | 51 | 52 | ```bash 53 | python -m markdown_exec._internal.debug # | xclip -selection clipboard 54 | ``` 55 | 56 | PASTE MARKDOWN OUTPUT HERE 57 | 58 | ### Additional context 59 | 62 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project. 4 | title: "feature: " 5 | labels: feature 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe. 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation update 3 | about: Point at unclear, missing or outdated documentation. 4 | title: "docs: " 5 | labels: docs 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is something unclear, missing or outdated in our documentation? 10 | 11 | 12 | ### Relevant code snippets 13 | 14 | 15 | ### Link to the relevant documentation section 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4-change.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Change request 3 | about: Suggest any other kind of change for this project. 4 | title: "change: " 5 | assignees: pawamoy 6 | --- 7 | 8 | ### Is your change request related to a problem? Please describe. 9 | 10 | 11 | ### Describe the solution you'd like 12 | 13 | 14 | ### Describe alternatives you've considered 15 | 16 | 17 | ### Additional context 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: I have a question / I need help 4 | url: https://github.com/pawamoy/markdown-exec/discussions/new?category=q-a 5 | about: Ask and answer questions in the Discussions tab. 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | env: 14 | LANG: en_US.utf-8 15 | LC_ALL: en_US.utf-8 16 | PYTHONIOENCODING: UTF-8 17 | PYTHON_VERSIONS: "" 18 | 19 | jobs: 20 | 21 | quality: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | fetch-tags: true 31 | 32 | - name: Set up Graphviz 33 | uses: ts-graphviz/setup-graphviz@v1 34 | 35 | - name: Setup Python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: "3.12" 39 | 40 | - name: Setup uv 41 | uses: astral-sh/setup-uv@v5 42 | with: 43 | enable-cache: true 44 | cache-dependency-glob: pyproject.toml 45 | 46 | - name: Install dependencies 47 | run: make setup 48 | 49 | - name: Check if the documentation builds correctly 50 | run: make check-docs 51 | 52 | - name: Check the code quality 53 | run: make check-quality 54 | 55 | - name: Check if the code is correctly typed 56 | run: make check-types 57 | 58 | - name: Check for breaking changes in the API 59 | run: make check-api 60 | 61 | - name: Store objects inventory for tests 62 | uses: actions/upload-artifact@v4 63 | with: 64 | name: objects.inv 65 | path: site/objects.inv 66 | 67 | exclude-test-jobs: 68 | runs-on: ubuntu-latest 69 | outputs: 70 | jobs: ${{ steps.exclude-jobs.outputs.jobs }} 71 | steps: 72 | - id: exclude-jobs 73 | run: | 74 | if ${{ github.repository_owner == 'pawamoy-insiders' }}; then 75 | echo 'jobs=[ 76 | {"os": "macos-latest"}, 77 | {"os": "windows-latest"}, 78 | {"python-version": "3.10"}, 79 | {"python-version": "3.11"}, 80 | {"python-version": "3.12"}, 81 | {"python-version": "3.13"}, 82 | {"python-version": "3.14"} 83 | ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT 84 | else 85 | echo 'jobs=[ 86 | {"os": "macos-latest", "resolution": "lowest-direct"}, 87 | {"os": "windows-latest", "resolution": "lowest-direct"} 88 | ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT 89 | fi 90 | 91 | tests: 92 | 93 | needs: 94 | - quality 95 | - exclude-test-jobs 96 | strategy: 97 | matrix: 98 | os: 99 | - ubuntu-latest 100 | - macos-latest 101 | - windows-latest 102 | python-version: 103 | - "3.9" 104 | - "3.10" 105 | - "3.11" 106 | - "3.12" 107 | - "3.13" 108 | - "3.14" 109 | resolution: 110 | - highest 111 | - lowest-direct 112 | exclude: ${{ fromJSON(needs.exclude-test-jobs.outputs.jobs) }} 113 | runs-on: ${{ matrix.os }} 114 | continue-on-error: ${{ matrix.python-version == '3.14' }} 115 | 116 | steps: 117 | - name: Checkout 118 | uses: actions/checkout@v4 119 | with: 120 | fetch-depth: 0 121 | fetch-tags: true 122 | 123 | - name: Setup Python 124 | uses: actions/setup-python@v5 125 | with: 126 | python-version: ${{ matrix.python-version }} 127 | allow-prereleases: true 128 | 129 | - name: Setup uv 130 | uses: astral-sh/setup-uv@v5 131 | with: 132 | enable-cache: true 133 | cache-dependency-glob: pyproject.toml 134 | cache-suffix: ${{ matrix.resolution }} 135 | 136 | - name: Install dependencies 137 | env: 138 | UV_RESOLUTION: ${{ matrix.resolution }} 139 | run: make setup 140 | 141 | - name: Download objects inventory 142 | uses: actions/download-artifact@v4 143 | with: 144 | name: objects.inv 145 | path: site/ 146 | 147 | - name: Run the test suite 148 | run: make test 149 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: push 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | if: startsWith(github.ref, 'refs/tags/') 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | fetch-tags: true 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | - name: Setup uv 22 | uses: astral-sh/setup-uv@v5 23 | - name: Build dists 24 | if: github.repository_owner == 'pawamoy-insiders' 25 | run: uv tool run --from build pyproject-build 26 | - name: Upload dists artifact 27 | uses: actions/upload-artifact@v4 28 | if: github.repository_owner == 'pawamoy-insiders' 29 | with: 30 | name: markdown-exec-insiders 31 | path: ./dist/* 32 | - name: Prepare release notes 33 | if: github.repository_owner != 'pawamoy-insiders' 34 | run: uv tool run git-changelog --release-notes > release-notes.md 35 | - name: Create release with assets 36 | uses: softprops/action-gh-release@v2 37 | if: github.repository_owner == 'pawamoy-insiders' 38 | with: 39 | files: ./dist/* 40 | - name: Create release 41 | uses: softprops/action-gh-release@v2 42 | if: github.repository_owner != 'pawamoy-insiders' 43 | with: 44 | body_path: release-notes.md 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | .idea/ 3 | .vscode/ 4 | 5 | # python 6 | *.egg-info/ 7 | *.py[cod] 8 | .venv/ 9 | .venvs/ 10 | /build/ 11 | /dist/ 12 | 13 | # tools 14 | .coverage* 15 | /.pdm-build/ 16 | /htmlcov/ 17 | /site/ 18 | uv.lock 19 | 20 | # cache 21 | .cache/ 22 | .pytest_cache/ 23 | .mypy_cache/ 24 | .ruff_cache/ 25 | __pycache__/ 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | * Trolling, insulting or derogatory comments, and personal or political attacks 23 | * Public or private harassment 24 | * Publishing others' private information, such as a physical or email address, without their explicit permission 25 | * Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at dev@pawamoy.fr. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 76 | 77 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 78 | 79 | [homepage]: https://www.contributor-covenant.org 80 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 81 | [Mozilla CoC]: https://github.com/mozilla/diversity 82 | [FAQ]: https://www.contributor-covenant.org/faq 83 | [translations]: https://www.contributor-covenant.org/translations 84 | 85 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 4 | 5 | ## Environment setup 6 | 7 | Nothing easier! 8 | 9 | Fork and clone the repository, then: 10 | 11 | ```bash 12 | cd markdown-exec 13 | make setup 14 | ``` 15 | 16 | > NOTE: If it fails for some reason, you'll need to install [uv](https://github.com/astral-sh/uv) manually. 17 | > 18 | > You can install it with: 19 | > 20 | > ```bash 21 | > curl -LsSf https://astral.sh/uv/install.sh | sh 22 | > ``` 23 | > 24 | > Now you can try running `make setup` again, or simply `uv sync`. 25 | 26 | You now have the dependencies installed. 27 | 28 | Run `make help` to see all the available actions! 29 | 30 | ## Tasks 31 | 32 | The entry-point to run commands and tasks is the `make` Python script, located in the `scripts` directory. Try running `make` to show the available commands and tasks. The *commands* do not need the Python dependencies to be installed, 33 | while the *tasks* do. The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). 34 | 35 | If you work in VSCode, we provide [an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) for the project. 36 | 37 | ## Development 38 | 39 | As usual: 40 | 41 | 1. create a new branch: `git switch -c feature-or-bugfix-name` 42 | 1. edit the code and/or the documentation 43 | 44 | **Before committing:** 45 | 46 | 1. run `make format` to auto-format the code 47 | 1. run `make check` to check everything (fix any warning) 48 | 1. run `make test` to run the tests (fix any issue) 49 | 1. if you updated the documentation or the project dependencies: 50 | 1. run `make docs` 51 | 1. go to http://localhost:8000 and check that everything looks good 52 | 1. follow our [commit message convention](#commit-message-convention) 53 | 54 | If you are unsure about how to fix or ignore a warning, just let the continuous integration fail, and we will help you during review. 55 | 56 | Don't bother updating the changelog, we will take care of this. 57 | 58 | ## Commit message convention 59 | 60 | Commit messages must follow our convention based on the [Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): 61 | 62 | ``` 63 | [(scope)]: Subject 64 | 65 | [Body] 66 | ``` 67 | 68 | **Subject and body must be valid Markdown.** Subject must have proper casing (uppercase for first letter if it makes sense), but no dot at the end, and no punctuation in general. 69 | 70 | Scope and body are optional. Type can be: 71 | 72 | - `build`: About packaging, building wheels, etc. 73 | - `chore`: About packaging or repo/files management. 74 | - `ci`: About Continuous Integration. 75 | - `deps`: Dependencies update. 76 | - `docs`: About documentation. 77 | - `feat`: New feature. 78 | - `fix`: Bug fix. 79 | - `perf`: About performance. 80 | - `refactor`: Changes that are not features or bug fixes. 81 | - `style`: A change in code style/format. 82 | - `tests`: About tests. 83 | 84 | If you write a body, please add trailers at the end (for example issues and PR references, or co-authors), without relying on GitHub's flavored Markdown: 85 | 86 | ``` 87 | Body. 88 | 89 | Issue #10: https://github.com/namespace/project/issues/10 90 | Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 91 | ``` 92 | 93 | These "trailers" must appear at the end of the body, without any blank lines between them. The trailer title can contain any character except colons `:`. We expect a full URI for each trailer, not just GitHub autolinks (for example, full GitHub URLs for commits and issues, not the hash or the #issue-number). 94 | 95 | We do not enforce a line length on commit messages summary and body, but please avoid very long summaries, and very long lines in the body, unless they are part of code blocks that must not be wrapped. 96 | 97 | ## Pull requests guidelines 98 | 99 | Link to any related issue in the Pull Request message. 100 | 101 | During the review, we recommend using fixups: 102 | 103 | ```bash 104 | # SHA is the SHA of the commit you want to fix 105 | git commit --fixup=SHA 106 | ``` 107 | 108 | Once all the changes are approved, you can squash your commits: 109 | 110 | ```bash 111 | git rebase -i --autosquash main 112 | ``` 113 | 114 | And force-push: 115 | 116 | ```bash 117 | git push -f 118 | ``` 119 | 120 | If this seems all too complicated, you can push or force-push each new commit, and we will squash them ourselves if needed, before merging. 121 | -------------------------------------------------------------------------------- /FUNDING.json: -------------------------------------------------------------------------------- 1 | { 2 | "drips": { 3 | "ethereum": { 4 | "ownedBy": "0xaaac25D56367b6c9e0496c092679072720382A0e" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022, Timothée Mazzucotelli 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # If you have `direnv` loaded in your shell, and allow it in the repository, 2 | # the `make` command will point at the `scripts/make` shell script. 3 | # This Makefile is just here to allow auto-completion in the terminal. 4 | 5 | actions = \ 6 | allrun \ 7 | changelog \ 8 | check \ 9 | check-api \ 10 | check-docs \ 11 | check-quality \ 12 | check-types \ 13 | clean \ 14 | coverage \ 15 | docs \ 16 | docs-deploy \ 17 | format \ 18 | help \ 19 | multirun \ 20 | release \ 21 | run \ 22 | setup \ 23 | test \ 24 | vscode 25 | 26 | .PHONY: $(actions) 27 | $(actions): 28 | @python scripts/make "$@" 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown Exec 2 | 3 | [![ci](https://github.com/pawamoy/markdown-exec/workflows/ci/badge.svg)](https://github.com/pawamoy/markdown-exec/actions?query=workflow%3Aci) 4 | [![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://pawamoy.github.io/markdown-exec/) 5 | [![pypi version](https://img.shields.io/pypi/v/markdown-exec.svg)](https://pypi.org/project/markdown-exec/) 6 | [![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#markdown-exec:gitter.im) 7 | 8 | Utilities to execute code blocks in Markdown files. 9 | 10 | For example, you write a Python code block that computes some HTML, 11 | and this HTML is injected in place of the code block. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | pip install "markdown-exec[ansi]" 17 | ``` 18 | 19 | The `ansi` extra provides the necessary bits (`pygments-ansi-color` and a CSS file) 20 | to render ANSI colors in HTML code blocks. The CSS file is automatically added 21 | to MkDocs' `extra_css` when Markdown Exec is activated via `plugins` (see below). 22 | 23 | ## Configuration 24 | 25 | This extension relies on the 26 | [SuperFences](https://facelessuser.github.io/pymdown-extensions/extensions/superfences/) 27 | extension of 28 | [PyMdown Extensions](https://facelessuser.github.io/pymdown-extensions/). 29 | 30 | To allow execution of code blocks, 31 | configure a custom fence from Python: 32 | 33 | ```python 34 | from markdown import Markdown 35 | from markdown_exec import formatter, validator 36 | 37 | Markdown( 38 | extensions=["pymdownx.superfences"], 39 | extension_configs={ 40 | "pymdownx.superfences": { 41 | "custom_fences": [ 42 | { 43 | "name": "python", 44 | "class": "python", 45 | "validator": validator, 46 | "format": formatter, 47 | } 48 | # ...one fence for each language we support: 49 | # bash, console, md, markdown, py, python, pycon, sh, tree 50 | ] 51 | } 52 | } 53 | ) 54 | ``` 55 | 56 | ...or in MkDocs configuration file, as a Markdown extension: 57 | 58 | ```yaml 59 | # mkdocs.yml 60 | markdown_extensions: 61 | - pymdownx.superfences: 62 | custom_fences: 63 | - name: python 64 | class: python 65 | validator: !!python/name:markdown_exec.validator 66 | format: !!python/name:markdown_exec.formatter 67 | # ...one fence for each language we support: 68 | # bash, console, md, markdown, py, python, pycon, sh, tree 69 | ``` 70 | 71 | ...or in MkDocs configuration file, as a plugin: 72 | 73 | ```yaml 74 | # mkdocs.yml 75 | plugins: 76 | - search 77 | - markdown-exec 78 | 79 | # SuperFences must still be enabled! 80 | markdown_extensions: 81 | - pymdownx.superfences 82 | ``` 83 | 84 | We do recommend enabling Markdown Exec with the MkDocs plugin 85 | if you are using MkDocs: it will take care of adding relevant 86 | assets (CSS/JS) to the final site when needed. 87 | 88 | ## Usage 89 | 90 | You are now able to execute code blocks instead of displaying them: 91 | 92 | ````md 93 | ```python exec="on" 94 | print("Hello Markdown!") 95 | ``` 96 | ```` 97 | 98 | The `exec` option will be true for every possible value except `0`, `no`, `off` and `false` (case insensitive). 99 | 100 | Below you can see an example of running a bash script that is expected to 101 | return a non-zero exit code: 102 | 103 | ````md 104 | ```bash exec="1" source="tabbed-left" returncode="2" 105 | grep extra_css README.md && exit 2 106 | ``` 107 | ```` 108 | 109 | See [usage](https://pawamoy.github.io/markdown-exec/usage/) for more details, 110 | and the [gallery](https://pawamoy.github.io/markdown-exec/gallery/) for more examples! 111 | -------------------------------------------------------------------------------- /config/coverage.ini: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = true 3 | parallel = true 4 | source = 5 | src/ 6 | tests/ 7 | 8 | [coverage:paths] 9 | equivalent = 10 | src/ 11 | .venv/lib/*/site-packages/ 12 | .venvs/*/lib/*/site-packages/ 13 | 14 | [coverage:report] 15 | precision = 2 16 | omit = 17 | src/*/__init__.py 18 | src/*/__main__.py 19 | tests/__init__.py 20 | exclude_lines = 21 | pragma: no cover 22 | if TYPE_CHECKING 23 | 24 | [coverage:json] 25 | output = htmlcov/coverage.json 26 | -------------------------------------------------------------------------------- /config/git-changelog.toml: -------------------------------------------------------------------------------- 1 | bump = "auto" 2 | convention = "angular" 3 | in-place = true 4 | output = "CHANGELOG.md" 5 | parse-refs = false 6 | parse-trailers = true 7 | sections = ["build", "deps", "feat", "fix", "refactor"] 8 | template = "keepachangelog" 9 | versioning = "pep440" 10 | -------------------------------------------------------------------------------- /config/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = true 3 | exclude = (?x)( 4 | tests/fixtures/ | 5 | docs/snippets/gallery/ | 6 | docs/snippets/usage/ 7 | ) 8 | warn_unused_ignores = true 9 | show_error_codes = true 10 | -------------------------------------------------------------------------------- /config/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = 3 | test_*.py 4 | addopts = 5 | --cov 6 | --cov-config config/coverage.ini 7 | testpaths = 8 | tests 9 | 10 | # action:message_regex:warning_class:module_regex:line 11 | filterwarnings = 12 | error 13 | # TODO: remove once pytest-xdist 4 is released 14 | ignore:.*rsyncdir:DeprecationWarning:xdist 15 | -------------------------------------------------------------------------------- /config/ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | line-length = 120 3 | 4 | [lint] 5 | exclude = [ 6 | "tests/fixtures/*.py", 7 | ] 8 | select = [ 9 | "A", "ANN", "ARG", 10 | "B", "BLE", 11 | "C", "C4", 12 | "COM", 13 | "D", "DTZ", 14 | "E", "ERA", "EXE", 15 | "F", "FBT", 16 | "G", 17 | "I", "ICN", "INP", "ISC", 18 | "N", 19 | "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", 20 | "Q", 21 | "RUF", "RSE", "RET", 22 | "S", "SIM", "SLF", 23 | "T", "T10", "T20", "TCH", "TID", "TRY", 24 | "UP", 25 | "W", 26 | "YTT", 27 | ] 28 | ignore = [ 29 | "A001", # Variable is shadowing a Python builtin 30 | "ANN101", # Missing type annotation for self 31 | "ANN102", # Missing type annotation for cls 32 | "ANN204", # Missing return type annotation for special method __str__ 33 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 34 | "ARG005", # Unused lambda argument 35 | "C901", # Too complex 36 | "D105", # Missing docstring in magic method 37 | "D417", # Missing argument description in the docstring 38 | "E501", # Line too long 39 | "ERA001", # Commented out code 40 | "G004", # Logging statement uses f-string 41 | "PLR0911", # Too many return statements 42 | "PLR0912", # Too many branches 43 | "PLR0913", # Too many arguments to function call 44 | "PLR0915", # Too many statements 45 | "SLF001", # Private member accessed 46 | "TRY003", # Avoid specifying long messages outside the exception class 47 | ] 48 | 49 | [lint.per-file-ignores] 50 | "src/**/cli.py" = [ 51 | "T201", # Print statement 52 | ] 53 | "src/*/debug.py" = [ 54 | "T201", # Print statement 55 | ] 56 | "!src/*/*.py" = [ 57 | "D100", # Missing docstring in public module 58 | ] 59 | "!src/**.py" = [ 60 | "D101", # Missing docstring in public class 61 | "D103", # Missing docstring in public function 62 | ] 63 | "scripts/*.py" = [ 64 | "INP001", # File is part of an implicit namespace package 65 | "T201", # Print statement 66 | ] 67 | "tests/**.py" = [ 68 | "ARG005", # Unused lambda argument 69 | "FBT001", # Boolean positional arg in function definition 70 | "PLR2004", # Magic value used in comparison 71 | "S101", # Use of assert detected 72 | ] 73 | 74 | [lint.flake8-quotes] 75 | docstring-quotes = "double" 76 | 77 | [lint.flake8-tidy-imports] 78 | ban-relative-imports = "all" 79 | 80 | [lint.isort] 81 | known-first-party = ["markdown_exec"] 82 | 83 | [lint.pydocstyle] 84 | convention = "google" 85 | 86 | [format] 87 | exclude = [ 88 | "tests/fixtures/*.py", 89 | ] 90 | docstring-code-format = true 91 | docstring-code-line-length = 80 92 | -------------------------------------------------------------------------------- /config/vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "python (current file)", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "integratedTerminal", 10 | "justMyCode": false, 11 | "args": "${command:pickArgs}" 12 | }, 13 | { 14 | "name": "run", 15 | "type": "debugpy", 16 | "request": "launch", 17 | "module": "markdown_exec", 18 | "console": "integratedTerminal", 19 | "justMyCode": false, 20 | "args": "${command:pickArgs}" 21 | }, 22 | { 23 | "name": "docs", 24 | "type": "debugpy", 25 | "request": "launch", 26 | "module": "mkdocs", 27 | "justMyCode": false, 28 | "args": [ 29 | "serve", 30 | "-v" 31 | ] 32 | }, 33 | { 34 | "name": "test", 35 | "type": "debugpy", 36 | "request": "launch", 37 | "module": "pytest", 38 | "justMyCode": false, 39 | "args": [ 40 | "-c=config/pytest.ini", 41 | "-vvv", 42 | "--no-cov", 43 | "--dist=no", 44 | "tests", 45 | "-k=${input:tests_selection}" 46 | ] 47 | } 48 | ], 49 | "inputs": [ 50 | { 51 | "id": "tests_selection", 52 | "type": "promptString", 53 | "description": "Tests selection", 54 | "default": "" 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /config/vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/.venv*/**": true, 4 | "**/.venvs*/**": true, 5 | "**/venv*/**": true 6 | }, 7 | "mypy-type-checker.args": [ 8 | "--config-file=config/mypy.ini" 9 | ], 10 | "python.testing.unittestEnabled": false, 11 | "python.testing.pytestEnabled": true, 12 | "python.testing.pytestArgs": [ 13 | "--config-file=config/pytest.ini" 14 | ], 15 | "ruff.enable": true, 16 | "ruff.format.args": [ 17 | "--config=config/ruff.toml" 18 | ], 19 | "ruff.lint.args": [ 20 | "--config=config/ruff.toml" 21 | ], 22 | "yaml.schemas": { 23 | "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" 24 | }, 25 | "yaml.customTags": [ 26 | "!ENV scalar", 27 | "!ENV sequence", 28 | "!relative scalar", 29 | "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", 30 | "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", 31 | "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" 32 | ] 33 | } -------------------------------------------------------------------------------- /config/vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "changelog", 6 | "type": "process", 7 | "command": "scripts/make", 8 | "args": ["changelog"] 9 | }, 10 | { 11 | "label": "check", 12 | "type": "process", 13 | "command": "scripts/make", 14 | "args": ["check"] 15 | }, 16 | { 17 | "label": "check-quality", 18 | "type": "process", 19 | "command": "scripts/make", 20 | "args": ["check-quality"] 21 | }, 22 | { 23 | "label": "check-types", 24 | "type": "process", 25 | "command": "scripts/make", 26 | "args": ["check-types"] 27 | }, 28 | { 29 | "label": "check-docs", 30 | "type": "process", 31 | "command": "scripts/make", 32 | "args": ["check-docs"] 33 | }, 34 | { 35 | "label": "check-api", 36 | "type": "process", 37 | "command": "scripts/make", 38 | "args": ["check-api"] 39 | }, 40 | { 41 | "label": "clean", 42 | "type": "process", 43 | "command": "scripts/make", 44 | "args": ["clean"] 45 | }, 46 | { 47 | "label": "docs", 48 | "type": "process", 49 | "command": "scripts/make", 50 | "args": ["docs"] 51 | }, 52 | { 53 | "label": "docs-deploy", 54 | "type": "process", 55 | "command": "scripts/make", 56 | "args": ["docs-deploy"] 57 | }, 58 | { 59 | "label": "format", 60 | "type": "process", 61 | "command": "scripts/make", 62 | "args": ["format"] 63 | }, 64 | { 65 | "label": "release", 66 | "type": "process", 67 | "command": "scripts/make", 68 | "args": ["release", "${input:version}"] 69 | }, 70 | { 71 | "label": "setup", 72 | "type": "process", 73 | "command": "scripts/make", 74 | "args": ["setup"] 75 | }, 76 | { 77 | "label": "test", 78 | "type": "process", 79 | "command": "scripts/make", 80 | "args": ["test", "coverage"], 81 | "group": "test" 82 | }, 83 | { 84 | "label": "vscode", 85 | "type": "process", 86 | "command": "scripts/make", 87 | "args": ["vscode"] 88 | } 89 | ], 90 | "inputs": [ 91 | { 92 | "id": "version", 93 | "type": "promptString", 94 | "description": "Version" 95 | } 96 | ] 97 | } -------------------------------------------------------------------------------- /docs/.overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | 5 | Fund this project through 6 | sponsorship 7 | 8 | {% include ".icons/octicons/heart-fill-16.svg" %} 9 | — 10 | 11 | Follow 12 | @pawamoy on 13 | 14 | 15 | {% include ".icons/fontawesome/brands/mastodon.svg" %} 16 | 17 | Fosstodon 18 | 19 | for updates 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /docs/.overrides/partials/comments.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/.overrides/partials/path-item.html: -------------------------------------------------------------------------------- 1 | {# Fix breadcrumbs for when mkdocs-section-index is used. #} 2 | {# See https://github.com/squidfunk/mkdocs-material/issues/7614. #} 3 | 4 | 5 | {% macro render_content(nav_item) %} 6 | 7 | {{ nav_item.title }} 8 | 9 | {% endmacro %} 10 | 11 | 12 | {% macro render(nav_item, ref=nav_item) %} 13 | {% if nav_item.is_page %} 14 |
  • 15 | 16 | {{ render_content(ref) }} 17 | 18 |
  • 19 | {% elif nav_item.children %} 20 | {{ render(nav_item.children | first, ref) }} 21 | {% endif %} 22 | {% endmacro %} 23 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | 5 | --8<-- "CHANGELOG.md" 6 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Code of Conduct 3 | --- 4 | 5 | --8<-- "CODE_OF_CONDUCT.md" 6 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | --8<-- "CONTRIBUTING.md" 6 | -------------------------------------------------------------------------------- /docs/credits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Credits 3 | hide: 4 | - toc 5 | --- 6 | 7 | ```python exec="yes" 8 | --8<-- "scripts/gen_credits.py" 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/css/insiders.css: -------------------------------------------------------------------------------- 1 | @keyframes heart { 2 | 3 | 0%, 4 | 40%, 5 | 80%, 6 | 100% { 7 | transform: scale(1); 8 | } 9 | 10 | 20%, 11 | 60% { 12 | transform: scale(1.15); 13 | } 14 | } 15 | 16 | @keyframes vibrate { 17 | 0%, 2%, 4%, 6%, 8%, 10%, 12%, 14%, 16%, 18% { 18 | -webkit-transform: translate3d(-2px, 0, 0); 19 | transform: translate3d(-2px, 0, 0); 20 | } 21 | 1%, 3%, 5%, 7%, 9%, 11%, 13%, 15%, 17%, 19% { 22 | -webkit-transform: translate3d(2px, 0, 0); 23 | transform: translate3d(2px, 0, 0); 24 | } 25 | 20%, 100% { 26 | -webkit-transform: translate3d(0, 0, 0); 27 | transform: translate3d(0, 0, 0); 28 | } 29 | } 30 | 31 | .heart { 32 | color: #e91e63; 33 | } 34 | 35 | .pulse { 36 | animation: heart 1000ms infinite; 37 | } 38 | 39 | .vibrate { 40 | animation: vibrate 2000ms infinite; 41 | } 42 | 43 | .new-feature svg { 44 | fill: var(--md-accent-fg-color) !important; 45 | } 46 | 47 | a.insiders { 48 | color: #e91e63; 49 | } 50 | 51 | .sponsorship-list { 52 | width: 100%; 53 | } 54 | 55 | .sponsorship-item { 56 | border-radius: 100%; 57 | display: inline-block; 58 | height: 1.6rem; 59 | margin: 0.1rem; 60 | overflow: hidden; 61 | width: 1.6rem; 62 | } 63 | 64 | .sponsorship-item:focus, .sponsorship-item:hover { 65 | transform: scale(1.1); 66 | } 67 | 68 | .sponsorship-item img { 69 | filter: grayscale(100%) opacity(75%); 70 | height: auto; 71 | width: 100%; 72 | } 73 | 74 | .sponsorship-item:focus img, .sponsorship-item:hover img { 75 | filter: grayscale(0); 76 | } 77 | 78 | .sponsorship-item.private { 79 | background: var(--md-default-fg-color--lightest); 80 | color: var(--md-default-fg-color); 81 | font-size: .6rem; 82 | font-weight: 700; 83 | line-height: 1.6rem; 84 | text-align: center; 85 | } 86 | 87 | .mastodon { 88 | color: #897ff8; 89 | border-radius: 100%; 90 | box-shadow: inset 0 0 0 .05rem currentcolor; 91 | display: inline-block; 92 | height: 1.2rem !important; 93 | padding: .25rem; 94 | transition: all .25s; 95 | vertical-align: bottom !important; 96 | width: 1.2rem; 97 | } 98 | 99 | .premium-sponsors { 100 | text-align: center; 101 | } 102 | 103 | #silver-sponsors img { 104 | height: 140px; 105 | } 106 | 107 | #bronze-sponsors img { 108 | height: 140px; 109 | } 110 | 111 | #bronze-sponsors p { 112 | display: flex; 113 | flex-wrap: wrap; 114 | justify-content: center; 115 | } 116 | 117 | #bronze-sponsors a { 118 | display: block; 119 | flex-shrink: 0; 120 | } 121 | 122 | .sponsors-total { 123 | font-weight: bold; 124 | } -------------------------------------------------------------------------------- /docs/css/material.css: -------------------------------------------------------------------------------- 1 | /* More space at the bottom of the page. */ 2 | .md-main__inner { 3 | margin-bottom: 1.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /docs/css/mkdocstrings.css: -------------------------------------------------------------------------------- 1 | /* Indentation. */ 2 | div.doc-contents:not(.first) { 3 | padding-left: 25px; 4 | border-left: .05rem solid var(--md-typeset-table-color); 5 | } 6 | 7 | /* Mark external links as such. */ 8 | a.external::after, 9 | a.autorefs-external::after { 10 | /* https://primer.style/octicons/arrow-up-right-24 */ 11 | mask-image: url('data:image/svg+xml,'); 12 | -webkit-mask-image: url('data:image/svg+xml,'); 13 | content: ' '; 14 | 15 | display: inline-block; 16 | vertical-align: middle; 17 | position: relative; 18 | 19 | height: 1em; 20 | width: 1em; 21 | background-color: currentColor; 22 | } 23 | 24 | a.external:hover::after, 25 | a.autorefs-external:hover::after { 26 | background-color: var(--md-accent-fg-color); 27 | } 28 | 29 | /* Tree-like output for backlinks. */ 30 | .doc-backlink-list { 31 | --tree-clr: var(--md-default-fg-color); 32 | --tree-font-size: 1rem; 33 | --tree-item-height: 1; 34 | --tree-offset: 1rem; 35 | --tree-thickness: 1px; 36 | --tree-style: solid; 37 | display: grid; 38 | list-style: none !important; 39 | } 40 | 41 | .doc-backlink-list li > span:first-child { 42 | text-indent: .3rem; 43 | } 44 | .doc-backlink-list li { 45 | padding-inline-start: var(--tree-offset); 46 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 47 | position: relative; 48 | margin-left: 0 !important; 49 | 50 | &:last-child { 51 | border-color: transparent; 52 | } 53 | &::before{ 54 | content: ''; 55 | position: absolute; 56 | top: calc(var(--tree-item-height) / 2 * -1 * var(--tree-font-size) + var(--tree-thickness)); 57 | left: calc(var(--tree-thickness) * -1); 58 | width: calc(var(--tree-offset) + var(--tree-thickness) * 2); 59 | height: calc(var(--tree-item-height) * var(--tree-font-size)); 60 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 61 | border-bottom: var(--tree-thickness) var(--tree-style) var(--tree-clr); 62 | } 63 | &::after{ 64 | content: ''; 65 | position: absolute; 66 | border-radius: 50%; 67 | background-color: var(--tree-clr); 68 | top: calc(var(--tree-item-height) / 2 * 1rem); 69 | left: var(--tree-offset) ; 70 | translate: calc(var(--tree-thickness) * -1) calc(var(--tree-thickness) * -1); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /docs/gallery.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | --- 5 | 6 | # Gallery 7 | 8 | Welcome to our gallery of examples! 9 | 10 | ## Diagrams, charts, graphs, plots 11 | 12 | ### with [Diagrams](https://github.com/mingrammer/diagrams) 13 | 14 | > Diagram as Code for prototyping cloud system architectures. 15 | 16 | ````md exec="1" source="tabbed-right" 17 | ```python exec="true" html="true" 18 | --8<-- "gallery/diagrams.py" 19 | ``` 20 | ```` 21 | 22 | ### with [D2](https://d2lang.com/) 23 | 24 | > A modern diagram scripting language that turns text to diagrams. 25 | 26 | ````md exec="1" source="tabbed-right" 27 | ```python exec="true" html="true" 28 | --8<-- "gallery/d2.py" 29 | ``` 30 | ```` 31 | 32 | ### with [Matplotlib](https://matplotlib.org/) 33 | 34 | > Matplotlib is a comprehensive library for creating static, animated, and interactive visualizations in Python. 35 | 36 | ````md exec="1" source="tabbed-right" 37 | ```python exec="1" html="1" 38 | --8<-- "gallery/matplotlib.py" 39 | ``` 40 | ```` 41 | 42 | ### with [pipdeptree](https://github.com/tox-dev/pipdeptree) 43 | 44 | > A command line utility to display dependency tree of the installed Python packages. 45 | 46 | We call `pipdeptree` with its `--mermaid` option to generate a [Mermaid](https://mermaid.js.org/) diagram. 47 | 48 | ````md exec="1" source="tabbed-right" 49 | ```bash exec="1" result="mermaid" 50 | # Change the direction of the graph from top-down to left-right, 51 | # and remove local version identifiers from our own package. 52 | pipdeptree -p markdown-exec --mermaid 2>/dev/null | 53 | sed -E 's/\.dev.+"\]$/"]/;s/\+d.*"\]$/"]/' 54 | ``` 55 | ```` 56 | 57 | Another example with more dependencies: 58 | 59 | ````md exec="1" source="tabbed-right" 60 | ```bash exec="1" result="mermaid" 61 | pipdeptree -p mkdocstrings-python --mermaid 2>/dev/null | 62 | sed 's/flowchart TD/flowchart LR/' 63 | ``` 64 | ```` 65 | 66 | ### with [Plotly](https://plotly.com/python/) 67 | 68 | > The interactive graphing library for Python ✨ 69 | 70 | ````md exec="1" source="tabbed-right" 71 | ```python exec="true" html="true" 72 | --8<-- "gallery/plotly.py" 73 | ``` 74 | ```` 75 | 76 | ### with [pydeps](https://github.com/thebjorn/pydeps) 77 | 78 | > Python Module Dependency graphs. 79 | 80 | pydeps uses [Graphviz](https://graphviz.org/) under the hood to generate graphs. In this example we add links to the code reference in related nodes. Try clicking on the `markdown_exec` nodes! 81 | 82 | ````md exec="1" source="tabbed-right" 83 | ```python exec="true" html="true" 84 | --8<-- "gallery/pydeps.py" 85 | ``` 86 | ```` 87 | 88 | ## Code snippets 89 | 90 | ### with [Rich](https://github.com/Textualize/rich) 91 | 92 | > Rich is a Python library for rich text and beautiful formatting in the terminal. 93 | 94 | ````md exec="1" source="tabbed-right" 95 | ```python exec="true" html="true" 96 | --8<-- "gallery/rich.py" 97 | ``` 98 | ```` 99 | 100 | ## Console output 101 | 102 | If you installed Markdown Exec with the `ansi` extra (`pip install markdown-exec[ansi]`), the ANSI colors in the output of shell commands will be translated to HTML/CSS, allowing to render them naturally in your documentation pages. For this to happen, use the [`result="ansi"` option](http://localhost:8000/markdown-exec/usage/#wrap-result-in-a-code-block). 103 | 104 | ````md exec="1" source="tabbed-right" 105 | ```bash exec="true" result="ansi" 106 | --8<-- "gallery/ansi.sh" 107 | ``` 108 | ```` 109 | 110 | ### with [Rich](https://github.com/Textualize/rich) 111 | 112 | > Rich is a Python library for rich text and beautiful formatting in the terminal. 113 | 114 | ````md exec="1" source="tabbed-right" 115 | ```python exec="true" html="true" 116 | --8<-- "gallery/rich_terminal.py" 117 | ``` 118 | ```` 119 | 120 | ## SVG drawings 121 | 122 | ### with [Chalk](https://github.com/chalk-diagrams/chalk) 123 | 124 | > A declarative drawing API in Python. 125 | 126 | ````md exec="1" source="tabbed-right" 127 | ```python exec="true" html="true" 128 | --8<-- "gallery/chalk.py" 129 | ``` 130 | ```` 131 | ### with [Drawsvg 2](https://github.com/cduck/drawsvg) 132 | 133 | > Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets. 134 | 135 | ````md exec="1" source="tabbed-right" 136 | ```python exec="true" html="true" 137 | --8<-- "gallery/drawsvg.py" 138 | ``` 139 | ```` 140 | 141 | ### with [Hyperbolic](https://github.com/cduck/hyperbolic) 142 | 143 | > A Python 3 library for constructing and drawing hyperbolic geometry. 144 | 145 | ````md exec="1" source="tabbed-right" 146 | ```python exec="true" html="true" 147 | --8<-- "gallery/hyperbolic.py" 148 | ``` 149 | ```` 150 | 151 | ## QRCodes 152 | 153 | ### with [qrcode](https://pypi.org/project/qrcode/) 154 | 155 | > Python QR Code image generator. 156 | 157 | ````md exec="1" source="tabbed-right" 158 | ```python exec="true" html="true" 159 | --8<-- "gallery/qrcode.py" 160 | ``` 161 | ```` 162 | 163 | ## TUI screenshots 164 | 165 | ### with [Textual](https://github.com/Textualize/textual) 166 | 167 | > Textual is a *Rapid Application Development* framework for Python, built by [Textualize.io](https://www.textualize.io/). 168 | 169 | ````md exec="1" source="tabbed-right" 170 | ```python exec="1" html="true" 171 | --8<-- "gallery/textual.py" 172 | ``` 173 | ```` 174 | 175 | ## File-trees 176 | 177 | This example displays a file-tree of the current project, in which you can descend thanks to Material for MkDocs' [code annotations](https://squidfunk.github.io/mkdocs-material/reference/code-blocks/#code-annotations). It uses a recursive Python function which accept a code block session name as parameter 🤯: 178 | 179 | ````md exec="1" source="tabbed-right" 180 | ```python exec="1" session="filetree" 181 | --8<-- "gallery/expandable_filetree.py" 182 | ``` 183 | 184 | ```python exec="1" session="filetree" 185 | exptree(".", "filetree") 186 | ``` 187 | ```` 188 | 189 | ## Python CLI documentation 190 | 191 | ### with [`argparse`](https://docs.python.org/3/library/argparse.html#module-argparse) (code block) 192 | 193 | If you know a project is using `argparse` to build its command line interface, and if it exposes its parser, then you can get the help message directly from the parser. 194 | 195 | ````md exec="1" source="tabbed-right" 196 | ```python exec="true" 197 | --8<-- "gallery/argparse_format.py" 198 | ``` 199 | ```` 200 | 201 | ### with [`argparse`](https://docs.python.org/3/library/argparse.html#module-argparse) (Markdown) 202 | 203 | In this example, we inspect the `argparse` parser to build better-looking Markdown/HTML contents. We simply use the description and iterate on options, but more complex stuff is possible of course. 204 | 205 | ````md exec="1" source="tabbed-right" 206 | ```python exec="true" updatetoc="no" 207 | --8<-- "gallery/argparse.py" 208 | ``` 209 | ```` 210 | 211 | ### with [`runpy`](https://docs.python.org/3/library/runpy.html#module-runpy) 212 | 213 | This example uses Python's `runpy` module to run another Python module. This other module's output is captured by temporarily patching `sys.stdout` with a text buffer. 214 | 215 | ````md exec="1" source="tabbed-right" 216 | ```python exec="true" 217 | --8<-- "gallery/runpy.py" 218 | ``` 219 | ```` 220 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | hide: 4 | - feedback 5 | --- 6 | 7 | --8<-- "README.md" 8 | -------------------------------------------------------------------------------- /docs/insiders/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Markdown Exec Insiders 4 | 5 | ### 1.0.1 June 15, 2023 { id="1.0.1" } 6 | 7 | - Support HTML minification by wrapping code in pre tags 8 | - Catch JS error on pages without Pyodide fences 9 | 10 | ### 1.0.0 April 26, 2023 { id="1.0.0" } 11 | 12 | - Add a [`pyodide` fence](../usage/pyodide.md) 13 | -------------------------------------------------------------------------------- /docs/insiders/goals.yml: -------------------------------------------------------------------------------- 1 | goals: 2 | 500: 3 | name: PlasmaVac User Guide 4 | features: 5 | - name: Pyodide fence 6 | ref: /usage/pyodide/ 7 | since: 2023/04/26 8 | 1000: 9 | name: GraviFridge Fluid Renewal 10 | features: [] 11 | 1500: 12 | name: HyperLamp Navigation Tips 13 | features: [] 14 | 2000: 15 | name: FusionDrive Ejection Configuration 16 | features: [] 17 | -------------------------------------------------------------------------------- /docs/insiders/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started with Insiders 3 | --- 4 | 5 | # Getting started with Insiders 6 | 7 | *Markdown Exec Insiders* is a compatible drop-in replacement for *Markdown Exec*, and can be installed similarly using `pip` or `git`. Note that in order to access the Insiders repository, you need to [become an eligible sponsor][] of @pawamoy on GitHub. 8 | 9 | ## Installation 10 | 11 | ### with the `insiders` tool 12 | 13 | [`insiders`][insiders-tool] is a tool that helps you keep up-to-date versions of Insiders projects in the PyPI index of your choice (self-hosted, Google registry, Artifactory, etc.). 14 | 15 | **We kindly ask that you do not upload the distributions to public registries, as it is against our [Terms of use][].** 16 | 17 | ### with pip (ssh/https) 18 | 19 | *Markdown Exec Insiders* can be installed with `pip` [using SSH][install-pip-ssh]: 20 | 21 | ```bash 22 | pip install git+ssh://git@github.com/pawamoy-insiders/markdown-exec.git 23 | ``` 24 | 25 | Or using HTTPS: 26 | 27 | ```bash 28 | pip install git+https://${GH_TOKEN}@github.com/pawamoy-insiders/markdown-exec.git 29 | ``` 30 | 31 | >? NOTE: **How to get a GitHub personal access token?** The `GH_TOKEN` environment variable is a GitHub token. It can be obtained by creating a [personal access token][github-pat] for your GitHub account. It will give you access to the Insiders repository, programmatically, from the command line or GitHub Actions workflows: 32 | > 33 | > 1. Go to https://github.com/settings/tokens 34 | > 2. Click on [Generate a new token][github-pat-new] 35 | > 3. Enter a name and select the [`repo`][scopes] scope 36 | > 4. Generate the token and store it in a safe place 37 | > 38 | > Note that the personal access token must be kept secret at all times, as it allows the owner to access your private repositories. 39 | 40 | ### with Git 41 | 42 | Of course, you can use *Markdown Exec Insiders* directly using Git: 43 | 44 | ``` 45 | git clone git@github.com:pawamoy-insiders/markdown-exec 46 | ``` 47 | 48 | When cloning with Git, the package must be installed: 49 | 50 | ``` 51 | pip install -e markdown-exec 52 | ``` 53 | 54 | ## Upgrading 55 | 56 | When upgrading Insiders, you should always check the version of *Markdown Exec* which makes up the first part of the version qualifier. For example, a version like `8.x.x.4.x.x` means that Insiders `4.x.x` is currently based on `8.x.x`. 57 | 58 | If the major version increased, it's a good idea to consult the [changelog][] and go through the steps to ensure your configuration is up to date and all necessary changes have been made. 59 | 60 | [become an eligible sponsor]: ./index.md#how-to-become-a-sponsor 61 | [changelog]: ./changelog.md 62 | [github-pat]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token 63 | [github-pat-new]: https://github.com/settings/tokens/new 64 | [insiders-tool]: https://pawamoy.github.io/insiders-project/ 65 | [install-pip-ssh]: https://docs.github.com/en/authentication/connecting-to-github-with-ssh 66 | [scopes]: https://docs.github.com/en/developers/apps/scopes-for-oauth-apps#available-scopes 67 | [terms of use]: ./index.md#terms 68 | -------------------------------------------------------------------------------- /docs/js/feedback.js: -------------------------------------------------------------------------------- 1 | const feedback = document.forms.feedback; 2 | feedback.hidden = false; 3 | 4 | feedback.addEventListener("submit", function(ev) { 5 | ev.preventDefault(); 6 | const commentElement = document.getElementById("feedback"); 7 | commentElement.style.display = "block"; 8 | feedback.firstElementChild.disabled = true; 9 | const data = ev.submitter.getAttribute("data-md-value"); 10 | const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); 11 | if (note) { 12 | note.hidden = false; 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /docs/js/insiders.js: -------------------------------------------------------------------------------- 1 | function humanReadableAmount(amount) { 2 | const strAmount = String(amount); 3 | if (strAmount.length >= 4) { 4 | return `${strAmount.slice(0, strAmount.length - 3)},${strAmount.slice(-3)}`; 5 | } 6 | return strAmount; 7 | } 8 | 9 | function getJSON(url, callback) { 10 | var xhr = new XMLHttpRequest(); 11 | xhr.open('GET', url, true); 12 | xhr.responseType = 'json'; 13 | xhr.onload = function () { 14 | var status = xhr.status; 15 | if (status === 200) { 16 | callback(null, xhr.response); 17 | } else { 18 | callback(status, xhr.response); 19 | } 20 | }; 21 | xhr.send(); 22 | } 23 | 24 | function updatePremiumSponsors(dataURL, rank) { 25 | let capRank = rank.charAt(0).toUpperCase() + rank.slice(1); 26 | getJSON(dataURL + `/sponsors${capRank}.json`, function (err, sponsors) { 27 | const sponsorsDiv = document.getElementById(`${rank}-sponsors`); 28 | if (sponsors.length > 0) { 29 | let html = ''; 30 | html += `${capRank} sponsors

    ` 31 | sponsors.forEach(function (sponsor) { 32 | html += ` 33 | 34 | ${sponsor.name} 35 | 36 | ` 37 | }); 38 | html += '

    ' 39 | sponsorsDiv.innerHTML = html; 40 | } 41 | }); 42 | } 43 | 44 | function updateInsidersPage(author_username) { 45 | const sponsorURL = `https://github.com/sponsors/${author_username}` 46 | const dataURL = `https://raw.githubusercontent.com/${author_username}/sponsors/main`; 47 | getJSON(dataURL + '/numbers.json', function (err, numbers) { 48 | document.getElementById('sponsors-count').innerHTML = numbers.count; 49 | Array.from(document.getElementsByClassName('sponsors-total')).forEach(function (element) { 50 | element.innerHTML = '$ ' + humanReadableAmount(numbers.total); 51 | }); 52 | getJSON(dataURL + '/sponsors.json', function (err, sponsors) { 53 | const sponsorsElem = document.getElementById('sponsors'); 54 | const privateSponsors = numbers.count - sponsors.length; 55 | sponsors.forEach(function (sponsor) { 56 | sponsorsElem.innerHTML += ` 57 | 58 | 59 | 60 | `; 61 | }); 62 | if (privateSponsors > 0) { 63 | sponsorsElem.innerHTML += ` 64 | 65 | +${privateSponsors} 66 | 67 | `; 68 | } 69 | }); 70 | }); 71 | updatePremiumSponsors(dataURL, "gold"); 72 | updatePremiumSponsors(dataURL, "silver"); 73 | updatePremiumSponsors(dataURL, "bronze"); 74 | } 75 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: License 3 | hide: 4 | - feedback 5 | --- 6 | 7 | # License 8 | 9 | ``` 10 | --8<-- "LICENSE" 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/reference/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API reference 3 | hide: 4 | - navigation 5 | --- 6 | 7 | # ::: markdown_exec 8 | options: 9 | show_submodules: true 10 | -------------------------------------------------------------------------------- /docs/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft-07/schema", 3 | "title": "Utilities to execute code blocks in Markdown files.nto your site.", 4 | "oneOf": [ 5 | { 6 | "markdownDescription": "https://pawamoy.github.io/markdown-exec", 7 | "enum": [ 8 | "markdown-exec" 9 | ] 10 | }, 11 | { 12 | "type": "object", 13 | "properties": { 14 | "markdown-exec": { 15 | "markdownDescription": "https://pawamoy.github.io/markdown-exec", 16 | "type": "object", 17 | "properties": { 18 | "languages": { 19 | "title": "The languages to enabled execution for.", 20 | "type": "array", 21 | "items": { 22 | "type": "string" 23 | } 24 | } 25 | }, 26 | "additionalProperties": false 27 | } 28 | }, 29 | "additionalProperties": false 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /docs/snippets/gallery/ansi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # credits to https://github.com/42picky/42picky.github.io 3 | text="xYz" # Some test text 4 | echo -e "\n 40m 41m 42m 43m 44m 45m 46m 47m" 5 | for FGs in ' m' ' 1m' ' 30m' '1;30m' ' 31m' '1;31m' ' 32m' \ 6 | '1;32m' ' 33m' '1;33m' ' 34m' '1;34m' ' 35m' '1;35m' \ 7 | ' 36m' '1;36m' ' 37m' '1;37m'; do 8 | FG=${FGs// /} 9 | echo -en " $FGs \033[$FG ${text} " 10 | for BG in 40m 41m 42m 43m 44m 45m 46m 47m; do 11 | echo -en "$EINS \033[$FG\033[${BG} ${text} \033[0m" 12 | done 13 | echo 14 | done 15 | echo -------------------------------------------------------------------------------- /docs/snippets/gallery/argparse.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from duty.cli import get_parser 4 | 5 | parser = get_parser() 6 | lines = [] 7 | lines.append(f"## duty") 8 | if parser.description: 9 | lines.append(parser.description) 10 | lines.append("\nOptions:\n") 11 | for action in parser._actions: 12 | opts = [f"`{opt}`" for opt in action.option_strings] 13 | if not opts: 14 | continue 15 | line = "- " + ",".join(opts) 16 | if action.metavar: 17 | line += f" `{action.metavar}`" 18 | line += f": {action.help}" 19 | if action.default and action.default != argparse.SUPPRESS: 20 | line += f"(default: {action.default})" 21 | lines.append(line) 22 | print("\n".join(lines)) 23 | -------------------------------------------------------------------------------- /docs/snippets/gallery/argparse_format.py: -------------------------------------------------------------------------------- 1 | from duty.cli import get_parser 2 | 3 | parser = get_parser() 4 | print(f"```\n{parser.format_help()}\n```") 5 | -------------------------------------------------------------------------------- /docs/snippets/gallery/chalk.py: -------------------------------------------------------------------------------- 1 | from tempfile import NamedTemporaryFile 2 | from chalk import Diagram, triangle, unit_x 3 | from colour import Color 4 | 5 | papaya = Color("#ff9700") 6 | 7 | def sierpinski(n: int, size: int) -> Diagram: 8 | if n <= 1: 9 | return triangle(size) 10 | else: 11 | smaller = sierpinski(n - 1, size / 2) 12 | return smaller.above(smaller.beside(smaller, unit_x).center_xy()) 13 | 14 | d = sierpinski(5, 4).fill_color(papaya) 15 | 16 | # Chalk doesn't provide an easy method to get a string directly, 17 | # so we use a temporary file. 18 | with NamedTemporaryFile("w+") as tmpfile: 19 | d.render_svg(tmpfile.name, height=256) 20 | tmpfile.seek(0) 21 | svg = tmpfile.read() 22 | 23 | print(svg) 24 | -------------------------------------------------------------------------------- /docs/snippets/gallery/d2.py: -------------------------------------------------------------------------------- 1 | import os # markdown-exec: hide 2 | if "CI" in os.environ: # markdown-exec: hide 3 | print("D2 is not installed in CI, skipping this gallery example.") # markdown-exec: hide 4 | raise SystemExit(0) # markdown-exec: hide 5 | import subprocess 6 | 7 | diagram = """ 8 | direction: right 9 | 10 | Before and after becoming friends: { 11 | 2007: Office chatter in 2007 { 12 | shape: sequence_diagram 13 | alice: Alice 14 | bob: Bobby 15 | 16 | awkward small talk: { 17 | alice -> bob: uhm, hi 18 | bob -> alice: oh, hello 19 | 20 | icebreaker attempt: { 21 | alice -> bob: what did you have for lunch? 22 | } 23 | 24 | unfortunate outcome: { 25 | bob -> alice: that's personal 26 | } 27 | } 28 | } 29 | 30 | 2012: Office chatter in 2012 { 31 | shape: sequence_diagram 32 | alice: Alice 33 | bob: Bobby 34 | alice -> bob: Want to play with ChatGPT? 35 | bob -> alice: Yes! 36 | bob -> alice.play: Write a play... 37 | alice.play -> bob.play: about 2 friends... 38 | bob.play -> alice.play: who find love... 39 | alice.play -> bob.play: in a sequence diagram 40 | } 41 | 42 | 2007 -> 2012: Five\nyears\nlater 43 | } 44 | """ 45 | 46 | # We simply run `d2` in a subprocess, passing it our diagram as input and capturing its output to print it. 47 | svg = subprocess.check_output(["d2", "-", "-"], input=diagram, stderr=subprocess.DEVNULL, text=True) 48 | print(svg) 49 | -------------------------------------------------------------------------------- /docs/snippets/gallery/diagrams.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from contextlib import suppress 3 | 4 | from diagrams import Diagram 5 | from diagrams.k8s.clusterconfig import HPA 6 | from diagrams.k8s.compute import Deployment, Pod, ReplicaSet 7 | from diagrams.k8s.network import Ingress, Service 8 | 9 | # By default, Diagrams tries to write the result on disk, so we prevent that by patching its `render` method, 10 | # and by ignoring the `FileNotFoundError` that ensues. 11 | # 12 | # Then we use its internal `dot` object and its `pipe` method to store the diagram in a variable, 13 | # as base64 encoded PNG data. 14 | # 15 | # Finally we output an HTML image with the base64 data. 16 | # Using SVG is not possible here since Diagrams embeds actual, smaller PNG files in the result, 17 | # files which are not automatically added to the final site. 18 | with suppress(FileNotFoundError): 19 | with Diagram("Exposed Pod with 3 Replicas", show=False) as diagram: 20 | diagram.render = lambda: None 21 | net = Ingress("domain.com") >> Service("svc") 22 | net >> [Pod("pod1"), Pod("pod2"), Pod("pod3")] << ReplicaSet("rs") << Deployment("dp") << HPA("hpa") 23 | png = b64encode(diagram.dot.pipe(format="png")).decode() 24 | 25 | # Wrapping the image in a div prevents it from being wrapped in a paragraph, 26 | # which would add unnecessary space around it. 27 | print(f'
    ') 28 | -------------------------------------------------------------------------------- /docs/snippets/gallery/drawsvg.py: -------------------------------------------------------------------------------- 1 | import drawsvg as draw 2 | 3 | d = draw.Drawing(200, 200, origin='center') 4 | 5 | # Animate the position and color of circle 6 | c = draw.Circle(0, 0, 20, fill='red') 7 | # See for supported attributes: 8 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animate 9 | c.append_anim(draw.Animate('cy', '6s', '-80;80;-80', 10 | repeatCount='indefinite')) 11 | c.append_anim(draw.Animate('cx', '6s', '0;80;0;-80;0', 12 | repeatCount='indefinite')) 13 | c.append_anim(draw.Animate('fill', '6s', 'red;green;blue;yellow', 14 | calc_mode='discrete', 15 | repeatCount='indefinite')) 16 | d.append(c) 17 | 18 | # Animate a black circle around an ellipse 19 | ellipse = draw.Path() 20 | ellipse.M(-90, 0) 21 | ellipse.A(90, 40, 360, True, True, 90, 0) # Ellipse path 22 | ellipse.A(90, 40, 360, True, True, -90, 0) 23 | ellipse.Z() 24 | c2 = draw.Circle(0, 0, 10) 25 | # See for supported attributes: 26 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animate_motion 27 | c2.append_anim(draw.AnimateMotion(ellipse, '3s', 28 | repeatCount='indefinite')) 29 | # See for supported attributes: 30 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animate_transform 31 | c2.append_anim(draw.AnimateTransform('scale', '3s', '1,2;2,1;1,2;2,1;1,2', 32 | repeatCount='indefinite')) 33 | d.append(c2) 34 | print(d.as_svg()) -------------------------------------------------------------------------------- /docs/snippets/gallery/expandable_filetree.py: -------------------------------------------------------------------------------- 1 | from fnmatch import fnmatch 2 | from pathlib import Path 3 | 4 | exclude = {"dist", "*cache*", ".devbox", ".hypothesis", ".pdm*", ".coverage*", "profile.*"} 5 | no_recurse = {".venv*", "site", "htmlcov", ".git"} 6 | 7 | 8 | def exptree(path: str, session: str) -> None: 9 | # List files and directories separately. 10 | files = [] 11 | dirs = [] 12 | for node in Path(path).iterdir(): 13 | if any(fnmatch(node.name, pattern) for pattern in exclude): 14 | continue 15 | if node.is_dir(): 16 | dirs.append(node) 17 | else: 18 | files.append(node) 19 | 20 | # Print directories first, then files (both sorted). 21 | recurse = [] 22 | print("```tree") 23 | for directory in sorted(dirs): 24 | if any(fnmatch(directory.name, pattern) for pattern in no_recurse): 25 | print(f"{directory.name}/") 26 | else: 27 | recurse.append(directory.name) 28 | # Add code annotation at the end. 29 | print(f"{directory.name}/ # ({len(recurse)})!") 30 | for file in sorted(files): 31 | print(file.name) 32 | print("```\n") 33 | 34 | # Print contents of each annotated directory. 35 | for index, directory in enumerate(recurse, 1): 36 | new_path = f"{path}/{directory}" 37 | print(f"{index}. \n") 38 | # The recursive part! 39 | print(f' ```python exec="1" session="{session}"') 40 | print(f' exptree("{new_path}", "{session}")') 41 | print(" ```\n") 42 | -------------------------------------------------------------------------------- /docs/snippets/gallery/hyperbolic.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from drawsvg import Drawing 4 | from hyperbolic.poincare import * 5 | from hyperbolic.poincare.util import triangle_side_for_angles 6 | import hyperbolic.tiles as htiles 7 | 8 | 9 | p1 = 4 10 | p2 = 3 11 | q = 3 12 | rotate = 0 13 | 14 | theta1, theta2 = math.pi*2/p1, math.pi*2/p2 15 | phi_sum = math.pi*2/q 16 | r1 = triangle_side_for_angles(theta1/2, phi_sum/2, theta2/2) 17 | r2 = triangle_side_for_angles(theta2/2, phi_sum/2, theta1/2) 18 | 19 | t_gen1 = htiles.TileGen.make_regular(p1, hr=r1, skip=1) 20 | t_gen2 = htiles.TileGen.make_regular(p2, hr=r2, skip=1) 21 | 22 | t_layout = htiles.TileLayout() 23 | t_layout.add_generator(t_gen1, (1,)*p1) 24 | t_layout.add_generator(t_gen2, (0,)*p2, htiles.TileDecoratorNull()) 25 | start_tile = t_layout.default_start_tile(rotate_deg=rotate) 26 | 27 | t1 = start_tile 28 | t2 = t_layout.place_tile(t1.sides[-1]) 29 | t3 = t_layout.place_tile(t2.sides[-1]) 30 | point_base = t3.vertices[-1] 31 | points = [Transform.rotation(deg=-i*360/p1).apply_to_point(point_base) 32 | for i in range(p1)] 33 | vertices = start_tile.vertices 34 | edges = [] 35 | for i, point in enumerate(points): 36 | v1 = vertices[i] 37 | v2 = vertices[(i+1)%p1] 38 | edge = Hypercycle.from_points(*v1, *v2, *point, segment=True, exclude_mid=True) 39 | edges.append(edge) 40 | decorate_poly = Polygon(edges=edges, vertices=vertices) 41 | decorator1 = htiles.TileDecoratorPolygons(decorate_poly) 42 | t_layout.set_decorator(decorator1, 0) 43 | 44 | start_tile = t_layout.default_start_tile(rotate_deg=rotate) 45 | tiles = t_layout.tile_plane(start_tile, depth=6) 46 | 47 | d = Drawing(2, 2, origin='center') 48 | #d.draw(euclid.Circle(0, 0, 1), fill='silver') 49 | for tile in tiles: 50 | d.draw(tile, hwidth=0.02, fill='red') 51 | tiles[0].decorator = None 52 | d.draw( 53 | Hypercycle.from_points( 54 | *tiles[0].vertices[0], *tiles[0].vertices[1], *point_base 55 | ), 56 | hwidth=0.02, 57 | fill='black', 58 | ) 59 | 60 | d.set_render_size(w=400) 61 | print(d.as_svg()) 62 | -------------------------------------------------------------------------------- /docs/snippets/gallery/matplotlib.py: -------------------------------------------------------------------------------- 1 | # https://matplotlib.org/stable/gallery/lines_bars_and_markers/scatter_demo2.html 2 | from io import StringIO 3 | 4 | import matplotlib.cbook as cbook 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | 8 | # Load a numpy record array from yahoo csv data with fields date, open, close, 9 | # volume, adj_close from the mpl-data/example directory. The record array 10 | # stores the date as an np.datetime64 with a day unit ('D') in the date column. 11 | price_data = cbook.get_sample_data("goog.npz")["price_data"] 12 | price_data = price_data[-250:] # get the most recent 250 trading days 13 | 14 | delta1 = np.diff(price_data["adj_close"]) / price_data["adj_close"][:-1] 15 | 16 | # Marker size in units of points^2 17 | volume = (15 * price_data["volume"][:-2] / price_data["volume"][0])**2 18 | close = 0.003 * price_data["close"][:-2] / 0.003 * price_data["open"][:-2] 19 | 20 | fig, ax = plt.subplots() 21 | ax.scatter(delta1[:-1], delta1[1:], c=close, s=volume, alpha=0.5) 22 | 23 | ax.set_xlabel(r"$\Delta_i$", fontsize=15) 24 | ax.set_ylabel(r"$\Delta_{i+1}$", fontsize=15) 25 | ax.set_title("Volume and percent change") 26 | 27 | ax.grid(True) 28 | fig.tight_layout() 29 | 30 | buffer = StringIO() 31 | plt.savefig(buffer, format="svg") 32 | print(buffer.getvalue()) 33 | -------------------------------------------------------------------------------- /docs/snippets/gallery/plotly.py: -------------------------------------------------------------------------------- 1 | import plotly.express as px 2 | 3 | fig = px.bar(x=["a", "b", "c"], y=[1, 3, 2]) 4 | print(fig.to_html(full_html=False, include_plotlyjs="cdn")) 5 | -------------------------------------------------------------------------------- /docs/snippets/gallery/pydeps.py: -------------------------------------------------------------------------------- 1 | from pydeps import cli, colors, dot, py2depgraph 2 | from pydeps.pydeps import depgraph_to_dotsrc 3 | from pydeps.target import Target 4 | 5 | # Note: pydeps wasn't designed to be used in such a programatic way, so the code is a bit convoluted, 6 | # but you could make a function of it, put it in an importable script/module, 7 | # and reuse it cleanly in your executed code blocks. 8 | 9 | cli.verbose = cli._not_verbose 10 | options = cli.parse_args(["src/markdown_exec", "--noshow"]) 11 | colors.START_COLOR = options["start_color"] 12 | target = Target(options["fname"]) 13 | with target.chdir_work(): 14 | dep_graph = py2depgraph.py2dep(target, **options) 15 | dot_src = depgraph_to_dotsrc(target, dep_graph, **options) 16 | svg = dot.call_graphviz_dot(dot_src, "svg").decode() 17 | svg = "".join(svg.splitlines()[6:]) 18 | svg = svg.replace('fill="white"', 'fill="transparent"') 19 | reference = "../reference" 20 | modules = ( 21 | "markdown_exec", 22 | "markdown_exec.formatters", 23 | "markdown_exec.formatters.base", 24 | "markdown_exec.formatters.bash", 25 | "markdown_exec.formatters.console", 26 | "markdown_exec.formatters.markdown", 27 | "markdown_exec.formatters.pycon", 28 | "markdown_exec.formatters.pyodide", 29 | "markdown_exec.formatters.python", 30 | "markdown_exec.formatters.sh", 31 | "markdown_exec.formatters.tree", 32 | "markdown_exec.logger", 33 | "markdown_exec.mkdocs_plugin", 34 | "markdown_exec.processors", 35 | "markdown_exec.rendering", 36 | ) 37 | for module in modules: 38 | svg_title = module.replace(".", "_") 39 | title_tag = f"{svg_title}" 40 | href = f"{reference}/{module.replace('.', '/')}/" 41 | svg = svg.replace(title_tag, f'{module}') 42 | svg = svg.replace("", "") 43 | print(svg) 44 | -------------------------------------------------------------------------------- /docs/snippets/gallery/qrcode.py: -------------------------------------------------------------------------------- 1 | import qrcode 2 | from qrcode.image.svg import SvgPathImage 3 | 4 | img = qrcode.make("https://github.com/sponsors/lincolnloop", box_size=20, border=2, image_factory=SvgPathImage) 5 | print(f'
    {img.to_string().decode("utf8")}
    ') 6 | -------------------------------------------------------------------------------- /docs/snippets/gallery/rich.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from rich.console import Console 4 | from rich.padding import Padding 5 | from rich.syntax import Syntax 6 | 7 | # Here we hardcode the code snippet we want to render, 8 | # but we could instead include it from somewhere else using the `pymdownx.snippets` extension 9 | # (https://facelessuser.github.io/pymdown-extensions/extensions/snippets/) 10 | # or by reading it dynamically from Python. 11 | code = """ 12 | from contextlib import asynccontextmanager 13 | import httpx 14 | 15 | 16 | class BookClient(httpx.AsyncClient): 17 | async def get_book(self, book_id: int) -> str: 18 | response = await self.get(f"/books/{book_id}") 19 | return response.text 20 | 21 | 22 | @asynccontextmanager 23 | async def book_client(*args, **kwargs): 24 | async with BookClient(*args, **kwargs) as client: 25 | yield client 26 | """ 27 | 28 | # We prevent Rich from actually writing to the terminal. 29 | with open(os.devnull, "w") as devnull: 30 | console = Console(record=True, width=65, file=devnull, markup=False) 31 | renderable = Syntax(code, "python", theme="material") 32 | renderable = Padding(renderable, (0,), expand=False) 33 | console.print(renderable, markup=False) 34 | svg = console.export_svg(title="async context manager") 35 | 36 | # Wrapping the SVG in a div prevents it from being wrapped in a paragraph, 37 | # which would add unnecessary space around it. 38 | print(f"
    {svg}
    ") 39 | -------------------------------------------------------------------------------- /docs/snippets/gallery/rich_terminal.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from rich.console import Console 4 | 5 | report = """$ griffe check griffe -ssrc -b0.24.0 -a0.23.0 6 | [bold]src/griffe/loader.py[/]:156: GriffeLoader.resolve_aliases([#7faeff]only_exported[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 7 | [bold]src/griffe/loader.py[/]:156: GriffeLoader.resolve_aliases([#7faeff]only_exported[/]): [#afaf72]Parameter default was changed[/]: True -> None 8 | [bold]src/griffe/loader.py[/]:156: GriffeLoader.resolve_aliases([#7faeff]only_known_modules[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 9 | [bold]src/griffe/loader.py[/]:156: GriffeLoader.resolve_aliases([#7faeff]only_known_modules[/]): [#afaf72]Parameter default was changed[/]: True -> None 10 | [bold]src/griffe/loader.py[/]:156: GriffeLoader.resolve_aliases([#7faeff]max_iterations[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 11 | [bold]src/griffe/loader.py[/]:308: GriffeLoader.resolve_module_aliases([#7faeff]only_exported[/]): [#afaf72]Parameter was removed[/] 12 | [bold]src/griffe/loader.py[/]:308: GriffeLoader.resolve_module_aliases([#7faeff]only_known_modules[/]): [#afaf72]Parameter was removed[/] 13 | [bold]src/griffe/git.py[/]:39: tmp_worktree([#7faeff]commit[/]): [#afaf72]Parameter was removed[/] 14 | [bold]src/griffe/git.py[/]:39: tmp_worktree([#7faeff]repo[/]): [#afaf72]Positional parameter was moved[/]: position: from 2 to 1 (-1) 15 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]commit[/]): [#afaf72]Parameter was removed[/] 16 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]repo[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 17 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]submodules[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 18 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]try_relative_path[/]): [#afaf72]Parameter was removed[/] 19 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]extensions[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 20 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]search_paths[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 21 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]docstring_parser[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 22 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]docstring_options[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 23 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]lines_collection[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 24 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]modules_collection[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 25 | [bold]src/griffe/git.py[/]:75: load_git([#7faeff]allow_inspection[/]): [#afaf72]Parameter kind was changed[/]: positional or keyword -> keyword-only 26 | """ 27 | 28 | with open(os.devnull, "w") as devnull: 29 | console = Console(record=True, width=150, file=devnull) 30 | console.print(report, markup=True, highlight=False) 31 | print(console.export_html(inline_styles=True, code_format="
    {code}
    ")) 32 | -------------------------------------------------------------------------------- /docs/snippets/gallery/runpy.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import warnings 3 | from contextlib import suppress 4 | from io import StringIO 5 | from runpy import run_module 6 | 7 | old_argv = list(sys.argv) 8 | sys.argv = ["mkdocs"] 9 | old_stdout = sys.stdout 10 | sys.stdout = StringIO() 11 | warnings.filterwarnings("ignore", category=RuntimeWarning) 12 | with suppress(SystemExit): 13 | run_module("mkdocs", run_name="__main__") 14 | output = sys.stdout.getvalue() 15 | sys.stdout = old_stdout 16 | sys.argv = old_argv 17 | 18 | print(f"```\n{output}\n```") 19 | -------------------------------------------------------------------------------- /docs/snippets/gallery/textual.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.widgets import Static 3 | from textual._doc import take_svg_screenshot 4 | 5 | 6 | class TextApp(App): 7 | CSS = """ 8 | Screen { 9 | background: darkblue; 10 | color: white; 11 | layout: vertical; 12 | } 13 | Static { 14 | height: auto; 15 | padding: 2; 16 | border: heavy white; 17 | background: #ffffff 30%; 18 | content-align: center middle; 19 | } 20 | """ 21 | 22 | def compose(self) -> ComposeResult: 23 | yield Static("Hello") 24 | yield Static("[b]World![/b]") 25 | 26 | 27 | print(take_svg_screenshot(app=TextApp(), terminal_size=(80, 24))) 28 | -------------------------------------------------------------------------------- /docs/snippets/usage/boolean_matrix.py: -------------------------------------------------------------------------------- 1 | print() 2 | print("a | b | a \\|\\| b") 3 | print("--- | --- | ---") 4 | for a in (True, False): 5 | for b in (True, False): 6 | print(f"{a} | {b} | {a or b}") 7 | print() 8 | -------------------------------------------------------------------------------- /docs/snippets/usage/hide.py: -------------------------------------------------------------------------------- 1 | print("Hello World!") 2 | print("
    ") # markdown-exec: hide 3 | -------------------------------------------------------------------------------- /docs/snippets/usage/multiple.pycon: -------------------------------------------------------------------------------- 1 | >>> name = "Baron" 2 | >>> print(name) 3 | Baron 4 | >>> age = "???" 5 | >>> print(age) 6 | ??? 7 | -------------------------------------------------------------------------------- /docs/snippets/usage/platform_html.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | print( 4 | f""" 5 | 11 | """ 12 | ) 13 | -------------------------------------------------------------------------------- /docs/snippets/usage/platform_md.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from textwrap import dedent 3 | 4 | print( 5 | # we must dedent, otherwise Markdown 6 | # will render it as a code block! 7 | dedent( 8 | f""" 9 | - machine: `{platform.machine()}` 10 | - version: `{platform.version()}` 11 | - platform: `{platform.platform()}` 12 | - system: `{platform.system()}` 13 | """ 14 | ) 15 | ) 16 | -------------------------------------------------------------------------------- /docs/snippets/usage/source.py: -------------------------------------------------------------------------------- 1 | print("I'm the result!") 2 | -------------------------------------------------------------------------------- /docs/snippets/usage/source.pycon: -------------------------------------------------------------------------------- 1 | >>> print("I'm the result!") 2 | I'm not the result... 3 | -------------------------------------------------------------------------------- /docs/usage/pyodide.md: -------------------------------------------------------------------------------- 1 | # Pyodide 2 | 3 | [:octicons-tag-24: Insiders 1.0.0](../insiders/changelog.md#1.0.0) 4 | 5 | This special `pyodide` fence uses [Pyodide](https://pyodide.org), [Ace](https://ace.c9.io/) 6 | and [Highlight.js](https://highlightjs.org/) to render an interactive Python editor. 7 | Everything runs on the client side. The first time Pyodide is loaded by the browser 8 | can be a bit long, but then it will be cached and the next time you load the page 9 | it will be much faster. 10 | 11 | Click the **:material-play: Run** button in the top-right corner, or hit ++ctrl+enter++ to run the code. 12 | You can install packages with Micropip: 13 | 14 | ````md exec="1" source="tabbed-right" tabs="Markdown|Rendered" 15 | ```pyodide 16 | import micropip 17 | 18 | print("Installing cowsay...") 19 | await micropip.install("cowsay") 20 | print("done!") 21 | ``` 22 | ```` 23 | 24 | Then you can import and use the packages you installed: 25 | 26 | ````md exec="1" source="tabbed-right" tabs="Markdown|Rendered" 27 | ```pyodide 28 | import cowsay 29 | cowsay.cow("Hello World") 30 | ``` 31 | ```` 32 | 33 | Packages installed with Micropip are cached by the browser as well, 34 | making future installations much faster. 35 | 36 | ## Pyodide version 37 | 38 | You can select a specific Pyodide version with the `version` option: 39 | 40 | ````md 41 | ```pyodide version="0.26.4" 42 | print("Hello.") 43 | ``` 44 | ```` 45 | 46 | NOTE: **All Pyodide blocks on the same page should use the same version!** 47 | 48 | ## Sessions 49 | 50 | Editors with the same session share the same `globals()` dictionary, 51 | so you can reuse variables, classes, imports, etc., from another editor 52 | within the same session. This is why you can import `cowsay` in this editor, 53 | given you actually installed it in the first. Sessions are ephemeral: 54 | everything is reset when reloading the page. This means you cannot persist 55 | sessions across multiple pages. Try refreshing your page 56 | and running the code of the second editor: you should get a ModuleNotFoundError. 57 | 58 | To use other sessions, simply pass the `session="name"` option to the code block: 59 | 60 | ````md exec="1" source="tabbed-right" tabs="Markdown|Rendered" 61 | ```pyodide session="something" 62 | something = "hello" 63 | ``` 64 | ```` 65 | 66 | Now lets print it in another editor with the same session: 67 | 68 | ````md exec="1" source="tabbed-right" tabs="Markdown|Rendered" 69 | ```pyodide session="something" 70 | print(something) 71 | ``` 72 | ```` 73 | 74 | And in another editor with the default session: 75 | 76 | ````md exec="1" source="tabbed-right" tabs="Markdown|Rendered" 77 | ```pyodide 78 | print(something) 79 | ``` 80 | ```` 81 | 82 | ## Pre-installing packages 83 | 84 | In your own documentation pages, you might not want to add 85 | `import micropip; await micropip.install("your-package")` 86 | to every editor to show how to use your package. In this case, 87 | you can use the `install` option to pre-install packages. 88 | The option takes a list of comma-separated package distribution names: 89 | 90 | ````md exec="1" source="tabbed-right" tabs="Markdown|Rendered" 91 | ```pyodide install="griffe,dependenpy" 92 | import griffe 93 | import dependenpy 94 | print("OK!") 95 | ``` 96 | ```` 97 | 98 | ## Excluding assets 99 | 100 | When you add a Pyodide fence to a page, 101 | Markdown Exec will inject ` 20 | 21 | 22 | 23 | 24 | """ 25 | 26 | _template = """ 27 |
    28 |
    29 | Editor (session: %(session)s)%(play_emoji)s Run 30 |
    31 |
    %(initial_code)s
    32 |
    33 | Output%(clear_emoji)s Clear 34 |
    35 |
    36 |
    37 | 38 | 43 | """ 44 | 45 | _counter = 0 46 | 47 | 48 | def _format_pyodide(code: str, md: Markdown, session: str, extra: dict, **options: Any) -> str: # noqa: ARG001 49 | global _counter # noqa: PLW0603 50 | _counter += 1 51 | version = extra.pop("version", "0.26.4").lstrip("v") 52 | install = extra.pop("install", "") 53 | install = install.split(",") if install else [] 54 | exclude_assets = extra.pop("assets", "1").lower() in {"0", "false", "no", "off"} 55 | theme = extra.pop("theme", "tomorrow,tomorrow_night") 56 | if "," not in theme: 57 | theme = f"{theme},{theme}" 58 | theme_light, theme_dark = theme.split(",") 59 | 60 | data = { 61 | "id_prefix": f"exec-{_counter}--", 62 | "initial_code": code, 63 | "install": install, 64 | "theme_light": theme_light.strip(), 65 | "theme_dark": theme_dark.strip(), 66 | "session": session or "default", 67 | "play_emoji": _play_emoji, 68 | "clear_emoji": _clear_emoji, 69 | } 70 | rendered = _template % data 71 | if exclude_assets: 72 | return rendered 73 | return _assets.format(version=version) + rendered 74 | -------------------------------------------------------------------------------- /src/markdown_exec/_internal/formatters/python.py: -------------------------------------------------------------------------------- 1 | # Formatter for executing Python code. 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | import sys 7 | import traceback 8 | from collections import defaultdict 9 | from functools import partial 10 | from io import StringIO 11 | from types import ModuleType 12 | from typing import Any 13 | 14 | from markdown_exec._internal.formatters._exec_python import exec_python 15 | from markdown_exec._internal.formatters.base import ExecutionError, base_format 16 | from markdown_exec._internal.rendering import code_block 17 | 18 | _sessions_globals: dict[str, dict] = defaultdict(dict) 19 | _sessions_counter: dict[str | None, int] = defaultdict(int) 20 | _code_blocks: dict[str, list[str]] = {} 21 | 22 | 23 | def _buffer_print(buffer: StringIO, *texts: str, end: str = "\n", **kwargs: Any) -> None: # noqa: ARG001 24 | buffer.write(" ".join(str(text) for text in texts) + end) 25 | 26 | 27 | def _code_block_id( 28 | id: str | None = None, # noqa: A002 29 | session: str | None = None, 30 | title: str | None = None, 31 | ) -> str: 32 | _sessions_counter[session] += 1 33 | if id: 34 | code_block_id = f"id {id}" 35 | elif session: 36 | code_block_id = f"session {session}; n{_sessions_counter[session]}" 37 | if title: 38 | code_block_id = f"{code_block_id}; title {title}" 39 | else: 40 | code_block_id = f"n{_sessions_counter[session]}" 41 | if title: 42 | code_block_id = f"{code_block_id}; title {title}" 43 | return f"" 44 | 45 | 46 | def _run_python( 47 | code: str, 48 | returncode: int | None = None, # noqa: ARG001 49 | session: str | None = None, 50 | id: str | None = None, # noqa: A002 51 | **extra: str, 52 | ) -> str: 53 | title = extra.get("title") 54 | code_block_id = _code_block_id(id, session, title) 55 | _code_blocks[code_block_id] = code.split("\n") 56 | exec_globals = _sessions_globals[session] if session else {} 57 | 58 | # Other libraries expect functions to have a valid `__module__` attribute. 59 | # To achieve this, we need to add a `__name__` attribute to the globals. 60 | # We compute the name from the code block ID, replacing invalid characters with `_`. 61 | # We also create a module object with the same name and add it to `sys.modules`, 62 | # because that's what yet other libraries expect (`dataclasses` for example). 63 | module_name = re.sub(r"[^a-zA-Z\d]+", "_", code_block_id) 64 | exec_globals["__name__"] = module_name 65 | sys.modules[module_name] = ModuleType(module_name) 66 | 67 | buffer = StringIO() 68 | exec_globals["print"] = partial(_buffer_print, buffer) 69 | 70 | try: 71 | exec_python(code, code_block_id, exec_globals) 72 | except Exception as error: 73 | trace = traceback.TracebackException.from_exception(error) 74 | for frame in trace.stack: 75 | if frame.filename.startswith("= (3, 13): 77 | frame._lines = _code_blocks[frame.filename][frame.lineno - 1] # type: ignore[attr-defined,operator] 78 | else: 79 | frame._line = _code_blocks[frame.filename][frame.lineno - 1] # type: ignore[attr-defined,operator] 80 | raise ExecutionError(code_block("python", "".join(trace.format()), **extra)) from error 81 | return buffer.getvalue() 82 | 83 | 84 | def _format_python(**kwargs: Any) -> str: 85 | return base_format(language="python", run=_run_python, **kwargs) 86 | -------------------------------------------------------------------------------- /src/markdown_exec/_internal/formatters/sh.py: -------------------------------------------------------------------------------- 1 | # Formatter for executing shell code. 2 | 3 | from __future__ import annotations 4 | 5 | import subprocess 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters.base import ExecutionError, base_format 9 | from markdown_exec._internal.rendering import code_block 10 | 11 | 12 | def _run_sh( 13 | code: str, 14 | returncode: int | None = None, 15 | session: str | None = None, # noqa: ARG001 16 | id: str | None = None, # noqa: A002,ARG001 17 | **extra: str, 18 | ) -> str: 19 | process = subprocess.run( # noqa: S603 20 | ["sh", "-c", code], # noqa: S607 21 | stdout=subprocess.PIPE, 22 | stderr=subprocess.STDOUT, 23 | text=True, 24 | check=False, 25 | ) 26 | if process.returncode != returncode: 27 | raise ExecutionError(code_block("sh", process.stdout, **extra), process.returncode) 28 | return process.stdout 29 | 30 | 31 | def _format_sh(**kwargs: Any) -> str: 32 | return base_format(language="sh", run=_run_sh, **kwargs) 33 | -------------------------------------------------------------------------------- /src/markdown_exec/_internal/formatters/tree.py: -------------------------------------------------------------------------------- 1 | # Formatter for file-system trees. 2 | 3 | from __future__ import annotations 4 | 5 | from textwrap import dedent 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from markdown_exec._internal.rendering import MarkdownConverter, code_block 9 | 10 | if TYPE_CHECKING: 11 | from markdown import Markdown 12 | 13 | 14 | def _rec_build_tree(lines: list[str], parent: list, offset: int, base_indent: int) -> int: 15 | while offset < len(lines): 16 | line = lines[offset] 17 | lstripped = line.lstrip() 18 | indent = len(line) - len(lstripped) 19 | if indent == base_indent: 20 | parent.append((lstripped, [])) 21 | offset += 1 22 | elif indent > base_indent: 23 | offset = _rec_build_tree(lines, parent[-1][1], offset, indent) 24 | else: 25 | return offset 26 | return offset 27 | 28 | 29 | def _build_tree(code: str) -> list[tuple[str, list]]: 30 | lines = dedent(code.strip()).split("\n") 31 | root_layer: list[tuple[str, list]] = [] 32 | _rec_build_tree(lines, root_layer, 0, 0) 33 | return root_layer 34 | 35 | 36 | def _rec_format_tree(tree: list[tuple[str, list]], *, root: bool = True) -> list[str]: 37 | lines = [] 38 | n_items = len(tree) 39 | for index, node in enumerate(tree): 40 | last = index == n_items - 1 41 | prefix = "" if root else f"{'└' if last else '├'}── " 42 | if node[1]: 43 | lines.append(f"{prefix}📁 {node[0]}") 44 | sublines = _rec_format_tree(node[1], root=False) 45 | if root: 46 | lines.extend(sublines) 47 | else: 48 | indent_char = " " if last else "│" 49 | lines.extend([f"{indent_char} {line}" for line in sublines]) 50 | else: 51 | name = node[0].split()[0] 52 | icon = "📁" if name.endswith("/") else "📄" 53 | lines.append(f"{prefix}{icon} {node[0]}") 54 | return lines 55 | 56 | 57 | def _format_tree(code: str, md: Markdown, result: str, **options: Any) -> str: 58 | markdown = MarkdownConverter(md) 59 | output = "\n".join(_rec_format_tree(_build_tree(code))) 60 | return markdown.convert(code_block(result or "bash", output, **options.get("extra", {}))) 61 | -------------------------------------------------------------------------------- /src/markdown_exec/_internal/logger.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Any, Callable, ClassVar 5 | 6 | 7 | class _Logger: 8 | _default_logger: Any = logging.getLogger 9 | _instances: ClassVar[dict[str, _Logger]] = {} 10 | 11 | # See same code in Griffe project. 12 | def __init__(self, name: str) -> None: 13 | # Default logger that can be patched by third-party. 14 | self._logger = self.__class__._default_logger(name) 15 | 16 | def __getattr__(self, name: str) -> Any: 17 | # Forward everything to the logger. 18 | return getattr(self._logger, name) 19 | 20 | @classmethod 21 | def get(cls, name: str) -> _Logger: 22 | """Get a logger instance. 23 | 24 | Parameters: 25 | name: The logger name. 26 | 27 | Returns: 28 | The logger instance. 29 | """ 30 | if name not in cls._instances: 31 | cls._instances[name] = cls(name) 32 | return cls._instances[name] 33 | 34 | @classmethod 35 | def _patch_loggers(cls, get_logger_func: Callable) -> None: 36 | # Patch current instances. 37 | for name, instance in cls._instances.items(): 38 | instance._logger = get_logger_func(name) 39 | # Future instances will be patched as well. 40 | cls._default_logger = get_logger_func 41 | 42 | 43 | def get_logger(name: str) -> _Logger: 44 | """Create and return a new logger instance. 45 | 46 | Parameters: 47 | name: The logger name. 48 | 49 | Returns: 50 | The logger. 51 | """ 52 | return _Logger.get(name) 53 | 54 | 55 | def patch_loggers(get_logger_func: Callable[[str], Any]) -> None: 56 | """Patch loggers. 57 | 58 | We provide the `patch_loggers`function so dependant libraries 59 | can patch loggers as they see fit. 60 | 61 | For example, to fit in the MkDocs logging configuration 62 | and prefix each log message with the module name: 63 | 64 | ```python 65 | import logging 66 | from markdown_exec.logger import patch_loggers 67 | 68 | 69 | class LoggerAdapter(logging.LoggerAdapter): 70 | def __init__(self, prefix, logger): 71 | super().__init__(logger, {}) 72 | self.prefix = prefix 73 | 74 | def process(self, msg, kwargs): 75 | return f"{self.prefix}: {msg}", kwargs 76 | 77 | 78 | def get_logger(name): 79 | logger = logging.getLogger(f"mkdocs.plugins.{name}") 80 | return LoggerAdapter(name.split(".", 1)[0], logger) 81 | 82 | 83 | patch_loggers(get_logger) 84 | ``` 85 | 86 | Parameters: 87 | get_logger_func: A function accepting a name as parameter and returning a logger. 88 | """ 89 | _Logger._patch_loggers(get_logger_func) 90 | -------------------------------------------------------------------------------- /src/markdown_exec/_internal/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import re 5 | from typing import TYPE_CHECKING, Any 6 | 7 | if TYPE_CHECKING: 8 | from markdown import Markdown 9 | 10 | from markdown_exec._internal.formatters.base import default_tabs 11 | from markdown_exec._internal.formatters.bash import _format_bash 12 | from markdown_exec._internal.formatters.console import _format_console 13 | from markdown_exec._internal.formatters.markdown import _format_markdown 14 | from markdown_exec._internal.formatters.pycon import _format_pycon 15 | from markdown_exec._internal.formatters.pyodide import _format_pyodide 16 | from markdown_exec._internal.formatters.python import _format_python 17 | from markdown_exec._internal.formatters.sh import _format_sh 18 | from markdown_exec._internal.formatters.tree import _format_tree 19 | 20 | MARKDOWN_EXEC_AUTO = [lang.strip() for lang in os.getenv("MARKDOWN_EXEC_AUTO", "").split(",")] 21 | """Languages to automatically execute.""" 22 | 23 | formatters = { 24 | "bash": _format_bash, 25 | "console": _format_console, 26 | "md": _format_markdown, 27 | "markdown": _format_markdown, 28 | "py": _format_python, 29 | "python": _format_python, 30 | "pycon": _format_pycon, 31 | "pyodide": _format_pyodide, 32 | "sh": _format_sh, 33 | "tree": _format_tree, 34 | } 35 | """Formatters for each language.""" 36 | 37 | # negative look behind: matches only if | (pipe) if not preceded by \ (backslash) 38 | _tabs_re = re.compile(r"(? bool: 48 | """Validate code blocks inputs. 49 | 50 | Parameters: 51 | language: The code language, like python or bash. 52 | inputs: The code block inputs, to be sorted into options and attrs. 53 | options: The container for options. 54 | attrs: The container for attrs: 55 | md: The Markdown instance. 56 | 57 | Returns: 58 | Success or not. 59 | """ 60 | exec_value = language in MARKDOWN_EXEC_AUTO or _to_bool(inputs.pop("exec", "no")) 61 | if language not in {"tree", "pyodide"} and not exec_value: 62 | return False 63 | id_value = inputs.pop("id", "") 64 | id_prefix_value = inputs.pop("idprefix", None) 65 | html_value = _to_bool(inputs.pop("html", "no")) 66 | source_value = inputs.pop("source", "") 67 | result_value = inputs.pop("result", "") 68 | returncode_value = int(inputs.pop("returncode", "0")) 69 | session_value = inputs.pop("session", "") 70 | update_toc_value = _to_bool(inputs.pop("updatetoc", "yes")) 71 | tabs_value = inputs.pop("tabs", "|".join(default_tabs)) 72 | tabs = tuple(_tabs_re.split(tabs_value, maxsplit=1)) 73 | workdir_value = inputs.pop("workdir", None) 74 | width_value = int(inputs.pop("width", "0")) 75 | options["id"] = id_value 76 | options["id_prefix"] = id_prefix_value 77 | options["html"] = html_value 78 | options["source"] = source_value 79 | options["result"] = result_value 80 | options["returncode"] = returncode_value 81 | options["session"] = session_value 82 | options["update_toc"] = update_toc_value 83 | options["tabs"] = tabs 84 | options["workdir"] = workdir_value 85 | options["width"] = width_value 86 | options["extra"] = inputs 87 | return True 88 | 89 | 90 | def formatter( 91 | source: str, 92 | language: str, 93 | css_class: str, # noqa: ARG001 94 | options: dict[str, Any], 95 | md: Markdown, 96 | classes: list[str] | None = None, # noqa: ARG001 97 | id_value: str = "", # noqa: ARG001 98 | attrs: dict[str, Any] | None = None, # noqa: ARG001 99 | **kwargs: Any, # noqa: ARG001 100 | ) -> str: 101 | """Execute code and return HTML. 102 | 103 | Parameters: 104 | source: The code to execute. 105 | language: The code language, like python or bash. 106 | css_class: The CSS class to add to the HTML element. 107 | options: The container for options. 108 | attrs: The container for attrs: 109 | md: The Markdown instance. 110 | classes: Additional CSS classes. 111 | id_value: An optional HTML id. 112 | attrs: Additional attributes 113 | **kwargs: Additional arguments passed to SuperFences default formatters. 114 | 115 | Returns: 116 | HTML contents. 117 | """ 118 | fmt = formatters.get(language, lambda source, **kwargs: source) 119 | return fmt(code=source, md=md, **options) # type: ignore[operator] 120 | 121 | 122 | def _to_bool(value: str) -> bool: 123 | return value.lower() not in {"", "no", "off", "false", "0"} 124 | -------------------------------------------------------------------------------- /src/markdown_exec/_internal/mkdocs_plugin.py: -------------------------------------------------------------------------------- 1 | # This module contains an optional plugin for MkDocs. 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING, Any 9 | 10 | from mkdocs.config import config_options 11 | from mkdocs.config.base import Config 12 | from mkdocs.exceptions import PluginError 13 | from mkdocs.plugins import BasePlugin 14 | from mkdocs.utils import write_file 15 | 16 | from markdown_exec._internal.logger import patch_loggers 17 | from markdown_exec._internal.main import formatter, formatters, validator 18 | from markdown_exec._internal.rendering import MarkdownConverter, markdown_config 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import MutableMapping 22 | 23 | from jinja2 import Environment 24 | from mkdocs.config.defaults import MkDocsConfig 25 | from mkdocs.structure.files import Files 26 | 27 | try: 28 | __import__("pygments_ansi_color") 29 | except ImportError: 30 | _ansi_ok = False 31 | else: 32 | _ansi_ok = True 33 | 34 | 35 | class _LoggerAdapter(logging.LoggerAdapter): 36 | def __init__(self, prefix: str, logger: logging.Logger) -> None: 37 | super().__init__(logger, {}) 38 | self.prefix = prefix 39 | 40 | def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]: 41 | return f"{self.prefix}: {msg}", kwargs 42 | 43 | 44 | def _get_logger(name: str) -> _LoggerAdapter: 45 | logger = logging.getLogger(f"mkdocs.plugins.{name}") 46 | return _LoggerAdapter(name.split(".", 1)[0], logger) 47 | 48 | 49 | patch_loggers(_get_logger) 50 | 51 | 52 | class MarkdownExecPluginConfig(Config): 53 | """Configuration of the plugin (for `mkdocs.yml`).""" 54 | 55 | ansi = config_options.Choice(("auto", "off", "required", True, False), default="auto") 56 | """Whether the `ansi` extra is required when installing the package.""" 57 | languages = config_options.ListOfItems( 58 | config_options.Choice(formatters.keys()), 59 | default=list(formatters.keys()), 60 | ) 61 | """Which languages to enabled the extension for.""" 62 | 63 | 64 | class MarkdownExecPlugin(BasePlugin[MarkdownExecPluginConfig]): 65 | """MkDocs plugin to easily enable custom fences for code blocks execution.""" 66 | 67 | def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None: 68 | """Configure the plugin. 69 | 70 | Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config). 71 | In this hook, we add custom fences for all the supported languages. 72 | 73 | We also save the Markdown extensions configuration 74 | into [`markdown_config`][markdown_exec.markdown_config]. 75 | 76 | Arguments: 77 | config: The MkDocs config object. 78 | 79 | Returns: 80 | The modified config. 81 | """ 82 | if "pymdownx.superfences" not in config["markdown_extensions"]: 83 | message = "The 'markdown-exec' plugin requires the 'pymdownx.superfences' Markdown extension to work." 84 | raise PluginError(message) 85 | if self.config.ansi in ("required", True) and not _ansi_ok: 86 | raise PluginError( 87 | "The configuration for the 'markdown-exec' plugin requires " 88 | "that it is installed with the 'ansi' extra. " 89 | "Install it with 'pip install markdown-exec[ansi]'.", 90 | ) 91 | self.mkdocs_config_dir = os.getenv("MKDOCS_CONFIG_DIR") 92 | os.environ["MKDOCS_CONFIG_DIR"] = os.path.dirname(config["config_file_path"]) 93 | self.languages = self.config.languages 94 | mdx_configs = config.setdefault("mdx_configs", {}) 95 | superfences = mdx_configs.setdefault("pymdownx.superfences", {}) 96 | custom_fences = superfences.setdefault("custom_fences", []) 97 | for language in self.languages: 98 | custom_fences.append( 99 | { 100 | "name": language, 101 | "class": language, 102 | "validator": validator, 103 | "format": formatter, 104 | }, 105 | ) 106 | markdown_config.save(config.markdown_extensions, config.mdx_configs) 107 | return config 108 | 109 | def on_env( 110 | self, 111 | env: Environment, 112 | *, 113 | config: MkDocsConfig, 114 | files: Files, # noqa: ARG002 115 | ) -> Environment | None: 116 | """Add assets to the environment.""" 117 | if self.config.ansi in ("required", True) or (self.config.ansi == "auto" and _ansi_ok): 118 | self._add_css(config, "ansi.css") 119 | if "pyodide" in self.languages: 120 | self._add_css(config, "pyodide.css") 121 | self._add_js(config, "pyodide.js") 122 | return env 123 | 124 | def on_post_build(self, *, config: MkDocsConfig) -> None: # noqa: ARG002 125 | """Reset the plugin state.""" 126 | MarkdownConverter.counter = 0 127 | markdown_config.reset() 128 | if self.mkdocs_config_dir is None: 129 | os.environ.pop("MKDOCS_CONFIG_DIR", None) 130 | else: 131 | os.environ["MKDOCS_CONFIG_DIR"] = self.mkdocs_config_dir 132 | 133 | def _add_asset(self, config: MkDocsConfig, asset_file: str, asset_type: str) -> None: 134 | asset_filename = f"assets/_markdown_exec_{asset_file}" 135 | asset_content = Path(__file__).parent.parent.joinpath("assets", asset_file).read_text() 136 | write_file(asset_content.encode("utf-8"), os.path.join(config.site_dir, asset_filename)) 137 | config[f"extra_{asset_type}"].insert(0, asset_filename) 138 | 139 | def _add_css(self, config: MkDocsConfig, css_file: str) -> None: 140 | self._add_asset(config, css_file, "css") 141 | 142 | def _add_js(self, config: MkDocsConfig, js_file: str) -> None: 143 | self._add_asset(config, js_file, "javascript") 144 | -------------------------------------------------------------------------------- /src/markdown_exec/_internal/processors.py: -------------------------------------------------------------------------------- 1 | # This module contains a Markdown extension 2 | # allowing to integrate generated headings into the ToC. 3 | 4 | from __future__ import annotations 5 | 6 | import copy 7 | import re 8 | from typing import TYPE_CHECKING 9 | from xml.etree.ElementTree import Element 10 | 11 | from markdown.treeprocessors import Treeprocessor 12 | from markdown.util import HTML_PLACEHOLDER_RE 13 | 14 | if TYPE_CHECKING: 15 | from markdown import Markdown 16 | from markupsafe import Markup 17 | 18 | 19 | # code taken from mkdocstrings, credits to @oprypin 20 | class IdPrependingTreeprocessor(Treeprocessor): 21 | """Prepend the configured prefix to IDs of all HTML elements.""" 22 | 23 | name = "markdown_exec_ids" 24 | """The name of the treeprocessor.""" 25 | 26 | def __init__(self, md: Markdown, id_prefix: str) -> None: 27 | super().__init__(md) 28 | self.id_prefix = id_prefix 29 | """The prefix to prepend to IDs.""" 30 | 31 | def run(self, root: Element) -> None: 32 | """Run the treeprocessor.""" 33 | if not self.id_prefix: 34 | return 35 | for el in root.iter(): 36 | id_attr = el.get("id") 37 | if id_attr: 38 | el.set("id", self.id_prefix + id_attr) 39 | 40 | href_attr = el.get("href") 41 | if href_attr and href_attr.startswith("#"): 42 | el.set("href", "#" + self.id_prefix + href_attr[1:]) 43 | 44 | name_attr = el.get("name") 45 | if name_attr: 46 | el.set("name", self.id_prefix + name_attr) 47 | 48 | if el.tag == "label": 49 | for_attr = el.get("for") 50 | if for_attr: 51 | el.set("for", self.id_prefix + for_attr) 52 | 53 | 54 | # code taken from mkdocstrings, credits to @oprypin 55 | class HeadingReportingTreeprocessor(Treeprocessor): 56 | """Records the heading elements encountered in the document.""" 57 | 58 | name = "markdown_exec_record_headings" 59 | """The name of the treeprocessor.""" 60 | regex = re.compile("[Hh][1-6]") 61 | """The regex to match heading tags.""" 62 | 63 | def __init__(self, md: Markdown, headings: list[Element]): 64 | super().__init__(md) 65 | self.headings = headings 66 | """The list of heading elements.""" 67 | 68 | def run(self, root: Element) -> None: 69 | """Run the treeprocessor.""" 70 | for el in root.iter(): 71 | if self.regex.fullmatch(el.tag): 72 | el = copy.copy(el) # noqa: PLW2901 73 | # 'toc' extension's first pass (which we require to build heading stubs/ids) also edits the HTML. 74 | # Undo the permalink edit so we can pass this heading to the outer pass of the 'toc' extension. 75 | if len(el) > 0 and el[-1].get("class") == self.md.treeprocessors["toc"].permalink_class: # type: ignore[attr-defined] 76 | del el[-1] 77 | self.headings.append(el) 78 | 79 | 80 | class InsertHeadings(Treeprocessor): 81 | """Our headings insertor.""" 82 | 83 | name = "markdown_exec_insert_headings" 84 | """The name of the treeprocessor.""" 85 | 86 | def __init__(self, md: Markdown): 87 | """Initialize the object. 88 | 89 | Arguments: 90 | md: A `markdown.Markdown` instance. 91 | """ 92 | super().__init__(md) 93 | self.headings: dict[Markup, list[Element]] = {} 94 | """The dictionary of headings.""" 95 | 96 | def run(self, root: Element) -> None: 97 | """Run the treeprocessor.""" 98 | if not self.headings: 99 | return 100 | 101 | for el in root.iter(): 102 | match = HTML_PLACEHOLDER_RE.match(el.text or "") 103 | if match: 104 | counter = int(match.group(1)) 105 | markup: Markup = self.md.htmlStash.rawHtmlBlocks[counter] # type: ignore[assignment] 106 | if headings := self.headings.get(markup): 107 | div = Element("div", {"class": "markdown-exec"}) 108 | div.extend(headings) 109 | el.append(div) 110 | 111 | 112 | class RemoveHeadings(Treeprocessor): 113 | """Our headings remover.""" 114 | 115 | name = "markdown_exec_remove_headings" 116 | """The name of the treeprocessor.""" 117 | 118 | def run(self, root: Element) -> None: 119 | """Run the treeprocessor.""" 120 | self._remove_duplicated_headings(root) 121 | 122 | def _remove_duplicated_headings(self, parent: Element) -> None: 123 | carry_text = "" 124 | for el in reversed(parent): # Reversed mainly for the ability to mutate during iteration. 125 | if el.tag == "div" and el.get("class") == "markdown-exec": 126 | # Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML). 127 | carry_text = (el.text or "") + carry_text 128 | parent.remove(el) 129 | else: 130 | if carry_text: 131 | el.tail = (el.tail or "") + carry_text 132 | carry_text = "" 133 | self._remove_duplicated_headings(el) 134 | 135 | if carry_text: 136 | parent.text = (parent.text or "") + carry_text 137 | -------------------------------------------------------------------------------- /src/markdown_exec/assets/pyodide.css: -------------------------------------------------------------------------------- 1 | html[data-theme="light"] { 2 | @import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow.css" 3 | } 4 | 5 | html[data-theme="dark"] { 6 | @import "https://cdn.jsdelivr.net/npm/highlightjs-themes@1.0.0/tomorrow-night-blue.min.css" 7 | } 8 | 9 | 10 | .ace_gutter { 11 | z-index: 1; 12 | } 13 | 14 | .pyodide-editor { 15 | width: 100%; 16 | min-height: 200px; 17 | max-height: 400px; 18 | font-size: .85em; 19 | } 20 | 21 | .pyodide-editor-bar { 22 | color: var(--md-primary-bg-color); 23 | background-color: var(--md-primary-fg-color); 24 | width: 100%; 25 | font: monospace; 26 | font-size: 0.75em; 27 | padding: 2px 0 2px; 28 | } 29 | 30 | .pyodide-bar-item { 31 | padding: 0 18px 0; 32 | display: inline-block; 33 | width: 50%; 34 | } 35 | 36 | .pyodide pre { 37 | margin: 0; 38 | } 39 | 40 | .pyodide-output { 41 | width: 100%; 42 | margin-bottom: -15px; 43 | min-height: 46px; 44 | max-height: 400px 45 | } 46 | 47 | .pyodide-clickable { 48 | cursor: pointer; 49 | text-align: right; 50 | } 51 | 52 | /* For themes other than Material. */ 53 | .pyodide .twemoji svg { 54 | width: 1rem; 55 | } 56 | -------------------------------------------------------------------------------- /src/markdown_exec/assets/pyodide.js: -------------------------------------------------------------------------------- 1 | var _sessions = {}; 2 | 3 | function getSession(name, pyodide) { 4 | if (!(name in _sessions)) { 5 | _sessions[name] = pyodide.globals.get("dict")(); 6 | } 7 | return _sessions[name]; 8 | } 9 | 10 | function writeOutput(element, string) { 11 | element.innerHTML += string + '\n'; 12 | } 13 | 14 | function clearOutput(element) { 15 | element.innerHTML = ''; 16 | } 17 | 18 | async function evaluatePython(pyodide, editor, output, session) { 19 | pyodide.setStdout({ batched: (string) => { writeOutput(output, new Option(string).innerHTML); } }); 20 | let result, code = editor.getValue(); 21 | clearOutput(output); 22 | try { 23 | result = await pyodide.runPythonAsync(code, { globals: getSession(session, pyodide) }); 24 | } catch (error) { 25 | writeOutput(output, new Option(error.toString()).innerHTML); 26 | } 27 | if (result) writeOutput(output, new Option(result).innerHTML); 28 | hljs.highlightElement(output); 29 | } 30 | 31 | async function initPyodide() { 32 | try { 33 | let pyodide = await loadPyodide(); 34 | await pyodide.loadPackage("micropip"); 35 | return pyodide; 36 | } catch(error) { 37 | return null; 38 | } 39 | } 40 | 41 | function getTheme() { 42 | return document.body.getAttribute('data-md-color-scheme'); 43 | } 44 | 45 | function setTheme(editor, currentTheme, light, dark) { 46 | // https://gist.github.com/RyanNutt/cb8d60997d97905f0b2aea6c3b5c8ee0 47 | if (currentTheme === "default") { 48 | editor.setTheme("ace/theme/" + light); 49 | document.querySelector(`link[title="light"]`).removeAttribute("disabled"); 50 | document.querySelector(`link[title="dark"]`).setAttribute("disabled", "disabled"); 51 | } else if (currentTheme === "slate") { 52 | editor.setTheme("ace/theme/" + dark); 53 | document.querySelector(`link[title="dark"]`).removeAttribute("disabled"); 54 | document.querySelector(`link[title="light"]`).setAttribute("disabled", "disabled"); 55 | } 56 | } 57 | 58 | function updateTheme(editor, light, dark) { 59 | // Create a new MutationObserver instance 60 | const observer = new MutationObserver((mutations) => { 61 | // Loop through the mutations that occurred 62 | mutations.forEach((mutation) => { 63 | // Check if the mutation was a change to the data-md-color-scheme attribute 64 | if (mutation.attributeName === 'data-md-color-scheme') { 65 | // Get the new value of the attribute 66 | const newColorScheme = mutation.target.getAttribute('data-md-color-scheme'); 67 | // Update the editor theme 68 | setTheme(editor, newColorScheme, light, dark); 69 | } 70 | }); 71 | }); 72 | 73 | // Configure the observer to watch for changes to the data-md-color-scheme attribute 74 | observer.observe(document.body, { 75 | attributes: true, 76 | attributeFilter: ['data-md-color-scheme'], 77 | }); 78 | } 79 | 80 | async function setupPyodide(idPrefix, install = null, themeLight = 'tomorrow', themeDark = 'tomorrow_night', session = null) { 81 | const editor = ace.edit(idPrefix + "editor"); 82 | const run = document.getElementById(idPrefix + "run"); 83 | const clear = document.getElementById(idPrefix + "clear"); 84 | const output = document.getElementById(idPrefix + "output"); 85 | 86 | updateTheme(editor, themeLight, themeDark); 87 | 88 | editor.session.setMode("ace/mode/python"); 89 | setTheme(editor, getTheme(), themeLight, themeDark); 90 | 91 | writeOutput(output, "Initializing..."); 92 | let pyodide = await pyodidePromise; 93 | if (install && install.length) { 94 | try { 95 | micropip = pyodide.pyimport("micropip"); 96 | for (const package of install) 97 | await micropip.install(package); 98 | clearOutput(output); 99 | } catch (error) { 100 | clearOutput(output); 101 | writeOutput(output, `Could not install one or more packages: ${install.join(", ")}\n`); 102 | writeOutput(output, new Option(error.toString()).innerHTML); 103 | } 104 | } else { 105 | clearOutput(output); 106 | } 107 | run.onclick = () => evaluatePython(pyodide, editor, output, session); 108 | clear.onclick = () => clearOutput(output); 109 | output.parentElement.parentElement.addEventListener("keydown", (event) => { 110 | if (event.ctrlKey && event.key.toLowerCase() === 'enter') { 111 | event.preventDefault(); 112 | run.click(); 113 | } 114 | }); 115 | } 116 | 117 | var pyodidePromise = initPyodide(); 118 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal import formatters 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(formatters, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/base.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import base 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.base` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(base, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/bash.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import bash 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.bash` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(bash, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/console.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import console 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.console` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(console, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/markdown.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import markdown 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.markdown` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(markdown, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/pycon.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import pycon 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.pycon` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(pycon, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/pyodide.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import pyodide 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.pyodide` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(pyodide, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/python.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import python 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.python` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(python, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/sh.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import sh 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.sh` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(sh, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/formatters/tree.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal.formatters import tree 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.formatters.tree` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(tree, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/logger.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal import logger 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.logger` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(logger, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/mkdocs_plugin.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal import mkdocs_plugin 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.mkdocs_plugin` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(mkdocs_plugin, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/processors.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal import processors 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.processors` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(processors, name) 18 | -------------------------------------------------------------------------------- /src/markdown_exec/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawamoy/markdown-exec/9df9d5e38540478574a91929773f7b5dc4471a9a/src/markdown_exec/py.typed -------------------------------------------------------------------------------- /src/markdown_exec/rendering.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `markdown_exec` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from markdown_exec._internal import rendering 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `markdown_exec.rendering` is deprecated. Import from `markdown_exec` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(rendering, name) 18 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests suite for `markdown_exec`.""" 2 | 3 | from pathlib import Path 4 | 5 | TESTS_DIR = Path(__file__).parent 6 | TMP_DIR = TESTS_DIR / "tmp" 7 | FIXTURES_DIR = TESTS_DIR / "fixtures" 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration for the pytest test suite.""" 2 | 3 | import pytest 4 | from markdown import Markdown 5 | 6 | from markdown_exec import formatter, formatters, validator 7 | 8 | 9 | @pytest.fixture 10 | def md() -> Markdown: 11 | """Return a Markdown instance. 12 | 13 | Returns: 14 | Markdown instance. 15 | """ 16 | fences = [ 17 | { 18 | "name": language, 19 | "class": language, 20 | "validator": validator, 21 | "format": formatter, 22 | } 23 | for language in formatters 24 | ] 25 | return Markdown( 26 | extensions=["pymdownx.superfences", "pymdownx.tabbed"], 27 | extension_configs={"pymdownx.superfences": {"custom_fences": fences}}, 28 | ) 29 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for our own API exposition.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import defaultdict 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | import griffe 10 | import pytest 11 | from mkdocstrings import Inventory 12 | 13 | import markdown_exec 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | 18 | 19 | @pytest.fixture(name="loader", scope="module") 20 | def _fixture_loader() -> griffe.GriffeLoader: 21 | loader = griffe.GriffeLoader() 22 | loader.load("markdown_exec") 23 | loader.resolve_aliases() 24 | return loader 25 | 26 | 27 | @pytest.fixture(name="internal_api", scope="module") 28 | def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: 29 | return loader.modules_collection["markdown_exec._internal"] 30 | 31 | 32 | @pytest.fixture(name="public_api", scope="module") 33 | def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: 34 | return loader.modules_collection["markdown_exec"] 35 | 36 | 37 | def _yield_public_objects( 38 | obj: griffe.Module | griffe.Class, 39 | *, 40 | modules: bool = False, 41 | modulelevel: bool = True, 42 | inherited: bool = False, 43 | special: bool = False, 44 | ) -> Iterator[griffe.Object | griffe.Alias]: 45 | for member in obj.all_members.values() if inherited else obj.members.values(): 46 | try: 47 | if member.is_module: 48 | if member.is_alias or not member.is_public: 49 | continue 50 | if modules: 51 | yield member 52 | yield from _yield_public_objects( 53 | member, # type: ignore[arg-type] 54 | modules=modules, 55 | modulelevel=modulelevel, 56 | inherited=inherited, 57 | special=special, 58 | ) 59 | elif member.is_public and (special or not member.is_special): 60 | yield member 61 | else: 62 | continue 63 | if member.is_class and not modulelevel: 64 | yield from _yield_public_objects( 65 | member, # type: ignore[arg-type] 66 | modules=modules, 67 | modulelevel=False, 68 | inherited=inherited, 69 | special=special, 70 | ) 71 | except (griffe.AliasResolutionError, griffe.CyclicAliasError): 72 | continue 73 | 74 | 75 | @pytest.fixture(name="modulelevel_internal_objects", scope="module") 76 | def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 77 | return list(_yield_public_objects(internal_api, modulelevel=True)) 78 | 79 | 80 | @pytest.fixture(name="internal_objects", scope="module") 81 | def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 82 | return list(_yield_public_objects(internal_api, modulelevel=False, special=True)) 83 | 84 | 85 | @pytest.fixture(name="public_objects", scope="module") 86 | def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 87 | return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True)) 88 | 89 | 90 | @pytest.fixture(name="inventory", scope="module") 91 | def _fixture_inventory() -> Inventory: 92 | inventory_file = Path(__file__).parent.parent / "site" / "objects.inv" 93 | if not inventory_file.exists(): 94 | raise pytest.skip("The objects inventory is not available.") 95 | with inventory_file.open("rb") as file: 96 | return Inventory.parse_sphinx(file) 97 | 98 | 99 | def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 100 | """All public objects in the internal API are exposed under `markdown_exec`.""" 101 | not_exposed = [ 102 | obj.path 103 | for obj in modulelevel_internal_objects 104 | if obj.name not in markdown_exec.__all__ or not hasattr(markdown_exec, obj.name) 105 | ] 106 | assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed)) 107 | 108 | 109 | def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 110 | """All internal objects have unique names.""" 111 | names_to_paths = defaultdict(list) 112 | for obj in modulelevel_internal_objects: 113 | names_to_paths[obj.name].append(obj.path) 114 | non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1] 115 | assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) 116 | 117 | 118 | def test_single_locations(public_api: griffe.Module) -> None: 119 | """All objects have a single public location.""" 120 | 121 | def _public_path(obj: griffe.Object | griffe.Alias) -> bool: 122 | return obj.is_public and (obj.parent is None or _public_path(obj.parent)) 123 | 124 | multiple_locations = {} 125 | for obj_name in markdown_exec.__all__: 126 | obj = public_api[obj_name] 127 | if obj.aliases and ( 128 | public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)] 129 | ): 130 | multiple_locations[obj.path] = public_aliases 131 | assert not multiple_locations, "Multiple public locations:\n" + "\n".join( 132 | f"{path}: {aliases}" for path, aliases in multiple_locations.items() 133 | ) 134 | 135 | 136 | def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None: 137 | """All public objects are added to the inventory.""" 138 | ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"} 139 | not_in_inventory = [ 140 | obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory 141 | ] 142 | msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}" 143 | assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory))) 144 | 145 | 146 | def test_inventory_matches_api( 147 | inventory: Inventory, 148 | public_objects: list[griffe.Object | griffe.Alias], 149 | loader: griffe.GriffeLoader, 150 | ) -> None: 151 | """The inventory doesn't contain any additional Python object.""" 152 | not_in_api = [] 153 | public_api_paths = {obj.path for obj in public_objects} 154 | public_api_paths.add("markdown_exec") 155 | # YORE: Bump 2: Remove block. 156 | ignore_modules = { 157 | "markdown_exec.formatters", 158 | "markdown_exec.formatters.base", 159 | "markdown_exec.formatters.bash", 160 | "markdown_exec.formatters.console", 161 | "markdown_exec.formatters.markdown", 162 | "markdown_exec.formatters.pycon", 163 | "markdown_exec.formatters.pyodide", 164 | "markdown_exec.formatters.python", 165 | "markdown_exec.formatters.sh", 166 | "markdown_exec.formatters.tree", 167 | "markdown_exec.logger", 168 | "markdown_exec.mkdocs_plugin", 169 | "markdown_exec.processors", 170 | "markdown_exec.rendering", 171 | } 172 | 173 | for item in inventory.values(): 174 | # YORE: Bump 2: Remove block. 175 | if item.name in ignore_modules: 176 | continue 177 | 178 | if ( 179 | item.domain == "py" 180 | and "(" not in item.name 181 | and (item.name == "markdown_exec" or item.name.startswith("markdown_exec.")) 182 | ): 183 | obj = loader.modules_collection[item.name] 184 | if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): 185 | not_in_api.append(item.name) 186 | msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}" 187 | assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api))) 188 | 189 | 190 | def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None: 191 | """No module docstrings should be written in our internal API. 192 | 193 | The reasoning is that docstrings are addressed to users of the public API, 194 | but internal modules are not exposed to users, so they should not have docstrings. 195 | """ 196 | 197 | def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: 198 | for member in obj.modules.values(): 199 | yield member 200 | yield from _modules(member) 201 | 202 | for obj in _modules(internal_api): 203 | assert not obj.docstring 204 | -------------------------------------------------------------------------------- /tests/test_base_formatter.py: -------------------------------------------------------------------------------- 1 | """Tests for the base formatter.""" 2 | 3 | import os 4 | import subprocess 5 | 6 | import pytest 7 | from markdown import Markdown 8 | 9 | from markdown_exec import base_format 10 | 11 | 12 | def test_no_p_around_html(md: Markdown) -> None: 13 | """Assert HTML isn't wrapped in a `p` tag. 14 | 15 | Parameters: 16 | md: A Markdown instance (fixture). 17 | """ 18 | code = "
    hello
    " 19 | html = base_format( 20 | language="whatever", 21 | run=lambda code, **_: code, 22 | code=code, 23 | md=md, 24 | html=True, 25 | ) 26 | assert html == code 27 | 28 | 29 | @pytest.mark.parametrize("html", [True, False]) 30 | def test_render_source(md: Markdown, html: bool) -> None: 31 | """Assert source is rendered. 32 | 33 | Parameters: 34 | md: A Markdown instance (fixture). 35 | html: Whether output is HTML or not. 36 | """ 37 | markup = base_format( 38 | language="python", 39 | run=lambda code, **_: code, 40 | code="hello", 41 | md=md, 42 | html=html, 43 | source="tabbed-left", 44 | ) 45 | assert "Source" in markup 46 | 47 | 48 | def test_render_console_plus_ansi_result(md: Markdown) -> None: 49 | """Assert we can render source as console style with `ansi` highlight. 50 | 51 | Parameters: 52 | md: A Markdown instance (fixture). 53 | """ 54 | markup = base_format( 55 | language="bash", 56 | run=lambda code, **_: code, 57 | code="echo -e '\033[31mhello'", 58 | md=md, 59 | html=False, 60 | source="console", 61 | result="ansi", 62 | ) 63 | assert "ansi" in markup 64 | 65 | 66 | def test_dont_render_anything_if_output_is_empty(md: Markdown) -> None: 67 | """Assert nothing is rendered if output is empty. 68 | 69 | Parameters: 70 | md: A Markdown instance (fixture). 71 | """ 72 | markup = base_format( 73 | language="bash", 74 | run=lambda code, **_: "", 75 | code="whatever", 76 | md=md, 77 | ) 78 | assert not markup 79 | 80 | 81 | def test_render_source_even_if_output_is_empty(md: Markdown) -> None: 82 | """Assert source is rendered even if output is empty. 83 | 84 | Parameters: 85 | md: A Markdown instance (fixture). 86 | """ 87 | markup = base_format( 88 | language="bash", 89 | run=lambda code, **_: "", 90 | code="whatever", 91 | md=md, 92 | source="tabbed-left", 93 | ) 94 | assert "Source" in markup 95 | 96 | 97 | @pytest.mark.skipif(os.name != "posix", reason="No time for the annoying OS.") 98 | def test_changing_working_directory(md: Markdown) -> None: 99 | """Assert we can change the working directory with `workdir`. 100 | 101 | Parameters: 102 | md: A Markdown instance (fixture). 103 | """ 104 | markup = base_format( 105 | language="python", 106 | run=lambda code, **_: subprocess.check_output(code, shell=True, text=True), # noqa: S602 107 | code="pwd", 108 | md=md, 109 | workdir="/", 110 | ) 111 | assert markup == "

    /

    " 112 | 113 | 114 | @pytest.mark.skipif(os.name != "posix", reason="No time for the annoying OS.") 115 | def test_console_width(md: Markdown) -> None: 116 | """Assert we can change the console width with `width`. 117 | 118 | Parameters: 119 | md: A Markdown instance (fixture). 120 | """ 121 | for width in (10, 1000): 122 | markup = base_format( 123 | language="bash", 124 | run=lambda code, **_: subprocess.check_output(code, shell=True, text=True), # noqa: S602, 125 | code="echo width: $COLUMNS", 126 | md=md, 127 | width=width, 128 | ) 129 | assert f"width: {width}" in markup 130 | -------------------------------------------------------------------------------- /tests/test_converter.py: -------------------------------------------------------------------------------- 1 | """Tests for the Markdown converter.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from textwrap import dedent 7 | from typing import TYPE_CHECKING 8 | 9 | import pytest 10 | from markdown.extensions.toc import TocExtension 11 | 12 | from markdown_exec import MarkdownConfig, markdown_config 13 | 14 | if TYPE_CHECKING: 15 | from markdown import Markdown 16 | 17 | 18 | def test_rendering_nested_blocks(md: Markdown) -> None: 19 | """Assert nested blocks are properly handled. 20 | 21 | Parameters: 22 | md: A Markdown instance (fixture). 23 | """ 24 | html = md.convert( 25 | dedent( 26 | """ 27 | ````md exec="1" 28 | ```python exec="1" 29 | print("**Bold!**") 30 | ``` 31 | ```` 32 | """, 33 | ), 34 | ) 35 | assert html == "

    Bold!

    " 36 | 37 | 38 | def test_instantiating_config_singleton() -> None: 39 | """Assert that the Markdown config instances act as a singleton.""" 40 | assert MarkdownConfig() is markdown_config 41 | markdown_config.save([], {}) 42 | markdown_config.reset() 43 | 44 | 45 | @pytest.mark.parametrize( 46 | ("id", "id_prefix", "expected"), 47 | [ 48 | ("", None, 'id="exec-\\d+--heading"'), 49 | ("", "", 'id="heading"'), 50 | ("", "some-prefix-", 'id="some-prefix-heading"'), 51 | ("some-id", None, 'id="some-id-heading"'), 52 | ("some-id", "", 'id="heading"'), 53 | ("some-id", "some-prefix-", 'id="some-prefix-heading"'), 54 | ], 55 | ) 56 | def test_prefixing_headings(md: Markdown, id: str, id_prefix: str | None, expected: str) -> None: # noqa: A002 57 | """Assert that we prefix headings as specified. 58 | 59 | Parameters: 60 | md: A Markdown instance (fixture). 61 | id: The code block id. 62 | id_prefix: The code block id prefix. 63 | expected: The id we expect to find in the HTML. 64 | """ 65 | TocExtension().extendMarkdown(md) 66 | prefix = f'idprefix="{id_prefix}"' if id_prefix is not None else "" 67 | html = md.convert( 68 | dedent( 69 | f""" 70 | ```python exec="1" id="{id}" {prefix} 71 | print("# HEADING") 72 | ``` 73 | """, 74 | ), 75 | ) 76 | assert re.search(expected, html) 77 | -------------------------------------------------------------------------------- /tests/test_headings.py: -------------------------------------------------------------------------------- 1 | """Tests for headings.""" 2 | 3 | from __future__ import annotations 4 | 5 | from textwrap import dedent 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from markdown import Markdown 10 | 11 | 12 | def test_headings_removal(md: Markdown) -> None: 13 | """Headings should leave no trace behind. 14 | 15 | Parameters: 16 | md: A Markdown instance (fixture). 17 | """ 18 | html = md.convert( 19 | dedent( 20 | """ 21 | === "File layout" 22 | 23 | ```tree 24 | ./ 25 | hello.md 26 | ``` 27 | """, 28 | ), 29 | ) 30 | assert 'class="markdown-exec"' not in html 31 | -------------------------------------------------------------------------------- /tests/test_python.py: -------------------------------------------------------------------------------- 1 | """Tests for the Python formatters.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from textwrap import dedent 7 | from typing import TYPE_CHECKING 8 | 9 | if TYPE_CHECKING: 10 | import pytest 11 | from markdown import Markdown 12 | 13 | 14 | def test_output_markdown(md: Markdown) -> None: 15 | """Assert Markdown is converted to HTML. 16 | 17 | Parameters: 18 | md: A Markdown instance (fixture). 19 | """ 20 | html = md.convert( 21 | dedent( 22 | """ 23 | ```python exec="yes" 24 | print("**Bold!**") 25 | ``` 26 | """, 27 | ), 28 | ) 29 | assert html == "

    Bold!

    " 30 | 31 | 32 | def test_output_html(md: Markdown) -> None: 33 | """Assert HTML is injected as is. 34 | 35 | Parameters: 36 | md: A Markdown instance (fixture). 37 | """ 38 | html = md.convert( 39 | dedent( 40 | """ 41 | ```python exec="yes" html="yes" 42 | print("**Bold!**") 43 | ``` 44 | """, 45 | ), 46 | ) 47 | assert html == "

    **Bold!**\n

    " 48 | 49 | 50 | def test_error_raised(md: Markdown, caplog: pytest.LogCaptureFixture) -> None: 51 | """Assert errors properly log a warning and return a formatted traceback. 52 | 53 | Parameters: 54 | md: A Markdown instance (fixture). 55 | caplog: Pytest fixture to capture logs. 56 | """ 57 | html = md.convert( 58 | dedent( 59 | """ 60 | ```python exec="yes" 61 | raise ValueError("oh no!") 62 | ``` 63 | """, 64 | ), 65 | ) 66 | assert "Traceback" in html 67 | assert "ValueError" in html 68 | assert "oh no!" in html 69 | assert "Execution of python code block exited with errors" in caplog.text 70 | 71 | 72 | def test_can_print_non_string_objects(md: Markdown) -> None: 73 | """Assert we can print non-string objects. 74 | 75 | Parameters: 76 | md: A Markdown instance (fixture). 77 | """ 78 | html = md.convert( 79 | dedent( 80 | """ 81 | ```python exec="yes" 82 | class NonString: 83 | def __str__(self): 84 | return "string" 85 | 86 | nonstring = NonString() 87 | print(nonstring, nonstring) 88 | ``` 89 | """, 90 | ), 91 | ) 92 | assert "Traceback" not in html 93 | 94 | 95 | def test_sessions(md: Markdown) -> None: 96 | """Assert sessions can be reused. 97 | 98 | Parameters: 99 | md: A Markdown instance (fixture). 100 | """ 101 | html = md.convert( 102 | dedent( 103 | """ 104 | ```python exec="1" session="a" 105 | a = 1 106 | ``` 107 | 108 | ```pycon exec="1" session="b" 109 | >>> b = 2 110 | ``` 111 | 112 | ```pycon exec="1" session="a" 113 | >>> print(f"a = {a}") 114 | >>> try: 115 | ... print(b) 116 | ... except NameError: 117 | ... print("ok") 118 | ... else: 119 | ... print("ko") 120 | ``` 121 | 122 | ```python exec="1" session="b" 123 | print(f"b = {b}") 124 | try: 125 | print(a) 126 | except NameError: 127 | print("ok") 128 | else: 129 | print("ko") 130 | ``` 131 | """, 132 | ), 133 | ) 134 | assert "a = 1" in html 135 | assert "b = 2" in html 136 | assert "ok" in html 137 | assert "ko" not in html 138 | 139 | 140 | def test_reporting_errors_in_sessions(md: Markdown, caplog: pytest.LogCaptureFixture) -> None: 141 | """Assert errors and source lines are correctly reported across sessions. 142 | 143 | Parameters: 144 | md: A Markdown instance (fixture). 145 | caplog: Pytest fixture to capture logs. 146 | """ 147 | html = md.convert( 148 | dedent( 149 | """ 150 | ```python exec="1" session="a" 151 | def fraise(): 152 | raise RuntimeError("strawberry") 153 | ``` 154 | 155 | ```python exec="1" session="a" 156 | print("hello") 157 | fraise() 158 | ``` 159 | """, 160 | ), 161 | ) 162 | assert "Traceback" in html 163 | assert "strawberry" in html 164 | assert "fraise()" in caplog.text 165 | assert 'raise RuntimeError("strawberry")' in caplog.text 166 | 167 | 168 | def test_removing_output_from_pycon_code(md: Markdown) -> None: 169 | """Assert output lines are removed from pycon snippets. 170 | 171 | Parameters: 172 | md: A Markdown instance (fixture). 173 | """ 174 | html = md.convert( 175 | dedent( 176 | """ 177 | ```pycon exec="1" source="console" 178 | >>> print("ok") 179 | ko 180 | ``` 181 | """, 182 | ), 183 | ) 184 | assert "ok" in html 185 | assert "ko" not in html 186 | 187 | 188 | def test_functions_have_a_module_attribute(md: Markdown) -> None: 189 | """Assert functions have a `__module__` attribute. 190 | 191 | Parameters: 192 | md: A Markdown instance (fixture). 193 | """ 194 | html = md.convert( 195 | dedent( 196 | """ 197 | ```python exec="1" 198 | def func(): 199 | pass 200 | 201 | print(f"`{func.__module__}`") 202 | ``` 203 | """, 204 | ), 205 | ) 206 | assert "_code_block_n" in html 207 | 208 | 209 | def test_future_annotations_do_not_leak_into_user_code(md: Markdown) -> None: 210 | """Assert future annotations do not leak into user code. 211 | 212 | Parameters: 213 | md: A Markdown instance (fixture). 214 | """ 215 | html = md.convert( 216 | dedent( 217 | """ 218 | ```python exec="1" 219 | class Int: 220 | ... 221 | 222 | def f(x: Int) -> None: 223 | return x + 1.0 224 | 225 | print(f"`{f.__annotations__['x']}`") 226 | ``` 227 | """, 228 | ), 229 | ) 230 | assert "Int" not in html 231 | assert re.search(r"class '_code_block_n\d+_\.Int'", html) 232 | -------------------------------------------------------------------------------- /tests/test_shell.py: -------------------------------------------------------------------------------- 1 | """Tests for the shell formatters.""" 2 | 3 | from __future__ import annotations 4 | 5 | from textwrap import dedent 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | import pytest 10 | from markdown import Markdown 11 | 12 | 13 | def test_output_markdown(md: Markdown) -> None: 14 | """Assert Markdown is converted to HTML. 15 | 16 | Parameters: 17 | md: A Markdown instance (fixture). 18 | """ 19 | html = md.convert( 20 | dedent( 21 | """ 22 | ```sh exec="yes" 23 | echo "**Bold!**" 24 | ``` 25 | """, 26 | ), 27 | ) 28 | assert html == "

    Bold!

    " 29 | 30 | 31 | def test_output_html(md: Markdown) -> None: 32 | """Assert HTML is injected as is. 33 | 34 | Parameters: 35 | md: A Markdown instance (fixture). 36 | """ 37 | html = md.convert( 38 | dedent( 39 | """ 40 | ```sh exec="yes" html="yes" 41 | echo "**Bold!**" 42 | ``` 43 | """, 44 | ), 45 | ) 46 | assert html == "

    **Bold!**\n

    " 47 | 48 | 49 | def test_error_raised(md: Markdown, caplog: pytest.LogCaptureFixture) -> None: 50 | """Assert errors properly log a warning and return a formatted traceback. 51 | 52 | Parameters: 53 | md: A Markdown instance (fixture). 54 | caplog: Pytest fixture to capture logs. 55 | """ 56 | html = md.convert( 57 | dedent( 58 | """ 59 | ```sh exec="yes" 60 | echo("wrong syntax") 61 | ``` 62 | """, 63 | ), 64 | ) 65 | assert "error" in html 66 | assert "Execution of sh code block exited with unexpected code 2" in caplog.text 67 | 68 | 69 | def test_return_code(md: Markdown, caplog: pytest.LogCaptureFixture) -> None: 70 | """Assert return code is used correctly. 71 | 72 | Parameters: 73 | md: A Markdown instance (fixture). 74 | caplog: Pytest fixture to capture logs. 75 | """ 76 | html = md.convert( 77 | dedent( 78 | """ 79 | ```sh exec="yes" returncode="1" 80 | echo Not in the mood 81 | exit 1 82 | ``` 83 | """, 84 | ), 85 | ) 86 | assert "Not in the mood" in html 87 | assert "exited with" not in caplog.text 88 | -------------------------------------------------------------------------------- /tests/test_toc.py: -------------------------------------------------------------------------------- 1 | """Tests for the logic updating the table of contents.""" 2 | 3 | from __future__ import annotations 4 | 5 | from textwrap import dedent 6 | from typing import TYPE_CHECKING 7 | 8 | from markdown.extensions.toc import TocExtension 9 | 10 | if TYPE_CHECKING: 11 | from markdown import Markdown 12 | 13 | 14 | def test_updating_toc(md: Markdown) -> None: 15 | """Assert ToC is updated with generated headings. 16 | 17 | Parameters: 18 | md: A Markdown instance (fixture). 19 | """ 20 | TocExtension().extendMarkdown(md) 21 | html = md.convert( 22 | dedent( 23 | """ 24 | ```python exec="yes" 25 | print("# big heading") 26 | ``` 27 | """, 28 | ), 29 | ) 30 | assert " None: 35 | """Assert ToC is not updated with generated headings. 36 | 37 | Parameters: 38 | md: A Markdown instance (fixture). 39 | """ 40 | TocExtension().extendMarkdown(md) 41 | html = md.convert( 42 | dedent( 43 | """ 44 | ```python exec="yes" updatetoc="no" 45 | print("# big heading") 46 | ``` 47 | """, 48 | ), 49 | ) 50 | assert " None: 55 | """Assert ToC is not updated with generated headings. 56 | 57 | Parameters: 58 | md: A Markdown instance (fixture). 59 | """ 60 | TocExtension().extendMarkdown(md) 61 | html = md.convert( 62 | dedent( 63 | """ 64 | ```python exec="yes" updatetoc="no" 65 | print("# big heading") 66 | ``` 67 | 68 | ```python exec="yes" updatetoc="yes" 69 | print("## medium heading") 70 | ``` 71 | 72 | ```python exec="yes" updatetoc="no" 73 | print("### small heading") 74 | ``` 75 | 76 | ```python exec="yes" updatetoc="yes" 77 | print("#### tiny heading") 78 | ``` 79 | """, 80 | ), 81 | ) 82 | assert " None: 9 | """Assert we can highlight lines in the output. 10 | 11 | Parameters: 12 | md: A Markdown instance (fixture). 13 | """ 14 | html = md.convert( 15 | dedent( 16 | """ 17 | ```tree hl_lines="2" 18 | 1 19 | 2 20 | 3 21 | ``` 22 | """, 23 | ), 24 | ) 25 | assert '' in html 26 | -------------------------------------------------------------------------------- /tests/test_validator.py: -------------------------------------------------------------------------------- 1 | """Tests for the `validator` function.""" 2 | 3 | import pytest 4 | from markdown.core import Markdown 5 | 6 | from markdown_exec import validator 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("exec_value", "expected"), 11 | [ 12 | ("yes", True), 13 | ("YES", True), 14 | ("on", True), 15 | ("ON", True), 16 | ("whynot", True), 17 | ("true", True), 18 | ("TRUE", True), 19 | ("1", True), 20 | ("-1", True), 21 | ("0", False), 22 | ("no", False), 23 | ("NO", False), 24 | ("off", False), 25 | ("OFF", False), 26 | ("false", False), 27 | ("FALSE", False), 28 | ], 29 | ) 30 | def test_validate(md: Markdown, exec_value: str, expected: bool) -> None: 31 | """Assert the validator returns True or False given inputs. 32 | 33 | Parameters: 34 | md: A Markdown instance. 35 | exec_value: The exec option value, passed from the code block. 36 | expected: Expected validation result. 37 | """ 38 | assert validator("whatever", inputs={"exec": exec_value}, options={}, attrs={}, md=md) is expected 39 | --------------------------------------------------------------------------------