pax_global_header00006660000000000000000000000064147444650750014532gustar00rootroot0000000000000052 comment=a561f7f5440dd7d75442d9a88c337fa0ca404ec1 pytest-textual-snapshot-1.1.0/000077500000000000000000000000001474446507500164025ustar00rootroot00000000000000pytest-textual-snapshot-1.1.0/.gitignore000066400000000000000000000015771474446507500204040ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ venv/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ .pytest_cache # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask instance folder instance/ # Sphinx documentation docs/_build/ # MkDocs documentation /site/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version pytest-textual-snapshot-1.1.0/LICENSE000066400000000000000000000020661474446507500174130ustar00rootroot00000000000000 The MIT License (MIT) Copyright (c) 2023 Textualize Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pytest-textual-snapshot-1.1.0/README.md000066400000000000000000000062101474446507500176600ustar00rootroot00000000000000# `pytest-textual-snapshot` A pytest plugin for snapshot testing Textual applications. image ## Installation Install using `pip`: ``` pip install pytest-textual-snapshot ``` After installing, the `snap_compare` fixture will automatically be made available. ## About A `pytest-textual-snapshot` test saves an SVG screenshot of a running Textual app to disk. The next time the test runs, it takes another screenshot and compares it to the saved one. If the new screenshot differs from the old one, the test fails. This is a convenient way to quickly and automatically detect visual regressions in your applications. ## Usage ### Running tests You can run your tests using `pytest` as normal. You can use `pytest-xdist` to run your tests in parallel. #### My snapshot test failed, what do I do? If your snapshot test fails, it means that the screenshot taken during the test session differs from the last screenshot taken. This change is shown in the failure report, which you'll be given a linked to in the event of a failure. If the diff shown in the failure report looks correct, you can update the snapshot on disk by running `pytest` with the `--snapshot-update` flag. ### Writing tests #### Basic usage Inject the `snap_compare` fixture into your test and call it with an app instance or the path to the Textual app (the file containing the `App` subclass). ```python def test_my_app(snap_compare): app = MyTextualApp() # a *non-running* Textual `App` instance assert snap_compare(app) ``` ```python def test_something(snap_compare): assert snap_compare("path/to/app.py") ``` #### Pressing keys Key presses can be simulated before the screenshot is taken. ```python def test_something(snap_compare): assert snap_compare("path/to/app.py", press=["tab", "left", "a"]) ``` #### Run code before screenshot You can run some code before capturing a screenshot using the `run_before` parameter. ```python def test_something(snap_compare): async def run_before(pilot: Pilot): await pilot.press("ctrl+p") # You can run arbitrary code before the screenshot occurs: await disable_blink_for_active_cursors(pilot) await pilot.press(*"view") assert snap_compare(MyApp(), run_before=run_before) ``` #### Customizing the size of the terminal If you need to change the size of the terminal (for example to fit in more content or test layout-related code), you can adjust the `terminal_size` parameter. ```python def test_another_thing(snap_compare): assert snap_compare(MyApp(), terminal_size=(80, 34)) ``` #### Quickly opening paths in your editor If you passed a path to `snap_compare`, you can quickly open the path in your editor by setting the `TEXTUAL_SNAPSHOT_FILE_OPEN_PREFIX` environment variable based on the editor you want to use. Clicking on the path in the snapshot report will open the path in your editor. - `file://` - default, most likely opening in your browser - `code://file/` - opens the path in VS Code - `cursor://file/` - opens the path in Cursor - `pycharm://` - opens the path in PyCharm pytest-textual-snapshot-1.1.0/poetry.lock000066400000000000000000000471111474446507500206020ustar00rootroot00000000000000# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "exceptiongroup" version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] test = ["pytest (>=6)"] [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] name = "jinja2" version = "3.1.4" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, ] [package.dependencies] MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] [[package]] name = "linkify-it-py" version = "2.0.3" description = "Links recognition library with FULL unicode support." optional = false python-versions = ">=3.7" files = [ {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, ] [package.dependencies] uc-micro-py = "*" [package.extras] benchmark = ["pytest", "pytest-benchmark"] dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] doc = ["myst-parser", "sphinx", "sphinx-book-theme"] test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, ] [package.dependencies] linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] code-style = ["pre-commit (>=3.0,<4.0)"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins"] profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" files = [ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] name = "mdit-py-plugins" version = "0.4.1" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.8" files = [ {file = "mdit_py_plugins-0.4.1-py3-none-any.whl", hash = "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a"}, {file = "mdit_py_plugins-0.4.1.tar.gz", hash = "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c"}, ] [package.dependencies] markdown-it-py = ">=1.0.0,<4.0.0" [package.extras] code-style = ["pre-commit"] rtd = ["myst-parser", "sphinx-book-theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] [[package]] name = "packaging" version = "24.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "pygments" version = "2.18.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" files = [ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" version = "8.3.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"}, {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "rich" version = "13.7.1" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" files = [ {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "syrupy" version = "4.8.0" description = "Pytest Snapshot Test Utility" optional = false python-versions = ">=3.8.1" files = [ {file = "syrupy-4.8.0-py3-none-any.whl", hash = "sha256:544f4ec6306f4b1c460fdab48fd60b2c7fe54a6c0a8243aeea15f9ad9c638c3f"}, {file = "syrupy-4.8.0.tar.gz", hash = "sha256:648f0e9303aaa8387c8365d7314784c09a6bab0a407455c6a01d6a4f5c6a8ede"}, ] [package.dependencies] pytest = ">=7.0.0,<9.0.0" [[package]] name = "textual" version = "0.73.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0,>=3.8" files = [ {file = "textual-0.73.0-py3-none-any.whl", hash = "sha256:4d93d80d203f7fb7ba51828a546e8777019700d529a1b405ceee313dea2edfc2"}, {file = "textual-0.73.0.tar.gz", hash = "sha256:ccd1e873370577f557dfdf2b3411f2a4f68b57d4365f9d83a00d084afb15f5a6"}, ] [package.dependencies] markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} rich = ">=13.3.3" typing-extensions = ">=4.4.0,<5.0.0" [package.extras] syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree-sitter-languages (==1.10.2)"] [[package]] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] [[package]] name = "typing-extensions" version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] name = "uc-micro-py" version = "1.0.3" description = "Micro subset of unicode data files for linkify-it-py projects." optional = false python-versions = ">=3.7" files = [ {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, ] [package.extras] test = ["coverage", "pytest", "pytest-cov"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" content-hash = "72c5351a0f8d29b3ebe1a75af0d04759845193d2af0f5bcd4434789a3895ef5a" pytest-textual-snapshot-1.1.0/pyproject.toml000066400000000000000000000026771474446507500213320ustar00rootroot00000000000000[tool.poetry] name = "pytest-textual-snapshot" version = "1.1.0" description = "Snapshot testing for Textual apps" authors = ["Darren Burns "] maintainers = ["Will McGugan "] license = "MIT" readme = "README.md" homepage = "https://github.com/darrenburns/pytest-textual-snapshot" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Pytest", "Intended Audience :: Developers", "Topic :: Software Development :: Testing", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Operating System :: OS Independent", "License :: OSI Approved :: MIT License" ] include = ["resources/**/*"] [tool.poetry.dependencies] python = "^3.8.1" pytest = ">=8.0.0" rich = ">=12.0.0" textual = ">=0.28.0" syrupy = "4.8.0" jinja2 = ">=3.0.0" [tool.poetry.plugins."pytest11"] "textual-snapshot" = "pytest_textual_snapshot" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" pytest-textual-snapshot-1.1.0/pytest_textual_snapshot.py000066400000000000000000000332341474446507500237760ustar00rootroot00000000000000from __future__ import annotations import inspect import os import pickle import re import shutil from dataclasses import dataclass from datetime import datetime from operator import attrgetter from os import PathLike from pathlib import Path, PurePath from random import random from tempfile import mkdtemp from typing import Any, Awaitable, Union, Optional, Callable, Iterable, TYPE_CHECKING import pytest from _pytest.config import ExitCode from _pytest.fixtures import FixtureRequest from _pytest.main import Session from _pytest.terminal import TerminalReporter from jinja2 import Template from rich.console import Console from syrupy import SnapshotAssertion from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode from textual.app import App if TYPE_CHECKING: from _pytest.nodes import Item from textual.pilot import Pilot class SVGImageExtension(SingleFileSnapshotExtension): _file_extension = "svg" _write_mode = WriteMode.TEXT def _read_snapshot_data_from_location(self, *args, **kwargs) -> Optional["SerializableData"]: """Normalize SVG data right after they are loaded from persistent storage.""" data = super()._read_snapshot_data_from_location(*args, **kwargs) if data is not None: data = normalize_svg(data) return data def serialize(self, *args, **kwargs) -> "SerializedData": """Normalize SVG data before they get compared against a snapshot and before they get persisted to storage.""" return normalize_svg(super().serialize(*args, **kwargs)) class TemporaryDirectory: """A temporary that survives forking. This provides something akin to tempfile.TemporaryDirectory, but this version is not removed automatically when a process exits. """ def __init__(self, name: str = ""): if name: self.name = name else: self.name = mkdtemp(None, None, None) def cleanup(self): """Clean up the temporary directory.""" shutil.rmtree(self.name, ignore_errors=True) @dataclass class PseudoConsole: """Something that looks enough like a Console to fill a Jinja2 template.""" legacy_windows: bool size: ConsoleDimensions @dataclass class PseudoApp: """Something that looks enough like an App to fill a Jinja2 template. This can be pickled OK, whereas the 'real' application involved in a test may contain unpickleable data. """ console: PseudoConsole def individualize_svg(svg: str, unique_id: Optional[str] = None) -> str: """Inject a random id, à la rich.Console.export_svg().""" unique_id = str(int(random() * 1e10)) if unique_id is None else unique_id return re.sub(r"\bterminal(?:-\d+)?-([\w-]+)", rf"terminal-{unique_id}-\1", svg) def normalize_svg(svg: str) -> str: """Strip the unique id generated by rich.Console.export_svg().""" return re.sub(r"\bterminal-\d+-([\w-]+)", r"terminal-\1", svg) def pytest_addoption(parser): parser.addoption( "--snapshot-report", action="store", default="snapshot_report.html", help="Snapshot test output HTML path.", ) def app_stash_key() -> pytest.StashKey: try: return app_stash_key._key except AttributeError: from textual.app import App app_stash_key._key = pytest.StashKey[App]() return app_stash_key() def node_to_report_path(node: Item) -> Path: """Generate a report file name for a test node.""" tempdir = get_tempdir() path, _, name = node.reportinfo() temp = Path(path.parent) base = [] while temp != temp.parent and temp.name != "tests": base.append(temp.name) temp = temp.parent parts = [] if base: parts.append("_".join(reversed(base))) parts.append(path.name.replace(".", "_")) parts.append(name.replace("[", "_").replace("]", "_")) return Path(tempdir.name) / "_".join(parts) @pytest.fixture def snap_compare( snapshot: SnapshotAssertion, request: FixtureRequest ) -> Callable[[str | PurePath], bool]: """ This fixture returns a function which can be used to compare the output of a Textual app with the output of the same app in the past. This is snapshot testing, and it used to catch regressions in output. """ # Switch so one file per snapshot, stored as plain simple SVG file. snapshot = snapshot.use_extension(SVGImageExtension) def compare( app: str | PurePath | App[Any], press: Iterable[str] = (), terminal_size: tuple[int, int] = (80, 24), run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, ) -> bool: """ Compare a current screenshot of the app running at app_path, with a previously accepted (validated by human) snapshot stored on disk. When the `--snapshot-update` flag is supplied (provided by syrupy), the snapshot on disk will be updated to match the current screenshot. Args: app (str): An `App` instance or the path to an App. Relative paths are relative to the location of the test this function is called from. If the path contains an App, that file should *not* call `App.run` itself, as this is done automatically by this function. press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size. run_before: An arbitrary callable that runs arbitrary code before taking the screenshot. Use this to simulate complex user interactions with the app that cannot be simulated by key presses. Returns: Whether the screenshot matches the snapshot. """ from textual._import_app import import_app node = request.node if isinstance(app, App): app_instance = app app_path = "" else: path = Path(app) if path.is_absolute(): # If the user supplies an absolute path, just use it directly. app_path = str(path.resolve()) app_instance = import_app(app_path) else: # If a relative path is supplied by the user, it's relative to the location of the pytest node, # NOT the location that `pytest` was invoked from. node_path = node.path.parent resolved = (node_path / app).resolve() app_path = str(resolved) app_instance = import_app(app_path) from textual._doc import take_svg_screenshot actual_screenshot = take_svg_screenshot( app=app_instance, press=press, terminal_size=terminal_size, run_before=run_before, ) console = Console(legacy_windows=False, force_terminal=True) p_app = PseudoApp(PseudoConsole(console.legacy_windows, console.size)) result = snapshot == actual_screenshot # This code must come below the comparison above, as it uses data generated by the comparison. execution_index = ( snapshot._custom_index and snapshot._execution_name_index.get(snapshot._custom_index) ) or snapshot.num_executions - 1 assertion_result = snapshot.executions.get(execution_index) snapshot_exists = ( execution_index in snapshot.executions and assertion_result and assertion_result.final_data is not None ) expected_svg_text = str(snapshot) full_path, line_number, name = node.reportinfo() data = ( result, expected_svg_text, actual_screenshot, p_app, full_path, line_number, name, inspect.getdoc(node.function) or "", app_path, snapshot_exists, ) data_path = node_to_report_path(request.node) data_path.write_bytes(pickle.dumps(data)) return result return compare @dataclass class SvgSnapshotDiff: """Model representing a diff between current screenshot of an app, and the snapshot on disk. This is ultimately intended to be used in a Jinja2 template.""" snapshot: Optional[str] actual: Optional[str] test_name: str path: PathLike line_number: int app: App[Any] """The app instance which was tested.""" environment: dict[str, str] """The environment variables from the host which ran the test.""" docstring: str """If the underlying test functions contains a docstring, we'll include it in the test report.""" app_path: Path | None """If the app was loaded from a path, we'll include that path in the test report for easier access. This will be None if an App instance was directly passed to `snap_compare`.""" snapshot_exists: bool """True if the there was a snapshot available to compare the test output to, otherwise False.""" def pytest_sessionstart( session: Session, ) -> None: """Set up a temporary directory to store snapshots. The temporary directory name is stored in an environment vairable so that pytest-xdist worker child processes can retrieve it. """ if os.environ.get("PYTEST_XDIST_WORKER") is None: tempdir = TemporaryDirectory() os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"] = tempdir.name def get_tempdir(): """Get the TemporaryDirectory.""" return TemporaryDirectory(os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"]) def pytest_sessionfinish( session: Session, exitstatus: Union[int, ExitCode], ) -> None: """Called after whole test run finished, right before returning the exit status to the system. Generates the snapshot report and writes it to disk. """ if os.environ.get("PYTEST_XDIST_WORKER") is None: tempdir = get_tempdir() diffs, num_snapshots_passing = retrieve_svg_diffs(tempdir) save_svg_diffs(diffs, session, num_snapshots_passing) tempdir.cleanup() def retrieve_svg_diffs( tempdir: TemporaryDirectory, ) -> tuple[list[SvgSnapshotDiff], int]: """Retrieve snapshot diffs from the temporary directory.""" diffs: list[SvgSnapshotDiff] = [] pass_count = 0 n = 0 for data_path in Path(tempdir.name).iterdir(): ( passed, expect_svg_text, svg_text, app, full_path, line_index, name, docstring, app_path, snapshot_exists, ) = pickle.loads(data_path.read_bytes()) pass_count += 1 if passed else 0 if not passed: n += 1 diffs.append( SvgSnapshotDiff( snapshot=individualize_svg(str(expect_svg_text)), actual=individualize_svg(svg_text), test_name=name, path=full_path, line_number=line_index + 1, app=app, environment=dict(os.environ), docstring=docstring, app_path=Path(app_path) if app_path else None, snapshot_exists=snapshot_exists, ) ) return diffs, pass_count def save_svg_diffs( diffs: list[SvgSnapshotDiff], session: Session, num_snapshots_passing: int, ) -> None: """Save any detected differences to an HTML formatted report.""" if diffs: diff_sort_key = attrgetter("test_name") diffs = sorted(diffs, key=diff_sort_key) this_file_path = Path(__file__) snapshot_template_path = ( this_file_path.parent / "resources" / "snapshot_report_template.jinja2" ) snapshot_report_path = session.config.getoption("--snapshot-report") snapshot_report_path = Path(snapshot_report_path) snapshot_report_path = Path.cwd() / snapshot_report_path snapshot_report_path.parent.mkdir(parents=True, exist_ok=True) template = Template(snapshot_template_path.read_text()) num_fails = len(diffs) num_snapshot_tests = len(diffs) + num_snapshots_passing rendered_report = template.render( diffs=diffs, passes=num_snapshots_passing, fails=num_fails, pass_percentage=100 * (num_snapshots_passing / max(num_snapshot_tests, 1)), fail_percentage=100 * (num_fails / max(num_snapshot_tests, 1)), num_snapshot_tests=num_snapshot_tests, now=datetime.utcnow(), file_open_prefix=os.getenv("TEXTUAL_SNAPSHOT_FILE_OPEN_PREFIX", "file://"), ) with open(snapshot_report_path, "w+", encoding="utf-8") as snapshot_file: snapshot_file.write(rendered_report) session.config._textual_snapshots = diffs session.config._textual_snapshot_html_report = snapshot_report_path def pytest_terminal_summary( terminalreporter: TerminalReporter, exitstatus: ExitCode, config: pytest.Config, ) -> None: """Add a section to terminal summary reporting. Displays the link to the snapshot report that was generated in a prior hook. """ if os.environ.get("PYTEST_XDIST_WORKER") is None: diffs = getattr(config, "_textual_snapshots", None) console = Console(legacy_windows=False, force_terminal=True) if diffs: snapshot_report_location = config._textual_snapshot_html_report console.print("[b red]Textual Snapshot Report", style="red") console.print( f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" ) console.print(f"[dim]{snapshot_report_location}\n") pytest-textual-snapshot-1.1.0/resources/000077500000000000000000000000001474446507500204145ustar00rootroot00000000000000pytest-textual-snapshot-1.1.0/resources/snapshot_report_template.jinja2000066400000000000000000000303561474446507500266470ustar00rootroot00000000000000 Textual Snapshot Test Report
{{ diffs | length }} snapshots changed {{ passes }} snapshots matched
{% for diff in diffs %}
{{ diff.test_name }} {{ diff.path }}:{{ diff.line_number }} {% if diff.snapshot_exists %}
{% endif %}
{{ diff.actual }}
{% if diff.app_path %} {% endif %}
{{ diff.docstring }}
{# If a historical snapshot exists for this test, then display it, otherwise display a message to the user. #} {% if diff.snapshot_exists %} {{ diff.snapshot }} {% else %}

No history for this test

If you're happy with the content on the left, save it to disk by running pytest with the --snapshot-update flag.

Unexpected?

Snapshots are named after the name of the test you call snap_compare in by default.
If you've renamed a test, the association between the snapshot and the test is lost, and you'll need to run with --snapshot-update to associate the snapshot with the new test name.

{% endif %}
{% if diff.snapshot_exists %}
Historical snapshot
{% endif %}
{# Modal with debug info: #}
{% endfor %}

If you're happy with the test output, run pytest with the --snapshot-update flag to update the snapshot.

Report generated at UTC {{ now }}.