├── LICENSE ├── .gitignore ├── pyproject.toml ├── README.md ├── resources └── snapshot_report_template.jinja2 ├── pytest_textual_snapshot.py └── poetry.lock /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2023 Textualize 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | venv/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | .pytest_cache 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask instance folder 59 | instance/ 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | 64 | # MkDocs documentation 65 | /site/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest-textual-snapshot" 3 | version = "1.1.0" 4 | description = "Snapshot testing for Textual apps" 5 | authors = ["Darren Burns "] 6 | maintainers = ["Will McGugan "] 7 | license = "MIT" 8 | readme = "README.md" 9 | homepage = "https://github.com/darrenburns/pytest-textual-snapshot" 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Framework :: Pytest", 13 | "Intended Audience :: Developers", 14 | "Topic :: Software Development :: Testing", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.8", 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 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: Implementation :: CPython", 25 | "Programming Language :: Python :: Implementation :: PyPy", 26 | "Operating System :: OS Independent", 27 | "License :: OSI Approved :: MIT License" 28 | ] 29 | include = ["resources/**/*"] 30 | 31 | [tool.poetry.dependencies] 32 | python = "^3.8.1" 33 | pytest = ">=8.0.0" 34 | rich = ">=12.0.0" 35 | textual = ">=0.28.0" 36 | syrupy = "4.8.0" 37 | jinja2 = ">=3.0.0" 38 | 39 | [tool.poetry.plugins."pytest11"] 40 | "textual-snapshot" = "pytest_textual_snapshot" 41 | 42 | [build-system] 43 | requires = ["poetry-core>=1.0.0"] 44 | build-backend = "poetry.core.masonry.api" 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `pytest-textual-snapshot` 2 | 3 | A pytest plugin for snapshot testing Textual applications. 4 | 5 | image 6 | 7 | ## Installation 8 | 9 | Install using `pip`: 10 | 11 | ``` 12 | pip install pytest-textual-snapshot 13 | ``` 14 | 15 | After installing, the `snap_compare` fixture will automatically be made available. 16 | 17 | ## About 18 | 19 | A `pytest-textual-snapshot` test saves an SVG screenshot of a running Textual app to disk. 20 | The next time the test runs, it takes another screenshot and compares it to the saved one. 21 | If the new screenshot differs from the old one, the test fails. 22 | This is a convenient way to quickly and automatically detect visual regressions in your applications. 23 | 24 | ## Usage 25 | 26 | ### Running tests 27 | 28 | You can run your tests using `pytest` as normal. You can use `pytest-xdist` to run your tests in parallel. 29 | 30 | #### My snapshot test failed, what do I do? 31 | 32 | If your snapshot test fails, it means that the screenshot taken during the test session 33 | differs from the last screenshot taken. 34 | This change is shown in the failure report, which you'll be given a linked to in the event of a failure. 35 | 36 | If the diff shown in the failure report looks correct, you can update the snapshot on disk 37 | by running `pytest` with the `--snapshot-update` flag. 38 | 39 | ### Writing tests 40 | 41 | #### Basic usage 42 | 43 | Inject the `snap_compare` fixture into your test and call 44 | it with an app instance or the path to the Textual app (the file containing the `App` subclass). 45 | 46 | ```python 47 | def test_my_app(snap_compare): 48 | app = MyTextualApp() # a *non-running* Textual `App` instance 49 | assert snap_compare(app) 50 | ``` 51 | 52 | ```python 53 | def test_something(snap_compare): 54 | assert snap_compare("path/to/app.py") 55 | ``` 56 | 57 | #### Pressing keys 58 | 59 | Key presses can be simulated before the screenshot is taken. 60 | 61 | ```python 62 | def test_something(snap_compare): 63 | assert snap_compare("path/to/app.py", press=["tab", "left", "a"]) 64 | ``` 65 | 66 | #### Run code before screenshot 67 | 68 | You can run some code before capturing a screenshot using the `run_before` parameter. 69 | 70 | ```python 71 | def test_something(snap_compare): 72 | async def run_before(pilot: Pilot): 73 | await pilot.press("ctrl+p") 74 | # You can run arbitrary code before the screenshot occurs: 75 | await disable_blink_for_active_cursors(pilot) 76 | await pilot.press(*"view") 77 | 78 | assert snap_compare(MyApp(), run_before=run_before) 79 | ``` 80 | 81 | #### Customizing the size of the terminal 82 | 83 | 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. 84 | 85 | ```python 86 | def test_another_thing(snap_compare): 87 | assert snap_compare(MyApp(), terminal_size=(80, 34)) 88 | ``` 89 | 90 | #### Quickly opening paths in your editor 91 | 92 | 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. 93 | 94 | - `file://` - default, most likely opening in your browser 95 | - `code://file/` - opens the path in VS Code 96 | - `cursor://file/` - opens the path in Cursor 97 | - `pycharm://` - opens the path in PyCharm 98 | 99 | -------------------------------------------------------------------------------- /resources/snapshot_report_template.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Textual Snapshot Test Report 7 | 9 | 57 | 58 | 59 | 60 |
61 |
62 |
63 | 66 |
67 |
68 |
69 | 70 | {{ diffs | length }} snapshots changed 71 | 72 | 73 | {{ passes }} snapshots matched 74 | 75 |
76 |
77 |
78 | 79 | {% for diff in diffs %} 80 |
81 |
82 |
83 |
84 | 85 | {{ diff.test_name }} 86 | 87 | {{ diff.path }}:{{ diff.line_number }} 88 | 89 | 90 | {% if diff.snapshot_exists %} 91 |
92 | 94 | 97 |
98 | {% endif %} 99 |
100 |
101 |
102 |
103 | {{ diff.actual }} 104 |
105 | {% if diff.app_path %} 106 | 111 | {% endif %} 112 |
{{ diff.docstring }}
113 | 120 |
121 |
122 |
123 |
124 | 127 |
128 |
129 | {# If a historical snapshot exists for this test, then display it, 130 | otherwise display a message to the user. #} 131 | {% if diff.snapshot_exists %} 132 | {{ diff.snapshot }} 133 | {% else %} 134 |
135 |
136 |

No history for this test

137 |

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

139 |
Unexpected?
140 |

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

147 |
148 |
149 | {% endif %} 150 |
151 | {% if diff.snapshot_exists %} 152 |
153 | 154 | Historical snapshot 155 | 156 |
157 | {% endif %} 158 |
159 |
160 |
161 |
162 | 163 | {# Modal with debug info: #} 164 | 221 |
222 |
223 | {% endfor %} 224 | 225 |
226 |
227 |
228 |
229 |

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

232 |
233 |
234 |
235 |
236 | 237 |
238 |
239 |
240 |

Report generated at UTC {{ now }}.

241 |
242 |
243 |
244 | 245 |
246 | 247 | 248 | 251 | 252 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /pytest_textual_snapshot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import inspect 3 | 4 | import os 5 | import pickle 6 | import re 7 | import shutil 8 | from dataclasses import dataclass 9 | from datetime import datetime 10 | from operator import attrgetter 11 | from os import PathLike 12 | from pathlib import Path, PurePath 13 | from random import random 14 | from tempfile import mkdtemp 15 | from typing import Any, Awaitable, Union, Optional, Callable, Iterable, TYPE_CHECKING 16 | 17 | import pytest 18 | from _pytest.config import ExitCode 19 | from _pytest.fixtures import FixtureRequest 20 | from _pytest.main import Session 21 | from _pytest.terminal import TerminalReporter 22 | from jinja2 import Template 23 | from rich.console import Console 24 | from syrupy import SnapshotAssertion 25 | from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode 26 | from textual.app import App 27 | 28 | if TYPE_CHECKING: 29 | from _pytest.nodes import Item 30 | from textual.pilot import Pilot 31 | 32 | 33 | class SVGImageExtension(SingleFileSnapshotExtension): 34 | _file_extension = "svg" 35 | _write_mode = WriteMode.TEXT 36 | 37 | def _read_snapshot_data_from_location(self, *args, **kwargs) -> Optional["SerializableData"]: 38 | """Normalize SVG data right after they are loaded from persistent storage.""" 39 | data = super()._read_snapshot_data_from_location(*args, **kwargs) 40 | if data is not None: 41 | data = normalize_svg(data) 42 | return data 43 | 44 | def serialize(self, *args, **kwargs) -> "SerializedData": 45 | """Normalize SVG data before they get compared against a snapshot and 46 | before they get persisted to storage.""" 47 | return normalize_svg(super().serialize(*args, **kwargs)) 48 | 49 | 50 | class TemporaryDirectory: 51 | """A temporary that survives forking. 52 | 53 | This provides something akin to tempfile.TemporaryDirectory, but this 54 | version is not removed automatically when a process exits. 55 | """ 56 | 57 | def __init__(self, name: str = ""): 58 | if name: 59 | self.name = name 60 | else: 61 | self.name = mkdtemp(None, None, None) 62 | 63 | def cleanup(self): 64 | """Clean up the temporary directory.""" 65 | shutil.rmtree(self.name, ignore_errors=True) 66 | 67 | 68 | @dataclass 69 | class PseudoConsole: 70 | """Something that looks enough like a Console to fill a Jinja2 template.""" 71 | 72 | legacy_windows: bool 73 | size: ConsoleDimensions 74 | 75 | 76 | @dataclass 77 | class PseudoApp: 78 | """Something that looks enough like an App to fill a Jinja2 template. 79 | 80 | This can be pickled OK, whereas the 'real' application involved in a test 81 | may contain unpickleable data. 82 | """ 83 | 84 | console: PseudoConsole 85 | 86 | 87 | def individualize_svg(svg: str, unique_id: Optional[str] = None) -> str: 88 | """Inject a random id, à la rich.Console.export_svg().""" 89 | unique_id = str(int(random() * 1e10)) if unique_id is None else unique_id 90 | return re.sub(r"\bterminal(?:-\d+)?-([\w-]+)", rf"terminal-{unique_id}-\1", svg) 91 | 92 | def normalize_svg(svg: str) -> str: 93 | """Strip the unique id generated by rich.Console.export_svg().""" 94 | return re.sub(r"\bterminal-\d+-([\w-]+)", r"terminal-\1", svg) 95 | 96 | def pytest_addoption(parser): 97 | parser.addoption( 98 | "--snapshot-report", 99 | action="store", 100 | default="snapshot_report.html", 101 | help="Snapshot test output HTML path.", 102 | ) 103 | 104 | 105 | def app_stash_key() -> pytest.StashKey: 106 | try: 107 | return app_stash_key._key 108 | except AttributeError: 109 | from textual.app import App 110 | 111 | app_stash_key._key = pytest.StashKey[App]() 112 | return app_stash_key() 113 | 114 | 115 | def node_to_report_path(node: Item) -> Path: 116 | """Generate a report file name for a test node.""" 117 | tempdir = get_tempdir() 118 | path, _, name = node.reportinfo() 119 | temp = Path(path.parent) 120 | base = [] 121 | while temp != temp.parent and temp.name != "tests": 122 | base.append(temp.name) 123 | temp = temp.parent 124 | parts = [] 125 | if base: 126 | parts.append("_".join(reversed(base))) 127 | parts.append(path.name.replace(".", "_")) 128 | parts.append(name.replace("[", "_").replace("]", "_")) 129 | return Path(tempdir.name) / "_".join(parts) 130 | 131 | 132 | @pytest.fixture 133 | def snap_compare( 134 | snapshot: SnapshotAssertion, request: FixtureRequest 135 | ) -> Callable[[str | PurePath], bool]: 136 | """ 137 | This fixture returns a function which can be used to compare the output of a Textual 138 | app with the output of the same app in the past. This is snapshot testing, and it 139 | used to catch regressions in output. 140 | """ 141 | # Switch so one file per snapshot, stored as plain simple SVG file. 142 | snapshot = snapshot.use_extension(SVGImageExtension) 143 | 144 | def compare( 145 | app: str | PurePath | App[Any], 146 | press: Iterable[str] = (), 147 | terminal_size: tuple[int, int] = (80, 24), 148 | run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, 149 | ) -> bool: 150 | """ 151 | Compare a current screenshot of the app running at app_path, with 152 | a previously accepted (validated by human) snapshot stored on disk. 153 | When the `--snapshot-update` flag is supplied (provided by syrupy), 154 | the snapshot on disk will be updated to match the current screenshot. 155 | 156 | Args: 157 | app (str): An `App` instance or the path to an App. Relative paths are relative to the location of the 158 | test this function is called from. If the path contains an App, that file should *not* call `App.run` 159 | itself, as this is done automatically by this function. 160 | press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. 161 | terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size. 162 | run_before: An arbitrary callable that runs arbitrary code before taking the 163 | screenshot. Use this to simulate complex user interactions with the app 164 | that cannot be simulated by key presses. 165 | 166 | Returns: 167 | Whether the screenshot matches the snapshot. 168 | """ 169 | from textual._import_app import import_app 170 | 171 | node = request.node 172 | 173 | if isinstance(app, App): 174 | app_instance = app 175 | app_path = "" 176 | else: 177 | path = Path(app) 178 | if path.is_absolute(): 179 | # If the user supplies an absolute path, just use it directly. 180 | app_path = str(path.resolve()) 181 | app_instance = import_app(app_path) 182 | else: 183 | # If a relative path is supplied by the user, it's relative to the location of the pytest node, 184 | # NOT the location that `pytest` was invoked from. 185 | node_path = node.path.parent 186 | resolved = (node_path / app).resolve() 187 | app_path = str(resolved) 188 | app_instance = import_app(app_path) 189 | 190 | from textual._doc import take_svg_screenshot 191 | 192 | actual_screenshot = take_svg_screenshot( 193 | app=app_instance, 194 | press=press, 195 | terminal_size=terminal_size, 196 | run_before=run_before, 197 | ) 198 | console = Console(legacy_windows=False, force_terminal=True) 199 | p_app = PseudoApp(PseudoConsole(console.legacy_windows, console.size)) 200 | 201 | result = snapshot == actual_screenshot 202 | 203 | # This code must come below the comparison above, as it uses data generated by the comparison. 204 | execution_index = ( 205 | snapshot._custom_index 206 | and snapshot._execution_name_index.get(snapshot._custom_index) 207 | ) or snapshot.num_executions - 1 208 | assertion_result = snapshot.executions.get(execution_index) 209 | 210 | snapshot_exists = ( 211 | execution_index in snapshot.executions 212 | and assertion_result 213 | and assertion_result.final_data is not None 214 | ) 215 | 216 | expected_svg_text = str(snapshot) 217 | full_path, line_number, name = node.reportinfo() 218 | 219 | data = ( 220 | result, 221 | expected_svg_text, 222 | actual_screenshot, 223 | p_app, 224 | full_path, 225 | line_number, 226 | name, 227 | inspect.getdoc(node.function) or "", 228 | app_path, 229 | snapshot_exists, 230 | ) 231 | data_path = node_to_report_path(request.node) 232 | data_path.write_bytes(pickle.dumps(data)) 233 | 234 | return result 235 | 236 | return compare 237 | 238 | 239 | @dataclass 240 | class SvgSnapshotDiff: 241 | """Model representing a diff between current screenshot of an app, 242 | and the snapshot on disk. This is ultimately intended to be used in 243 | a Jinja2 template.""" 244 | 245 | snapshot: Optional[str] 246 | actual: Optional[str] 247 | test_name: str 248 | path: PathLike 249 | line_number: int 250 | app: App[Any] 251 | """The app instance which was tested.""" 252 | environment: dict[str, str] 253 | """The environment variables from the host which ran the test.""" 254 | docstring: str 255 | """If the underlying test functions contains a docstring, we'll include it in the test report.""" 256 | app_path: Path | None 257 | """If the app was loaded from a path, we'll include that path in the test report for easier access. 258 | This will be None if an App instance was directly passed to `snap_compare`.""" 259 | snapshot_exists: bool 260 | """True if the there was a snapshot available to compare the test output to, otherwise False.""" 261 | 262 | 263 | def pytest_sessionstart( 264 | session: Session, 265 | ) -> None: 266 | """Set up a temporary directory to store snapshots. 267 | 268 | The temporary directory name is stored in an environment vairable so that 269 | pytest-xdist worker child processes can retrieve it. 270 | """ 271 | if os.environ.get("PYTEST_XDIST_WORKER") is None: 272 | tempdir = TemporaryDirectory() 273 | os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"] = tempdir.name 274 | 275 | 276 | def get_tempdir(): 277 | """Get the TemporaryDirectory.""" 278 | return TemporaryDirectory(os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"]) 279 | 280 | 281 | def pytest_sessionfinish( 282 | session: Session, 283 | exitstatus: Union[int, ExitCode], 284 | ) -> None: 285 | """Called after whole test run finished, right before returning the exit status to the system. 286 | Generates the snapshot report and writes it to disk. 287 | """ 288 | if os.environ.get("PYTEST_XDIST_WORKER") is None: 289 | tempdir = get_tempdir() 290 | diffs, num_snapshots_passing = retrieve_svg_diffs(tempdir) 291 | save_svg_diffs(diffs, session, num_snapshots_passing) 292 | tempdir.cleanup() 293 | else: 294 | config = session.config 295 | plugin_manager = config.pluginmanager 296 | syrupy = plugin_manager.getplugin('syrupy') 297 | plugin_manager.unregister(syrupy) 298 | 299 | 300 | def retrieve_svg_diffs( 301 | tempdir: TemporaryDirectory, 302 | ) -> tuple[list[SvgSnapshotDiff], int]: 303 | """Retrieve snapshot diffs from the temporary directory.""" 304 | diffs: list[SvgSnapshotDiff] = [] 305 | pass_count = 0 306 | 307 | n = 0 308 | for data_path in Path(tempdir.name).iterdir(): 309 | ( 310 | passed, 311 | expect_svg_text, 312 | svg_text, 313 | app, 314 | full_path, 315 | line_index, 316 | name, 317 | docstring, 318 | app_path, 319 | snapshot_exists, 320 | ) = pickle.loads(data_path.read_bytes()) 321 | pass_count += 1 if passed else 0 322 | if not passed: 323 | n += 1 324 | diffs.append( 325 | SvgSnapshotDiff( 326 | snapshot=individualize_svg(str(expect_svg_text)), 327 | actual=individualize_svg(svg_text), 328 | test_name=name, 329 | path=full_path, 330 | line_number=line_index + 1, 331 | app=app, 332 | environment=dict(os.environ), 333 | docstring=docstring, 334 | app_path=Path(app_path) if app_path else None, 335 | snapshot_exists=snapshot_exists, 336 | ) 337 | ) 338 | return diffs, pass_count 339 | 340 | 341 | def save_svg_diffs( 342 | diffs: list[SvgSnapshotDiff], 343 | session: Session, 344 | num_snapshots_passing: int, 345 | ) -> None: 346 | """Save any detected differences to an HTML formatted report.""" 347 | if diffs: 348 | diff_sort_key = attrgetter("test_name") 349 | diffs = sorted(diffs, key=diff_sort_key) 350 | 351 | this_file_path = Path(__file__) 352 | snapshot_template_path = ( 353 | this_file_path.parent / "resources" / "snapshot_report_template.jinja2" 354 | ) 355 | 356 | snapshot_report_path = session.config.getoption("--snapshot-report") 357 | snapshot_report_path = Path(snapshot_report_path) 358 | snapshot_report_path = Path.cwd() / snapshot_report_path 359 | snapshot_report_path.parent.mkdir(parents=True, exist_ok=True) 360 | template = Template(snapshot_template_path.read_text()) 361 | 362 | num_fails = len(diffs) 363 | num_snapshot_tests = len(diffs) + num_snapshots_passing 364 | 365 | rendered_report = template.render( 366 | diffs=diffs, 367 | passes=num_snapshots_passing, 368 | fails=num_fails, 369 | pass_percentage=100 * (num_snapshots_passing / max(num_snapshot_tests, 1)), 370 | fail_percentage=100 * (num_fails / max(num_snapshot_tests, 1)), 371 | num_snapshot_tests=num_snapshot_tests, 372 | now=datetime.utcnow(), 373 | file_open_prefix=os.getenv("TEXTUAL_SNAPSHOT_FILE_OPEN_PREFIX", "file://"), 374 | ) 375 | with open(snapshot_report_path, "w+", encoding="utf-8") as snapshot_file: 376 | snapshot_file.write(rendered_report) 377 | 378 | session.config._textual_snapshots = diffs 379 | session.config._textual_snapshot_html_report = snapshot_report_path 380 | 381 | 382 | def pytest_terminal_summary( 383 | terminalreporter: TerminalReporter, 384 | exitstatus: ExitCode, 385 | config: pytest.Config, 386 | ) -> None: 387 | """Add a section to terminal summary reporting. 388 | Displays the link to the snapshot report that was generated in a prior hook. 389 | """ 390 | if os.environ.get("PYTEST_XDIST_WORKER") is None: 391 | diffs = getattr(config, "_textual_snapshots", None) 392 | console = Console(legacy_windows=False, force_terminal=True) 393 | if diffs: 394 | snapshot_report_location = config._textual_snapshot_html_report 395 | console.print("[b red]Textual Snapshot Report", style="red") 396 | console.print( 397 | f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" 398 | f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" 399 | ) 400 | console.print(f"[dim]{snapshot_report_location}\n") 401 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "colorama" 5 | version = "0.4.6" 6 | description = "Cross-platform colored terminal text." 7 | optional = false 8 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 9 | files = [ 10 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 11 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 12 | ] 13 | 14 | [[package]] 15 | name = "exceptiongroup" 16 | version = "1.2.2" 17 | description = "Backport of PEP 654 (exception groups)" 18 | optional = false 19 | python-versions = ">=3.7" 20 | files = [ 21 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 22 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 23 | ] 24 | 25 | [package.extras] 26 | test = ["pytest (>=6)"] 27 | 28 | [[package]] 29 | name = "iniconfig" 30 | version = "2.0.0" 31 | description = "brain-dead simple config-ini parsing" 32 | optional = false 33 | python-versions = ">=3.7" 34 | files = [ 35 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 36 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 37 | ] 38 | 39 | [[package]] 40 | name = "jinja2" 41 | version = "3.1.4" 42 | description = "A very fast and expressive template engine." 43 | optional = false 44 | python-versions = ">=3.7" 45 | files = [ 46 | {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, 47 | {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, 48 | ] 49 | 50 | [package.dependencies] 51 | MarkupSafe = ">=2.0" 52 | 53 | [package.extras] 54 | i18n = ["Babel (>=2.7)"] 55 | 56 | [[package]] 57 | name = "linkify-it-py" 58 | version = "2.0.3" 59 | description = "Links recognition library with FULL unicode support." 60 | optional = false 61 | python-versions = ">=3.7" 62 | files = [ 63 | {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, 64 | {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, 65 | ] 66 | 67 | [package.dependencies] 68 | uc-micro-py = "*" 69 | 70 | [package.extras] 71 | benchmark = ["pytest", "pytest-benchmark"] 72 | dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] 73 | doc = ["myst-parser", "sphinx", "sphinx-book-theme"] 74 | test = ["coverage", "pytest", "pytest-cov"] 75 | 76 | [[package]] 77 | name = "markdown-it-py" 78 | version = "3.0.0" 79 | description = "Python port of markdown-it. Markdown parsing, done right!" 80 | optional = false 81 | python-versions = ">=3.8" 82 | files = [ 83 | {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, 84 | {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, 85 | ] 86 | 87 | [package.dependencies] 88 | linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} 89 | mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} 90 | mdurl = ">=0.1,<1.0" 91 | 92 | [package.extras] 93 | benchmarking = ["psutil", "pytest", "pytest-benchmark"] 94 | code-style = ["pre-commit (>=3.0,<4.0)"] 95 | 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)"] 96 | linkify = ["linkify-it-py (>=1,<3)"] 97 | plugins = ["mdit-py-plugins"] 98 | profiling = ["gprof2dot"] 99 | rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] 100 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 101 | 102 | [[package]] 103 | name = "markupsafe" 104 | version = "2.1.5" 105 | description = "Safely add untrusted strings to HTML/XML markup." 106 | optional = false 107 | python-versions = ">=3.7" 108 | files = [ 109 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, 110 | {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, 111 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, 112 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, 113 | {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, 114 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, 115 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, 116 | {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, 117 | {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, 118 | {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, 119 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, 120 | {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, 121 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, 122 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, 123 | {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, 124 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, 125 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, 126 | {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, 127 | {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, 128 | {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, 129 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, 130 | {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, 131 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, 132 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, 133 | {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, 134 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, 135 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, 136 | {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, 137 | {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, 138 | {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, 139 | {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, 140 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, 141 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, 142 | {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, 143 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, 144 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, 145 | {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, 146 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, 147 | {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, 148 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, 149 | {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, 150 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, 151 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, 152 | {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, 153 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, 154 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, 155 | {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, 156 | {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, 157 | {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, 158 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, 159 | {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, 160 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, 161 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, 162 | {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, 163 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, 164 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, 165 | {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, 166 | {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, 167 | {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, 168 | {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, 169 | ] 170 | 171 | [[package]] 172 | name = "mdit-py-plugins" 173 | version = "0.4.1" 174 | description = "Collection of plugins for markdown-it-py" 175 | optional = false 176 | python-versions = ">=3.8" 177 | files = [ 178 | {file = "mdit_py_plugins-0.4.1-py3-none-any.whl", hash = "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a"}, 179 | {file = "mdit_py_plugins-0.4.1.tar.gz", hash = "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c"}, 180 | ] 181 | 182 | [package.dependencies] 183 | markdown-it-py = ">=1.0.0,<4.0.0" 184 | 185 | [package.extras] 186 | code-style = ["pre-commit"] 187 | rtd = ["myst-parser", "sphinx-book-theme"] 188 | testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] 189 | 190 | [[package]] 191 | name = "mdurl" 192 | version = "0.1.2" 193 | description = "Markdown URL utilities" 194 | optional = false 195 | python-versions = ">=3.7" 196 | files = [ 197 | {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, 198 | {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, 199 | ] 200 | 201 | [[package]] 202 | name = "packaging" 203 | version = "24.1" 204 | description = "Core utilities for Python packages" 205 | optional = false 206 | python-versions = ">=3.8" 207 | files = [ 208 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 209 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 210 | ] 211 | 212 | [[package]] 213 | name = "pluggy" 214 | version = "1.5.0" 215 | description = "plugin and hook calling mechanisms for python" 216 | optional = false 217 | python-versions = ">=3.8" 218 | files = [ 219 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 220 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 221 | ] 222 | 223 | [package.extras] 224 | dev = ["pre-commit", "tox"] 225 | testing = ["pytest", "pytest-benchmark"] 226 | 227 | [[package]] 228 | name = "pygments" 229 | version = "2.18.0" 230 | description = "Pygments is a syntax highlighting package written in Python." 231 | optional = false 232 | python-versions = ">=3.8" 233 | files = [ 234 | {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, 235 | {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, 236 | ] 237 | 238 | [package.extras] 239 | windows-terminal = ["colorama (>=0.4.6)"] 240 | 241 | [[package]] 242 | name = "pytest" 243 | version = "8.3.1" 244 | description = "pytest: simple powerful testing with Python" 245 | optional = false 246 | python-versions = ">=3.8" 247 | files = [ 248 | {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"}, 249 | {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"}, 250 | ] 251 | 252 | [package.dependencies] 253 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 254 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 255 | iniconfig = "*" 256 | packaging = "*" 257 | pluggy = ">=1.5,<2" 258 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 259 | 260 | [package.extras] 261 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 262 | 263 | [[package]] 264 | name = "rich" 265 | version = "13.7.1" 266 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 267 | optional = false 268 | python-versions = ">=3.7.0" 269 | files = [ 270 | {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, 271 | {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, 272 | ] 273 | 274 | [package.dependencies] 275 | markdown-it-py = ">=2.2.0" 276 | pygments = ">=2.13.0,<3.0.0" 277 | typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} 278 | 279 | [package.extras] 280 | jupyter = ["ipywidgets (>=7.5.1,<9)"] 281 | 282 | [[package]] 283 | name = "syrupy" 284 | version = "4.8.0" 285 | description = "Pytest Snapshot Test Utility" 286 | optional = false 287 | python-versions = ">=3.8.1" 288 | files = [ 289 | {file = "syrupy-4.8.0-py3-none-any.whl", hash = "sha256:544f4ec6306f4b1c460fdab48fd60b2c7fe54a6c0a8243aeea15f9ad9c638c3f"}, 290 | {file = "syrupy-4.8.0.tar.gz", hash = "sha256:648f0e9303aaa8387c8365d7314784c09a6bab0a407455c6a01d6a4f5c6a8ede"}, 291 | ] 292 | 293 | [package.dependencies] 294 | pytest = ">=7.0.0,<9.0.0" 295 | 296 | [[package]] 297 | name = "textual" 298 | version = "0.73.0" 299 | description = "Modern Text User Interface framework" 300 | optional = false 301 | python-versions = "<4.0,>=3.8" 302 | files = [ 303 | {file = "textual-0.73.0-py3-none-any.whl", hash = "sha256:4d93d80d203f7fb7ba51828a546e8777019700d529a1b405ceee313dea2edfc2"}, 304 | {file = "textual-0.73.0.tar.gz", hash = "sha256:ccd1e873370577f557dfdf2b3411f2a4f68b57d4365f9d83a00d084afb15f5a6"}, 305 | ] 306 | 307 | [package.dependencies] 308 | markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} 309 | rich = ">=13.3.3" 310 | typing-extensions = ">=4.4.0,<5.0.0" 311 | 312 | [package.extras] 313 | syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree-sitter-languages (==1.10.2)"] 314 | 315 | [[package]] 316 | name = "tomli" 317 | version = "2.0.1" 318 | description = "A lil' TOML parser" 319 | optional = false 320 | python-versions = ">=3.7" 321 | files = [ 322 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 323 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 324 | ] 325 | 326 | [[package]] 327 | name = "typing-extensions" 328 | version = "4.12.2" 329 | description = "Backported and Experimental Type Hints for Python 3.8+" 330 | optional = false 331 | python-versions = ">=3.8" 332 | files = [ 333 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 334 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 335 | ] 336 | 337 | [[package]] 338 | name = "uc-micro-py" 339 | version = "1.0.3" 340 | description = "Micro subset of unicode data files for linkify-it-py projects." 341 | optional = false 342 | python-versions = ">=3.7" 343 | files = [ 344 | {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, 345 | {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, 346 | ] 347 | 348 | [package.extras] 349 | test = ["coverage", "pytest", "pytest-cov"] 350 | 351 | [metadata] 352 | lock-version = "2.0" 353 | python-versions = "^3.8.1" 354 | content-hash = "72c5351a0f8d29b3ebe1a75af0d04759845193d2af0f5bcd4434789a3895ef5a" 355 | --------------------------------------------------------------------------------