├── .gitattributes ├── .github ├── codecov.yml └── workflows │ ├── docs-preview.yml │ ├── docs-publish.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── demo.yml ├── docs │ ├── alternatives.md │ ├── api-reference │ │ ├── config.md │ │ ├── exceptions.md │ │ ├── formatting.md │ │ ├── parsing.md │ │ ├── reprexes.md │ │ └── session_info.md │ ├── configuration.md │ ├── css │ │ └── extra.css │ ├── design-philosophy.md │ ├── dos-and-donts.md │ ├── images │ │ ├── demo.gif │ │ ├── help-me-help-you.png │ │ ├── reprexlite.svg │ │ ├── reprexlite_white_blue.svg │ │ ├── reprexlite_white_transparent.svg │ │ └── vs-code-interactive-python.png │ ├── ipython-jupyter-magic.ipynb │ └── rendering-and-output-venues.md ├── main.py ├── mkdocs.yml └── overrides │ └── main.html ├── justfile ├── pyproject.toml ├── reprexlite ├── __init__.py ├── __main__.py ├── cli.py ├── config.py ├── exceptions.py ├── formatting.py ├── ipython.py ├── parsing.py ├── reprexes.py ├── session_info.py └── version.py ├── requirements-dev.txt ├── tests ├── __init__.py ├── assets │ ├── ad │ │ ├── ds.md │ │ ├── gh.md │ │ ├── html.html │ │ ├── py.py │ │ ├── rtf.rtf │ │ ├── slack.txt │ │ └── so.md │ ├── ds.md │ ├── gh.md │ ├── html.html │ ├── no_ad │ │ ├── ds.md │ │ ├── gh.md │ │ ├── html.html │ │ ├── py.py │ │ ├── rtf.rtf │ │ ├── slack.txt │ │ └── so.md │ ├── py.py │ ├── rtf.rtf │ ├── session_info │ │ ├── ds.md │ │ ├── gh.md │ │ ├── html.html │ │ ├── py.py │ │ ├── rtf.rtf │ │ ├── slack.txt │ │ └── so.md │ ├── slack.txt │ └── so.md ├── expected_formatted.py ├── test_cli.py ├── test_config.py ├── test_exceptions.py ├── test_formatting.py ├── test_ipython_editor.py ├── test_ipython_magics.py ├── test_parsing.py ├── test_reprexes.py ├── test_session_info.py └── utils.py └── uv.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Exclude test assets from GitHub language stats 2 | # https://github.com/github-linguist/linguist/blob/main/docs/overrides.md#using-gitattributes 3 | tests/assets/** linguist-generated 4 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 1 6 | round: down 7 | range: "70...100" 8 | status: 9 | project: # Coverage of whole project 10 | default: 11 | target: auto # Coverage target to pass; auto is base commit 12 | threshold: 5% # Allow coverage to drop by this much vs. base and still pass 13 | patch: # Coverage of lines in this change 14 | default: 15 | target: 80% # Coverage target to pass 16 | threshold: 20% # Allow coverage to drop by this much vs. base and still pass 17 | 18 | comment: 19 | layout: "diff,flags,tree" 20 | -------------------------------------------------------------------------------- /.github/workflows/docs-preview.yml: -------------------------------------------------------------------------------- 1 | name: docs-preview 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | name: Build docs and deploy preview 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: extractions/setup-just@v2 16 | 17 | - uses: astral-sh/setup-uv@v5 18 | 19 | - name: Build docs 20 | run: | 21 | just docs 22 | 23 | - name: Get preview identifier 24 | id: get_id 25 | run: | 26 | if [[ "${{ github.event_name }}" == "pull_request" ]]; then 27 | PREVIEW_IDENTIFIER=${{ github.event.number }} 28 | else 29 | PREVIEW_IDENTIFIER=$(git rev-parse --short HEAD) 30 | fi 31 | echo "PREVIEW_IDENTIFIER=$PREVIEW_IDENTIFIER" | tee -a $GITHUB_OUTPUT 32 | 33 | - name: Deploy preview to Netlify 34 | uses: nwtgck/actions-netlify@v2.0.0 35 | with: 36 | publish-dir: "./docs/site" 37 | production-deploy: false 38 | github-token: ${{ secrets.GITHUB_TOKEN }} 39 | deploy-message: "Deploy from GitHub Actions" 40 | enable-pull-request-comment: true 41 | enable-commit-comment: false 42 | overwrites-pull-request-comment: true 43 | alias: preview-${{ steps.get_id.outputs.PREVIEW_IDENTIFIER }} 44 | env: 45 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 46 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 47 | timeout-minutes: 1 48 | -------------------------------------------------------------------------------- /.github/workflows/docs-publish.yml: -------------------------------------------------------------------------------- 1 | name: docs-publish 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | inputs: 8 | deploy-as: 9 | type: choice 10 | description: Deploy as 11 | options: 12 | - latest 13 | - version 14 | default: latest 15 | is-stable: 16 | type: boolean 17 | description: Deploy as stable 18 | default: false 19 | version-format: 20 | type: string 21 | description: Version format 22 | default: "v{major_minor_version}" 23 | 24 | jobs: 25 | build: 26 | name: Build docs and publish 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Fail if deploying as latest but marked as stable 31 | if: github.event.inputs.deploy-as == 'latest' && github.event.inputs.is-stable == 'true' 32 | run: | 33 | echo "Error: Cannot deploy as 'latest' when 'is-stable' is true." 34 | exit 1 35 | 36 | - uses: actions/checkout@v4 37 | 38 | - uses: extractions/setup-just@v2 39 | 40 | - uses: astral-sh/setup-uv@v5 41 | 42 | - name: Run docs preprocessing 43 | run: | 44 | just _docs-preprocess 45 | 46 | - name: Set git user to github-actions[bot] 47 | run: | 48 | git config user.name github-actions[bot] 49 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 50 | 51 | - name: Fetch gh-pages branch 52 | run: | 53 | git fetch origin gh-pages --depth=1 54 | 55 | - name: Deploy as latest 56 | if: github.event.inputs.deploy-as == 'latest' || github.event_name == 'push' 57 | working-directory: docs/ 58 | run: | 59 | uv run mike deploy --push latest --title=latest 60 | 61 | - name: Get version tag 62 | id: get-version-tag 63 | if: github.event.inputs.deploy-as == 'version' 64 | run: | 65 | VERSION_TAG=$(uv run vspect read . "${{ github.event.inputs.version-format }}") 66 | echo "VERSION_TAG=${VERSION_TAG}" | tee -a $GITHUB_OUTPUT 67 | 68 | - name: Deploy as version 69 | if: github.event.inputs.deploy-as == 'version' 70 | working-directory: docs/ 71 | run: | 72 | TITLE=$(uv run vspect read .. "v{version}") 73 | TAG=${{ steps.get-version-tag.outputs.VERSION_TAG }} 74 | uv run mike deploy --push $TAG --title="${TITLE}" 75 | 76 | - name: Assign stable alias 77 | if: github.event.inputs.is-stable == 'true' 78 | working-directory: docs/ 79 | run: | 80 | TAG=${{ steps.get-version-tag.outputs.VERSION_TAG }} 81 | uv run mike alias --push --update-aliases $TAG stable 82 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | run-name: Release of ${{ inputs.version }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version: 8 | description: 'Version tag' 9 | required: true 10 | 11 | jobs: 12 | build: 13 | name: Publish new release 14 | runs-on: "ubuntu-latest" 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: astral-sh/setup-uv@v5 20 | 21 | - name: Check that versions match 22 | run: | 23 | echo "Input version tag: [${{ github.event.inputs.version }}] " 24 | PACKAGE_VERSION=$(uv run python -m vspect read .) 25 | echo "Package version: [$PACKAGE_VERSION]" 26 | [[ ${{ github.event.inputs.version }} == "v$PACKAGE_VERSION" ]] || { exit 1; } 27 | 28 | - name: Build package 29 | run: | 30 | uv build 31 | 32 | - name: Publish to Test PyPI 33 | uses: pypa/gh-action-pypi-publish@v1.3.0 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_TEST_TOKEN }} 37 | repository_url: https://test.pypi.org/legacy/ 38 | skip_existing: true 39 | 40 | - name: Publish to Production PyPI 41 | uses: pypa/gh-action-pypi-publish@v1.3.0 42 | with: 43 | user: __token__ 44 | password: ${{ secrets.PYPI_PROD_TOKEN }} 45 | skip_existing: false 46 | 47 | - id: extract-changelog 48 | uses: sean0x42/markdown-extract@v2.1.0 49 | with: 50 | file: CHANGELOG.md 51 | pattern: ${{ github.event.inputs.version }} 52 | 53 | - name: Write output to file 54 | run: | 55 | cat <<'__EOF__' > __CHANGELOG-extracted.md 56 | ${{ steps.extract-changelog.outputs.markdown }} 57 | __EOF__ 58 | 59 | - uses: ncipollo/release-action@v1 60 | with: 61 | tag: ${{ github.event.inputs.version }} 62 | commit: main 63 | artifacts: "dist/*.whl,dist/*.tar.gz" 64 | bodyFile: "__CHANGELOG-extracted.md" 65 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | schedule: 8 | # Run every Sunday 9 | - cron: "0 0 * * 0" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | code-quality: 14 | name: Code Quality 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: extractions/setup-just@v2 20 | 21 | - uses: astral-sh/setup-uv@v5 22 | 23 | - name: Lint package 24 | run: | 25 | uv run --group lint just lint 26 | 27 | - name: Typecheck package 28 | run: | 29 | uv run --group lint just typecheck 30 | 31 | tests: 32 | name: "Tests (${{ matrix.os }}, Python ${{ matrix.python-version }})" 33 | needs: code-quality 34 | runs-on: ${{ matrix.os }} 35 | strategy: 36 | matrix: 37 | os: [ubuntu-latest, macos-latest, windows-latest] 38 | python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - uses: actions/checkout@v4 44 | 45 | - uses: extractions/setup-just@v2 46 | 47 | - uses: astral-sh/setup-uv@v5 48 | 49 | - name: Run tests 50 | run: | 51 | just python=${{ matrix.python-version }} test 52 | 53 | - name: Upload coverage to codecov 54 | uses: codecov/codecov-action@v5 55 | with: 56 | files: ./coverage.xml 57 | fail_ci_if_error: ${{ (github.event_name == 'push' && true) || (github.event_name == 'pull_request' && true) || false }} 58 | token: ${{ secrets.CODECOV_TOKEN }} 59 | if: ${{ matrix.os == 'ubuntu-latest' }} 60 | 61 | notify: 62 | name: Notify failed build 63 | needs: [code-quality, tests] 64 | if: failure() && github.event.pull_request == null 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: jayqi/failed-build-issue-action@v1 68 | with: 69 | github-token: ${{ secrets.GITHUB_TOKEN }} 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # Docs 4 | docs/site 5 | docs/docs/changelog.md 6 | docs/docs/cli.md 7 | docs/docs/index.md 8 | 9 | ### Python 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | # .python-version 96 | 97 | # pipenv 98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 101 | # install all needed dependencies. 102 | #Pipfile.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## v1.0.0 (2025-02-15) 7 | 8 | This release involves major changes to reprexlite. There is a significant refactoring of the library internals and also many changes to the API. This enabled new feature and more customizability. 9 | 10 | _This release also removes support for Python 3.6, 3.7, and 3.8._ 11 | 12 | ### CLI and IPython User Interfaces 13 | 14 | #### Added 15 | 16 | - Added a new `--editor`/`-e` option to specify what editor to use. If not used, this has same behavior as before. This option is also the new way to launch the IPython interactive shell editor (by passing `ipython`). 17 | - Added new options to control parsing and output style. 18 | - `--prompt` and `--continuation` options let you set the primary and secondary prompt prefixes in rendered output. These default to empty srings `""` for "reprex-style" output. 19 | - A new `--parsing-method` option controls input-parsing behavior. 20 | - The default value `auto` can automatically handle "reprex-style" input as well as "doctest-style`/Python REPL input. 21 | - A value `declared` will use the values of `--prompt`, `--continuation`, and `--comment` for parsing input in addition to styling output. To handle input and output with different styes, you can override input-side values with the `--input-prompt`, `--input-continuation`, and `--input-comment` options. 22 | - Added support for configuration files, including support for `[tool.reprexlite]` in `pyproject.toml` files and for user-level configuration. See ["Configuration"](https://jayqi.github.io/reprexlite/stable/configuration/#configuration-files) for more details. 23 | 24 | #### Changed 25 | 26 | - Changed the way to access the IPython interactive shell editor. This is now launched by using the new `--editor`/`-e` option by passing `ipython`. The IPython shell editor also now respects other command line configuration options. It is now considered a stable feature and is no longer experimental. 27 | - Renamed the `--old-results` option to `--keep-old-results`. 28 | 29 | #### Fixed 30 | 31 | - Fixed bug that silenced output when using the IPython cell magic or the IPython shell editor and encountering an error where reprexlite couldn't render your code (such as a syntax error). This should now display an informative error message. 32 | 33 | ### Library 34 | 35 | #### Added 36 | 37 | - Added new `reprexlite.parsing` module which contains functions for parsing input. These functions yield tuples representing lines of the input with an enum indicating whether the line is code or a result. 38 | - Added new `reprexlite.reprexes` module which contains code for evaluating a reprex. 39 | - The new `Reprex` dataclass serves as the main container for reprex data. It holds parallel lists of `Statement`, `ParsedResult`, and `RawResult` data. 40 | - The `Reprex.from_input_lines` factory method creates a `Reprex` from the output of the `reprexlite.parsing` parsing functions. 41 | - The `Reprex.from_input` factory method wraps parsing and takes a string input. 42 | - The `Statement` dataclass holds code data and parsed concrete syntax tree. This serves a similar purpose to the old `Statement` class. 43 | - The `ParsedResult` dataclass holds old evaluation results parsed from the input, if any. 44 | - The `RawResult` dataclass holds the returned values from evaluating code. This serves a similar purpose to the old `Result` class. 45 | - Added new `reprexlite.config` module and `ReprexConfig` dataclass for holding configuration values. 46 | - Added new `reprexlite.exceptions` module with exception classes that subclass a base exception class `ReprexliteException`. 47 | 48 | #### Changed 49 | 50 | - Changed formatting abstractions in `reprexlite.formatting` module. 51 | - Rather than `*Reprex` classes that encapsulate reprex data, we now have formatter callables and take a rendered reprex output string as input and appropriately prepares the reprex output for a venue, such as adding venue-specific markup. 52 | - The `venues_dispatcher` dictionary in `reprexlite.formatting` is now a `formatter_registry` dictionary-like. 53 | - Formatters are added to the registry using a `formatter_registry.register` decorator instead of being hard-coded. 54 | 55 | #### Removed 56 | 57 | - Removed `reprexlite.code` module. The functionality in this module was reimplemented in the new `reprexlite.reprexes` and `reprexlite.parsing` modules. 58 | - Removed `reprexlite.reprex` module. The `reprex` function has been moved to `reprexlite.reprexes`. 59 | 60 | ### General 61 | 62 | #### Added 63 | 64 | - Added a "Rendering and Output Venues" page to the documentation that documents the different formatting options with examples. 65 | - Added a "Configuration" page to the documentation that provides a reference for configuration options and documents how to use configuration files. 66 | - Added an "Alternatives" page to the documentation that documents alternative tools. 67 | 68 | #### Changed 69 | 70 | - Changed reprexlite to use a pyproject.toml-based build process and metadata declaration. 71 | - Renamed `HISTORY.md` to `CHANGELOG.md`. 72 | 73 | ## v1.0.0a1 (2025-02-11) 74 | 75 | This is an early version of the 1.0.0 changes that has been available on the main branch of the repository since February 2023. It is being released as a pre-release version in case anyone wants to continue using it. Further significant changes are planned for the final 1.0.0 release. 76 | 77 | For the release notes for this version, see [here](https://github.com/jayqi/reprexlite/blob/v1.0.0a1/CHANGELOG.md#v100a1-2025-02-11). 78 | 79 | ## v0.5.0 (2020-02-20) 80 | 81 | - Added experimental IPython interactive editor which can be launched via command line with `reprex --ipython`. This modified IPython editor will run every cell automatically as a reprex. 82 | 83 | ## v0.4.3 (2021-11-05) 84 | 85 | - Added explicit setting of code evaluation namespace's `__name__` to `'__reprex__'`. Previously this was unset and would get inferred, and weird things like `'builtins'` would turn up. ([PR #44](https://github.com/jayqi/reprexlite/pull/44)) 86 | 87 | ## v0.4.2 (2021-02-28) 88 | 89 | - Added support for parsing code copied from an interactive Python shell (REPL) with `>>>` prompts. ([#29](https://github.com/jayqi/reprexlite/pull/29)) 90 | - Fixed issue where `tests` module was unintentionally included in distribution. ([#30](https://github.com/jayqi/reprexlite/pull/30)) 91 | - Fixed missing requirement `importlib_metadata` for Python 3.6 and 3.7. ([#31](https://github.com/jayqi/reprexlite/pull/31)) 92 | 93 | ## v0.4.1 (2021-02-27) 94 | 95 | - Added missing LICENSE file. 96 | 97 | ## v0.4.0 (2021-02-27) 98 | 99 | - Added optional IPython extension that enables `%%reprex` cell magic. See [documentation](https://jayqi.github.io/reprexlite/stable/ipython-jupyter-magic/) for usage. ([#21](https://github.com/jayqi/reprexlite/pull/21)) 100 | 101 | ## v0.3.1 (2021-02-26) 102 | 103 | - Documentation improvements. ([#14](https://github.com/jayqi/reprexlite/pull/14), [#19](https://github.com/jayqi/reprexlite/pull/19)) 104 | 105 | ## v0.3.0 (2021-02-25) 106 | 107 | - Changed pygments styling to use the "friendly" color scheme, which looks better for dark backgrounds. ([#15](https://github.com/jayqi/reprexlite/pull/15)) 108 | - Changed submodule organization for code related to reprex formatting. This is now in the `formatting` submodule. ([#17](https://github.com/jayqi/reprexlite/pull/17)) 109 | 110 | ## v0.2.0 (2021-02-20) 111 | 112 | - Removing old results from inputs: ([#8](https://github.com/jayqi/reprexlite/pull/8)) 113 | - Changed reprexes to—by default—remove lines matching the `comment` prefix (`#>` by default). This means that if your input code is a previously rendered reprex, the old results will be removed first and you effectively regenerate it. 114 | - Added a new option `old_results` that—if set to True—will preserve such lines. 115 | - Fixed a bug that caused intentional blank lines to be removed. ([#7](https://github.com/jayqi/reprexlite/pull/7)) 116 | - Added stdout capturing. Any content printed to stdout will be shown as a result in the reprex. ([#10](https://github.com/jayqi/reprexlite/pull/10)) 117 | - Added exception handling and stacktrace capture. If the input code has an exception, the stacktrace will be shown as a result in the reprex. ([#12](https://github.com/jayqi/reprexlite/pull/12)) 118 | 119 | ## v0.1.0 (2021-02-15) 120 | 121 | Initial release! 🎉 122 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to reprexlite 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jay Qi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reprexlite: Python reproducible examples for sharing 2 | 3 | [![Docs Status](https://img.shields.io/badge/docs-stable-informational)](https://jayqi.github.io/reprexlite/stable/) 4 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/reprexlite)](https://pypi.org/project/reprexlite/) 5 | [![PyPI Version](https://img.shields.io/pypi/v/reprexlite.svg)](https://pypi.org/project/reprexlite/) 6 | [![conda-forge Version](https://img.shields.io/conda/vn/conda-forge/reprexlite.svg)](https://anaconda.org/conda-forge/reprexlite) 7 | [![conda-forge feedstock](https://img.shields.io/badge/conda--forge-feedstock-yellowgreen)](https://github.com/conda-forge/reprexlite-feedstock) 8 | [![tests](https://github.com/jayqi/reprexlite/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/jayqi/reprexlite/actions/workflows/tests.yml?query=workflow%3Atests+branch%3Amain) 9 | [![codecov](https://codecov.io/gh/jayqi/reprexlite/branch/main/graph/badge.svg)](https://codecov.io/gh/jayqi/reprexlite) 10 | 11 | **reprexlite** is a tool for rendering **repr**oducible **ex**amples of Python code for sharing. With a [convenient CLI](#command-line-interface) and lightweight dependencies, you can quickly get it up and running in any virtual environment. It has an optional [integration with IPython](#ipython-integrations) for easy use with IPython or in Jupyter or VS Code. This project is inspired by R's [reprex](https://reprex.tidyverse.org/) package. 12 | 13 | 14 | 15 | ### What it does 16 | 17 | - Paste or type some Python code that you're interested in sharing. 18 | - reprexlite will execute that code in an isolated namespace. Any returned values or standard output will be captured and displayed as comments below their associated code. 19 | - The rendered reprex will be printed for you to share. Its format can be easily copied, pasted, and run as-is by someone else. Here's an example of an outputted reprex: 20 | 21 | ```python 22 | from itertools import product 23 | 24 | grid = list(product([1, 2, 3], [8, 16])) 25 | grid 26 | #> [(1, 8), (1, 16), (2, 8), (2, 16), (3, 8), (3, 16)] 27 | list(zip(*grid)) 28 | #> [(1, 1, 2, 2, 3, 3), (8, 16, 8, 16, 8, 16)] 29 | ``` 30 | 31 | Writing a good reprex takes thought and effort (see ["Reprex Do's and Don'ts"](https://jayqi.github.io/reprexlite/stable/dos-and-donts/) for tips). The goal of reprexlite is to be a tool that seamlessly handles the mechanical stuff, so you can devote your full attention to the important, creative work of writing the content. 32 | 33 | Reprex-style code formatting—namely, with outputs as comments—is also great for documentation. Users can copy and run with no modification. Consider using reprexlite when writing your documentation instead of copying code with `>>>` prompts from an interactive Python shell. In fact, reprexlite can parse code with `>>>` prompts and convert it into a reprex for you instead. 34 | 35 | reprexlite is a lightweight alternative to [reprexpy](https://github.com/crew102/reprexpy) and is similarly meant as a port of the R package [reprex](https://github.com/tidyverse/reprex). 36 | 37 | ### Why reproducible examples? 38 | 39 | If you're asking for help or reporting a bug, you are more likely to succeed in getting others to help you if you include a good reprex. If you're writing documentation, your readers will appreciate examples that they can easily run. See ["Design Philosophy"](https://jayqi.github.io/reprexlite/stable/design-philosophy/) for more on both "Why reproducible examples?" and "Why reprexlite?" 40 | 41 | ## Installation 42 | 43 | reprexlite is available on PyPI: 44 | 45 | ```bash 46 | pip install reprexlite 47 | ``` 48 | 49 | Optional dependencies can be specified using the ["extras" mechanism](https://packaging.python.org/tutorials/installing-packages/#installing-setuptools-extras), e.g., `reprexlite[ipython]`. Available extras are: 50 | 51 | - `black` : for optionally autoformatting your code 52 | - `ipython` : to use the IPython interactive shell editor or `%%reprex` IPython cell magic 53 | - `pygments` : for syntax highlighting and rendering the output as RTF 54 | 55 | ### Development version 56 | 57 | The development version of reprexlite is available on GitHub: 58 | 59 | ```bash 60 | pip install https://github.com/jayqi/reprexlite.git#egg=reprexlite 61 | ``` 62 | 63 | ## Basic usage 64 | 65 | ### Command-line interface 66 | 67 | The easiest way to use reprexlite is through the CLI. It allows you to create a reprex without entering a Python session. Simply invoke the command: 68 | 69 | ```bash 70 | reprex 71 | ``` 72 | 73 | This will take you into your system's default command-line text editor where you can type or paste your Python code. On macOS, for example, this will be `vim`. You can set your default editor using the [`$VISUAL` or `$EDITOR` environment variables](https://unix.stackexchange.com/a/73486)—I'm personally a fan of `nano`/`pico`. 74 | 75 | Once you're done, reprexlite will print out your reprex to console. 76 | 77 | To see available options, use the `--help` flag. 78 | 79 | ### IPython integrations 80 | 81 | There are two kinds of IPython integration: 82 | 83 | 1. [IPython interactive shell editor](#ipython-interactive-shell-editor), which opens up a special IPython session where all cells are run through reprexlite 84 | 2. [Cell magics](#ipythonjupyter-cell-magic), which let you designate individual cells in a normal IPython or Jupyter notebook for being run through reprexlite 85 | 86 | ### IPython interactive shell editor 87 | 88 | _Requires IPython._ `[ipython]` 89 | 90 | reprexlite optionally supports an IPython interactive shell editor. This is basically like a normal IPython interactive shell except that all cells are piped through reprexlite for rendering instead of the normal cell execution. It has the typical advantages of using IPython like auto-suggestions, history scrolling, and syntax highlighting. You can start the IPython editor by using the `--editor`/`-e` option: 91 | 92 | ```bash 93 | reprex -e ipython 94 | ``` 95 | 96 | If you need to configure anything, use the other CLI options alongside the editor option when launching the shell. 97 | 98 | Compared to using the IPython cell magic (next section), you don't need to load the reprexlite extension or write out the `%%reprex` cell magic every use. 99 | 100 | ### IPython/Jupyter Cell Magic 101 | 102 | _Requires IPython._ `[ipython]` 103 | 104 | reprexlite also has an optional IPython extension with a `%%reprex` cell magic. That means you can easily create a reprex in an [IPython shell](https://ipython.readthedocs.io/en/stable/) (requires IPython), in [Jupyter](https://jupyter.org/) (requires Jupyter), or in [VS Code's Interactive Python window](https://code.visualstudio.com/docs/python/jupyter-support-py) (requires ipykernel). This can be handy if you're already working in a Jupyter notebook and want to share some code and output, which otherwise doesn't neatly copy and paste in a nice format. 105 | 106 | To use, simply load the extension with 107 | 108 | ```python 109 | %load_ext reprexlite 110 | ``` 111 | 112 | and then put `%%reprex` at the top of a cell you want to create a reprex for: 113 | 114 | ```python 115 | %%reprex 116 | from itertools import product 117 | 118 | grid = list(product([1, 2, 3], [8, 16])) 119 | grid 120 | list(zip(*grid)) 121 | ``` 122 | 123 | The magic accepts the same inline option flags as the CLI. Use the line magic `%reprex` (note single `%`) to print out help. See the [documentation](https://jayqi.github.io/reprexlite/stable/ipython-jupyter-magic/) for more details. 124 | 125 | 126 | ### Python library 127 | 128 | The same functionality as the CLI is also available from the `reprex` function with an equivalent API. Simply pass a string with your code, and it will print out the reprex, as well as return a `Reprex` object that contains all the data and formatting machinery. See the [API documentation](https://jayqi.github.io/reprexlite/stable/api-reference/reprex/) for more details. 129 | 130 | ```python 131 | from reprexlite import reprex 132 | 133 | code = """ 134 | from itertools import product 135 | 136 | grid = list(product([1, 2, 3], [8, 16])) 137 | grid 138 | list(zip(*grid)) 139 | """ 140 | 141 | reprex(code) 142 | #> ```python 143 | #> from itertools import product 144 | #> 145 | #> grid = list(product([1, 2, 3], [8, 16])) 146 | #> grid 147 | #> #> [(1, 8), (1, 16), (2, 8), (2, 16), (3, 8), (3, 16)] 148 | #> list(zip(*grid)) 149 | #> #> [(1, 1, 2, 2, 3, 3), (8, 16, 8, 16, 8, 16)] 150 | #> ``` 151 | #> 152 | #> Created at 2021-02-26 00:32:00 PST by [reprexlite](https://github.com/jayqi/reprexlite) v0.3.0 153 | #> 154 | ``` 155 | -------------------------------------------------------------------------------- /docs/demo.yml: -------------------------------------------------------------------------------- 1 | # The configurations that used for the recording, feel free to edit them 2 | config: 3 | # Specify a command to be executed 4 | # like `/bin/bash -l`, `ls`, or any other commands 5 | # the default is bash for Linux 6 | # or powershell.exe for Windows 7 | # command: bash -l 8 | 9 | # Specify the current working directory path 10 | # the default is the current working directory path 11 | cwd: /Users/jqi 12 | 13 | # Export additional ENV variables 14 | env: 15 | recording: true 16 | 17 | # Explicitly set the number of columns 18 | # or use `auto` to take the current 19 | # number of columns of your shell 20 | cols: 80 21 | 22 | # Explicitly set the number of rows 23 | # or use `auto` to take the current 24 | # number of rows of your shell 25 | rows: 18 26 | 27 | # Amount of times to repeat GIF 28 | # If value is -1, play once 29 | # If value is 0, loop indefinitely 30 | # If value is a positive number, loop n times 31 | repeat: 0 32 | 33 | # Quality 34 | # 1 - 100 35 | quality: 100 36 | 37 | # Delay between frames in ms 38 | # If the value is `auto` use the actual recording delays 39 | frameDelay: auto 40 | 41 | # Maximum delay between frames in ms 42 | # Ignored if the `frameDelay` isn't set to `auto` 43 | # Set to `auto` to prevent limiting the max idle time 44 | maxIdleTime: auto #2000 45 | 46 | # The surrounding frame box 47 | # The `type` can be null, window, floating, or solid` 48 | # To hide the title use the value null 49 | # Don't forget to add a backgroundColor style with a null as type 50 | frameBox: 51 | type: floating 52 | title: Terminalizer 53 | style: 54 | border: 0px black solid 55 | boxShadow: none 56 | margin: 0px 57 | 58 | # Add a watermark image to the rendered gif 59 | # You need to specify an absolute path for 60 | # the image on your machine or a URL, and you can also 61 | # add your own CSS styles 62 | watermark: 63 | imagePath: null 64 | style: 65 | position: absolute 66 | right: 15px 67 | bottom: 15px 68 | width: 100px 69 | opacity: 0.9 70 | 71 | # Cursor style can be one of 72 | # `block`, `underline`, or `bar` 73 | cursorStyle: block 74 | 75 | # Font family 76 | # You can use any font that is installed on your machine 77 | # in CSS-like syntax 78 | fontFamily: "JetBrains Mono Slashed, Monaco, Lucida Console, Ubuntu Mono, Monospace" 79 | 80 | # The size of the font 81 | fontSize: 13 82 | 83 | # The height of lines 84 | lineHeight: 1.2 85 | 86 | # The spacing between letters 87 | letterSpacing: 0 88 | 89 | # Theme 90 | theme: 91 | background: "transparent" 92 | foreground: "#cbcccd" # "#afafaf" 93 | cursor: "#c7c7c7" 94 | black: "#232628" 95 | red: "#fc4384" 96 | green: "#b3e33b" 97 | yellow: "#ffa727" 98 | blue: "#75dff2" 99 | magenta: "#ae89fe" 100 | cyan: "#708387" 101 | white: "#d5d5d0" 102 | brightBlack: "#626566" 103 | brightRed: "#ff7fac" 104 | brightGreen: "#c8ed71" 105 | brightYellow: "#ebdf86" 106 | brightBlue: "#75dff2" 107 | brightMagenta: "#ae89fe" 108 | brightCyan: "#b1c6ca" 109 | brightWhite: "#f9f9f4" 110 | 111 | # Records, feel free to edit them 112 | records: 113 | - delay: 0 114 | content: "\e[1;36m~\e[0m via \e[1;33m\U0001F40D \e[0m\e[1;33m\e[0m\e[1;33mv3.9.0\e[0m\e[1;33m (\e[0m\e[1;33mdemo\e[0m\e[1;33m)\e[0m \r\nat \e[1;2;33m20:08:24\e[0m \e[1;32m❯\e[0m " 115 | - delay: 2000 116 | content: r 117 | - delay: 100 118 | content: e 119 | - delay: 50 120 | content: p 121 | - delay: 60 122 | content: r 123 | - delay: 80 124 | content: e 125 | - delay: 200 126 | content: x 127 | - delay: 2000 128 | content: "\r\n" 129 | - delay: 400 130 | content: "\e[?1049h\e[1;25r\e(B\e[m\e[4l\e[?7h\e[?12l\e[?25h\e[?1h\e=\e[?1h\e=\e[?1h\e=\e[39;49m\e[39;49m\e(B\e[m\e[H\e[2J\e(B\e[30;47m GNU nano 2.0.6 File: ...jgb0q09rh5l706c0000gn/T/editor-_yq3ds0e.txt \e[16;33H[ Read 0 lines ]\r\e[17d^G\e(B\e[m Get Help \e(B\e[30;47m^O\e(B\e[m WriteOut \e(B\e[30;47m^R\e(B\e[m Read File \e(B\e[30;47m^Y\e(B\e[m Prev Page \e(B\e[30;47m^K\e(B\e[m Cut Text \e(B\e[30;47m^C\e(B\e[m Cur Pos\r\e[18d\e(B\e[30;47m^X\e(B\e[m Exit\e[14G\e(B\e[30;47m^J\e(B\e[m Justify \e(B\e[30;47m^W\e(B\e[m Where Is \e(B\e[30;47m^V\e(B\e[m Next Page \e(B\e[30;47m^U\e(B\e[m UnCut Text\e(B\e[30;47m^T\e(B\e[m To Spell\r\e[3d" 131 | - delay: 2000 132 | content: "\e[1;71H\e(B\e[30;47mModified\r\e[3d\e(B\e[mfrom itertools import product\r\e[5dgrid = list(product([1, 2, 3], [8, 16]))\r\e[6dgrid\r\e[7dlist(zip(*grid))\r\e[16d\e[K\e[8d" 133 | - delay: 3000 134 | content: "\e[16d\e(B\e[30;47mSave modified buffer (ANSWERING \"No\" WILL DESTROY CHANGES) ? \e[17;1H Y\e(B\e[m Yes\e[K\r\e[18d\e(B\e[30;47m N\e(B\e[m No \e[14G \e(B\e[30;47m^C\e(B\e[m Cancel\e[K\e[16;62H" 135 | - delay: 1500 136 | content: "\r\e(B\e[30;47mFile Name to Write:$ds0e.txt \r\e[17d^G\e(B\e[m Get Help\e[17;21H\e(B\e[30;47m^T\e(B\e[m To Files\e[17;41H\e(B\e[30;47mM-M\e(B\e[m Mac Format\e[61G\e(B\e[30;47mM-P\e(B\e[m Prepend\r\e[18d\e(B\e[30;47m^C\e(B\e[m Cancel\e[17G \e(B\e[30;47mM-D\e(B\e[m DOS Format\e[41G\e(B\e[30;47mM-A\e(B\e[m Append\e[18;61H\e(B\e[30;47mM-B\e(B\e[m Backup File\e[16;29H" 137 | - delay: 1500 138 | content: "\r\e[17d\e[39;49m\e(B\e[m\e[J\e[1;71H\e(B\e[30;47m \e[16;31H\e(B\e[m\e[1K \e(B\e[30;47m[ Wrote 6 lines ]\e(B\e[m\e[K\e[18;80H\e[18;1H\e[?1049l\r\e[?1l\e>" 139 | - delay: 50 140 | content: "```python\r\nfrom itertools import product\r\n\r\ngrid = list(product([1, 2, 3], [8, 16]))\r\ngrid\r\n#> [(1, 8), (1, 16), (2, 8), (2, 16), (3, 8), (3, 16)]\r\nlist(zip(*grid))\r\n#> [(1, 1, 2, 2, 3, 3), (8, 16, 8, 16, 8, 16)]\r\n```\r\n\r\nCreated at 2021-02-25 20:08:32 PST by [reprexlite](https://github.com/jayqi/reprexlite) v0.3.0\r\n\r\n" 141 | - delay: 50 142 | content: "\r\n\e[1;36m~\e[0m via \e[1;33m\U0001F40D \e[0m\e[1;33m\e[0m\e[1;33mv3.9.0\e[0m\e[1;33m (\e[0m\e[1;33mdemo\e[0m\e[1;33m)\e[0m took \e[1;33m6s\e[0m \r\nat \e[1;2;33m20:08:32\e[0m \e[1;32m❯\e[0m " 143 | - delay: 8000 144 | content: " " 145 | -------------------------------------------------------------------------------- /docs/docs/alternatives.md: -------------------------------------------------------------------------------- 1 | # Alternatives 2 | 3 | ## Python Alternatives 4 | 5 | ### reprexpy [:fontawesome-brands-github:{.header-link-icon}](https://github.com/crew102/reprexpy) [:fontawesome-solid-book:{.header-link-icon}](https://reprexpy.readthedocs.io/) 6 | 7 | A more faithful port of the user experience of R's reprex package. This tool supports reading input from your clipboard and writing the output back to your clipboard. It also supports matplotlib plots by automatically uploading image files for the plot to imgur. It works by running your code in Jupyter notebooks behind the scenes. 8 | 9 | ## R Alternatives 10 | 11 | ### reprex [:fontawesome-brands-github:{.header-link-icon}](https://github.com/tidyverse/reprex) [:fontawesome-solid-book:{.header-link-icon}](https://reprex.tidyverse.org/) 12 | 13 | The original reprex tool created and maintained by developers from RStudio/Posit and the tidyverse community. 14 | -------------------------------------------------------------------------------- /docs/docs/api-reference/config.md: -------------------------------------------------------------------------------- 1 | # reprexlite.config 2 | 3 | ::: reprexlite.config 4 | -------------------------------------------------------------------------------- /docs/docs/api-reference/exceptions.md: -------------------------------------------------------------------------------- 1 | # reprexlite.exceptions 2 | 3 | ::: reprexlite.exceptions 4 | -------------------------------------------------------------------------------- /docs/docs/api-reference/formatting.md: -------------------------------------------------------------------------------- 1 | # reprexlite.formatting 2 | 3 | ::: reprexlite.formatting 4 | -------------------------------------------------------------------------------- /docs/docs/api-reference/parsing.md: -------------------------------------------------------------------------------- 1 | # reprexlite.parsing 2 | 3 | ::: reprexlite.parsing 4 | -------------------------------------------------------------------------------- /docs/docs/api-reference/reprexes.md: -------------------------------------------------------------------------------- 1 | # reprexlite.reprexes 2 | 3 | ::: reprexlite.reprexes 4 | -------------------------------------------------------------------------------- /docs/docs/api-reference/session_info.md: -------------------------------------------------------------------------------- 1 | # reprexlite.session_info 2 | 3 | ::: reprexlite.session_info 4 | -------------------------------------------------------------------------------- /docs/docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | reprexlite has the following configuration options. 4 | 5 | > [!NOTE] 6 | > Command-line option names for these configuration variables use hyphens instead of underscores. 7 | 8 | {{ create_config_help_table() }} 9 | 10 | ## Configuration files 11 | 12 | reprexlite supports reading default configuration values from configuration files. Both project-level files and user-level files are supported. 13 | 14 | ### `pyproject.toml` 15 | 16 | reprexlite will search the nearest `pyproject.toml` file in the current working directory and any parent directory. 17 | Configuration for reprexlite should be in the `[tool.reprexlite]` table following standard `pyproject.toml` specifications. For example: 18 | 19 | ```toml 20 | [tool.reprexlite] 21 | editor = "some_editor" 22 | ``` 23 | 24 | ### `reprexlite.toml` or `.reprexlite.toml` 25 | 26 | reprexlite also supports files named `reprexlite.toml` or `.reprexlite.toml` for project-level configuration. It will also search for these in the current working directory or any parent directory. 27 | 28 | For reprexlite-specific files, all configuration options should be declared in the root namespace. 29 | 30 | ```toml 31 | editor = "some_editor" 32 | ``` 33 | 34 | ### User-level configuration 35 | 36 | reprexlite supports searching standard platform-specific user configuration directories as determined by [platformdirs](https://github.com/tox-dev/platformdirs). Here are typical locations depending on platform: 37 | 38 | | Platform | Path | 39 | |----------|------------------------------------------------------------| 40 | | Linux | `~/.config/reprexlite/config.toml` | 41 | | MacOS | `~/Library/Application Support/reprexlite/config.toml` | 42 | | Windows | `C:\Users\\AppData\Local\reprexlite\config.toml` | 43 | 44 | You can check where your user configuration would be with 45 | 46 | ```bash 47 | python -m platformdirs 48 | ``` 49 | 50 | Look for the section `-- app dirs (without optional 'version')` for the value of `user_config_dir`. The value for `MyApp` is `reprexlite`. The configuration file should be named `config.toml` inside that directory. 51 | -------------------------------------------------------------------------------- /docs/docs/css/extra.css: -------------------------------------------------------------------------------- 1 | span.header-link-icon { 2 | vertical-align: baseline !important; 3 | } 4 | 5 | .header-link-icon>svg { 6 | height: 61.8%; 7 | } 8 | 9 | /* don't wrap table cell */ 10 | 11 | th.no-wrap, 12 | td.no-wrap { 13 | white-space: nowrap; 14 | } 15 | -------------------------------------------------------------------------------- /docs/docs/design-philosophy.md: -------------------------------------------------------------------------------- 1 | # Design Philosophy 2 | 3 | Reprex is a portmanteau for **repr**oducible **ex**ample. This term and its associated approach to minimal reproducible examples have been popularized within the R data science community, driven by the adoption of the [reprex](https://reprex.tidyverse.org/index.html) tidyverse package. 4 | 5 | > The reprex code: 6 | > 7 | > - Must run and, therefore, should be run by **the person posting**. No faking it. 8 | > - Should be easy for others to digest, so **they don’t necessarily have to run it**. You are encouraged to include selected bits of output. 9 | > - Should be easy for others to copy + paste + run, **if and only if they so choose**. Don’t let inclusion of output break executability. 10 | > 11 | >

"Package Philosophy," from the R reprex documentation

12 | 13 | Writing a good reprex takes thought and effort (see ["Reprex Do's and Don'ts"](../dos-and-donts) for tips). The goal of reprexlite is to be a tool for Python that handles the mechanical stuff—running your code, capturing the output, formatting everything—so you don't have to worry about it, and you can devote your full attention to the important, creative work of writing the content. The action of running your code and seeing the outputs can also be a helpful forcing function in really making sure your example works and produces what you intend. 14 | 15 | ## Why reproducible examples? 16 | 17 |

From the R reprex readme.

18 | 19 | ### If you're looking for help with a problem or bug... 20 | 21 | ...you are more likely to succeed if you make it as easy as possible for others to help you. You are asking people to do work on your behalf. Remember: most open-source software maintainers and StackOverflow posters are volunteers and are not obligated to help you. Even someone who is obligated to help you would still be able to get to an answer more quickly if you make it easier for them to understand your problem. 22 | 23 | Plus, the exercise of writing the reprex might even help you figure out how to solve the problem yourself. This is basically the principle of [rubber duck debugging](https://rubberduckdebugging.com/). 24 | 25 | ### If you're writing a tutorial or documentation... 26 | 27 | ...actual working examples are valuable to your users. A reprex—with complete directly runnable code and showing the expected outputs—will show your audience what you're demonstrating, and also give them the option to easily try for themselves. Doing is often the most effective way of learning how to do something in code. And, with documentation especially, people often just want to arrive at working code for their use case as quickly as possible. Something that they can just copy and run is exactly what they're looking for. 28 | 29 | ## Reprex vs. Copying from shell (doctest-style) 30 | 31 | A widely used approach for Python code examples is copying from an interactive Python shell. It is easily recognized from the `>>>` prompt before each code statement. Such a code example is sometimes called a "doctest" because the [`doctest` module](https://docs.python.org/3/library/doctest.html) from the Python standard library is able to parse it. 32 | 33 | ```python 34 | >>> import math 35 | >>> math.sqrt(4) 36 | 2.0 37 | ``` 38 | 39 | This style of code example takes no special tools to generate: simply open a `python` shell from command line, write your code, and copy what you see. Many Python packages use it for their documentation, e.g., [requests](https://requests.readthedocs.io/en/master/). There is also tooling for parsing it. The doctest module can run such examples in the docstrings of your scripts, and test that the output matches what is written. Other tools like [Sphinx](https://www.sphinx-doc.org/en/1.4.9/markup/code.html) are able to parse it when rendering your documentation into other formats. 40 | 41 | The drawback of doctest-style examples is that they are _not_ valid Python syntax, so you can't always just copy, paste, and run such examples. The `>>>` prompt is not valid. While IPython's interactive shell and Jupyter notebooks _do_ support executing code with the prompt, it won't work in a regular Python REPL or in Python scripts. Furthermore, since the outputs might be anything, they may not be valid Python syntax either, depending on their `repr`. A barebones class, for example, will look like `<__main__.MyClass object at 0x7f932a001400>` and is not valid. 42 | 43 | So, while no special tools were needed to _generate_ a doctest-style example, either special tools or manual editing are needed to _run_ it. This puts the burden on the person you're sharing with, which is counterproductive. As discussed in the previous section, we want reproducible examples to make it _easier_ for others to run your code. 44 | 45 | In contrast, reprexes _are_ valid Python code. Anyone can copy, paste, and run a reprex without any special tools or manual editing required. 46 | 47 | ```python 48 | import math 49 | math.sqrt(4) 50 | #> 2.0 51 | ``` 52 | 53 | If this has convinced you, you can take advantage of reprexlite's ability to parse doctest-style code and easily convert those examples to reprexes instead. 54 | 55 | ## reprexlite's Design 56 | 57 | The primary design goal of reprexlite is that it should be **quick and convenient** to use. That objective drove the emphasis on following the design characteristics: 58 | 59 | - **Lightweight**. reprexlite needs to be in your virtual environment to be able to run your code. By having minimal and lightweight dependencies itself, reprexlite is quick to install and is unlikely to conflict with your other dependencies. Any advanced functionality that require heavier dependencies are optional. 60 | - **Easy access**. reprexlite comes with a CLI, so you can quickly create a reprex without needing to start a Python shell or to import anything. 61 | - **And flexible**. The CLI isn't the only option. The [Python library](../api-reference/reprex/) provides programmatic access, and there is an optional [IPython/Jupyter extension](../ipython-jupyter-magic/) for use with a cell magic. 62 | 63 | The API, including the available configuration and naming of parameters, mostly matches both [R reprex](https://reprex.tidyverse.org/) and [reprexpy](https://github.com/crew102/reprexpy). The intent is that anyone familiar with these other tools can quickly feel comfortable with reprexlite. 64 | 65 | As a secondary objective, the reprexlite library is designed so that its underlying functionality is accessible and extensible. It has a modular object-oriented design based on composition. Individual parts, like the code parsing or the output formatting, can be used independently, extended, or replaced. Additionally, the library is thoroughly type-annotated and documented. 66 | 67 | ## Limitations 68 | 69 | Compared to [R reprex](https://reprex.tidyverse.org/) and [reprexpy](https://github.com/crew102/reprexpy), reprexlite does trade off some capabilities in favor of our design objective. Known limitations include: 70 | 71 | - **No clipboard integration.** The two main Python clipboard libraries, [pyperclip](https://github.com/asweigart/pyperclip) and [xerox](https://github.com/adityarathod/xerox), have non-Python dependencies on some OSes that make them sometimes difficult to install. However, command-line editor support built-in to the CLI is nearly as easy as reading from clipboard, and has the added benefit that you can see the code before it gets executed. 72 | - **No plot image support.** Both R reprex and reprexpy support automatically uploading plots to imgur.com and injecting the link into your outputted reprex. This always seemed to me like a weird default as it could lead to inadvertent leaking of private data. 73 | - **Code is not run in a subprocess, so it's not perfectly isolated.** reprexlite runs the code using `eval` and `exec` with a fresh namespace, but otherwise still executes code within the main Python process. That means, for example, modules that are stateful or have been monkeypatched could potentially leak that state into the reprex. 74 | 75 | By not supporting the first two functionalities, reprexlite has significantly fewer and simpler dependencies. Both of these features, while convenient, could lead to unintentional code execution or leaking data to the public web. From that perspective, I believe this is a worthwhile tradeoff. 76 | 77 | The third limitation is one where feedback is welcome. Hopefully it will only matter in unusual edge cases. If you have ideas for mitigation for the current `eval`-`exec` implementation, please [open an issue on GitHub](https://github.com/jayqi/reprexlite/issues). A subprocess-based implementation would solve this, but would be more difficult to capture output from—any implementation ideas for this approach are also welcome. 78 | -------------------------------------------------------------------------------- /docs/docs/dos-and-donts.md: -------------------------------------------------------------------------------- 1 | # Reprex Do's and Don'ts 2 | 3 | This article discusses how to write an effective reprex. If you're asking for help or sharing code with someone, you will be much more likely to succeed if you have a good reprex. If you still need to be convinced why you should write a reprex or use reprexlite, check out the first half of ["Design Philosophy"](../design-philosophy/). 4 | 5 | Writing a good reprex takes thought and effort. A tool like reprexlite is not a magic bullet—it's meant to take care of the mechanical stuff so **you** can devote your energy towards coming up with the right content. 6 | 7 | Many of the key ideas in this article are borrowed from R reprex's ["Reprex do's and don'ts"](https://reprex.tidyverse.org/articles/reprex-dos-and-donts.html) and StackOverflow's ["How to create a Minimal, Reproducible Example"](https://stackoverflow.com/help/minimal-reproducible-example). 8 | 9 | ## Your reprexes should be... 10 | 11 | ### Minimal 12 | 13 | - **Do** use the smallest, simplest data possible. 14 | - If you need a dataframe, the [`sklearn.datasets` module](https://scikit-learn.org/stable/datasets.html#datasets) has convenient toy datasets like `iris`. 15 | - The [Faker](https://faker.readthedocs.io/en/master/) library has utilities to help you generate fake data like names and addresses. 16 | - **Don't** include code unrelated to the specific thing you want to demonstrate. 17 | - **Do** ruthlessly remove unnecessary code. If you're not sure, try removing things bit by bit until it doesn't produce what you want anymore. 18 | - Consider starting your reprex from scratch. This helps force you to add in only what is needed. 19 | 20 | 21 | ### Readable 22 | 23 | - **Do** follow [code style best practices](https://www.python.org/dev/peps/pep-0008/). 24 | - Consider using the `style` option which will use [black](https://github.com/psf/black) to autoformat your code. 25 | - **Don't** sacrifice clarity for brevity. 26 | - **Don't** play [code golf](https://en.wikipedia.org/wiki/Code_golf). For loops and if-else blocks can often be more readable. 27 | - **Do** use clear, descriptive, and idiomatic naming conventions. 28 | 29 | 30 | ### Reproducible 31 | 32 | - **Do** include everything required to produce your example, including imports and custom class/function definitions. If you're using reprexlite, your code won't work without this anyways. 33 | - **Do** detail what versions of relevant package, Python, and OS you are using. 34 | - Consider using the `session_info` option, which will include information about your Python, OS, and installed packages at the end of your reprex. 35 | - **Do** double-check that your example reproduces the thing you want to show. One can often inadvertently solve a problem they were trying to debug when writing an example. 36 | - **Don't** hardcode paths that wouldn't exist on someone else's computer, especially absolute paths. 37 | 38 | ### Respectful of other people's computers 39 | 40 | - **Do** clean up after yourself if you create files. Take advantage of Python's [`tempfile` module](https://docs.python.org/3/library/tempfile.html) for creating temporary files and directories. 41 | - **Don't** delete files that you didn't create. 42 | 43 | ## This seems like a lot of work! 44 | 45 | > Yes, creating a great reprex requires work. You are asking other people to do work too. It’s a partnership. 46 | > 47 | > 80% of the time you will solve your own problem in the course of writing an excellent reprex. YMMV. 48 | > 49 | > The remaining 20% of the time, you will create a reprex that is more likely to elicit the desired behavior in others. 50 | > 51 | >

"Reprex do's and don'ts," from the R reprex documentation

52 | -------------------------------------------------------------------------------- /docs/docs/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/docs/docs/images/demo.gif -------------------------------------------------------------------------------- /docs/docs/images/help-me-help-you.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/docs/docs/images/help-me-help-you.png -------------------------------------------------------------------------------- /docs/docs/images/reprexlite.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 45 | 50 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 63 | 68 | #> 79 | 80 | 81 | -------------------------------------------------------------------------------- /docs/docs/images/reprexlite_white_blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 45 | 50 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 63 | 68 | 74 | #> 85 | 86 | 87 | -------------------------------------------------------------------------------- /docs/docs/images/reprexlite_white_transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 45 | 50 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 63 | 68 | #> 79 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /docs/docs/images/vs-code-interactive-python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/docs/docs/images/vs-code-interactive-python.png -------------------------------------------------------------------------------- /docs/docs/ipython-jupyter-magic.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "spoken-simulation", 6 | "metadata": {}, 7 | "source": [ 8 | "# IPython/Jupyter Magic" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "id": "coated-touch", 14 | "metadata": {}, 15 | "source": [ 16 | "Reprex-rendering is also available in IPython, Jupyter, and VS Code through an IPython cell magic. This functionality requires IPython to be installed at a minimum. (You can install both reprexlite and IPython together with `reprexlite[ipython]`.) \n", 17 | "\n", 18 | "To use, first load the extension:" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": 1, 24 | "id": "presidential-affiliation", 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "%load_ext reprexlite" 29 | ] 30 | }, 31 | { 32 | "cell_type": "markdown", 33 | "id": "impressed-rogers", 34 | "metadata": {}, 35 | "source": [ 36 | "and then simply use the `%%reprex` magic with a cell containing the code you want a reprex of." 37 | ] 38 | }, 39 | { 40 | "cell_type": "code", 41 | "execution_count": 2, 42 | "id": "driven-moderator", 43 | "metadata": {}, 44 | "outputs": [ 45 | { 46 | "name": "stdout", 47 | "output_type": "stream", 48 | "text": [ 49 | "```python\n", 50 | "from itertools import product\n", 51 | "\n", 52 | "grid = list(product([1, 2, 3], [8, 16]))\n", 53 | "grid\n", 54 | "#> [(1, 8), (1, 16), (2, 8), (2, 16), (3, 8), (3, 16)]\n", 55 | "list(zip(*grid))\n", 56 | "#> [(1, 1, 2, 2, 3, 3), (8, 16, 8, 16, 8, 16)]\n", 57 | "```\n", 58 | "\n", 59 | "Created at 2021-02-27 16:08:34 PST by [reprexlite](https://github.com/jayqi/reprexlite) v0.3.1\n", 60 | "\n" 61 | ] 62 | } 63 | ], 64 | "source": [ 65 | "%%reprex\n", 66 | "\n", 67 | "from itertools import product\n", 68 | "\n", 69 | "grid = list(product([1, 2, 3], [8, 16]))\n", 70 | "grid\n", 71 | "list(zip(*grid))" 72 | ] 73 | }, 74 | { 75 | "cell_type": "markdown", 76 | "id": "lesbian-beverage", 77 | "metadata": {}, 78 | "source": [ 79 | "That's it! The cell magic shares the same interface and command-line options as the CLI. " 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 3, 85 | "id": "israeli-start", 86 | "metadata": {}, 87 | "outputs": [ 88 | { 89 | "name": "stdout", 90 | "output_type": "stream", 91 | "text": [ 92 | "```\n", 93 | "x = 2\n", 94 | "x + 2\n", 95 | "#> 4\n", 96 | "```\n", 97 | "\n" 98 | ] 99 | } 100 | ], 101 | "source": [ 102 | "%%reprex -v slack\n", 103 | "x = 2\n", 104 | "x + 2" 105 | ] 106 | }, 107 | { 108 | "cell_type": "markdown", 109 | "id": "amazing-fireplace", 110 | "metadata": {}, 111 | "source": [ 112 | "## Print Help Documentation\n", 113 | "\n", 114 | "You can use the `%reprex` line magic (single-`%`) to print out documentation." 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 4, 120 | "id": "preliminary-amino", 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "name": "stdout", 125 | "output_type": "stream", 126 | "text": [ 127 | "reprexlite v0.3.1 IPython Magic\n", 128 | "\n", 129 | "Cell Magic Usage: %%reprex [OPTIONS]\n", 130 | "\n", 131 | " Render reproducible examples of Python code for sharing. Your code will be\n", 132 | " executed and the results will be embedded as comments below their associated\n", 133 | " lines.\n", 134 | "\n", 135 | " Additional markup will be added that is appropriate to the choice of venue\n", 136 | " option. For example, for the default `gh` venue for GitHub Flavored\n", 137 | " Markdown, the final reprex will look like:\n", 138 | "\n", 139 | " ----------------------------------------\n", 140 | " ```python\n", 141 | " arr = [1, 2, 3, 4, 5]\n", 142 | " [x + 1 for x in arr]\n", 143 | " #> [2, 3, 4, 5, 6]\n", 144 | " max(arr) - min(arr)\n", 145 | " #> 4\n", 146 | " ```\n", 147 | " \n", 148 | " Created at 2021-02-27 00:13:55 PST by [reprexlite](https://github.com/jayqi/reprexlite) v0.3.1\n", 149 | " ----------------------------------------\n", 150 | "\n", 151 | " The supported venue formats are:\n", 152 | " \n", 153 | " - gh : GitHub Flavored Markdown\n", 154 | " - so : StackOverflow, alias for gh\n", 155 | " - ds : Discourse, alias for gh\n", 156 | " - html : HTML\n", 157 | " - py : Python script\n", 158 | " - rtf : Rich Text Format\n", 159 | " - slack : Slack\n", 160 | "\n", 161 | "Options:\n", 162 | " -i, --infile PATH Read code from an input file instead via\n", 163 | " editor.\n", 164 | "\n", 165 | " -o, --outfile PATH Write output to file instead of printing to\n", 166 | " console.\n", 167 | "\n", 168 | " -v, --venue [gh|so|ds|html|py|rtf|slack]\n", 169 | " Output format appropriate to the venue where\n", 170 | " you plan to share this code. [default: gh]\n", 171 | "\n", 172 | " --advertise / --no-advertise Whether to include footer that credits\n", 173 | " reprexlite. If unspecified, will depend on\n", 174 | " specified venue's default.\n", 175 | "\n", 176 | " --session-info Whether to include details about session and\n", 177 | " installed packages.\n", 178 | "\n", 179 | " --style Autoformat code with black. Requires black to\n", 180 | " be installed.\n", 181 | "\n", 182 | " --comment TEXT Comment prefix to use for results returned by\n", 183 | " expressions. [default: #>]\n", 184 | "\n", 185 | " --old-results Keep old results, i.e., lines that match the\n", 186 | " prefix specified by the --comment option. If\n", 187 | " not using this option, then such lines are\n", 188 | " removed, meaning that an input that is a\n", 189 | " reprex will be effectively regenerated.\n", 190 | "\n" 191 | ] 192 | } 193 | ], 194 | "source": [ 195 | "%reprex" 196 | ] 197 | }, 198 | { 199 | "cell_type": "markdown", 200 | "id": "minor-wrong", 201 | "metadata": {}, 202 | "source": [ 203 | "## VS Code Interactive Python Windows" 204 | ] 205 | }, 206 | { 207 | "cell_type": "markdown", 208 | "id": "introductory-degree", 209 | "metadata": {}, 210 | "source": [ 211 | "If you're in VS Code and `ipykernel` is installed, you similarly use the `%%reprex` cell magic with [Python Interactive windows](https://code.visualstudio.com/docs/python/jupyter-support-py). For a file set to Python language mode, use `# %%` to mark an IPython cell that can then be run. Or you can open the Interactive window on its own via \"Jupyter: Create Interactive Window\" through the [Command Palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette). See [VS Code docs](https://code.visualstudio.com/docs/python/jupyter-support-py) for more info." 212 | ] 213 | }, 214 | { 215 | "cell_type": "markdown", 216 | "id": "significant-found", 217 | "metadata": {}, 218 | "source": [ 219 | "\n", 220 | " \"%%reprex\n", 221 | "" 222 | ] 223 | }, 224 | { 225 | "cell_type": "markdown", 226 | "id": "virgin-resident", 227 | "metadata": {}, 228 | "source": [ 229 | "## Isolated Namespace" 230 | ] 231 | }, 232 | { 233 | "cell_type": "markdown", 234 | "id": "aboriginal-whale", 235 | "metadata": {}, 236 | "source": [ 237 | "Note that—just like other ways of rendering a reprex—your code is evaluated in an isolated namespace that is separate from the namespace of your IPython session or your notebook. That means, for example, variables defined in your notebook won't exist in your reprex." 238 | ] 239 | }, 240 | { 241 | "cell_type": "code", 242 | "execution_count": 5, 243 | "id": "cosmetic-oklahoma", 244 | "metadata": {}, 245 | "outputs": [], 246 | "source": [ 247 | "notebook_var = 2" 248 | ] 249 | }, 250 | { 251 | "cell_type": "code", 252 | "execution_count": 6, 253 | "id": "regular-checklist", 254 | "metadata": {}, 255 | "outputs": [ 256 | { 257 | "name": "stdout", 258 | "output_type": "stream", 259 | "text": [ 260 | "```python\n", 261 | "notebook_var\n", 262 | "#> Traceback (most recent call last):\n", 263 | "#> File \"/Users/jqi/repos/reprexlite/reprexlite/code.py\", line 69, in evaluate\n", 264 | "#> result = eval(str(self).strip(), scope, scope)\n", 265 | "#> File \"\", line 1, in \n", 266 | "#> NameError: name 'notebook_var' is not defined\n", 267 | "```\n", 268 | "\n" 269 | ] 270 | } 271 | ], 272 | "source": [ 273 | "%%reprex --no-advertise\n", 274 | "\n", 275 | "notebook_var" 276 | ] 277 | }, 278 | { 279 | "attachments": {}, 280 | "cell_type": "markdown", 281 | "id": "93d12f40", 282 | "metadata": {}, 283 | "source": [ 284 | "## Also check out the IPython shell editor\n", 285 | "\n", 286 | "reprexlite also supports an IPython interactive shell editor. This has the same requirements as using the cell magic (IPython is installed). To use it, simply call use the `reprex` CLI with:\n", 287 | "\n", 288 | "```bash\n", 289 | "reprex -e ipython\n", 290 | "```\n", 291 | "\n", 292 | "This will launch a special IPython interactive shell where all cells are piped through reprexlite for execution. It's like the cell magic, but without needing to specify any magics!" 293 | ] 294 | } 295 | ], 296 | "metadata": { 297 | "kernelspec": { 298 | "display_name": "reprexlite", 299 | "language": "python", 300 | "name": "python3" 301 | }, 302 | "language_info": { 303 | "codemirror_mode": { 304 | "name": "ipython", 305 | "version": 3 306 | }, 307 | "file_extension": ".py", 308 | "mimetype": "text/x-python", 309 | "name": "python", 310 | "nbconvert_exporter": "python", 311 | "pygments_lexer": "ipython3", 312 | "version": "3.9.16" 313 | }, 314 | "vscode": { 315 | "interpreter": { 316 | "hash": "dc97a6ebad8f303f7a87e0941dc6fb3cacd790a105294cc6b5d6affb0588e6de" 317 | } 318 | } 319 | }, 320 | "nbformat": 4, 321 | "nbformat_minor": 5 322 | } 323 | -------------------------------------------------------------------------------- /docs/docs/rendering-and-output-venues.md: -------------------------------------------------------------------------------- 1 | # Rendering and Output Venues 2 | 3 | A rendered reprex will be code plus the computed outputs plus additional formatting markup appropriate for some particular output venue. For example, the `gh` venue (GitHub) will be in GitHub-flavored markdown and may look like this: 4 | 5 | ```` 6 | ```python 7 | 2+2 8 | #> 4 9 | ``` 10 | ```` 11 | 12 | The venue can be set using the `--venue / -v` command-line flag or the `venue` configuration file option. The following section documents the available output venue options. 13 | 14 | ## Venue options 15 | 16 | {{ create_venue_help_table() }} 17 | 18 | ## Formatter functions 19 | 20 | {{ create_venue_help_examples() }} 21 | 22 | ## Under the hood and Python API 23 | 24 | There are two steps to rendering a reprex: 25 | 26 | 1. The `Reprex.render()` method renders a reprex instance as just the code and outputs. 27 | 2. A formatter function from `reprexlite.formatting` (see [above](#formatter-functions)) formats the rendered reprex code and outputs for the specified venue. 28 | 29 | The whole process is encapsulated in the `Reprex.render_and_format()` method. 30 | -------------------------------------------------------------------------------- /docs/main.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import dataclasses 3 | from textwrap import dedent 4 | from typing import Union 5 | 6 | from griffe import Docstring, DocstringSectionAdmonition, DocstringSectionText 7 | from py_markdown_table.markdown_table import markdown_table 8 | from typenames import typenames 9 | 10 | from reprexlite.config import ReprexConfig 11 | from reprexlite.formatting import formatter_registry 12 | 13 | 14 | def define_env(env): 15 | "Hook function" 16 | 17 | docstring = Docstring(ReprexConfig.__doc__, lineno=1) 18 | parsed = docstring.parse("google") 19 | descriptions = {param.name: param.description.replace("\n", " ") for param in parsed[1].value} 20 | 21 | @env.macro 22 | def create_config_help_table(): 23 | out = dedent( 24 | """\ 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {} 33 | 34 |
NameTypeDescription
35 | """ 36 | ) 37 | out = out.format( 38 | "\n".join( 39 | f""" 40 | 41 | {field.name} 42 | {typenames(field.type)} 43 | {descriptions[field.name]} 44 | 45 | """ 46 | for field in dataclasses.fields(ReprexConfig) 47 | ) 48 | ) 49 | out = out.replace( 50 | '"Venues Formatting"', '"Venues Formatting"' 51 | ) 52 | return out 53 | 54 | @env.macro 55 | def create_venue_help_table(): 56 | data = [ 57 | { 58 | "Venue Keyword": f"`{venue_key.value}`", 59 | "Description": formatter_entry.label, 60 | "Formatter Function": f"[`{formatter_entry.fn.__name__}`](#{formatter_entry.fn.__name__})", 61 | } 62 | for venue_key, formatter_entry in formatter_registry.items() 63 | ] 64 | table = markdown_table(data) 65 | return table.set_params(row_sep="markdown", quote=False).get_markdown() 66 | 67 | @env.macro 68 | def create_venue_help_examples(): 69 | data = defaultdict(list) 70 | for key, entry in formatter_registry.items(): 71 | data[entry.fn].append(key) 72 | 73 | out = [] 74 | for fn, keys in data.items(): 75 | keys_list = ", ".join(f"`{key.value}`" for key in keys) 76 | out.append(f"### `{fn.__name__}`") 77 | 78 | # Parse docstring 79 | docstring = Docstring(fn.__doc__, lineno=1) 80 | parsed = docstring.parse("google") 81 | 82 | for section in parsed: 83 | if isinstance(section, DocstringSectionText): 84 | out.append("") 85 | out.append(section.value) 86 | elif isinstance(section, DocstringSectionAdmonition): 87 | out.append("") 88 | out.append(f"**Used for venues**: {keys_list}") 89 | out.append("") 90 | out.append(f"**{section.title}**") 91 | out.append("") 92 | out.append("````") 93 | admonition = section.value 94 | out.append(admonition.description) 95 | out.append("````") 96 | out.append("") 97 | out.append( 98 | "" 99 | "↳ [API documentation]" 100 | f"(api-reference/formatting.md#reprexlite.formatting.{fn.__qualname__})" 101 | "" 102 | ) 103 | return "\n".join(out) 104 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: reprexlite 2 | site_url: https://jayqi.github.io/reprexlite 3 | site_description: Render reproducible examples of Python code for sharing. 4 | repo_url: https://github.com/jayqi/reprexlite 5 | 6 | nav: 7 | - Home: "index.md" 8 | - Design Philosophy: "design-philosophy.md" 9 | - Reprex Do's and Don'ts: "dos-and-donts.md" 10 | - Usage: 11 | - Basic Usage: "./#basic-usage" 12 | - CLI Help: "cli.md" 13 | - Rendering and Output Venues: "rendering-and-output-venues.md" 14 | - Configuration: "configuration.md" 15 | - IPython/Jupyter Magic: "ipython-jupyter-magic.ipynb" 16 | - Library: 17 | - API Reference: 18 | - reprexlite.config: "api-reference/config.md" 19 | - reprexlite.exceptions: "api-reference/exceptions.md" 20 | - reprexlite.formatting: "api-reference/formatting.md" 21 | - reprexlite.parsing: "api-reference/parsing.md" 22 | - reprexlite.reprexes: "api-reference/reprexes.md" 23 | - reprexlite.session_info: "api-reference/session_info.md" 24 | - Changelog: "changelog.md" 25 | - Alternatives: "alternatives.md" 26 | 27 | theme: 28 | name: material 29 | features: 30 | - navigation.sections # top-level groups are section headers 31 | - navigation.top # adds back-to-top button 32 | logo: images/reprexlite.svg 33 | favicon: images/reprexlite_white_blue.svg 34 | palette: 35 | # Palette toggle for automatic mode 36 | - media: "(prefers-color-scheme)" 37 | primary: indigo 38 | accent: blue 39 | toggle: 40 | icon: material/brightness-auto 41 | name: Switch to light mode 42 | # Palette toggle for light mode 43 | - media: "(prefers-color-scheme: light)" 44 | primary: indigo 45 | accent: blue 46 | scheme: default 47 | toggle: 48 | icon: material/brightness-7 49 | name: Switch to dark mode 50 | # Palette toggle for dark mode 51 | - media: "(prefers-color-scheme: dark)" 52 | primary: indigo 53 | accent: blue 54 | scheme: slate 55 | toggle: 56 | icon: material/brightness-4 57 | name: Switch to system preference 58 | custom_dir: overrides/ 59 | 60 | extra_css: 61 | - css/extra.css 62 | 63 | markdown_extensions: 64 | - attr_list: 65 | - github-callouts: 66 | - mdx_truly_sane_lists: 67 | - pymdownx.emoji: 68 | emoji_index: !!python/name:material.extensions.emoji.twemoji 69 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 70 | - pymdownx.highlight: 71 | - pymdownx.superfences: 72 | - tables: 73 | - toc: 74 | permalink: true 75 | toc_depth: 3 76 | 77 | plugins: 78 | - search: 79 | - macros: 80 | - mkdocs-jupyter: 81 | - mkdocstrings: 82 | default_handler: python 83 | handlers: 84 | python: 85 | paths: [../reprexlite] 86 | options: 87 | # General 88 | docstring_style: google 89 | # Headings options 90 | heading_level: 2 91 | show_root_toc_entry: false 92 | show_root_full_path: false 93 | show_root_heading: false 94 | show_category_heading: true 95 | # Members options 96 | filters: ["!^_", "^__init__$"] 97 | # Docstrings options 98 | show_if_no_docstring: false 99 | merge_init_into_class: true 100 | # Signatures/annotation options 101 | annotations_path: brief 102 | separate_signature: true 103 | show_signature_annotations: true 104 | unwrap_annotated: true 105 | # Additional options 106 | show_source: true 107 | - mike: 108 | alias_type: copy 109 | canonical_version: stable 110 | version_selector: true 111 | 112 | extra: 113 | version: 114 | provider: mike 115 | default: stable 116 | alias: true 117 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block outdated %} 4 | You are not viewing the current stable version. 5 | 6 | Click here to go to stable. 7 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | python := shell("cat .python-version") 2 | 3 | # Print this help documentation 4 | help: 5 | just --list 6 | 7 | # Sync dev environment dependencies 8 | sync: 9 | uv sync --all-extras 10 | 11 | # Run linting 12 | lint: 13 | ruff format --check reprexlite tests 14 | ruff check reprexlite tests 15 | 16 | # Run formatting 17 | format: 18 | ruff format reprexlite tests 19 | 20 | # Run static typechecking 21 | typecheck: 22 | mypy reprexlite --install-types --non-interactive 23 | 24 | # Run tests 25 | test *args: 26 | uv run --python {{python}} --no-editable --all-extras --no-dev --group test --isolated \ 27 | python -I -m pytest {{args}} 28 | 29 | # Run all tests with Python version matrix 30 | test-all: 31 | for python in 3.9 3.10 3.11 3.12 3.13; do \ 32 | just python=$python test; \ 33 | done 34 | 35 | # Generate test assets 36 | generate-test-assets: 37 | uv run --python {{python}} --all-extras --no-dev --group test --isolated \ 38 | python -I tests/expected_formatted.py 39 | 40 | # Preprocessing for docs 41 | _docs-preprocess: 42 | @echo "# CLI Help Documentation\n" > docs/docs/cli.md 43 | @echo '```bash' >> docs/docs/cli.md 44 | @echo "reprex --help" >> docs/docs/cli.md 45 | @echo '```' >> docs/docs/cli.md 46 | @echo "" >> docs/docs/cli.md 47 | @echo '```' >> docs/docs/cli.md 48 | @COLUMNS=80 uv run reprex --help >> docs/docs/cli.md 49 | @echo '```' >> docs/docs/cli.md 50 | sed 's|https://raw.githubusercontent.com/jayqi/reprexlite/main/docs/docs/images/demo.gif|images/demo.gif|g' README.md \ 51 | | sed 's|https://jayqi.github.io/reprexlite/stable/||g' \ 52 | > docs/docs/index.md 53 | sed 's|https://jayqi.github.io/reprexlite/stable/||g' CHANGELOG.md \ 54 | > docs/docs/changelog.md 55 | 56 | # Generate docs website 57 | docs: _docs-preprocess 58 | uv run --directory docs/ python -I -m mkdocs build 59 | 60 | # Serve built docs 61 | docs-serve: docs 62 | uv tool run quickhttp docs/site/ 63 | 64 | # Generate demo gif 65 | demo-render: 66 | terminalizer render docs/demo.yml -o docs/docs/images/demo.gif -q 100 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "reprexlite" 7 | version = "1.0.0" 8 | description = "Render reproducible examples of Python code for sharing." 9 | readme = "README.md" 10 | authors = [{ name = "Jay Qi", email = "jayqi.opensource@gmail.com" }] 11 | license = { file = "LICENSE" } 12 | keywords = ["reprex", "reproducible examples"] 13 | classifiers = [ 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | ] 24 | requires-python = ">=3.9" 25 | dependencies = [ 26 | "cyclopts>=3", 27 | "libcst", 28 | "platformdirs", 29 | "typing_extensions>4 ; python_version < '3.11'", 30 | ] 31 | 32 | [project.optional-dependencies] 33 | black = ["black"] 34 | pygments = ["Pygments"] 35 | ipython = ["ipython"] 36 | 37 | [project.scripts] 38 | reprex = "reprexlite.cli:entrypoint" 39 | 40 | [project.urls] 41 | "Repository" = "https://github.com/jayqi/reprexlite" 42 | "Documentation" = "https://jayqi.github.io/reprexlite/" 43 | "Bug Tracker" = "https://github.com/jayqi/reprexlite/issues" 44 | "Changelog" = "https://jayqi.github.io/reprexlite/stable/changelog/" 45 | 46 | [dependency-groups] 47 | dev = [ 48 | { include-group = "lint" }, 49 | { include-group = "docs" }, 50 | ] 51 | lint = [ 52 | "mypy[install-types]", 53 | "ruff", 54 | ] 55 | docs = [ 56 | "markdown-callouts", 57 | "mkdocs", 58 | "mkdocs-jupyter", 59 | "mkdocs-macros-plugin", 60 | "mkdocs-material>=9.5.23", 61 | "mike", 62 | "mkdocstrings[python]", 63 | "mkdocstrings-python>=1.15.1", 64 | "mdx-truly-sane-lists", 65 | "py-markdown-table", 66 | "typenames", 67 | "vspect", 68 | ] 69 | test = [ 70 | "pytest", 71 | "coverage", 72 | "pytest-cov", 73 | "pytest-echo>=1.8.1", 74 | "tqdm", 75 | ] 76 | 77 | [tool.uv] 78 | upgrade = true 79 | resolution = "highest" 80 | 81 | [tool.ruff] 82 | line-length = 99 83 | src = ["reprexlite", "tests"] 84 | exclude = ["tests/assets"] 85 | 86 | [tool.ruff.lint] 87 | select = [ 88 | "E", # Pyflakes 89 | "F", # Pycodestyle 90 | "I", # isort 91 | ] 92 | unfixable = ["F"] 93 | 94 | [tool.ruff.lint.isort] 95 | known-first-party = ["reprexlite"] 96 | known-third-party = ["IPython"] 97 | force-sort-within-sections = true 98 | 99 | [tool.mypy] 100 | ignore_missing_imports = true 101 | 102 | [tool.pytest.ini_options] 103 | minversion = "6.0" 104 | addopts = "--cov=reprexlite --cov-report=term --cov-report=html --cov-report=xml --echo-version=*" 105 | testpaths = ["tests"] 106 | 107 | [tool.coverage.run] 108 | source = ["reprexlite"] 109 | 110 | [tool.coverage.paths] 111 | source = [ 112 | "reprexlite/", 113 | "*/site-packages/reprexlite/", 114 | ] 115 | -------------------------------------------------------------------------------- /reprexlite/__init__.py: -------------------------------------------------------------------------------- 1 | from reprexlite.exceptions import IPythonNotFoundError 2 | from reprexlite.reprexes import Reprex, ReprexConfig, reprex 3 | from reprexlite.version import __version__ 4 | 5 | try: 6 | from reprexlite.ipython import load_ipython_extension # noqa: F401 7 | except IPythonNotFoundError: 8 | pass 9 | 10 | __all__ = [ 11 | "Reprex", 12 | "ReprexConfig", 13 | "reprex", 14 | ] 15 | 16 | __version__ 17 | -------------------------------------------------------------------------------- /reprexlite/__main__.py: -------------------------------------------------------------------------------- 1 | from reprexlite.cli import app 2 | 3 | if __name__ == "__main__": 4 | app._name = ("python -m reprexlite",) 5 | app() 6 | -------------------------------------------------------------------------------- /reprexlite/cli.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import json 3 | import os 4 | from pathlib import Path 5 | import platform 6 | import subprocess 7 | import sys 8 | import tempfile 9 | from typing import Annotated, Optional 10 | 11 | from cyclopts import App, Parameter 12 | import cyclopts.config 13 | from platformdirs import user_config_dir 14 | 15 | from reprexlite.config import ReprexConfig 16 | from reprexlite.exceptions import ( 17 | EditorError, 18 | InputSyntaxError, 19 | IPythonNotFoundError, 20 | ) 21 | from reprexlite.formatting import formatter_registry 22 | from reprexlite.reprexes import Reprex 23 | from reprexlite.version import __version__ 24 | 25 | 26 | def get_version(): 27 | return __version__ 28 | 29 | 30 | HELP_TEMPLATE = """ 31 | Render reproducible examples of Python code for sharing. Your code will be executed and, in 32 | the default output style, the results will be embedded as comments below their associated 33 | lines. 34 | 35 | By default, your system's default command-line text editor will open for you to type or paste 36 | in your code. This editor can be changed by setting either of the `VISUAL` or `EDITOR` environment 37 | variables, or by explicitly passing in the --editor program. The interactive IPython editor 38 | requires IPython to be installed. You can also instead specify an input file with the 39 | --infile / -i option. 40 | 41 | Additional markup will be added that is appropriate to the choice of venue formatting. For 42 | example, for the default 'gh' venue for GitHub Flavored Markdown, the final reprex output will 43 | look like: 44 | 45 | ```` 46 | ```python 47 | arr = [1, 2, 3, 4, 5] 48 | [x + 1 for x in arr] 49 | #> [2, 3, 4, 5, 6] 50 | max(arr) - min(arr) 51 | #> 4 52 | ``` 53 | 54 | Created at 2021-02-27 00:13 PST by [reprexlite](https://github.com/jayqi/reprexlite) v{version} 55 | ```` 56 | 57 | The supported venue formats are: 58 | {venue_formats} 59 | """ # noqa: E501 60 | 61 | 62 | def get_help(): 63 | help_text = HELP_TEMPLATE.format( 64 | version=get_version(), 65 | venue_formats="\n".join( 66 | f"- {key.value} : {entry.label}" for key, entry in formatter_registry.items() 67 | ), 68 | ) 69 | return help_text 70 | 71 | 72 | pyproject_toml_loader = cyclopts.config.Toml( 73 | "pyproject.toml", 74 | root_keys=("tool", "reprexlite"), 75 | search_parents=True, 76 | use_commands_as_keys=False, 77 | ) 78 | 79 | reprexlite_toml_loader = cyclopts.config.Toml( 80 | "reprexlite.toml", 81 | search_parents=True, 82 | use_commands_as_keys=False, 83 | ) 84 | 85 | dot_reprexlite_toml_loader = cyclopts.config.Toml( 86 | ".reprexlite.toml", 87 | search_parents=True, 88 | use_commands_as_keys=False, 89 | ) 90 | 91 | 92 | user_reprexlite_toml_loader = cyclopts.config.Toml( 93 | Path(user_config_dir(appname="reprexlite")) / "config.toml", 94 | search_parents=False, 95 | use_commands_as_keys=False, 96 | ) 97 | 98 | app = App( 99 | name="reprex", 100 | version=get_version, 101 | help_format="markdown", 102 | help=get_help(), 103 | config=( 104 | pyproject_toml_loader, 105 | reprexlite_toml_loader, 106 | dot_reprexlite_toml_loader, 107 | user_reprexlite_toml_loader, 108 | ), 109 | ) 110 | 111 | 112 | def launch_ipython(config: ReprexConfig): 113 | try: 114 | from reprexlite.ipython import ReprexTerminalIPythonApp 115 | except IPythonNotFoundError: 116 | print( 117 | "IPythonNotFoundError: ipython is required to be installed to use the IPython " 118 | "interactive editor." 119 | ) 120 | sys.exit(1) 121 | ReprexTerminalIPythonApp.set_reprex_config(config) 122 | ReprexTerminalIPythonApp.launch_instance(argv=[]) 123 | sys.exit(0) 124 | 125 | 126 | def launch_editor(editor) -> str: 127 | fw, name = tempfile.mkstemp(prefix="reprexlite-", suffix=".py") 128 | try: 129 | os.close(fw) # Close the file descriptor 130 | # Open editor and edit the file 131 | proc = subprocess.Popen(args=f"{editor} {name}", shell=True) 132 | exit_code = proc.wait() 133 | if exit_code != 0: 134 | raise EditorError(f"{editor}: Editing failed with exit code {exit_code}") 135 | 136 | # Read the file back in 137 | with open(name, "rb") as fp: 138 | content = fp.read() 139 | return content.decode("utf-8-sig").replace("\r\n", "\n") 140 | except OSError as e: 141 | raise EditorError(f"{editor}: Editing failed: {e}") from e 142 | finally: 143 | os.unlink(name) 144 | 145 | 146 | def get_editor() -> str: 147 | """Determine an editor to use for editing code.""" 148 | for key in "VISUAL", "EDITOR": 149 | env_val = os.environ.get(key) 150 | if env_val: 151 | return env_val 152 | if platform.system() == "Windows": 153 | return "notepad" 154 | for editor in ("sensible-editor", "vim", "nano"): 155 | if os.system(f"which {editor} >/dev/null 2>&1") == 0: 156 | return editor 157 | return "vi" 158 | 159 | 160 | def handle_editor(config: ReprexConfig) -> str: 161 | """Determines what to do based on the editor configuration.""" 162 | editor = config.editor or get_editor() 163 | if editor == "ipython": 164 | launch_ipython(config) 165 | sys.exit(0) 166 | else: 167 | return launch_editor(editor) 168 | 169 | 170 | @app.default 171 | def main( 172 | *, 173 | infile: Annotated[ 174 | Optional[Path], 175 | Parameter( 176 | name=("--infile", "-i"), 177 | help="Read code from this file instead of opening an editor.", 178 | ), 179 | ] = None, 180 | outfile: Annotated[ 181 | Optional[Path], 182 | Parameter( 183 | name=("--outfile", "-o"), 184 | help="Write rendered reprex to this file instead of standard out.", 185 | ), 186 | ] = None, 187 | config: Annotated[ReprexConfig, Parameter(name="*")] = ReprexConfig(), 188 | verbose: Annotated[ 189 | tuple[bool, ...], 190 | Parameter( 191 | name=("--verbose",), show_default=False, negative=False, help="Increase verbosity." 192 | ), 193 | ] = (), 194 | debug: Annotated[bool, Parameter(show=False)] = False, 195 | ): 196 | verbosity = sum(verbose) 197 | if verbosity: 198 | sys.stderr.write("infile: {}\n".format(infile)) 199 | sys.stderr.write("outfile: {}\n".format(outfile)) 200 | sys.stderr.write("config: {}\n".format(config)) 201 | 202 | if debug: 203 | data = {"infile": infile, "outfile": outfile, "config": dataclasses.asdict(config)} 204 | sys.stdout.write(json.dumps(data)) 205 | return data 206 | 207 | if infile: 208 | if verbose: 209 | sys.stderr.write(f"Reading from input file: {infile}") 210 | with infile.open("r") as fp: 211 | input = fp.read() 212 | else: 213 | input = handle_editor(config) 214 | if input.strip() == "": 215 | print("No input provided or saved via the editor. Exiting.") 216 | sys.exit(0) 217 | 218 | try: 219 | r = Reprex.from_input(input=input, config=config) 220 | except InputSyntaxError as e: 221 | print("ERROR: reprexlite has encountered an error while evaluating your input.") 222 | print(e) 223 | raise 224 | 225 | if outfile: 226 | with outfile.open("w") as fp: 227 | fp.write(r.render_and_format(terminal=False)) 228 | print(f"Wrote rendered reprex to {outfile}") 229 | else: 230 | print(r.render_and_format(terminal=True), end="") 231 | 232 | return r 233 | 234 | 235 | def entrypoint(): 236 | """Entrypoint for the reprex command-line interface. This function is configured as the reprex 237 | entrypoint under [project.scripts]. 238 | https://packaging.python.org/en/latest/specifications/entry-points/#use-for-scripts 239 | """ 240 | app() 241 | -------------------------------------------------------------------------------- /reprexlite/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Annotated, Optional 4 | 5 | from cyclopts import Parameter 6 | 7 | from reprexlite.exceptions import ( 8 | InvalidParsingMethodError, 9 | InvalidVenueError, 10 | PromptLengthMismatchError, 11 | ) 12 | 13 | 14 | class ParsingMethod(str, Enum): 15 | """Methods for parsing input strings. 16 | 17 | Attributes: 18 | AUTO (str): Automatically identify reprex-style or doctest-style input. 19 | DECLARED (str): Use configured values for parsing. 20 | """ 21 | 22 | AUTO = "auto" 23 | DECLARED = "declared" 24 | 25 | 26 | class Venue(str, Enum): 27 | """Enum for specifying the output venue for a reprex. 28 | 29 | Attributes: 30 | GH (str): GitHub-flavored Markdown 31 | DS (str): Discourse 32 | SO (str): StackOverflow 33 | HTML (str): HTML 34 | PY (str): Python script 35 | RTF (str): Rich Text Format 36 | SLACK (str): Slack markup 37 | """ 38 | 39 | GH = "gh" 40 | DS = "ds" 41 | SO = "so" 42 | HTML = "html" 43 | PY = "py" 44 | RTF = "rtf" 45 | SLACK = "slack" 46 | 47 | 48 | @dataclass 49 | class ReprexConfig: 50 | """Configuration dataclass for reprexlite. Used to configure input parsing and output 51 | formatting. 52 | 53 | Args: 54 | editor (Optional[str]): Command-line program name of editor to use. If not specified, 55 | check $EDITOR and $VISUAL environment variables. If 'ipython', will launch the IPython 56 | interactive editor. 57 | venue (str): Key to identify the output venue that the reprex will be shared in. Used to 58 | select an appropriate formatter. See "Venues Formatting" documentation for formats 59 | included with reprexlite. 60 | advertise (bool): Whether to include a footer that credits reprexlite. If unspecified, will 61 | depend on specified venue formatter's default. 62 | session_info (bool): Include details about session and environment that the reprex was 63 | generated with. 64 | style (bool): Whether to autoformat code with black. Requires black to be installed. 65 | prompt (str): Prefix to use as primary prompt for code lines. 66 | continuation (str): Prefix to use as secondary prompt for continued code lines. 67 | comment (str): Prefix to use for results returned by expressions. 68 | keep_old_results (bool): Whether to additionally include results of expressions detected in 69 | the original input when formatting the reprex output. 70 | parsing_method (str): Method for parsing input. 'auto' will automatically detect either 71 | default reprex-style input or standard doctest-style input. 'declared' will allow you 72 | to specify custom line prefixes. Values for 'prompt', 'continuation', and 'comment' 73 | will be used for both output formatting and input parsing, unless the associated 74 | 'input_*' override settings are supplied. 75 | input_prompt (str): Prefix to use as primary prompt for code lines when parsing input. Only 76 | used if 'parsing_method' is 'declared'. If not set, 'prompt' is used for both input 77 | parsing and output formatting. 78 | input_continuation (str): Prefix to use as secondary prompt for continued code lines when 79 | parsing input. Only used if 'parsing_method' is 'declared'. If not set, 'prompt' is 80 | used for both input parsing and output formatting. 81 | input_comment (str): Prefix to use for results returned by expressions when parsing input. 82 | Only used if 'parsing_method' is 'declared'. If not set, 'prompt' is used for both 83 | input parsing and output formatting. 84 | """ 85 | 86 | editor: Annotated[Optional[str], Parameter(name=("--editor", "-e"))] = None 87 | # Formatting 88 | venue: Annotated[Venue, Parameter(name=("--venue", "-v"))] = Venue.GH 89 | advertise: Optional[bool] = None 90 | session_info: bool = False 91 | style: bool = False 92 | prompt: str = "" 93 | continuation: str = "" 94 | comment: str = "#>" 95 | keep_old_results: bool = False 96 | # Parsing 97 | parsing_method: ParsingMethod = ParsingMethod.AUTO 98 | input_prompt: Optional[str] = None 99 | input_continuation: Optional[str] = None 100 | input_comment: Optional[str] = None 101 | 102 | def __post_init__(self): 103 | # Validate venue 104 | try: 105 | Venue(self.venue) 106 | except ValueError: 107 | raise InvalidVenueError( 108 | f"{self.venue} is not a valid value for parsing method." 109 | f"Valid values are: {list(m.value for m in Venue)}" 110 | ) 111 | # Validate prompt and continuation prefixes 112 | if len(self.prompt) != len(self.continuation): 113 | raise PromptLengthMismatchError( 114 | f"Primary prompt ('{self.prompt}') and continuation prompt " 115 | f"('{self.continuation}') must be equal lengths." 116 | ) 117 | # Validate parsing method 118 | try: 119 | ParsingMethod(self.parsing_method) 120 | except ValueError: 121 | raise InvalidParsingMethodError( 122 | f"{self.parsing_method} is not a valid value for parsing method." 123 | f"Valid values are: {[pm.value for pm in ParsingMethod]}" 124 | ) 125 | 126 | @property 127 | def resolved_input_prompt(self): 128 | if self.input_prompt is not None: 129 | return self.input_prompt 130 | return self.prompt 131 | 132 | @property 133 | def resolved_input_continuation(self): 134 | if self.input_continuation is not None: 135 | return self.input_continuation 136 | return self.continuation 137 | 138 | @property 139 | def resolved_input_comment(self): 140 | if self.input_comment is not None: 141 | return self.input_comment 142 | return self.comment 143 | -------------------------------------------------------------------------------- /reprexlite/exceptions.py: -------------------------------------------------------------------------------- 1 | class ReprexliteException(Exception): 2 | """Base class for reprexlite exceptions.""" 3 | 4 | 5 | class BlackNotFoundError(ModuleNotFoundError, ReprexliteException): 6 | """Raised when ipython cannot be found when using a black-dependent feature.""" 7 | 8 | 9 | class EditorError(ReprexliteException): 10 | """Raised when an error occurs with the editor.""" 11 | 12 | 13 | class InputSyntaxError(SyntaxError, ReprexliteException): 14 | """Raised when encountering a syntax error when parsing input.""" 15 | 16 | 17 | class InvalidInputPrefixesError(ValueError, ReprexliteException): 18 | pass 19 | 20 | 21 | class InvalidParsingMethodError(ValueError, ReprexliteException): 22 | pass 23 | 24 | 25 | class InvalidVenueError(ValueError, ReprexliteException): 26 | pass 27 | 28 | 29 | class IPythonNotFoundError(ModuleNotFoundError, ReprexliteException): 30 | """Raised when ipython cannot be found when using an IPython-dependent feature.""" 31 | 32 | 33 | class MissingDependencyError(ImportError, ReprexliteException): 34 | pass 35 | 36 | 37 | class NoPrefixMatchError(ValueError, ReprexliteException): 38 | pass 39 | 40 | 41 | class PromptLengthMismatchError(ReprexliteException): 42 | pass 43 | 44 | 45 | class PygmentsNotFoundError(ModuleNotFoundError, ReprexliteException): 46 | """Raised when pygments cannot be found when using a pygments-dependent feature.""" 47 | 48 | 49 | class UnexpectedError(ReprexliteException): 50 | """Raised when an unexpected case happens.""" 51 | 52 | def __init__(self, msg: str, *args: object): 53 | if not msg.endswith(" "): 54 | msg += " " 55 | msg += ( 56 | "If you see this error from normal usage, please report at " 57 | "https://github.com/jayqi/reprexlite/issues" 58 | ) 59 | super().__init__(msg, *args) 60 | -------------------------------------------------------------------------------- /reprexlite/formatting.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from typing import Dict, Optional, Protocol 4 | 5 | from reprexlite.config import ReprexConfig, Venue 6 | from reprexlite.exceptions import PygmentsNotFoundError 7 | from reprexlite.session_info import SessionInfo 8 | from reprexlite.version import __version__ 9 | 10 | 11 | class Formatter(Protocol): 12 | def __call__(self, reprex_str: str, config: Optional[ReprexConfig] = None) -> str: ... 13 | 14 | 15 | @dataclass 16 | class FormatterRegistration: 17 | fn: Formatter 18 | label: str 19 | 20 | 21 | class FormatterRegistry: 22 | """Registry of formatters keyed by venue keywords.""" 23 | 24 | _registry: Dict[str, FormatterRegistration] = {} 25 | 26 | def __getitem__(self, key: Venue) -> FormatterRegistration: 27 | return self._registry[Venue(key)] 28 | 29 | def __contains__(self, key: Venue) -> bool: 30 | return Venue(key) in self._registry 31 | 32 | def items(self): 33 | return self._registry.items() 34 | 35 | def keys(self): 36 | return self._registry.keys() 37 | 38 | def values(self): 39 | return self._registry.values() 40 | 41 | def register(self, venue: Venue, label: str): 42 | """Decorator that registers a formatter implementation. 43 | 44 | Args: 45 | venue (str): Venue keyword that formatter will be registered to. 46 | label (str): Short human-readable label explaining the venue. 47 | """ 48 | 49 | def _register(fn: Formatter): 50 | self._registry[Venue(venue)] = FormatterRegistration(fn=fn, label=label) 51 | return fn 52 | 53 | return _register 54 | 55 | 56 | formatter_registry = FormatterRegistry() 57 | 58 | 59 | @formatter_registry.register(venue=Venue.DS, label=f"Discourse (alias for '{Venue.GH.value}')") 60 | @formatter_registry.register(venue=Venue.SO, label=f"StackOverflow (alias for '{Venue.GH.value}')") 61 | @formatter_registry.register(venue=Venue.GH, label="Github Flavored Markdown") 62 | def format_as_markdown( 63 | reprex_str: str, 64 | config: Optional[ReprexConfig] = None, 65 | ) -> str: 66 | """ 67 | Format a rendered reprex reprex as a GitHub-Flavored Markdown code block. By default, includes 68 | a footer that credits reprexlite. 69 | 70 | Args: 71 | reprex_str (str): The reprex string to render. 72 | config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. 73 | 74 | Returns: 75 | str: The rendered reprex 76 | 77 | Example: 78 | ```python 79 | 2+2 80 | #> 4 81 | ``` 82 | """ 83 | if config is None: 84 | config = ReprexConfig() 85 | advertise = config.advertise if config.advertise is not None else True 86 | out = [] 87 | out.append("```python") 88 | out.append(reprex_str) 89 | out.append("```") 90 | if advertise: 91 | out.append("\n" + Advertisement().markdown()) 92 | if config.session_info: 93 | out.append("\n
Session Info") 94 | out.append("```text") 95 | out.append(str(SessionInfo())) 96 | out.append("```") 97 | out.append("
") 98 | return "\n".join(out) + "\n" 99 | 100 | 101 | @formatter_registry.register(venue=Venue.HTML, label="HTML") 102 | def format_as_html(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: 103 | """Format a rendered reprex reprex as an HTML code block. If optional dependency Pygments is 104 | available, the rendered HTML will have syntax highlighting for the Python code. By default, 105 | includes a footer that credits reprexlite. 106 | 107 | Args: 108 | reprex_str (str): The reprex string to render. 109 | config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. 110 | 111 | Returns: 112 | str: The rendered reprex 113 | 114 | Example: 115 |
2+2
116 |         #> 4
117 | """ 118 | if config is None: 119 | config = ReprexConfig() 120 | advertise = config.advertise if config.advertise is not None else True 121 | out = [] 122 | try: 123 | from pygments import highlight 124 | from pygments.formatters import HtmlFormatter 125 | from pygments.lexers import PythonLexer 126 | 127 | formatter = HtmlFormatter(style="friendly", lineanchors=True, linenos=True, wrapcode=True) 128 | out.append(f"") 129 | out.append(highlight(str(reprex_str), PythonLexer(), formatter)) 130 | except ImportError: 131 | out.append(f"
{reprex_str}
") 132 | 133 | if advertise: 134 | out.append(Advertisement().html().strip()) 135 | if config.session_info: 136 | out.append("
Session Info") 137 | out.append(f"
{SessionInfo()}
") 138 | out.append("
") 139 | return "\n".join(out) + "\n" 140 | 141 | 142 | @formatter_registry.register(venue=Venue.PY, label="Python script") 143 | def format_as_python_script(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: 144 | """Format a rendered reprex reprex as a Python script. 145 | 146 | Args: 147 | reprex_str (str): The reprex string to render. 148 | config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. 149 | 150 | Returns: 151 | str: The rendered reprex 152 | 153 | Example: 154 | 2+2 155 | #> 4 156 | """ 157 | if config is None: 158 | config = ReprexConfig() 159 | advertise = config.advertise if config.advertise is not None else False 160 | out = [str(reprex_str)] 161 | if advertise: 162 | out.append("\n" + Advertisement().code_comment()) 163 | if config.session_info: 164 | out.append("") 165 | sess_lines = str(SessionInfo()).split("\n") 166 | out.extend("# " + line for line in sess_lines) 167 | return "\n".join(out) + "\n" 168 | 169 | 170 | @formatter_registry.register(venue=Venue.RTF, label="Rich Text Format") 171 | def format_as_rtf(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: 172 | """Format a rendered reprex reprex as a Rich Text Format (RTF) document. Requires dependency 173 | Pygments.""" 174 | if config is None: 175 | config = ReprexConfig() 176 | advertise = config.advertise if config.advertise is not None else False 177 | try: 178 | from pygments import highlight 179 | from pygments.formatters import RtfFormatter 180 | from pygments.lexers import PythonLexer 181 | except ModuleNotFoundError as e: 182 | if e.name == "pygments": 183 | raise PygmentsNotFoundError("Pygments is required for RTF output.", name="pygments") 184 | else: 185 | raise 186 | 187 | out = str(reprex_str) 188 | if advertise: 189 | out += "\n\n" + Advertisement().text() 190 | if config.session_info: 191 | out += "\n\n" + str(SessionInfo()) 192 | return highlight(out, PythonLexer(), RtfFormatter()) + "\n" 193 | 194 | 195 | @formatter_registry.register(venue=Venue.SLACK, label="Slack") 196 | def format_for_slack(reprex_str: str, config: Optional[ReprexConfig] = None) -> str: 197 | """Format a rendered reprex as Slack markup. 198 | 199 | Args: 200 | reprex_str (str): The reprex string to render. 201 | config (Optional[ReprexConfig]): Configuration for the reprex. Defaults to None. 202 | 203 | Returns: 204 | str: The rendered reprex 205 | 206 | Example: 207 | ``` 208 | 2+2 209 | #> 4 210 | ``` 211 | """ 212 | if config is None: 213 | config = ReprexConfig() 214 | advertise = config.advertise if config.advertise is not None else False 215 | out = [] 216 | out.append("```") 217 | out.append(str(reprex_str)) 218 | out.append("```") 219 | if advertise: 220 | out.append("\n" + Advertisement().text()) 221 | if config.session_info: 222 | out.append("\n```") 223 | out.append(str(SessionInfo())) 224 | out.append("```") 225 | return "\n".join(out) + "\n" 226 | 227 | 228 | class Advertisement: 229 | """Class for generating the advertisement note for reprexlite. 230 | 231 | Attributes: 232 | timestamp (str): Timestamp of instance instantiation 233 | version (str): Version of reprexlite 234 | """ 235 | 236 | pkg = "reprexlite" 237 | url = "https://github.com/jayqi/reprexlite" 238 | 239 | def __init__(self): 240 | self.timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z") 241 | self.version = f"v{__version__}" 242 | 243 | def markdown(self) -> str: 244 | """Render reprexlite advertisement in GitHub Flavored Markdown.""" 245 | return f"Created at {self.timestamp} by [{self.pkg}]({self.url}) {self.version}" 246 | 247 | def html(self) -> str: 248 | """Render reprexlite advertisement in HTML.""" 249 | return ( 250 | f"

Created at {self.timestamp} by " 251 | f'{self.pkg} {self.version}

' 252 | ) 253 | 254 | def code_comment(self) -> str: 255 | """Render reprexlite advertisement as a comment in Python code.""" 256 | return f"# {self.text()}" 257 | 258 | def text(self) -> str: 259 | """Render reprexlite advertisement in plain text.""" 260 | return f"Created at {self.timestamp} by {self.pkg} {self.version} <{self.url}>" 261 | -------------------------------------------------------------------------------- /reprexlite/ipython.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager, redirect_stdout 2 | import io 3 | import re 4 | from typing import Optional 5 | 6 | import reprexlite.cli 7 | from reprexlite.config import ReprexConfig 8 | from reprexlite.exceptions import IPythonNotFoundError 9 | from reprexlite.reprexes import Reprex 10 | from reprexlite.version import __version__ 11 | 12 | try: 13 | from IPython import InteractiveShell 14 | from IPython.core.magic import Magics, line_cell_magic, magics_class 15 | from IPython.core.release import version as ipython_version 16 | from IPython.core.usage import default_banner_parts 17 | from IPython.terminal.interactiveshell import TerminalInteractiveShell 18 | from IPython.terminal.ipapp import TerminalIPythonApp 19 | except ModuleNotFoundError as e: 20 | if e.name == "IPython": 21 | raise IPythonNotFoundError(*e.args, name="IPython") 22 | else: 23 | raise 24 | 25 | 26 | @contextmanager 27 | def patch_edit(input: str): 28 | """Patches typer.edit to return the input string instead of opening up the text editor. This 29 | is a trick to hook up the IPython cell magic's cell contents to the typer CLI app. 30 | """ 31 | 32 | def return_input(*args, **kwargs) -> str: 33 | return input 34 | 35 | original = reprexlite.cli.handle_editor 36 | setattr(reprexlite.cli, "handle_editor", return_input) 37 | yield 38 | setattr(reprexlite.cli, "handle_editor", original) 39 | 40 | 41 | @magics_class 42 | class ReprexMagics(Magics): 43 | @line_cell_magic 44 | def reprex(self, line: str, cell: Optional[str] = None): 45 | """reprex IPython magic. Use line magic %reprex to print help. Use cell magic %%reprex to 46 | render a reprex.""" 47 | # Line magic, print help 48 | if cell is None: 49 | with io.StringIO() as buffer, redirect_stdout(buffer): 50 | reprexlite.cli.app("--help") 51 | help_text = buffer.getvalue() 52 | help_text = re.sub(r"^Usage: reprex", r"Cell Magic Usage: %%reprex", help_text) 53 | print(f"reprexlite v{__version__} IPython Magic\n\n" + help_text) 54 | return 55 | # Cell magic, render reprex 56 | with patch_edit(cell): 57 | reprexlite.cli.app(line.split()) 58 | # print(stdout, end="") 59 | 60 | 61 | def load_ipython_extension(ipython: InteractiveShell): 62 | """Special function to register this module as an IPython extension. 63 | https://ipython.readthedocs.io/en/stable/config/extensions/#writing-extensions 64 | """ 65 | 66 | ipython.register_magics(ReprexMagics) 67 | 68 | 69 | ipython_banner_parts = [ 70 | default_banner_parts[0], 71 | f"reprexlite {__version__} -- Interactive reprex editor via IPython {ipython_version}.", 72 | ] 73 | 74 | 75 | class ReprexTerminalInteractiveShell(TerminalInteractiveShell): 76 | """Subclass of IPython's TerminalInteractiveShell that automatically executes all cells using 77 | reprexlite instead of normally.""" 78 | 79 | banner1 = "".join(ipython_banner_parts) # type: ignore 80 | _reprex_config: Optional[ReprexConfig] = None 81 | 82 | def run_cell(self, raw_cell: str, *args, **kwargs): 83 | # "exit()" and Ctrl+D short-circuit this and don't need to be handled 84 | if raw_cell != "exit": 85 | try: 86 | r = Reprex.from_input(raw_cell, config=self.reprex_config) 87 | print(r.render_and_format(terminal=True), end="") 88 | except Exception as e: 89 | print("ERROR: reprexlite has encountered an error while evaluating your input.") 90 | print(e, end="") 91 | 92 | # Store history 93 | self.history_manager.store_inputs(self.execution_count, raw_cell, raw_cell) # type: ignore 94 | self.execution_count += 1 95 | 96 | return None 97 | else: 98 | return super().run_cell(raw_cell, *args, **kwargs) 99 | 100 | @property 101 | def reprex_config(self) -> ReprexConfig: 102 | if self._reprex_config is None: 103 | self._reprex_config = ReprexConfig() 104 | return self._reprex_config 105 | 106 | 107 | class ReprexTerminalIPythonApp(TerminalIPythonApp): 108 | """Subclass of TerminalIPythonApp that launches ReprexTerminalInteractiveShell.""" 109 | 110 | interactive_shell_class = ReprexTerminalInteractiveShell # type: ignore 111 | 112 | @classmethod 113 | def set_reprex_config(cls, config: ReprexConfig): 114 | """Set the reprex config bound on the interactive shell.""" 115 | cls.interactive_shell_class._reprex_config = config # type: ignore 116 | -------------------------------------------------------------------------------- /reprexlite/parsing.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Iterator, Optional, Tuple 3 | 4 | from reprexlite.exceptions import ( 5 | InvalidInputPrefixesError, 6 | NoPrefixMatchError, 7 | PromptLengthMismatchError, 8 | UnexpectedError, 9 | ) 10 | 11 | 12 | def removeprefix(s: str, prefix: str) -> str: 13 | """Utility function to strip a prefix from a string, whether or not there is a single 14 | whitespace character. 15 | """ 16 | if s.startswith(prefix + " "): 17 | return s[len(prefix) + 1 :] 18 | elif s.startswith(prefix): 19 | return s[len(prefix) :] 20 | else: 21 | raise UnexpectedError( # pragma: nocover 22 | "removeprefix should not be called on input that does not match the prefix. " 23 | ) 24 | 25 | 26 | class LineType(Enum): 27 | """An enum for different types of lines in text input to [parse][reprexlite.parsing.parse]. 28 | 29 | Args: 30 | CODE (str): Line is code. 31 | RESULT (str): Line is the result of executing code. 32 | """ 33 | 34 | CODE = "CODE" 35 | RESULT = "RESULT" 36 | 37 | 38 | def parse( 39 | input: str, 40 | prompt: Optional[str], 41 | continuation: Optional[str], 42 | comment: Optional[str], 43 | ) -> Iterator[Tuple[str, LineType]]: 44 | """Generator function that parses input into lines of code or results. 45 | 46 | Args: 47 | input (str): String to parse 48 | prompt (Optional[str]): Prefix used as primary prompt of code lines 49 | continuation (Optional[str]): Prefix used as continuation prompt of code lines 50 | comment (Optional[str]): Prefix used to indicate result lines 51 | 52 | Yields: 53 | Iterator[Tuple[str, LineType]]: tuple of parsed line and line type 54 | """ 55 | if not any((prompt, continuation, comment)): 56 | raise InvalidInputPrefixesError( 57 | "Cannot parse input if all of prompt, continuation, and comment are blank." 58 | ) 59 | if len(prompt or "") != len(continuation or ""): 60 | raise PromptLengthMismatchError( 61 | f"Primary prompt ('{prompt}') and continuation prompt ('{continuation}') must be " 62 | "equal lengths." 63 | ) 64 | 65 | for line_no, line in enumerate(input.split("\n")): 66 | # Case 1: With Prompt/Continuation, no Comment (e.g., doctest style) 67 | if prompt and continuation and not comment: 68 | if line.startswith(prompt): 69 | yield removeprefix(line, prompt), LineType.CODE 70 | elif line.startswith(continuation): 71 | yield removeprefix(line, continuation), LineType.CODE 72 | elif line == "": 73 | yield line, LineType.CODE 74 | else: 75 | yield line, LineType.RESULT 76 | 77 | # Case 2: No Prompt or Continuation, with Comment (e.g., reprex style) 78 | elif not prompt and not continuation and comment: 79 | if line.startswith(comment): 80 | yield removeprefix(line, comment), LineType.RESULT 81 | else: 82 | yield line, LineType.CODE 83 | 84 | # Case 3: Both Prompt/Contiuation and Comment 85 | elif prompt and continuation and comment: 86 | if line.startswith(prompt): 87 | yield removeprefix(line, prompt), LineType.CODE 88 | elif line.startswith(continuation): 89 | yield removeprefix(line, continuation), LineType.CODE 90 | elif line.startswith(comment): 91 | yield removeprefix(line, comment), LineType.RESULT 92 | elif line == "": 93 | yield line, LineType.CODE 94 | else: 95 | raise NoPrefixMatchError( 96 | f"Line {line_no + 1} does not match any of prompt, continuation, or comment " 97 | f"prefixes: '{line}'" 98 | ) 99 | 100 | else: 101 | raise UnexpectedError("Unexpected case when using parse.") # pragma: nocover 102 | 103 | 104 | def parse_reprex(input: str) -> Iterator[Tuple[str, LineType]]: 105 | """Wrapper around [parse][reprexlite.parsing.parse] for parsing reprex-style input.""" 106 | yield from parse(input=input, prompt=None, continuation=None, comment="#>") 107 | 108 | 109 | def parse_doctest(input: str) -> Iterator[Tuple[str, LineType]]: 110 | """Wrapper around [parse][reprexlite.parsing.parse] for parsing doctest-style input.""" 111 | yield from parse(input=input, prompt=">>>", continuation="...", comment=None) 112 | 113 | 114 | def auto_parse(input: str) -> Iterator[Tuple[str, LineType]]: 115 | """Automatically parse input that is either doctest-style and reprex-style.""" 116 | if any(line.startswith(">>>") for line in input.split("\n")): 117 | yield from parse_doctest(input) 118 | else: 119 | yield from parse_reprex(input) 120 | -------------------------------------------------------------------------------- /reprexlite/reprexes.py: -------------------------------------------------------------------------------- 1 | from contextlib import redirect_stdout 2 | import dataclasses 3 | from io import StringIO 4 | from itertools import chain 5 | import os 6 | from pathlib import Path 7 | from pprint import pformat 8 | import traceback 9 | from typing import Any, Dict, List, Optional, Sequence, Tuple, Union 10 | 11 | try: 12 | from typing import Self # type: ignore # Python 3.11+ 13 | except ImportError: 14 | from typing_extensions import Self 15 | 16 | import libcst as cst 17 | 18 | from reprexlite.config import ParsingMethod, ReprexConfig 19 | from reprexlite.exceptions import BlackNotFoundError, InputSyntaxError, UnexpectedError 20 | from reprexlite.formatting import formatter_registry 21 | from reprexlite.parsing import LineType, auto_parse, parse 22 | 23 | 24 | @dataclasses.dataclass 25 | class RawResult: 26 | """Class that holds the result of evaluated code. Use `str(...)` on an instance to produce a 27 | pretty-formatted comment block representation of the result. 28 | 29 | Args: 30 | config (ReprexConfig): Configuration for formatting and parsing 31 | raw (Any): Some Python object that is the raw return value of evaluated Python code. 32 | stdout (str): Standard output from evaluated Python code. 33 | """ 34 | 35 | config: ReprexConfig 36 | raw: Any 37 | stdout: Optional[str] 38 | 39 | def __str__(self) -> str: 40 | if not self: 41 | raise UnexpectedError("Should not print a RawResult if it tests False.") 42 | lines = [] 43 | if self.stdout: 44 | lines.extend(self.stdout.split("\n")) 45 | if self.raw is not None: 46 | lines.extend(pformat(self.raw, indent=2, width=77).split("\n")) 47 | if self.config.comment: 48 | return "\n".join(self.config.comment + " " + line for line in lines) 49 | else: 50 | return "\n".join(lines) 51 | 52 | def __bool__(self) -> bool: 53 | """Tests whether instance contains anything to print.""" 54 | return not (self.raw is None and self.stdout is None) 55 | 56 | def __repr__(self) -> str: 57 | return ( 58 | f"" 59 | ) 60 | 61 | def __eq__(self, other: Any) -> bool: 62 | if isinstance(other, RawResult): 63 | return self.raw == other.raw and self.stdout == other.stdout 64 | else: 65 | return NotImplemented 66 | 67 | 68 | @dataclasses.dataclass 69 | class ParsedResult: 70 | """Class that holds parsed result from reading a reprex. 71 | 72 | Args: 73 | config (ReprexConfig): Configuration for formatting and parsing 74 | lines (List[str]): String content of result parsed from a reprex 75 | """ 76 | 77 | config: ReprexConfig 78 | lines: List[str] 79 | 80 | def __str__(self) -> str: 81 | if not self: 82 | raise UnexpectedError("Should not print a ParsedResult if it tests False.") 83 | return "\n".join(self.prefix * 2 + line for line in self.lines) 84 | 85 | def as_result_str(self) -> str: 86 | return "\n".join(self.prefix + line for line in self.lines) 87 | 88 | @property 89 | def prefix(self) -> str: 90 | if self.config.comment: 91 | return self.config.comment + " " 92 | else: 93 | return "" 94 | 95 | def __bool__(self) -> bool: 96 | """Tests whether instance contains anything to print.""" 97 | return bool(self.lines) 98 | 99 | def __repr__(self) -> str: 100 | joined = "\\n".join(self.lines) 101 | return f"" 102 | 103 | def __eq__(self, other: Any) -> bool: 104 | if isinstance(other, ParsedResult): 105 | return self.lines == other.lines 106 | elif isinstance(other, RawResult): 107 | if not bool(self) and not bool(other): 108 | return True 109 | return bool(self) and bool(other) and self.as_result_str() == str(other) 110 | else: 111 | return NotImplemented 112 | 113 | 114 | @dataclasses.dataclass 115 | class Statement: 116 | """Dataclass that holds a LibCST parsed statement. of code. 117 | 118 | Args: 119 | config (ReprexConfig): Configuration for formatting and parsing 120 | stmt (Union[libcst.SimpleStatementLine, libcst.BaseCompoundStatement]): LibCST parsed 121 | statement. 122 | """ 123 | 124 | config: ReprexConfig 125 | stmt: Union[cst.SimpleStatementLine, cst.BaseCompoundStatement, cst.EmptyLine] 126 | 127 | def evaluate(self, scope: dict) -> RawResult: 128 | """Evaluate code statement and produce a RawResult dataclass instance. 129 | 130 | Args: 131 | scope (dict): scope to use for evaluation 132 | 133 | Returns: 134 | RawResult: Dataclass instance holding evaluation results. 135 | """ 136 | if isinstance(self.stmt, cst.EmptyLine): 137 | return RawResult(config=self.config, raw=None, stdout=None) 138 | 139 | if "__name__" not in scope: 140 | scope["__name__"] = "__reprex__" 141 | stdout_io = StringIO() 142 | try: 143 | with redirect_stdout(stdout_io): 144 | try: 145 | # Treat as a single expression 146 | result = eval(self.code.strip(), scope) 147 | except SyntaxError: 148 | # Treat as a statement 149 | exec(self.code.strip(), scope) 150 | result = None 151 | stdout = stdout_io.getvalue().strip() 152 | except Exception as exc: 153 | result = None 154 | # Skip first step of traceback, since that is this evaluate method 155 | if exc.__traceback__ is not None: 156 | tb = exc.__traceback__.tb_next 157 | stdout = ( 158 | "Traceback (most recent call last):\n" 159 | + "".join(line for line in traceback.format_tb(tb)) 160 | + f"{type(exc).__name__}: {exc}" 161 | ) 162 | finally: 163 | stdout_io.close() 164 | 165 | return RawResult(config=self.config, raw=result, stdout=stdout or None) 166 | 167 | @property 168 | def raw_code(self) -> str: 169 | """Raw code of contained statement as a string.""" 170 | if isinstance(self.stmt, cst.EmptyLine): 171 | return cst.Module(body=[], header=[self.stmt]).code.rstrip() 172 | return cst.Module(body=[self.stmt]).code.rstrip() 173 | 174 | @property 175 | def code(self) -> str: 176 | """Code of contained statement. May be autoformatted depending on configuration.""" 177 | code = self.raw_code 178 | if self.config.style: 179 | try: 180 | from black import Mode, format_str 181 | except ModuleNotFoundError as e: 182 | if e.name == "black": 183 | raise BlackNotFoundError("Must install black to restyle code.", name="black") 184 | else: 185 | raise 186 | 187 | code = format_str(code, mode=Mode()) 188 | return code 189 | 190 | def __str__(self) -> str: 191 | out = self.code 192 | if self.config.prompt: 193 | # Add prompt and continuation prefixes to lines 194 | lines = out.split("\n") 195 | primary_found = False 196 | out = "" 197 | for line in lines: 198 | if line.strip() == "": 199 | # Whitespace line 200 | out += f"{self.config.prompt} " + line + "\n" 201 | elif line.startswith("#"): 202 | # Comment line 203 | out += f"{self.config.prompt} " + line + "\n" 204 | else: 205 | # Code line 206 | if not primary_found: 207 | out += f"{self.config.prompt} " + line + "\n" 208 | primary_found = True 209 | else: 210 | out += f"{self.config.continuation} " + line + "\n" 211 | return out.rstrip() 212 | 213 | def __bool__(self) -> bool: 214 | """Tests whether this instance contains anything to print. Always true for Statement.""" 215 | return True 216 | 217 | def __repr__(self) -> str: 218 | return f"" 219 | 220 | def __eq__(self, other: Any) -> bool: 221 | if isinstance(other, Statement): 222 | return self.raw_code == other.raw_code 223 | return NotImplemented 224 | 225 | 226 | @dataclasses.dataclass 227 | class Reprex: 228 | """Dataclass for a reprex, which holds Python code and results from evaluation. 229 | 230 | Args: 231 | config (ReprexConfig): Configuration for formatting and parsing 232 | statements (List[Statement]): List of parsed Python code statements 233 | results (List[RawResult]): List of results evaluated from statements 234 | old_results (List[ParsedResult]): List of any old results parsed from input code 235 | scope (Dict[str, Any]): Dictionary holding the scope that the reprex was evaluated in 236 | """ 237 | 238 | config: ReprexConfig 239 | statements: List[Statement] 240 | results: List[RawResult] 241 | old_results: List[ParsedResult] 242 | scope: Dict[str, Any] 243 | 244 | def __post_init__(self) -> None: 245 | if not (len(self.statements) == len(self.results) == len(self.old_results)): 246 | raise UnexpectedError( 247 | "statements, results, and old_results should all be the same length. " 248 | f"Got: {(len(self.statements), len(self.results), len(self.old_results))}." 249 | ) 250 | 251 | @classmethod 252 | def from_input( 253 | cls, 254 | input: str, 255 | config: Optional[ReprexConfig] = None, 256 | scope: Optional[Dict[str, Any]] = None, 257 | ) -> Self: 258 | """Create a Reprex instance from parsing and evaluating code from a string. 259 | 260 | Args: 261 | input (str): Input code 262 | config (Optional[ReprexConfig], optional): Configuration. Defaults to None, which will 263 | use default settings. 264 | scope (Optional[Dict[str, Any]], optional): Dictionary holding scope that the parsed 265 | code will be evaluated with. Defaults to None, which will create an empty 266 | dictionary. 267 | 268 | Returns: 269 | Reprex: New instance of Reprex. 270 | """ 271 | if config is None: 272 | config = ReprexConfig() 273 | if config.parsing_method == ParsingMethod.AUTO: 274 | lines = list(auto_parse(input)) 275 | elif config.parsing_method == ParsingMethod.DECLARED: 276 | lines = list( 277 | parse( 278 | input, 279 | prompt=config.resolved_input_prompt, 280 | continuation=config.resolved_input_continuation, 281 | comment=config.resolved_input_comment, 282 | ) 283 | ) 284 | else: 285 | raise UnexpectedError( # pragma: nocover 286 | f"Parsing method {config.parsing_method} is not implemented." 287 | ) 288 | return cls.from_input_lines(lines, config=config, scope=scope) 289 | 290 | @classmethod 291 | def from_input_lines( 292 | cls, 293 | lines: Sequence[Tuple[str, LineType]], 294 | config: Optional[ReprexConfig] = None, 295 | scope: Optional[Dict[str, Any]] = None, 296 | ) -> Self: 297 | """Creates a Reprex instance from the output of [parse][reprexlite.parsing.parse]. 298 | 299 | Args: 300 | lines (Sequence[Tuple[str, LineType]]): Output from parse. 301 | config (Optional[ReprexConfig], optional): Configuration. Defaults to None, which will 302 | use default settings. 303 | scope (Optional[Dict[str, Any]], optional): Dictionary holding scope that the parsed 304 | code will be evaluated with. Defaults to None, which will create an empty 305 | dictionary. 306 | 307 | Returns: 308 | Reprex: New instance of Reprex. 309 | """ 310 | if config is None: 311 | config = ReprexConfig() 312 | statements: List[Statement] = [] 313 | old_results: List[ParsedResult] = [] 314 | current_code_block: List[str] = [] 315 | current_result_block: List[str] = [] 316 | try: 317 | for line_content, line_type in lines: 318 | if line_type is LineType.CODE: 319 | # Flush results 320 | if current_result_block: 321 | old_results += [ParsedResult(config=config, lines=current_result_block)] 322 | current_result_block = [] 323 | # Append line to current code 324 | current_code_block.append(line_content) 325 | elif line_type is LineType.RESULT: 326 | # Flush code 327 | if current_code_block: 328 | # Parse code and create Statements 329 | tree: cst.Module = cst.parse_module("\n".join(current_code_block)) 330 | new_statements = ( 331 | [Statement(config=config, stmt=stmt) for stmt in tree.header] 332 | + [Statement(config=config, stmt=stmt) for stmt in tree.body] 333 | + [Statement(config=config, stmt=stmt) for stmt in tree.footer] 334 | ) 335 | statements += new_statements 336 | # Pad results with empty results, 1 fewer because of current_result_block 337 | old_results += [ParsedResult(config=config, lines=[])] * ( 338 | len(new_statements) - 1 339 | ) 340 | # Reset current code block 341 | current_code_block = [] 342 | # Append line to current results 343 | current_result_block.append(line_content) 344 | # Flush code 345 | if current_code_block: 346 | if all(not line for line in current_code_block): 347 | # Case where all lines are whitespace: strip and don't add 348 | new_statements = [] 349 | else: 350 | # Parse code and create Statements 351 | tree: cst.Module = cst.parse_module( # type: ignore[no-redef] 352 | "\n".join(current_code_block) 353 | ) 354 | new_statements = ( 355 | [Statement(config=config, stmt=stmt) for stmt in tree.header] 356 | + [Statement(config=config, stmt=stmt) for stmt in tree.body] 357 | + [Statement(config=config, stmt=stmt) for stmt in tree.footer] 358 | ) 359 | # Pad results with empty results, 1 fewer because of current_result_block 360 | statements += new_statements 361 | old_results += [ParsedResult(config=config, lines=[])] * (len(new_statements) - 1) 362 | # Flush results 363 | if current_result_block: 364 | old_results += [ParsedResult(config=config, lines=current_result_block)] 365 | # Pad results to equal length 366 | old_results += [ParsedResult(config=config, lines=[])] * ( 367 | len(statements) - len(old_results) 368 | ) 369 | 370 | # Evaluate for new results 371 | if scope is None: 372 | scope = {} 373 | results = [statement.evaluate(scope=scope) for statement in statements] 374 | return cls( 375 | config=config, 376 | statements=statements, 377 | results=results, 378 | old_results=old_results, 379 | scope=scope, 380 | ) 381 | except cst.ParserSyntaxError as e: 382 | raise InputSyntaxError(str(e)) from e 383 | 384 | def __eq__(self, other: Any) -> bool: 385 | if isinstance(other, Reprex): 386 | return ( 387 | self.config == other.config 388 | and all(left == right for left, right in zip(self.statements, other.statements)) 389 | and all(left == right for left, right in zip(self.results, other.results)) 390 | and all(left == right for left, right in zip(self.old_results, other.old_results)) 391 | ) 392 | else: 393 | return NotImplemented 394 | 395 | def __str__(self) -> str: 396 | return self.render() 397 | 398 | @property 399 | def results_match(self) -> bool: 400 | """Whether results of evaluating code match old results parsed from input.""" 401 | return all( 402 | result == old_result for result, old_result in zip(self.results, self.old_results) 403 | ) 404 | 405 | def render(self, terminal: bool = False) -> str: 406 | """Render the reprex as code.""" 407 | if self.config.keep_old_results: 408 | lines = chain.from_iterable(zip(self.statements, self.old_results, self.results)) 409 | else: 410 | lines = chain.from_iterable(zip(self.statements, self.results)) 411 | out = "\n".join(str(line) for line in lines if line) 412 | if not out.endswith("\n"): 413 | out += "\n" 414 | # if terminal=True and Pygments is available, apply syntax highlighting 415 | if terminal: 416 | try: 417 | from pygments import highlight 418 | from pygments.formatters import Terminal256Formatter 419 | from pygments.lexers import PythonLexer 420 | 421 | out = highlight(out, PythonLexer(), Terminal256Formatter(style="friendly")) 422 | except ModuleNotFoundError: 423 | pass 424 | return out 425 | 426 | def render_and_format(self, terminal: bool = False) -> str: 427 | """Render the reprex as code and format it for the configured output venue.""" 428 | out = self.render(terminal=terminal) 429 | formatter_fn = formatter_registry[self.config.venue].fn 430 | return formatter_fn(out.strip(), config=self.config) 431 | 432 | def __repr__(self) -> str: 433 | return f"" 434 | 435 | def _repr_html_(self) -> str: 436 | """HTML representation. Used for rendering in Jupyter.""" 437 | out = [] 438 | try: 439 | from pygments import highlight 440 | from pygments.formatters import HtmlFormatter 441 | from pygments.lexers import PythonLexer 442 | 443 | formatter = HtmlFormatter(style="friendly", wrapcode=True) 444 | out.append(f"") 445 | out.append(highlight(self.render_and_format(), PythonLexer(), formatter)) 446 | except ModuleNotFoundError: 447 | out.append(f"
{self.render_and_format()}
") 448 | return "\n".join(out) 449 | 450 | 451 | def to_snippet(s: str, n: int) -> str: 452 | if len(s) <= n: 453 | return rf"{s}" 454 | else: 455 | return rf"{s[:n]}..." 456 | 457 | 458 | def reprex( 459 | input: str, 460 | outfile: Optional[Union[str, os.PathLike]] = None, 461 | print_: bool = True, 462 | terminal: bool = False, 463 | config: Optional[ReprexConfig] = None, 464 | **kwargs, 465 | ) -> Reprex: 466 | """A convenient functional interface to render reproducible examples of Python code for 467 | sharing. This function will evaluate your code and, by default, print out your code with the 468 | evaluated results embedded as comments, formatted with additional markup appropriate to the 469 | sharing venue set by the `venue` keyword argument. The function returns an instance of 470 | [`Reprex`][reprexlite.reprexes.Reprex] which holds the relevant data. 471 | 472 | For example, for the `gh` venue for GitHub Flavored Markdown, you'll get a reprex whose 473 | formatted output looks like: 474 | 475 | ```` 476 | ```python 477 | x = 2 478 | x + 2 479 | #> 4 480 | ``` 481 | 482 | Created at 2021-02-15 16:58:47 PST by [reprexlite](https://github.com/jayqi/reprexlite) 483 | ```` 484 | 485 | 486 | Args: 487 | input (str): Input code to create a reprex for. 488 | outfile (Optional[str | os.PathLike]): If provided, path to write formatted reprex 489 | output to. Defaults to None, which does not write to any file. 490 | print_ (bool): Whether to print formatted reprex output to console. 491 | terminal (bool): Whether currently in a terminal. If true, will automatically apply code 492 | highlighting if pygments is installed. 493 | config (Optional[ReprexConfig]): Instance of the configuration dataclass. Default of none 494 | will instantiate one with default values. 495 | **kwargs: Configuration options from [ReprexConfig][reprexlite.config.ReprexConfig]. Any 496 | provided values will override values from provided config or the defaults. 497 | 498 | Returns: 499 | (Reprex) Reprex instance 500 | """ # noqa: E501 501 | 502 | if config is None: 503 | config = ReprexConfig(**kwargs) 504 | else: 505 | config = dataclasses.replace(config, **kwargs) 506 | 507 | config = ReprexConfig(**kwargs) 508 | if config.venue in ["html", "rtf"]: 509 | # Don't screw up output file or lexing for HTML and RTF with terminal syntax highlighting 510 | terminal = False 511 | r = Reprex.from_input(input, config=config) 512 | output = r.render_and_format(terminal=terminal) 513 | if outfile is not None: 514 | with Path(outfile).open("w") as fp: 515 | fp.write(r.render_and_format(terminal=False)) 516 | if print_: 517 | print(output) 518 | return r 519 | -------------------------------------------------------------------------------- /reprexlite/session_info.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import platform 3 | from typing import List, Tuple 4 | 5 | 6 | class SessionInfo: 7 | """Class for pretty-formatting Python session info. Includes details about your Python version, 8 | your operating system, and the Python packages installed in your current environment. 9 | 10 | Attributes: 11 | python_version (str): Python version for current session 12 | python_build_date (str): Date 13 | os (str): OS information for current session 14 | packages (List[Package]): List of Python packages installed in current virtual environment. 15 | """ 16 | 17 | def __init__(self) -> None: 18 | self.python_version: str = platform.python_version() 19 | self.python_build_date: str = platform.python_build()[1] 20 | 21 | self.os: str = platform.platform() 22 | self.packages: List[Package] = [ 23 | Package(distr) for distr in importlib.metadata.Distribution.discover() 24 | ] 25 | 26 | def __str__(self) -> str: 27 | lines = ["-- Session Info --" + "-" * 60] 28 | lines += tabulate( 29 | [ 30 | ("version", f"Python {self.python_version} ({self.python_build_date})"), 31 | ("os", self.os), 32 | ] 33 | ) 34 | lines += ["-- Packages --" + "-" * 64] 35 | lines += tabulate([(pkg.name, pkg.version) for pkg in sorted(self.packages)]) 36 | return "\n".join(lines).strip() 37 | 38 | 39 | class Package: 40 | """Interface for adapting [`importlib.metadata.Distribution`](https://docs.python.org/3/library/importlib.metadata.html#distributions) 41 | instances for introspection by [`SessionInfo`][reprexlite.session_info.SessionInfo]. 42 | """ # noqa: E501 43 | 44 | def __init__(self, distribution: importlib.metadata.Distribution): 45 | self.distribution = distribution 46 | 47 | @property 48 | def name(self) -> str: 49 | return self.distribution.metadata["Name"] 50 | 51 | @property 52 | def version(self) -> str: 53 | return self.distribution.version 54 | 55 | def __lt__(self, other) -> bool: 56 | if isinstance(other, Package): 57 | return self.name < other.name 58 | return NotImplemented # pragma: nocover 59 | 60 | 61 | def tabulate(rows: List[Tuple[str, str]]) -> List[str]: 62 | """Utility function for printing a two-column table as text with whitespace padding. 63 | 64 | Args: 65 | rows (List[Tuple[str, str]]): Rows of table as tuples of (left cell, right cell) 66 | 67 | Returns: 68 | Rows of table formatted as strings with whitespace padding 69 | """ 70 | left_max = max(len(row[0]) for row in rows) 71 | out = [] 72 | for left, right in rows: 73 | padding = (left_max + 1 - len(left)) * " " 74 | out.append(left + padding + right) 75 | return out 76 | -------------------------------------------------------------------------------- /reprexlite/version.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | __version__ = importlib.metadata.version(__name__.split(".", 1)[0]) 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e .[black,pygments,ipython] 2 | 3 | black 4 | build 5 | mdx_truly_sane_lists==1.3 6 | mike 7 | mkdocs>=1.2.2 8 | mkdocs-jupyter 9 | mkdocs-macros-plugin 10 | mkdocs-material>=7.2.6 11 | mkdocstrings[python-legacy]>=0.15.2 12 | mypy 13 | pip>=21.3 14 | py-markdown-table==0.3.3 15 | pytest 16 | pytest-cov 17 | ruff 18 | tqdm 19 | typenames 20 | wheel 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jayqi/reprexlite/b6a624a11b485073de8d680402a66756b65ea264/tests/__init__.py -------------------------------------------------------------------------------- /tests/assets/ad/ds.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | -------------------------------------------------------------------------------- /tests/assets/ad/gh.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | -------------------------------------------------------------------------------- /tests/assets/ad/html.html: -------------------------------------------------------------------------------- 1 | 76 |
1
77 | 2
78 | 3
x = 2
79 | x + 2
80 | #> 4
81 | 
82 | 83 |

Created at DATETIME by reprexlite vVERSION

84 | -------------------------------------------------------------------------------- /tests/assets/ad/py.py: -------------------------------------------------------------------------------- 1 | x = 2 2 | x + 2 3 | #> 4 4 | 5 | # Created at DATETIME by reprexlite vVERSION 6 | -------------------------------------------------------------------------------- /tests/assets/ad/rtf.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\uc0\deff0{\fonttbl{\f0\fmodern\fprq1\fcharset0;}} 2 | {\colortbl; 3 | \red187\green187\blue187; 4 | \red61\green123\blue123; 5 | \red156\green101\blue0; 6 | \red0\green128\blue0; 7 | \red176\green0\blue64; 8 | \red102\green102\blue102; 9 | \red170\green34\blue255; 10 | \red0\green0\blue255; 11 | \red203\green63\blue56; 12 | \red25\green23\blue124; 13 | \red136\green0\blue0; 14 | \red118\green118\blue0; 15 | \red113\green113\blue113; 16 | \red104\green120\blue34; 17 | \red186\green33\blue33; 18 | \red164\green90\blue119; 19 | \red170\green93\blue31; 20 | \red0\green0\blue128; 21 | \red128\green0\blue128; 22 | \red160\green0\blue0; 23 | \red0\green132\blue0; 24 | \red228\green0\blue0; 25 | \red0\green68\blue221; 26 | \red255\green0\blue0; 27 | } 28 | \f0\sa0 29 | \dntblnsbdb 30 | x {\cf6 =} {\cf6 2}{\cf1 \par} 31 | x {\cf6 +} {\cf6 2}{\cf1 \par} 32 | {\cf2\i #> 4}{\cf1 \par} 33 | {\cf1 \par} 34 | Created at DATETIME by reprexlite vVERSION {\cf6 <}https:{\cf6 /}{\cf6 /}github{\cf6 .}com{\cf6 /}jayqi{\cf6 /}reprexlite{\cf6 >}{\cf1 \par} 35 | } 36 | 37 | -------------------------------------------------------------------------------- /tests/assets/ad/slack.txt: -------------------------------------------------------------------------------- 1 | ``` 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by reprexlite vVERSION 8 | -------------------------------------------------------------------------------- /tests/assets/ad/so.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | -------------------------------------------------------------------------------- /tests/assets/ds.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | -------------------------------------------------------------------------------- /tests/assets/gh.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | -------------------------------------------------------------------------------- /tests/assets/html.html: -------------------------------------------------------------------------------- 1 | 76 |
1
77 | 2
78 | 3
x = 2
79 | x + 2
80 | #> 4
81 | 
82 | 83 |

Created at DATETIME by reprexlite vVERSION

84 | -------------------------------------------------------------------------------- /tests/assets/no_ad/ds.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/no_ad/gh.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/no_ad/html.html: -------------------------------------------------------------------------------- 1 | 76 |
1
77 | 2
78 | 3
x = 2
79 | x + 2
80 | #> 4
81 | 
82 | 83 | -------------------------------------------------------------------------------- /tests/assets/no_ad/py.py: -------------------------------------------------------------------------------- 1 | x = 2 2 | x + 2 3 | #> 4 4 | -------------------------------------------------------------------------------- /tests/assets/no_ad/rtf.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\uc0\deff0{\fonttbl{\f0\fmodern\fprq1\fcharset0;}} 2 | {\colortbl; 3 | \red187\green187\blue187; 4 | \red61\green123\blue123; 5 | \red156\green101\blue0; 6 | \red0\green128\blue0; 7 | \red176\green0\blue64; 8 | \red102\green102\blue102; 9 | \red170\green34\blue255; 10 | \red0\green0\blue255; 11 | \red203\green63\blue56; 12 | \red25\green23\blue124; 13 | \red136\green0\blue0; 14 | \red118\green118\blue0; 15 | \red113\green113\blue113; 16 | \red104\green120\blue34; 17 | \red186\green33\blue33; 18 | \red164\green90\blue119; 19 | \red170\green93\blue31; 20 | \red0\green0\blue128; 21 | \red128\green0\blue128; 22 | \red160\green0\blue0; 23 | \red0\green132\blue0; 24 | \red228\green0\blue0; 25 | \red0\green68\blue221; 26 | \red255\green0\blue0; 27 | } 28 | \f0\sa0 29 | \dntblnsbdb 30 | x {\cf6 =} {\cf6 2}{\cf1 \par} 31 | x {\cf6 +} {\cf6 2}{\cf1 \par} 32 | {\cf2\i #> 4}{\cf1 \par} 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tests/assets/no_ad/slack.txt: -------------------------------------------------------------------------------- 1 | ``` 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/no_ad/so.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/py.py: -------------------------------------------------------------------------------- 1 | x = 2 2 | x + 2 3 | #> 4 4 | -------------------------------------------------------------------------------- /tests/assets/rtf.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\uc0\deff0{\fonttbl{\f0\fmodern\fprq1\fcharset0;}} 2 | {\colortbl; 3 | \red187\green187\blue187; 4 | \red61\green123\blue123; 5 | \red156\green101\blue0; 6 | \red0\green128\blue0; 7 | \red176\green0\blue64; 8 | \red102\green102\blue102; 9 | \red170\green34\blue255; 10 | \red0\green0\blue255; 11 | \red203\green63\blue56; 12 | \red25\green23\blue124; 13 | \red136\green0\blue0; 14 | \red118\green118\blue0; 15 | \red113\green113\blue113; 16 | \red104\green120\blue34; 17 | \red186\green33\blue33; 18 | \red164\green90\blue119; 19 | \red170\green93\blue31; 20 | \red0\green0\blue128; 21 | \red128\green0\blue128; 22 | \red160\green0\blue0; 23 | \red0\green132\blue0; 24 | \red228\green0\blue0; 25 | \red0\green68\blue221; 26 | \red255\green0\blue0; 27 | } 28 | \f0\sa0 29 | \dntblnsbdb 30 | x {\cf6 =} {\cf6 2}{\cf1 \par} 31 | x {\cf6 +} {\cf6 2}{\cf1 \par} 32 | {\cf2\i #> 4}{\cf1 \par} 33 | } 34 | 35 | -------------------------------------------------------------------------------- /tests/assets/session_info/ds.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | 9 |
Session Info 10 | ```text 11 | -- Session Info -------------------------------------------------------------- 12 | version Python 3.x.y (Jan 01 2020 03:33:33) 13 | os GLaDOS 14 | -- Packages ------------------------------------------------------------------ 15 | datatable 1.0 16 | ggplot2 2.0 17 | pkgnet 3.0 18 | ``` 19 |
20 | -------------------------------------------------------------------------------- /tests/assets/session_info/gh.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | 9 |
Session Info 10 | ```text 11 | -- Session Info -------------------------------------------------------------- 12 | version Python 3.x.y (Jan 01 2020 03:33:33) 13 | os GLaDOS 14 | -- Packages ------------------------------------------------------------------ 15 | datatable 1.0 16 | ggplot2 2.0 17 | pkgnet 3.0 18 | ``` 19 |
20 | -------------------------------------------------------------------------------- /tests/assets/session_info/html.html: -------------------------------------------------------------------------------- 1 | 76 |
1
77 | 2
78 | 3
x = 2
79 | x + 2
80 | #> 4
81 | 
82 | 83 |

Created at DATETIME by reprexlite vVERSION

84 |
Session Info 85 |
-- Session Info --------------------------------------------------------------
86 | version Python 3.x.y (Jan 01 2020 03:33:33)
87 | os      GLaDOS
88 | -- Packages ------------------------------------------------------------------
89 | datatable 1.0
90 | ggplot2   2.0
91 | pkgnet    3.0
92 |
93 | -------------------------------------------------------------------------------- /tests/assets/session_info/py.py: -------------------------------------------------------------------------------- 1 | x = 2 2 | x + 2 3 | #> 4 4 | 5 | # -- Session Info -------------------------------------------------------------- 6 | # version Python 3.x.y (Jan 01 2020 03:33:33) 7 | # os GLaDOS 8 | # -- Packages ------------------------------------------------------------------ 9 | # datatable 1.0 10 | # ggplot2 2.0 11 | # pkgnet 3.0 12 | -------------------------------------------------------------------------------- /tests/assets/session_info/rtf.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\uc0\deff0{\fonttbl{\f0\fmodern\fprq1\fcharset0;}} 2 | {\colortbl; 3 | \red187\green187\blue187; 4 | \red61\green123\blue123; 5 | \red156\green101\blue0; 6 | \red0\green128\blue0; 7 | \red176\green0\blue64; 8 | \red102\green102\blue102; 9 | \red170\green34\blue255; 10 | \red0\green0\blue255; 11 | \red203\green63\blue56; 12 | \red25\green23\blue124; 13 | \red136\green0\blue0; 14 | \red118\green118\blue0; 15 | \red113\green113\blue113; 16 | \red104\green120\blue34; 17 | \red186\green33\blue33; 18 | \red164\green90\blue119; 19 | \red170\green93\blue31; 20 | \red0\green0\blue128; 21 | \red128\green0\blue128; 22 | \red160\green0\blue0; 23 | \red0\green132\blue0; 24 | \red228\green0\blue0; 25 | \red0\green68\blue221; 26 | \red255\green0\blue0; 27 | } 28 | \f0\sa0 29 | \dntblnsbdb 30 | x {\cf6 =} {\cf6 2}{\cf1 \par} 31 | x {\cf6 +} {\cf6 2}{\cf1 \par} 32 | {\cf2\i #> 4}{\cf1 \par} 33 | {\cf1 \par} 34 | {\cf6 -}{\cf6 -} Session Info {\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf1 \par} 35 | version Python {\cf6 3.}x{\cf6 .}y (Jan {\cf6 01} {\cf6 2020} {\cf6 03}:{\cf6 33}:{\cf6 33}){\cf1 \par} 36 | os GLaDOS{\cf1 \par} 37 | {\cf6 -}{\cf6 -} Packages {\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf6 -}{\cf1 \par} 38 | datatable {\cf6 1.0}{\cf1 \par} 39 | ggplot2 {\cf6 2.0}{\cf1 \par} 40 | pkgnet {\cf6 3.0}{\cf1 \par} 41 | } 42 | 43 | -------------------------------------------------------------------------------- /tests/assets/session_info/slack.txt: -------------------------------------------------------------------------------- 1 | ``` 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | ``` 8 | -- Session Info -------------------------------------------------------------- 9 | version Python 3.x.y (Jan 01 2020 03:33:33) 10 | os GLaDOS 11 | -- Packages ------------------------------------------------------------------ 12 | datatable 1.0 13 | ggplot2 2.0 14 | pkgnet 3.0 15 | ``` 16 | -------------------------------------------------------------------------------- /tests/assets/session_info/so.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | 9 |
Session Info 10 | ```text 11 | -- Session Info -------------------------------------------------------------- 12 | version Python 3.x.y (Jan 01 2020 03:33:33) 13 | os GLaDOS 14 | -- Packages ------------------------------------------------------------------ 15 | datatable 1.0 16 | ggplot2 2.0 17 | pkgnet 3.0 18 | ``` 19 |
20 | -------------------------------------------------------------------------------- /tests/assets/slack.txt: -------------------------------------------------------------------------------- 1 | ``` 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | -------------------------------------------------------------------------------- /tests/assets/so.md: -------------------------------------------------------------------------------- 1 | ```python 2 | x = 2 3 | x + 2 4 | #> 4 5 | ``` 6 | 7 | Created at DATETIME by [reprexlite](https://github.com/jayqi/reprexlite) vVERSION 8 | -------------------------------------------------------------------------------- /tests/expected_formatted.py: -------------------------------------------------------------------------------- 1 | """This module holds metadata about formatted test cases. It also can be run as a script to 2 | generate expected formatted test assets. 3 | 4 | python -m tests.expected_formatted 5 | """ 6 | 7 | from contextlib import contextmanager 8 | from dataclasses import dataclass 9 | from pathlib import Path 10 | import shutil 11 | import sys 12 | from textwrap import dedent 13 | from typing import Any, Dict 14 | 15 | from tqdm import tqdm 16 | 17 | from reprexlite import reprex 18 | from reprexlite.session_info import Package, SessionInfo 19 | 20 | ASSETS_DIR = (Path(__file__).parent / "assets").resolve() 21 | 22 | 23 | INPUT = dedent( 24 | """\ 25 | x = 2 26 | x + 2 27 | """ 28 | ) 29 | 30 | 31 | @dataclass 32 | class ExpectedReprex: 33 | filename: str 34 | kwargs: Dict[str, Any] 35 | 36 | 37 | expected_reprexes = [ 38 | # Defaults 39 | ExpectedReprex("gh.md", {"venue": "gh"}), 40 | ExpectedReprex("so.md", {"venue": "so"}), 41 | ExpectedReprex("ds.md", {"venue": "ds"}), 42 | ExpectedReprex("html.html", {"venue": "html"}), 43 | ExpectedReprex("py.py", {"venue": "py"}), 44 | ExpectedReprex("rtf.rtf", {"venue": "rtf"}), 45 | ExpectedReprex("slack.txt", {"venue": "slack"}), 46 | # With ad 47 | ExpectedReprex("ad/gh.md", {"venue": "gh", "advertise": True}), 48 | ExpectedReprex("ad/so.md", {"venue": "so", "advertise": True}), 49 | ExpectedReprex("ad/ds.md", {"venue": "ds", "advertise": True}), 50 | ExpectedReprex("ad/html.html", {"venue": "html", "advertise": True}), 51 | ExpectedReprex("ad/py.py", {"venue": "py", "advertise": True}), 52 | ExpectedReprex("ad/rtf.rtf", {"venue": "rtf", "advertise": True}), 53 | ExpectedReprex("ad/slack.txt", {"venue": "slack", "advertise": True}), 54 | # No ad 55 | ExpectedReprex("no_ad/gh.md", {"venue": "gh", "advertise": False}), 56 | ExpectedReprex("no_ad/so.md", {"venue": "so", "advertise": False}), 57 | ExpectedReprex("no_ad/ds.md", {"venue": "ds", "advertise": False}), 58 | ExpectedReprex("no_ad/html.html", {"venue": "html", "advertise": False}), 59 | ExpectedReprex("no_ad/py.py", {"venue": "py", "advertise": False}), 60 | ExpectedReprex("no_ad/rtf.rtf", {"venue": "rtf", "advertise": False}), 61 | ExpectedReprex("no_ad/slack.txt", {"venue": "slack", "advertise": False}), 62 | # With session info 63 | ExpectedReprex("session_info/gh.md", {"venue": "gh", "session_info": True}), 64 | ExpectedReprex("session_info/so.md", {"venue": "so", "session_info": True}), 65 | ExpectedReprex("session_info/ds.md", {"venue": "ds", "session_info": True}), 66 | ExpectedReprex("session_info/html.html", {"venue": "html", "session_info": True}), 67 | ExpectedReprex("session_info/py.py", {"venue": "py", "session_info": True}), 68 | ExpectedReprex("session_info/rtf.rtf", {"venue": "rtf", "session_info": True}), 69 | ExpectedReprex("session_info/slack.txt", {"venue": "slack", "session_info": True}), 70 | ] 71 | 72 | MOCK_VERSION = "VERSION" 73 | 74 | 75 | @contextmanager 76 | def patch_version(): 77 | version = sys.modules["reprexlite.formatting"].__version__ 78 | sys.modules["reprexlite.formatting"].__version__ = MOCK_VERSION 79 | yield 80 | sys.modules["reprexlite.formatting"].__version__ = version 81 | 82 | 83 | class MockDateTime: 84 | @classmethod 85 | def now(cls): 86 | return cls() 87 | 88 | def astimezone(self): 89 | return self 90 | 91 | def strftime(self, format): 92 | return "DATETIME" 93 | 94 | 95 | @contextmanager 96 | def patch_datetime(): 97 | datetime = sys.modules["reprexlite.formatting"].datetime 98 | sys.modules["reprexlite.formatting"].datetime = MockDateTime 99 | yield 100 | sys.modules["reprexlite.formatting"].datetime = datetime 101 | 102 | 103 | class MockPackage(Package): 104 | def __init__(self, name: str, version: str): 105 | self._name = name 106 | self._version = version 107 | 108 | @property 109 | def name(self): 110 | return self._name 111 | 112 | @property 113 | def version(self): 114 | return self._version 115 | 116 | 117 | class MockSessionInfo(SessionInfo): 118 | def __init__(self, *args, **kwargs): 119 | self.python_version = "3.x.y" 120 | self.python_build_date = "Jan 01 2020 03:33:33" 121 | self.os = "GLaDOS" 122 | self.packages = [ 123 | MockPackage("datatable", "1.0"), 124 | MockPackage("ggplot2", "2.0"), 125 | MockPackage("pkgnet", "3.0"), 126 | ] 127 | 128 | 129 | @contextmanager 130 | def patch_session_info(): 131 | sys.modules["reprexlite.formatting"].SessionInfo = MockSessionInfo 132 | yield 133 | sys.modules["reprexlite.formatting"].SessionInfo = SessionInfo 134 | 135 | 136 | if __name__ == "__main__": 137 | shutil.rmtree(ASSETS_DIR, ignore_errors=True) 138 | with patch_datetime(), patch_version(), patch_session_info(): 139 | for ereprex in tqdm(expected_reprexes): 140 | outfile = ASSETS_DIR / ereprex.filename 141 | outfile.parent.mkdir(exist_ok=True) 142 | reprex(INPUT, outfile=outfile, **ereprex.kwargs, print_=False) 143 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import subprocess 3 | import sys 4 | from textwrap import dedent 5 | 6 | import platformdirs 7 | import pytest 8 | 9 | import reprexlite.cli 10 | from reprexlite.cli import app, user_reprexlite_toml_loader 11 | from reprexlite.exceptions import InputSyntaxError, IPythonNotFoundError 12 | from reprexlite.version import __version__ 13 | from tests.utils import remove_ansi_escape 14 | 15 | INPUT = dedent( 16 | """\ 17 | x = 2 18 | x + 2 19 | """ 20 | ) 21 | EXPECTED = dedent( 22 | """\ 23 | x = 2 24 | x + 2 25 | #> 4 26 | """ 27 | ) 28 | 29 | 30 | @pytest.fixture 31 | def patch_edit(monkeypatch): 32 | class EditPatch: 33 | def __init__(self): 34 | self.input = INPUT 35 | 36 | def mock_edit(self, *args, **kwargs): 37 | sys.stderr.write("Mocking editor\n") 38 | return self.input 39 | 40 | patch = EditPatch() 41 | monkeypatch.setattr(reprexlite.cli, "handle_editor", patch.mock_edit) 42 | yield patch 43 | 44 | 45 | @pytest.fixture 46 | def no_ipython(monkeypatch): 47 | import_orig = builtins.__import__ 48 | 49 | def mocked_import(name, *args): 50 | if name.startswith("reprexlite.ipython"): 51 | raise IPythonNotFoundError 52 | return import_orig(name, *args) 53 | 54 | monkeypatch.setattr(builtins, "__import__", mocked_import) 55 | 56 | 57 | @pytest.fixture 58 | def project_dir(tmp_path, monkeypatch): 59 | project_dir = tmp_path / "project_dir" 60 | project_dir.mkdir() 61 | monkeypatch.chdir(project_dir) 62 | yield project_dir 63 | 64 | 65 | @pytest.fixture 66 | def user_config_dir(tmp_path, monkeypatch): 67 | user_config_dir = tmp_path / "user_config_dir" 68 | user_config_dir.mkdir() 69 | 70 | def _mock_get_user_config_dir(*args, **kwargs): 71 | return user_config_dir 72 | 73 | monkeypatch.setattr(platformdirs, "user_config_dir", _mock_get_user_config_dir) 74 | yield user_config_dir 75 | 76 | 77 | def test_reprex(project_dir, user_config_dir, patch_edit, capsys): 78 | assert reprexlite.cli.handle_editor == patch_edit.mock_edit 79 | capsys.readouterr() 80 | app([]) 81 | stdout = capsys.readouterr().out 82 | print(stdout) 83 | assert EXPECTED in remove_ansi_escape(stdout) 84 | 85 | 86 | def test_reprex_infile(project_dir, user_config_dir, tmp_path, capsys): 87 | infile = tmp_path / "infile.py" 88 | with infile.open("w") as fp: 89 | fp.write(INPUT) 90 | app(["-i", str(infile)]) 91 | stdout = capsys.readouterr().out 92 | print(stdout) 93 | assert EXPECTED in remove_ansi_escape(stdout) 94 | 95 | 96 | def test_reprex_outfile(project_dir, user_config_dir, patch_edit, tmp_path, capsys): 97 | outfile = tmp_path / "outfile.md" 98 | app(["-o", str(outfile)]) 99 | with outfile.open("r") as fp: 100 | assert EXPECTED in fp.read() 101 | stdout = capsys.readouterr().out 102 | print(stdout) 103 | assert str(outfile) in stdout 104 | 105 | 106 | def test_old_results(project_dir, user_config_dir, patch_edit, capsys): 107 | patch_edit.input = dedent( 108 | """\ 109 | arr = [1, 2, 3, 4, 5] 110 | [x + 1 for x in arr] 111 | #> old line 112 | """ 113 | ) 114 | 115 | # no --old-results (default) 116 | capsys.readouterr() 117 | app([]) 118 | stdout = capsys.readouterr().out 119 | print(stdout) 120 | assert "#> old line" not in stdout 121 | assert "#> [2, 3, 4, 5, 6]" in stdout 122 | 123 | # with --old-results 124 | app(["--keep-old-results"]) 125 | stdout = capsys.readouterr().out 126 | print(stdout) 127 | assert "#> old line" in stdout 128 | assert "#> [2, 3, 4, 5, 6]" in stdout 129 | 130 | 131 | def test_ipython_editor_launch(project_dir, user_config_dir): 132 | """Test that IPython interactive editor opens as expected. Not testing a reprex.""" 133 | 134 | result = subprocess.run( 135 | [sys.executable, "-I", "-m", "reprexlite", "-e", "ipython"], 136 | stdout=subprocess.PIPE, 137 | stderr=subprocess.PIPE, 138 | universal_newlines=True, 139 | text=True, 140 | input="exit", 141 | ) 142 | assert result.returncode == 0 143 | assert "Interactive reprex editor via IPython" in result.stdout # text from banner 144 | 145 | 146 | def test_ipython_editor_not_installed(project_dir, user_config_dir, no_ipython, capsys): 147 | """Test for expected error when opening the IPython interactive editor without IPython 148 | installed""" 149 | with pytest.raises(SystemExit) as excinfo: 150 | app(["-e", "ipython"]) 151 | assert excinfo.value.code == 1 152 | stdout = capsys.readouterr().out 153 | assert "ipython is required" in stdout 154 | 155 | 156 | def test_help(project_dir, user_config_dir, capsys): 157 | """Test the CLI with --help flag.""" 158 | app(["--help"]) 159 | stdout = capsys.readouterr().out 160 | assert "Render reproducible examples of Python code for sharing." in stdout 161 | 162 | 163 | def test_version(project_dir, user_config_dir, capsys): 164 | """Test the CLI with --version flag.""" 165 | app(["--version"]) 166 | stdout = capsys.readouterr().out 167 | assert stdout.strip() == __version__ 168 | 169 | 170 | def test_python_m_version(project_dir, user_config_dir): 171 | """Test the CLI with python -m and --version flag.""" 172 | result = subprocess.run( 173 | [sys.executable, "-I", "-m", "reprexlite", "--version"], 174 | stdout=subprocess.PIPE, 175 | stderr=subprocess.PIPE, 176 | universal_newlines=True, 177 | ) 178 | assert result.returncode == 0 179 | assert result.stdout.strip() == __version__ 180 | 181 | 182 | def test_pyproject_toml(project_dir, user_config_dir): 183 | pyproject_toml = project_dir / "pyproject.toml" 184 | with pyproject_toml.open("w") as fp: 185 | fp.write( 186 | dedent( 187 | """\ 188 | [tool.reprexlite] 189 | editor = "test_editor" 190 | """ 191 | ) 192 | ) 193 | params = app(["--debug"]) 194 | assert params["config"]["editor"] == "test_editor" 195 | 196 | 197 | @pytest.mark.parametrize("filename", [".reprexlite.toml", "reprexlite.toml"]) 198 | def test_reprexlite_toml(project_dir, user_config_dir, filename): 199 | reprexlite_toml = project_dir / filename 200 | with reprexlite_toml.open("w") as fp: 201 | fp.write( 202 | dedent( 203 | """\ 204 | editor = "test_editor" 205 | """ 206 | ) 207 | ) 208 | params = app(["--debug"]) 209 | assert params["config"]["editor"] == "test_editor" 210 | 211 | 212 | def test_user_config_dir(project_dir, user_config_dir, monkeypatch): 213 | with (user_config_dir / "config.toml").open("w") as fp: 214 | fp.write( 215 | dedent( 216 | """\ 217 | editor = "test_editor" 218 | """ 219 | ) 220 | ) 221 | monkeypatch.setattr(user_reprexlite_toml_loader, "path", user_config_dir / "config.toml") 222 | params = app(["--debug"]) 223 | assert params["config"]["editor"] == "test_editor" 224 | 225 | 226 | def test_input_syntax_error(project_dir, user_config_dir, patch_edit, capsys): 227 | assert reprexlite.cli.handle_editor == patch_edit.mock_edit 228 | capsys.readouterr() 229 | patch_edit.input = "=" 230 | with pytest.raises(InputSyntaxError): 231 | app([]) 232 | stdout = capsys.readouterr().out 233 | print(stdout) 234 | assert ( 235 | "ERROR: reprexlite has encountered an error while evaluating your input" 236 | in remove_ansi_escape(stdout) 237 | ) 238 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from reprexlite.config import ReprexConfig 4 | from reprexlite.exceptions import ( 5 | InvalidParsingMethodError, 6 | InvalidVenueError, 7 | PromptLengthMismatchError, 8 | ) 9 | 10 | 11 | def test_prompt_length_mismatch(): 12 | with pytest.raises(PromptLengthMismatchError): 13 | ReprexConfig(prompt="123", continuation="1234") 14 | 15 | 16 | def test_invalid_parsing_method(): 17 | with pytest.raises(InvalidParsingMethodError): 18 | ReprexConfig(parsing_method="???") 19 | 20 | 21 | def test_invalid_venue(): 22 | with pytest.raises(InvalidVenueError): 23 | ReprexConfig(venue="carnegie_hall") 24 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | from reprexlite.exceptions import UnexpectedError 2 | 3 | 4 | def test_unexpected_error_messsage(): 5 | """Test that UnexpectedError adds message to report an issue.""" 6 | e = UnexpectedError("Boo!") 7 | assert str(e).startswith("Boo! "), str(e) 8 | assert "report" in str(e), str(e) 9 | assert "github.com/jayqi/reprexlite/issues" in str(e), str(e) 10 | -------------------------------------------------------------------------------- /tests/test_formatting.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import sys 3 | from textwrap import dedent 4 | 5 | import pytest 6 | 7 | from reprexlite.config import ReprexConfig, Venue 8 | from reprexlite.exceptions import PygmentsNotFoundError 9 | from reprexlite.formatting import formatter_registry 10 | from reprexlite.reprexes import Reprex 11 | from tests.expected_formatted import ( 12 | ASSETS_DIR, 13 | INPUT, 14 | MOCK_VERSION, 15 | MockDateTime, 16 | MockSessionInfo, 17 | expected_reprexes, 18 | ) 19 | from tests.utils import assert_str_equals 20 | 21 | 22 | @pytest.fixture 23 | def patch_datetime(monkeypatch): 24 | monkeypatch.setattr(sys.modules["reprexlite.formatting"], "datetime", MockDateTime) 25 | 26 | 27 | @pytest.fixture 28 | def patch_version(monkeypatch): 29 | monkeypatch.setattr(sys.modules["reprexlite.formatting"], "__version__", MOCK_VERSION) 30 | 31 | 32 | @pytest.fixture 33 | def patch_session_info(monkeypatch): 34 | monkeypatch.setattr(sys.modules["reprexlite.formatting"], "SessionInfo", MockSessionInfo) 35 | 36 | 37 | @pytest.mark.parametrize("ereprex", expected_reprexes, ids=[e.filename for e in expected_reprexes]) 38 | def test_reprex(ereprex, patch_datetime, patch_session_info, patch_version): 39 | r = Reprex.from_input(INPUT, ReprexConfig(**ereprex.kwargs)) 40 | actual = r.render_and_format() 41 | with (ASSETS_DIR / ereprex.filename).open("r") as fp: 42 | assert str(actual) == fp.read() 43 | assert str(actual).endswith("\n") 44 | 45 | 46 | @pytest.fixture 47 | def no_pygments(monkeypatch): 48 | import_orig = builtins.__import__ 49 | 50 | def mocked_import(name, *args): 51 | if name.startswith("pygments"): 52 | raise ModuleNotFoundError(name="pygments") 53 | return import_orig(name, *args) 54 | 55 | monkeypatch.setattr(builtins, "__import__", mocked_import) 56 | 57 | 58 | def test_all_venues_have_formatters(): 59 | for venue in Venue: 60 | print(venue) 61 | assert venue in formatter_registry 62 | 63 | 64 | def test_html_no_pygments(patch_datetime, patch_version, no_pygments): 65 | r = Reprex.from_input(INPUT, ReprexConfig(venue="html")) 66 | actual = r.render_and_format() 67 | expected = dedent( 68 | """\ 69 |
x = 2
 70 |         x + 2
 71 |         #> 4
72 |

Created at DATETIME by reprexlite vVERSION

73 | """ # noqa: E501 74 | ) 75 | assert_str_equals(expected, str(actual)) 76 | assert str(actual).endswith("\n") 77 | 78 | 79 | def test_rtf_no_pygments(patch_datetime, patch_version, no_pygments): 80 | with pytest.raises(PygmentsNotFoundError): 81 | r = Reprex.from_input(INPUT, ReprexConfig(venue="rtf")) 82 | r.render_and_format() 83 | 84 | 85 | @pytest.fixture 86 | def pygments_bad_dependency(monkeypatch): 87 | """ModuleNotFoundError inside pygments""" 88 | module_name = "dependency_of_pygments" 89 | import_orig = builtins.__import__ 90 | 91 | def mocked_import(name, *args): 92 | if name.startswith("pygments"): 93 | raise ModuleNotFoundError(name=module_name) 94 | return import_orig(name, *args) 95 | 96 | monkeypatch.setattr(builtins, "__import__", mocked_import) 97 | yield module_name 98 | 99 | 100 | def test_rtf_pygments_bad_dependency(patch_datetime, patch_version, pygments_bad_dependency): 101 | """Test that a bad import inside pygments does not trigger PygmentsNotFoundError""" 102 | with pytest.raises(ModuleNotFoundError) as exc_info: 103 | r = Reprex.from_input(INPUT, ReprexConfig(venue="rtf")) 104 | r.render_and_format() 105 | assert not isinstance(exc_info.type, PygmentsNotFoundError) 106 | assert exc_info.value.name != "pygments" 107 | assert exc_info.value.name == pygments_bad_dependency 108 | 109 | 110 | def test_registry_methods(): 111 | keys = list(formatter_registry.keys()) 112 | assert keys 113 | values = list(formatter_registry.values()) 114 | assert values 115 | items = list(formatter_registry.items()) 116 | assert items 117 | assert items == list(zip(keys, values)) 118 | -------------------------------------------------------------------------------- /tests/test_ipython_editor.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import importlib 3 | import sys 4 | from textwrap import dedent 5 | 6 | from IPython.testing import globalipapp 7 | import pytest 8 | 9 | from reprexlite.exceptions import IPythonNotFoundError 10 | from reprexlite.ipython import ReprexTerminalInteractiveShell 11 | from reprexlite.reprexes import Reprex 12 | from tests.utils import remove_ansi_escape 13 | 14 | 15 | @pytest.fixture() 16 | def reprexlite_ipython(monkeypatch): 17 | monkeypatch.setattr(globalipapp, "TerminalInteractiveShell", ReprexTerminalInteractiveShell) 18 | monkeypatch.setattr(ReprexTerminalInteractiveShell, "_instance", None) 19 | ipython = globalipapp.start_ipython() 20 | yield ipython 21 | ipython.run_cell("exit") 22 | del globalipapp.start_ipython.already_called 23 | 24 | 25 | @pytest.fixture() 26 | def no_ipython(monkeypatch): 27 | import_orig = builtins.__import__ 28 | 29 | def mocked_import(name, *args): 30 | if name.startswith("IPython"): 31 | raise ModuleNotFoundError(name="IPython") 32 | return import_orig(name, *args) 33 | 34 | monkeypatch.setattr(builtins, "__import__", mocked_import) 35 | 36 | 37 | @pytest.fixture() 38 | def ipython_bad_dependency(monkeypatch): 39 | module_name = "dependency_of_ipython" 40 | import_orig = builtins.__import__ 41 | 42 | def mocked_import(name, *args): 43 | if name.startswith("IPython"): 44 | raise ModuleNotFoundError(name=module_name) 45 | return import_orig(name, *args) 46 | 47 | monkeypatch.setattr(builtins, "__import__", mocked_import) 48 | yield module_name 49 | 50 | 51 | def test_ipython_editor(reprexlite_ipython, capsys): 52 | input = dedent( 53 | """\ 54 | x = 2 55 | x + 2 56 | """ 57 | ) 58 | reprexlite_ipython.run_cell(input) 59 | captured = capsys.readouterr() 60 | r = Reprex.from_input(input) 61 | expected = r.render_and_format() 62 | 63 | print("\n---EXPECTED---\n") 64 | print(expected) 65 | print("\n---ACTUAL-----\n") 66 | print(captured.out) 67 | print("\n--------------\n") 68 | assert remove_ansi_escape(captured.out) == expected 69 | 70 | 71 | def test_no_ipython_error(no_ipython, monkeypatch): 72 | monkeypatch.delitem(sys.modules, "reprexlite.ipython") 73 | with pytest.raises(IPythonNotFoundError): 74 | importlib.import_module("reprexlite.ipython") 75 | 76 | 77 | def test_bad_ipython_dependency(ipython_bad_dependency, monkeypatch): 78 | """Test that a bad import inside IPython does not trigger IPythonNotFoundError""" 79 | monkeypatch.delitem(sys.modules, "reprexlite.ipython") 80 | with pytest.raises(ModuleNotFoundError) as exc_info: 81 | importlib.import_module("reprexlite.ipython") 82 | assert not isinstance(exc_info.type, IPythonNotFoundError) 83 | assert exc_info.value.name != "IPython" 84 | assert exc_info.value.name == ipython_bad_dependency 85 | -------------------------------------------------------------------------------- /tests/test_ipython_magics.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import importlib 3 | import sys 4 | from textwrap import dedent 5 | 6 | from IPython.terminal.interactiveshell import TerminalInteractiveShell 7 | from IPython.testing import globalipapp 8 | import pytest 9 | 10 | from reprexlite.config import ReprexConfig 11 | from reprexlite.reprexes import Reprex 12 | 13 | 14 | @pytest.fixture() 15 | def ipython(monkeypatch): 16 | monkeypatch.setattr(TerminalInteractiveShell, "_instance", None) 17 | ipython = globalipapp.start_ipython() 18 | ipython.run_line_magic("load_ext", "reprexlite") 19 | yield ipython 20 | ipython.run_cell("exit") 21 | del globalipapp.start_ipython.already_called 22 | 23 | 24 | @pytest.fixture() 25 | def no_ipython(monkeypatch): 26 | import_orig = builtins.__import__ 27 | 28 | def mocked_import(name, *args): 29 | if name.startswith("IPython"): 30 | raise ModuleNotFoundError(name="IPython") 31 | return import_orig(name, *args) 32 | 33 | monkeypatch.setattr(builtins, "__import__", mocked_import) 34 | 35 | 36 | def test_line_magic(ipython, capsys): 37 | ipython.run_line_magic("reprex", line="") 38 | captured = capsys.readouterr() 39 | print(captured.out) 40 | assert r"Cell Magic Usage: %%reprex" in captured.out 41 | 42 | 43 | def test_cell_magic(ipython, capsys): 44 | input = dedent( 45 | """\ 46 | x = 2 47 | x + 2 48 | """ 49 | ) 50 | ipython.run_cell_magic("reprex", line="--no-advertise --session-info", cell=input) 51 | captured = capsys.readouterr() 52 | 53 | r = Reprex.from_input(input, config=ReprexConfig(advertise=False, session_info=True)) 54 | expected = r.render_and_format(terminal=True) 55 | 56 | print("\n---EXPECTED---\n") 57 | print(expected) 58 | print("\n---ACTUAL-----\n") 59 | print(captured.out) 60 | print("\n--------------\n") 61 | 62 | assert captured.out == expected 63 | 64 | 65 | def test_no_ipython(no_ipython, monkeypatch): 66 | """Tests that not having ipython installed should not cause any import errors.""" 67 | monkeypatch.delitem(sys.modules, "reprexlite") 68 | monkeypatch.delitem(sys.modules, "reprexlite.ipython") 69 | importlib.import_module("reprexlite") 70 | -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | 5 | from reprexlite.exceptions import ( 6 | InvalidInputPrefixesError, 7 | NoPrefixMatchError, 8 | PromptLengthMismatchError, 9 | ) 10 | from reprexlite.parsing import LineType, auto_parse, parse, parse_doctest, parse_reprex 11 | 12 | 13 | def test_parse_reprex(): 14 | input = """\ 15 | import math 16 | 17 | def sqrt(x): 18 | return math.sqrt(x) 19 | 20 | # Here's a comment 21 | sqrt(4) 22 | #> 2.0 23 | """ 24 | 25 | actual = list(parse_reprex(dedent(input))) 26 | expected = [ 27 | ("import math", LineType.CODE), 28 | ("", LineType.CODE), 29 | ("def sqrt(x):", LineType.CODE), 30 | (" return math.sqrt(x)", LineType.CODE), 31 | ("", LineType.CODE), 32 | ("# Here's a comment", LineType.CODE), 33 | ("sqrt(4)", LineType.CODE), 34 | ("2.0", LineType.RESULT), 35 | ("", LineType.CODE), 36 | ] 37 | 38 | assert actual == expected 39 | 40 | 41 | def test_parse_doctest(): 42 | input = """\ 43 | >>> import math 44 | >>> 45 | >>> def sqrt(x): 46 | ... return math.sqrt(x) 47 | ... 48 | >>> # Here's a comment 49 | >>> sqrt(4) 50 | 2.0 51 | """ 52 | 53 | actual = list(parse_doctest(dedent(input))) 54 | expected = [ 55 | ("import math", LineType.CODE), 56 | ("", LineType.CODE), 57 | ("def sqrt(x):", LineType.CODE), 58 | (" return math.sqrt(x)", LineType.CODE), 59 | ("", LineType.CODE), 60 | ("# Here's a comment", LineType.CODE), 61 | ("sqrt(4)", LineType.CODE), 62 | ("2.0", LineType.RESULT), 63 | ("", LineType.CODE), 64 | ] 65 | 66 | assert actual == expected 67 | 68 | 69 | def test_parse_with_both_prompt_and_comment(): 70 | input = """\ 71 | >>> import math 72 | >>> 73 | >>> def sqrt(x): 74 | ... return math.sqrt(x) 75 | ... 76 | >>> # Here's a comment 77 | >>> sqrt(4) 78 | #> 2.0 79 | """ 80 | 81 | actual = list(parse(dedent(input), prompt=">>>", continuation="...", comment="#>")) 82 | expected = [ 83 | ("import math", LineType.CODE), 84 | ("", LineType.CODE), 85 | ("def sqrt(x):", LineType.CODE), 86 | (" return math.sqrt(x)", LineType.CODE), 87 | ("", LineType.CODE), 88 | ("# Here's a comment", LineType.CODE), 89 | ("sqrt(4)", LineType.CODE), 90 | ("2.0", LineType.RESULT), 91 | ("", LineType.CODE), 92 | ] 93 | 94 | assert actual == expected 95 | 96 | 97 | def test_parse_all_blank_prefixes(): 98 | input = """\ 99 | 2+2 100 | """ 101 | with pytest.raises(InvalidInputPrefixesError): 102 | list(parse(dedent(input), prompt=None, continuation=None, comment=None)) 103 | 104 | 105 | def test_auto_parse(): 106 | input_reprex = """\ 107 | import math 108 | 109 | def sqrt(x): 110 | return math.sqrt(x) 111 | 112 | # Here's a comment 113 | sqrt(4) 114 | #> 2.0 115 | """ 116 | 117 | input_doctest = """\ 118 | >>> import math 119 | >>> 120 | >>> def sqrt(x): 121 | ... return math.sqrt(x) 122 | ... 123 | >>> # Here's a comment 124 | >>> sqrt(4) 125 | 2.0 126 | """ 127 | 128 | expected = [ 129 | ("import math", LineType.CODE), 130 | ("", LineType.CODE), 131 | ("def sqrt(x):", LineType.CODE), 132 | (" return math.sqrt(x)", LineType.CODE), 133 | ("", LineType.CODE), 134 | ("# Here's a comment", LineType.CODE), 135 | ("sqrt(4)", LineType.CODE), 136 | ("2.0", LineType.RESULT), 137 | ("", LineType.CODE), 138 | ] 139 | 140 | actual_reprex = list(auto_parse(dedent(input_reprex))) 141 | assert actual_reprex == expected 142 | 143 | actual_doctest = list(auto_parse(dedent(input_doctest))) 144 | assert actual_doctest == expected 145 | 146 | 147 | def test_prompt_continuation_length_mismatch(): 148 | with pytest.raises(PromptLengthMismatchError): 149 | next(parse("2+2", prompt="123", continuation="1234", comment=None)) 150 | with pytest.raises(PromptLengthMismatchError): 151 | next(parse("", prompt=">>>", continuation=None, comment=None)) 152 | with pytest.raises(PromptLengthMismatchError): 153 | next(parse("", prompt=None, continuation="...", comment=None)) 154 | 155 | 156 | def test_no_prefix_match_error(): 157 | with pytest.raises(NoPrefixMatchError): 158 | list(parse("2+2", prompt=">>>", continuation=">>>", comment="#>")) 159 | -------------------------------------------------------------------------------- /tests/test_session_info.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from reprexlite.session_info import SessionInfo 4 | 5 | 6 | def test_session_info(): 7 | session_info = str(SessionInfo()) 8 | assert session_info 9 | assert platform.python_version() in session_info 10 | assert "pytest" in session_info 11 | 12 | lines = session_info.split("\n") 13 | for line in lines: 14 | assert len(line) <= 80 15 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any 3 | 4 | # https://stackoverflow.com/a/14693789/5957621 5 | ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") 6 | 7 | 8 | def remove_ansi_escape(s: str) -> str: 9 | return ANSI_ESCAPE_REGEX.sub("", s) 10 | 11 | 12 | def assert_str_equals(expected: str, actual: str): 13 | """Tests that strings are equivalent and prints out both if failure.""" 14 | to_print = "\n".join( 15 | [ 16 | "", 17 | "---EXPECTED---", 18 | expected, 19 | "---ACTUAL-----", 20 | actual, 21 | "--------------", 22 | ] 23 | ) 24 | assert expected == actual, to_print 25 | 26 | 27 | def assert_equals(left: Any, right: Any): 28 | """Tests equals in both directions""" 29 | assert left == right 30 | assert right == left 31 | 32 | 33 | def assert_not_equals(left: Any, right: Any): 34 | """Tests not equals in both directions""" 35 | assert left != right 36 | assert right != left 37 | --------------------------------------------------------------------------------