From 5dda8d2174a66a8064302b7b052cd9b1b15a4fbf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 5 Dec 2025 19:46:14 -0600 Subject: [PATCH 01/35] py(deps[dev]): Add syrupy for snapshot testing why: Enable snapshot testing for ASCII frame visualization what: - Add syrupy to dev dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8e26d8f8b..8ad0f57d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + "syrupy", # Coverage "codecov", "coverage", From 3fcbfe9224466276275195fa4af89970c478a85f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 5 Dec 2025 19:46:22 -0600 Subject: [PATCH 02/35] tests(textframe): Add TextFrame ASCII frame prototype why: Validate Syrupy snapshot testing for terminal frame visualization what: - Add TextFrame dataclass with content overflow detection - Add ContentOverflowError with Reality vs Mask visual - Add TextFrameSerializer extending AmberDataSerializer - Add TextFrameExtension for Syrupy integration - Add parametrized tests for rendering and nested serialization --- tests/textframe/__init__.py | 3 + tests/textframe/conftest.py | 25 ++++++ tests/textframe/core.py | 160 +++++++++++++++++++++++++++++++++++ tests/textframe/plugin.py | 70 +++++++++++++++ tests/textframe/test_core.py | 100 ++++++++++++++++++++++ 5 files changed, 358 insertions(+) create mode 100644 tests/textframe/__init__.py create mode 100644 tests/textframe/conftest.py create mode 100644 tests/textframe/core.py create mode 100644 tests/textframe/plugin.py create mode 100644 tests/textframe/test_core.py diff --git a/tests/textframe/__init__.py b/tests/textframe/__init__.py new file mode 100644 index 000000000..12528d217 --- /dev/null +++ b/tests/textframe/__init__.py @@ -0,0 +1,3 @@ +"""TextFrame ASCII terminal frame testing prototype.""" + +from __future__ import annotations diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py new file mode 100644 index 000000000..a5ed0a14e --- /dev/null +++ b/tests/textframe/conftest.py @@ -0,0 +1,25 @@ +"""Pytest configuration for TextFrame tests.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion + +from .plugin import TextFrameExtension + + +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Override default snapshot fixture to use TextFrameExtension. + + Parameters + ---------- + snapshot : SnapshotAssertion + The default syrupy snapshot fixture. + + Returns + ------- + SnapshotAssertion + Snapshot configured with TextFrame serialization. + """ + return snapshot.use_extension(TextFrameExtension) diff --git a/tests/textframe/core.py b/tests/textframe/core.py new file mode 100644 index 000000000..3f919036c --- /dev/null +++ b/tests/textframe/core.py @@ -0,0 +1,160 @@ +"""TextFrame - ASCII terminal frame simulator. + +This module provides a fixed-size ASCII frame for visualizing terminal content +with overflow detection and diagnostic rendering. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +class ContentOverflowError(ValueError): + """Raised when content does not fit into the configured frame dimensions. + + Attributes + ---------- + overflow_visual : str + A diagnostic ASCII visualization showing the content and a mask + of the valid/invalid areas. + """ + + def __init__(self, message: str, overflow_visual: str) -> None: + super().__init__(message) + self.overflow_visual = overflow_visual + + +@dataclass(slots=True) +class TextFrame: + """A fixed-size ASCII terminal frame simulator. + + Attributes + ---------- + content_width : int + Width of the inner content area. + content_height : int + Height of the inner content area. + fill_char : str + Character to pad empty space. Defaults to space. + content : list[str] + The current content lines. + + Examples + -------- + >>> frame = TextFrame(content_width=10, content_height=2) + >>> frame.set_content(["hello", "world"]) + >>> print(frame.render()) + +----------+ + |hello | + |world | + +----------+ + """ + + content_width: int + content_height: int + fill_char: str = " " + content: list[str] = field(default_factory=list) + + def set_content(self, lines: Sequence[str]) -> None: + """Set content, applying validation logic. + + Parameters + ---------- + lines : Sequence[str] + Lines of content to set. + + Raises + ------ + ContentOverflowError + If content exceeds frame dimensions. + """ + input_lines = list(lines) + + # Calculate dimensions + max_w = max((len(line) for line in input_lines), default=0) + max_h = len(input_lines) + + is_overflow = max_w > self.content_width or max_h > self.content_height + + if is_overflow: + visual = self._render_overflow(input_lines, max_w, max_h) + raise ContentOverflowError( + f"Content ({max_w}x{max_h}) exceeds frame " + f"({self.content_width}x{self.content_height})", + overflow_visual=visual, + ) + + self.content = input_lines + + def render(self) -> str: + """Render the frame as ASCII art. + + Returns + ------- + str + The rendered frame with borders. + """ + return self._draw_frame(self.content, self.content_width, self.content_height) + + def _render_overflow(self, lines: list[str], max_w: int, max_h: int) -> str: + """Render the diagnostic overflow view (Reality vs Mask). + + Parameters + ---------- + lines : list[str] + The overflow content lines. + max_w : int + Maximum width of content. + max_h : int + Maximum height of content. + + Returns + ------- + str + A visualization showing content frame and valid/invalid mask. + """ + display_w = max(self.content_width, max_w) + display_h = max(self.content_height, max_h) + + # 1. Reality Frame - shows actual content + reality = self._draw_frame(lines, display_w, display_h) + + # 2. Mask Frame - shows valid vs invalid areas + mask_lines = [] + for r in range(display_h): + row = [] + for c in range(display_w): + is_valid = r < self.content_height and c < self.content_width + row.append(" " if is_valid else ".") + mask_lines.append("".join(row)) + + mask = self._draw_frame(mask_lines, display_w, display_h) + return f"{reality}\n{mask}" + + def _draw_frame(self, lines: list[str], w: int, h: int) -> str: + """Draw a bordered frame around content. + + Parameters + ---------- + lines : list[str] + Content lines to frame. + w : int + Frame width (excluding borders). + h : int + Frame height (excluding borders). + + Returns + ------- + str + Bordered ASCII frame. + """ + border = f"+{'-' * w}+" + body = [] + for r in range(h): + line = lines[r] if r < len(lines) else "" + body.append(f"|{line.ljust(w, self.fill_char)}|") + return "\n".join([border, *body, border]) diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py new file mode 100644 index 000000000..bd43bf73c --- /dev/null +++ b/tests/textframe/plugin.py @@ -0,0 +1,70 @@ +"""Syrupy snapshot extension for TextFrame objects. + +This module provides a custom serializer that renders TextFrame objects +and ContentOverflowError exceptions as ASCII art in snapshot files. +""" + +from __future__ import annotations + +import typing as t + +from syrupy.extensions.amber import AmberSnapshotExtension +from syrupy.extensions.amber.serializer import AmberDataSerializer + +from .core import ContentOverflowError, TextFrame + + +class TextFrameSerializer(AmberDataSerializer): + """Custom serializer that renders TextFrame objects as ASCII frames. + + This serializer intercepts TextFrame and ContentOverflowError objects, + converting them to their ASCII representation before passing them + to the base serializer for formatting. + + Notes + ----- + By subclassing AmberDataSerializer, we ensure TextFrame objects are + correctly rendered even when nested inside lists, dicts, or other + data structures. + """ + + @classmethod + def _serialize( + cls, + data: t.Any, + *, + depth: int = 0, + **kwargs: t.Any, + ) -> str: + """Serialize data, converting TextFrame objects to ASCII. + + Parameters + ---------- + data : Any + The data to serialize. + depth : int + Current indentation depth. + **kwargs : Any + Additional serialization options. + + Returns + ------- + str + Serialized representation. + """ + # Intercept TextFrame: Render it to ASCII + if isinstance(data, TextFrame): + return super()._serialize(data.render(), depth=depth, **kwargs) + + # Intercept ContentOverflowError: Render the visual diff + if isinstance(data, ContentOverflowError): + return super()._serialize(data.overflow_visual, depth=depth, **kwargs) + + # Default behavior for all other types + return super()._serialize(data, depth=depth, **kwargs) + + +class TextFrameExtension(AmberSnapshotExtension): + """Syrupy extension that uses the TextFrameSerializer.""" + + serializer_class = TextFrameSerializer diff --git a/tests/textframe/test_core.py b/tests/textframe/test_core.py new file mode 100644 index 000000000..1702613f8 --- /dev/null +++ b/tests/textframe/test_core.py @@ -0,0 +1,100 @@ +"""Integration tests for TextFrame Syrupy snapshot testing.""" + +from __future__ import annotations + +import typing as t +from contextlib import nullcontext as does_not_raise + +import pytest +from syrupy.assertion import SnapshotAssertion + +from .core import ContentOverflowError, TextFrame + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +class Case(t.NamedTuple): + """Test case definition for parametrized tests.""" + + id: str + width: int + height: int + lines: Sequence[str] + expected_exception: type[BaseException] | None + + +CASES: tuple[Case, ...] = ( + Case( + id="basic_success", + width=10, + height=2, + lines=["hello", "world"], + expected_exception=None, + ), + Case( + id="overflow_width", + width=10, + height=2, + lines=["this line is too long", "row 2", "row 3"], + expected_exception=ContentOverflowError, + ), + Case( + id="empty_frame", + width=5, + height=2, + lines=[], + expected_exception=None, + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) +def test_frame_rendering(case: Case, snapshot: SnapshotAssertion) -> None: + """Verify TextFrame rendering with Syrupy snapshot. + + Parameters + ---------- + case : Case + Test case with frame dimensions and content. + snapshot : SnapshotAssertion + Syrupy snapshot fixture configured with TextFrameExtension. + """ + frame = TextFrame(content_width=case.width, content_height=case.height) + + ctx: t.Any = ( + pytest.raises(case.expected_exception) + if case.expected_exception + else does_not_raise() + ) + + with ctx as exc_info: + frame.set_content(case.lines) + + if case.expected_exception: + # The Plugin detects the Exception type and renders the ASCII visual diff + assert exc_info.value == snapshot + else: + # The Plugin detects the TextFrame type and renders the ASCII frame + assert frame == snapshot + + +def test_nested_serialization(snapshot: SnapshotAssertion) -> None: + """Verify that nested TextFrame objects serialize correctly. + + This demonstrates that the custom serializer works when TextFrame + objects are inside collections (lists, dicts). + + Parameters + ---------- + snapshot : SnapshotAssertion + Syrupy snapshot fixture configured with TextFrameExtension. + """ + f1 = TextFrame(content_width=5, content_height=1) + f1.set_content(["one"]) + + f2 = TextFrame(content_width=5, content_height=1) + f2.set_content(["two"]) + + # The serializer will find the frames inside this list and render them + assert [f1, f2] == snapshot From c8f5e17b19baf35678b4ba352ffc92e22e801156 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 5 Dec 2025 19:46:29 -0600 Subject: [PATCH 03/35] tests(textframe): Add snapshot baselines why: Store expected ASCII frame output for regression testing what: - Add snapshots for basic, empty, and overflow frame rendering - Add snapshot for nested TextFrame serialization --- tests/textframe/__snapshots__/test_core.ambr | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/textframe/__snapshots__/test_core.ambr diff --git a/tests/textframe/__snapshots__/test_core.ambr b/tests/textframe/__snapshots__/test_core.ambr new file mode 100644 index 000000000..e221afb5d --- /dev/null +++ b/tests/textframe/__snapshots__/test_core.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_frame_rendering[basic_success] + ''' + +----------+ + |hello | + |world | + +----------+ + ''' +# --- +# name: test_frame_rendering[empty_frame] + ''' + +-----+ + | | + | | + +-----+ + ''' +# --- +# name: test_frame_rendering[overflow_width] + ''' + +---------------------+ + |this line is too long| + |row 2 | + |row 3 | + +---------------------+ + +---------------------+ + | ...........| + | ...........| + |.....................| + +---------------------+ + ''' +# --- +# name: test_nested_serialization + list([ + ''' + +-----+ + |one | + +-----+ + ''', + ''' + +-----+ + |two | + +-----+ + ''', + ]) +# --- From c6d02c52700edfd0d085db60f26a94a65c447e1e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 08:52:27 -0600 Subject: [PATCH 04/35] TextFrame(feat[__post_init__]): Add dimension and fill_char validation why: Prevent invalid TextFrame instances from being created with zero/negative dimensions or multi-character fill strings. what: - Add __post_init__ to validate content_width > 0 - Add __post_init__ to validate content_height > 0 - Add __post_init__ to validate fill_char is single character --- tests/textframe/core.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/textframe/core.py b/tests/textframe/core.py index 3f919036c..667689a62 100644 --- a/tests/textframe/core.py +++ b/tests/textframe/core.py @@ -59,6 +59,24 @@ class TextFrame: fill_char: str = " " content: list[str] = field(default_factory=list) + def __post_init__(self) -> None: + """Validate frame dimensions and fill character. + + Raises + ------ + ValueError + If dimensions are not positive or fill_char is not a single character. + """ + if self.content_width <= 0: + msg = "content_width must be positive" + raise ValueError(msg) + if self.content_height <= 0: + msg = "content_height must be positive" + raise ValueError(msg) + if len(self.fill_char) != 1: + msg = "fill_char must be a single character" + raise ValueError(msg) + def set_content(self, lines: Sequence[str]) -> None: """Set content, applying validation logic. From bf87a3c82751b520d75abc0792f612d3eb45bb8d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 08:53:09 -0600 Subject: [PATCH 05/35] TextFrame(feat[overflow_behavior]): Add truncate mode for content overflow why: Allow flexible handling of oversized content - either error with visual diagnostic or silently truncate to fit. what: - Add OverflowBehavior type alias for "error" | "truncate" - Add overflow_behavior parameter with default "error" (backward compatible) - Implement truncate logic to clip width and height - Update docstrings to reflect new behavior --- tests/textframe/core.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/textframe/core.py b/tests/textframe/core.py index 667689a62..e6e94056d 100644 --- a/tests/textframe/core.py +++ b/tests/textframe/core.py @@ -12,6 +12,8 @@ if t.TYPE_CHECKING: from collections.abc import Sequence +OverflowBehavior = t.Literal["error", "truncate"] + class ContentOverflowError(ValueError): """Raised when content does not fit into the configured frame dimensions. @@ -38,6 +40,10 @@ class TextFrame: Width of the inner content area. content_height : int Height of the inner content area. + overflow_behavior : OverflowBehavior + How to handle content that exceeds frame dimensions. + - "error": Raise ContentOverflowError with visual diagnostic. + - "truncate": Silently clip content to fit. fill_char : str Character to pad empty space. Defaults to space. content : list[str] @@ -56,6 +62,7 @@ class TextFrame: content_width: int content_height: int + overflow_behavior: OverflowBehavior = "error" fill_char: str = " " content: list[str] = field(default_factory=list) @@ -78,7 +85,7 @@ def __post_init__(self) -> None: raise ValueError(msg) def set_content(self, lines: Sequence[str]) -> None: - """Set content, applying validation logic. + """Set content, applying validation or truncation based on overflow_behavior. Parameters ---------- @@ -88,7 +95,7 @@ def set_content(self, lines: Sequence[str]) -> None: Raises ------ ContentOverflowError - If content exceeds frame dimensions. + If content exceeds frame dimensions and overflow_behavior is "error". """ input_lines = list(lines) @@ -99,12 +106,18 @@ def set_content(self, lines: Sequence[str]) -> None: is_overflow = max_w > self.content_width or max_h > self.content_height if is_overflow: - visual = self._render_overflow(input_lines, max_w, max_h) - raise ContentOverflowError( - f"Content ({max_w}x{max_h}) exceeds frame " - f"({self.content_width}x{self.content_height})", - overflow_visual=visual, - ) + if self.overflow_behavior == "error": + visual = self._render_overflow(input_lines, max_w, max_h) + msg = ( + f"Content ({max_w}x{max_h}) exceeds frame " + f"({self.content_width}x{self.content_height})" + ) + raise ContentOverflowError(msg, overflow_visual=visual) + # Truncate mode: clip to frame dimensions + input_lines = [ + line[: self.content_width] + for line in input_lines[: self.content_height] + ] self.content = input_lines From 404553fe6a4e37aef51ee6a95029432785d7901b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 09:10:54 -0600 Subject: [PATCH 06/35] tests(textframe): Add truncate behavior test cases why: Verify the new overflow_behavior="truncate" mode works correctly for width, height, and combined overflow scenarios. what: - Add overflow_behavior field to Case NamedTuple with default "error" - Add truncate_width test case (clips horizontal overflow) - Add truncate_height test case (clips vertical overflow) - Add truncate_both test case (clips both dimensions) - Update test to pass overflow_behavior to TextFrame constructor --- tests/textframe/test_core.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/textframe/test_core.py b/tests/textframe/test_core.py index 1702613f8..c6d1a38d3 100644 --- a/tests/textframe/test_core.py +++ b/tests/textframe/test_core.py @@ -22,6 +22,7 @@ class Case(t.NamedTuple): height: int lines: Sequence[str] expected_exception: type[BaseException] | None + overflow_behavior: t.Literal["error", "truncate"] = "error" CASES: tuple[Case, ...] = ( @@ -46,6 +47,30 @@ class Case(t.NamedTuple): lines=[], expected_exception=None, ), + Case( + id="truncate_width", + width=5, + height=2, + lines=["hello world", "foo"], + expected_exception=None, + overflow_behavior="truncate", + ), + Case( + id="truncate_height", + width=10, + height=1, + lines=["row 1", "row 2", "row 3"], + expected_exception=None, + overflow_behavior="truncate", + ), + Case( + id="truncate_both", + width=5, + height=2, + lines=["hello world", "foo bar baz", "extra row"], + expected_exception=None, + overflow_behavior="truncate", + ), ) @@ -60,7 +85,11 @@ def test_frame_rendering(case: Case, snapshot: SnapshotAssertion) -> None: snapshot : SnapshotAssertion Syrupy snapshot fixture configured with TextFrameExtension. """ - frame = TextFrame(content_width=case.width, content_height=case.height) + frame = TextFrame( + content_width=case.width, + content_height=case.height, + overflow_behavior=case.overflow_behavior, + ) ctx: t.Any = ( pytest.raises(case.expected_exception) From 5cd7f60246832ceb36a4700d5fa90d98f91441f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 09:11:02 -0600 Subject: [PATCH 07/35] tests(textframe): Add snapshot baselines for truncate tests why: Capture expected output for truncate behavior test cases. what: - Add truncate_width snapshot (5x2 frame, clipped "hello") - Add truncate_height snapshot (10x1 frame, single row) - Add truncate_both snapshot (5x2 frame, both dimensions clipped) --- tests/textframe/__snapshots__/test_core.ambr | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/textframe/__snapshots__/test_core.ambr b/tests/textframe/__snapshots__/test_core.ambr index e221afb5d..b164695c3 100644 --- a/tests/textframe/__snapshots__/test_core.ambr +++ b/tests/textframe/__snapshots__/test_core.ambr @@ -29,6 +29,29 @@ +---------------------+ ''' # --- +# name: test_frame_rendering[truncate_both] + ''' + +-----+ + |hello| + |foo b| + +-----+ + ''' +# --- +# name: test_frame_rendering[truncate_height] + ''' + +----------+ + |row 1 | + +----------+ + ''' +# --- +# name: test_frame_rendering[truncate_width] + ''' + +-----+ + |hello| + |foo | + +-----+ + ''' +# --- # name: test_nested_serialization list([ ''' From 021f84489083551acb3ede8dd1b26066c950a6cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 10:59:45 -0600 Subject: [PATCH 08/35] tests(textframe): Add pytest_assertrepr_compare hook why: Provide rich assertion output for TextFrame comparisons without requiring syrupy for basic equality checks. what: - Add pytest_assertrepr_compare hook for TextFrame == TextFrame - Show dimension mismatches (width, height) - Show content diff using difflib.ndiff --- tests/textframe/conftest.py | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py index a5ed0a14e..9a69ce407 100644 --- a/tests/textframe/conftest.py +++ b/tests/textframe/conftest.py @@ -2,12 +2,67 @@ from __future__ import annotations +import typing as t +from difflib import ndiff + import pytest from syrupy.assertion import SnapshotAssertion +from .core import TextFrame from .plugin import TextFrameExtension +def pytest_assertrepr_compare( + config: pytest.Config, + op: str, + left: t.Any, + right: t.Any, +) -> list[str] | None: + """Provide rich assertion output for TextFrame comparisons. + + This hook provides detailed diff output when two TextFrame objects + are compared with ==, showing dimension mismatches and content diffs. + + Parameters + ---------- + config : pytest.Config + The pytest configuration object. + op : str + The comparison operator (e.g., "==", "!="). + left : Any + The left operand of the comparison. + right : Any + The right operand of the comparison. + + Returns + ------- + list[str] | None + List of explanation lines, or None to use default behavior. + """ + if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): + return None + if op != "==": + return None + + lines = ["TextFrame comparison failed:"] + + # Dimension mismatch + if left.content_width != right.content_width: + lines.append(f" width: {left.content_width} != {right.content_width}") + if left.content_height != right.content_height: + lines.append(f" height: {left.content_height} != {right.content_height}") + + # Content diff + left_render = left.render().splitlines() + right_render = right.render().splitlines() + if left_render != right_render: + lines.append("") + lines.append("Content diff:") + lines.extend(ndiff(right_render, left_render)) + + return lines + + @pytest.fixture def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: """Override default snapshot fixture to use TextFrameExtension. From 1d3c55bd89d7079427f038ec137f0e339d331d8c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:04:20 -0600 Subject: [PATCH 09/35] tests(textframe): Switch to SingleFileSnapshotExtension why: Individual .frame files provide cleaner git diffs and easier review than a single .ambr file with all snapshots. what: - Replace AmberSnapshotExtension with SingleFileSnapshotExtension - Set file extension to .frame - Simplify serialize() method (removed nested serializer class) --- tests/textframe/plugin.py | 67 ++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py index bd43bf73c..bb2d696d7 100644 --- a/tests/textframe/plugin.py +++ b/tests/textframe/plugin.py @@ -1,70 +1,63 @@ """Syrupy snapshot extension for TextFrame objects. -This module provides a custom serializer that renders TextFrame objects -and ContentOverflowError exceptions as ASCII art in snapshot files. +This module provides a single-file snapshot extension that renders TextFrame +objects and ContentOverflowError exceptions as ASCII art in .frame files. """ from __future__ import annotations import typing as t -from syrupy.extensions.amber import AmberSnapshotExtension -from syrupy.extensions.amber.serializer import AmberDataSerializer +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode from .core import ContentOverflowError, TextFrame -class TextFrameSerializer(AmberDataSerializer): - """Custom serializer that renders TextFrame objects as ASCII frames. +class TextFrameExtension(SingleFileSnapshotExtension): + """Single-file extension for TextFrame snapshots (.frame files). - This serializer intercepts TextFrame and ContentOverflowError objects, - converting them to their ASCII representation before passing them - to the base serializer for formatting. + Each test snapshot is stored in its own .frame file, providing cleaner + git diffs compared to the multi-snapshot .ambr format. Notes ----- - By subclassing AmberDataSerializer, we ensure TextFrame objects are - correctly rendered even when nested inside lists, dicts, or other - data structures. + This extension serializes: + - TextFrame objects → their render() output + - ContentOverflowError → their overflow_visual attribute + - Other types → str() representation """ - @classmethod - def _serialize( - cls, + _write_mode = WriteMode.TEXT + file_extension = "frame" + + def serialize( + self, data: t.Any, *, - depth: int = 0, - **kwargs: t.Any, + exclude: t.Any = None, + include: t.Any = None, + matcher: t.Any = None, ) -> str: - """Serialize data, converting TextFrame objects to ASCII. + """Serialize data to ASCII frame representation. Parameters ---------- data : Any The data to serialize. - depth : int - Current indentation depth. - **kwargs : Any - Additional serialization options. + exclude : Any + Properties to exclude (unused for TextFrame). + include : Any + Properties to include (unused for TextFrame). + matcher : Any + Custom matcher (unused for TextFrame). Returns ------- str - Serialized representation. + ASCII representation of the data. """ - # Intercept TextFrame: Render it to ASCII if isinstance(data, TextFrame): - return super()._serialize(data.render(), depth=depth, **kwargs) - - # Intercept ContentOverflowError: Render the visual diff + return data.render() if isinstance(data, ContentOverflowError): - return super()._serialize(data.overflow_visual, depth=depth, **kwargs) - - # Default behavior for all other types - return super()._serialize(data, depth=depth, **kwargs) - - -class TextFrameExtension(AmberSnapshotExtension): - """Syrupy extension that uses the TextFrameSerializer.""" - - serializer_class = TextFrameSerializer + return data.overflow_visual + return str(data) From 0006b28ceebc5626d5a2b7a26b3ee7c519fe8a6a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:04:27 -0600 Subject: [PATCH 10/35] tests(textframe): Remove old .ambr snapshot file why: Replaced by individual .frame files from SingleFileSnapshotExtension. what: - Delete test_core.ambr --- tests/textframe/__snapshots__/test_core.ambr | 68 -------------------- 1 file changed, 68 deletions(-) delete mode 100644 tests/textframe/__snapshots__/test_core.ambr diff --git a/tests/textframe/__snapshots__/test_core.ambr b/tests/textframe/__snapshots__/test_core.ambr deleted file mode 100644 index b164695c3..000000000 --- a/tests/textframe/__snapshots__/test_core.ambr +++ /dev/null @@ -1,68 +0,0 @@ -# serializer version: 1 -# name: test_frame_rendering[basic_success] - ''' - +----------+ - |hello | - |world | - +----------+ - ''' -# --- -# name: test_frame_rendering[empty_frame] - ''' - +-----+ - | | - | | - +-----+ - ''' -# --- -# name: test_frame_rendering[overflow_width] - ''' - +---------------------+ - |this line is too long| - |row 2 | - |row 3 | - +---------------------+ - +---------------------+ - | ...........| - | ...........| - |.....................| - +---------------------+ - ''' -# --- -# name: test_frame_rendering[truncate_both] - ''' - +-----+ - |hello| - |foo b| - +-----+ - ''' -# --- -# name: test_frame_rendering[truncate_height] - ''' - +----------+ - |row 1 | - +----------+ - ''' -# --- -# name: test_frame_rendering[truncate_width] - ''' - +-----+ - |hello| - |foo | - +-----+ - ''' -# --- -# name: test_nested_serialization - list([ - ''' - +-----+ - |one | - +-----+ - ''', - ''' - +-----+ - |two | - +-----+ - ''', - ]) -# --- From e78e74f62daceb54561eae877bdea2488d82f27e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:04:40 -0600 Subject: [PATCH 11/35] tests(textframe): Add .frame snapshot baselines why: New snapshot format from SingleFileSnapshotExtension. what: - Add test_frame_rendering[basic_success].frame - Add test_frame_rendering[overflow_width].frame - Add test_frame_rendering[empty_frame].frame - Add test_frame_rendering[truncate_width].frame - Add test_frame_rendering[truncate_height].frame - Add test_frame_rendering[truncate_both].frame - Add test_nested_serialization.frame --- .../test_frame_rendering[basic_success].frame | 4 ++++ .../test_core/test_frame_rendering[empty_frame].frame | 4 ++++ .../test_frame_rendering[overflow_width].frame | 10 ++++++++++ .../test_frame_rendering[truncate_both].frame | 4 ++++ .../test_frame_rendering[truncate_height].frame | 3 +++ .../test_frame_rendering[truncate_width].frame | 4 ++++ .../test_core/test_nested_serialization.frame | 1 + 7 files changed, 30 insertions(+) create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame create mode 100644 tests/textframe/__snapshots__/test_core/test_nested_serialization.frame diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame new file mode 100644 index 000000000..3eab75f59 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[basic_success].frame @@ -0,0 +1,4 @@ ++----------+ +|hello | +|world | ++----------+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame new file mode 100644 index 000000000..cf1ef75e4 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[empty_frame].frame @@ -0,0 +1,4 @@ ++-----+ +| | +| | ++-----+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame new file mode 100644 index 000000000..b0d9eea5d --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[overflow_width].frame @@ -0,0 +1,10 @@ ++---------------------+ +|this line is too long| +|row 2 | +|row 3 | ++---------------------+ ++---------------------+ +| ...........| +| ...........| +|.....................| ++---------------------+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame new file mode 100644 index 000000000..60f5caf67 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_both].frame @@ -0,0 +1,4 @@ ++-----+ +|hello| +|foo b| ++-----+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame new file mode 100644 index 000000000..db3bb2c36 --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_height].frame @@ -0,0 +1,3 @@ ++----------+ +|row 1 | ++----------+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame new file mode 100644 index 000000000..0b31cc26a --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_frame_rendering[truncate_width].frame @@ -0,0 +1,4 @@ ++-----+ +|hello| +|foo | ++-----+ \ No newline at end of file diff --git a/tests/textframe/__snapshots__/test_core/test_nested_serialization.frame b/tests/textframe/__snapshots__/test_core/test_nested_serialization.frame new file mode 100644 index 000000000..d2f9adc7b --- /dev/null +++ b/tests/textframe/__snapshots__/test_core/test_nested_serialization.frame @@ -0,0 +1 @@ +[TextFrame(content_width=5, content_height=1, overflow_behavior='error', fill_char=' ', content=['one']), TextFrame(content_width=5, content_height=1, overflow_behavior='error', fill_char=' ', content=['two'])] \ No newline at end of file From 1f4c5388b2334d640b51689f75f6ba7828648c0c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:06:13 -0600 Subject: [PATCH 12/35] docs(textframe): Document assertion customization patterns why: Provide reference for TextFrame usage and architectural decisions. what: - Document syrupy integration (SingleFileSnapshotExtension) - Document pytest_assertrepr_compare hook pattern - Document overflow_behavior modes - Include examples and architectural insights from syrupy/pytest/CPython - Add to internals toctree --- docs/internals/index.md | 1 + docs/internals/textframe.md | 190 ++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 docs/internals/textframe.md diff --git a/docs/internals/index.md b/docs/internals/index.md index c3748026a..3db824efb 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -47,6 +47,7 @@ api/libtmux._internal.dataclasses api/libtmux._internal.query_list api/libtmux._internal.constants api/libtmux._internal.sparse_array +textframe ``` ## Environmental variables diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md new file mode 100644 index 000000000..2f999567b --- /dev/null +++ b/docs/internals/textframe.md @@ -0,0 +1,190 @@ +# TextFrame - ASCII Frame Simulator + +:::{warning} +This is a testing utility in `tests/textframe/`. It is **not** part of the public API. +::: + +TextFrame provides a fixed-size ASCII frame simulator for visualizing terminal content with overflow detection and diagnostic rendering. It integrates with [syrupy](https://github.com/tophat/syrupy) for snapshot testing and pytest for rich assertion output. + +## Overview + +TextFrame is designed for testing terminal UI components. It provides: + +- Fixed-dimension ASCII frames with borders +- Configurable overflow behavior (error or truncate) +- Syrupy snapshot testing with `.frame` files +- Rich pytest assertion output for frame comparisons + +## Core Components + +### TextFrame Dataclass + +```python +from tests.textframe.core import TextFrame, ContentOverflowError + +# Create a frame with fixed dimensions +frame = TextFrame(content_width=10, content_height=2) +frame.set_content(["hello", "world"]) +print(frame.render()) +``` + +Output: +``` ++----------+ +|hello | +|world | ++----------+ +``` + +### Overflow Behavior + +TextFrame supports two overflow behaviors: + +**Error mode (default):** Raises `ContentOverflowError` with a visual diagnostic showing the content and a mask of valid/invalid areas. + +```python +frame = TextFrame(content_width=5, content_height=2, overflow_behavior="error") +frame.set_content(["this line is too long"]) # Raises ContentOverflowError +``` + +The exception includes an `overflow_visual` attribute showing: +1. A "Reality" frame with the actual content +2. A "Mask" frame showing valid (space) vs invalid (dot) areas + +**Truncate mode:** Silently clips content to fit the frame dimensions. + +```python +frame = TextFrame(content_width=5, content_height=1, overflow_behavior="truncate") +frame.set_content(["hello world", "extra row"]) +print(frame.render()) +``` + +Output: +``` ++-----+ +|hello| ++-----+ +``` + +## Syrupy Integration + +### SingleFileSnapshotExtension + +TextFrame uses syrupy's `SingleFileSnapshotExtension` to store each snapshot in its own `.frame` file. This provides: + +- Cleaner git diffs (one file per test vs all-in-one `.ambr`) +- Easier code review of snapshot changes +- Human-readable ASCII art in snapshot files + +### Extension Implementation + +```python +# tests/textframe/plugin.py +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode + +class TextFrameExtension(SingleFileSnapshotExtension): + _write_mode = WriteMode.TEXT + file_extension = "frame" + + def serialize(self, data, **kwargs): + if isinstance(data, TextFrame): + return data.render() + if isinstance(data, ContentOverflowError): + return data.overflow_visual + return str(data) +``` + +Key design decisions: + +1. **`file_extension = "frame"`**: Uses `.frame` suffix for snapshot files instead of the default `.raw` +2. **`_write_mode = WriteMode.TEXT`**: Stores snapshots as text (not binary) +3. **Custom serialization**: Renders TextFrame objects and ContentOverflowError exceptions as ASCII art + +### Fixture Override Pattern + +The snapshot fixture is overridden in `conftest.py` using syrupy's `use_extension()` pattern: + +```python +# tests/textframe/conftest.py +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + return snapshot.use_extension(TextFrameExtension) +``` + +This pattern works because pytest fixtures that request themselves receive the parent scope's version. + +### Snapshot Directory Structure + +``` +tests/textframe/__snapshots__/ + test_core/ + test_frame_rendering[basic_success].frame + test_frame_rendering[overflow_width].frame + test_frame_rendering[empty_frame].frame + ... +``` + +## Pure pytest Assertion Hook + +For TextFrame-to-TextFrame comparisons (without syrupy), a `pytest_assertrepr_compare` hook provides rich diff output: + +```python +# tests/textframe/conftest.py +def pytest_assertrepr_compare(config, op, left, right): + if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): + return None + if op != "==": + return None + + lines = ["TextFrame comparison failed:"] + + # Dimension mismatch + if left.content_width != right.content_width: + lines.append(f" width: {left.content_width} != {right.content_width}") + if left.content_height != right.content_height: + lines.append(f" height: {left.content_height} != {right.content_height}") + + # Content diff using difflib.ndiff + left_render = left.render().splitlines() + right_render = right.render().splitlines() + if left_render != right_render: + lines.append("") + lines.append("Content diff:") + lines.extend(ndiff(right_render, left_render)) + + return lines +``` + +This hook intercepts `assert frame1 == frame2` comparisons and shows: +- Dimension mismatches (width/height) +- Line-by-line diff using `difflib.ndiff` + +## Architecture Patterns + +### From syrupy + +- **Extension hierarchy**: `SingleFileSnapshotExtension` extends `AbstractSyrupyExtension` +- **Serialization**: Override `serialize()` for custom data types +- **File naming**: `file_extension` class attribute controls snapshot file suffix + +### From pytest + +- **`pytest_assertrepr_compare` hook**: Return `list[str]` for custom assertion output +- **Fixture override pattern**: Request same-named fixture to get parent scope's version +- **`ndiff` for diffs**: Character-level diff with `+`/`-` prefixes + +### From CPython dataclasses + +- **`@dataclass(slots=True)`**: Memory-efficient, prevents accidental attribute assignment +- **`__post_init__`**: Validation after dataclass initialization +- **Type aliases**: `OverflowBehavior = Literal["error", "truncate"]` + +## Files + +| File | Purpose | +|------|---------| +| `tests/textframe/core.py` | `TextFrame` dataclass and `ContentOverflowError` | +| `tests/textframe/plugin.py` | Syrupy `TextFrameExtension` | +| `tests/textframe/conftest.py` | Fixture override and `pytest_assertrepr_compare` hook | +| `tests/textframe/test_core.py` | Parametrized tests with snapshot assertions | +| `tests/textframe/__snapshots__/test_core/*.frame` | Snapshot baselines | From fd1302602b1ef67d2b5c96051d63057f430c0094 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:23:24 -0600 Subject: [PATCH 13/35] libtmux(textframe): Move core module to src/libtmux/textframe/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Enable distribution of TextFrame for downstream users. what: - Move tests/textframe/core.py → src/libtmux/textframe/core.py - Create src/libtmux/textframe/__init__.py with public API exports - Update test imports to use libtmux.textframe --- src/libtmux/textframe/__init__.py | 7 +++++++ {tests => src/libtmux}/textframe/core.py | 0 tests/textframe/conftest.py | 3 ++- tests/textframe/plugin.py | 2 +- tests/textframe/test_core.py | 2 +- 5 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 src/libtmux/textframe/__init__.py rename {tests => src/libtmux}/textframe/core.py (100%) diff --git a/src/libtmux/textframe/__init__.py b/src/libtmux/textframe/__init__.py new file mode 100644 index 000000000..d4f833c27 --- /dev/null +++ b/src/libtmux/textframe/__init__.py @@ -0,0 +1,7 @@ +"""TextFrame - ASCII terminal frame simulator with pytest/syrupy integration.""" + +from __future__ import annotations + +from libtmux.textframe.core import ContentOverflowError, TextFrame + +__all__ = ["ContentOverflowError", "TextFrame"] diff --git a/tests/textframe/core.py b/src/libtmux/textframe/core.py similarity index 100% rename from tests/textframe/core.py rename to src/libtmux/textframe/core.py diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py index 9a69ce407..09e06f073 100644 --- a/tests/textframe/conftest.py +++ b/tests/textframe/conftest.py @@ -8,7 +8,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from .core import TextFrame +from libtmux.textframe import TextFrame + from .plugin import TextFrameExtension diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py index bb2d696d7..6a2c7387a 100644 --- a/tests/textframe/plugin.py +++ b/tests/textframe/plugin.py @@ -10,7 +10,7 @@ from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode -from .core import ContentOverflowError, TextFrame +from libtmux.textframe import ContentOverflowError, TextFrame class TextFrameExtension(SingleFileSnapshotExtension): diff --git a/tests/textframe/test_core.py b/tests/textframe/test_core.py index c6d1a38d3..a5c99858b 100644 --- a/tests/textframe/test_core.py +++ b/tests/textframe/test_core.py @@ -8,7 +8,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from .core import ContentOverflowError, TextFrame +from libtmux.textframe import ContentOverflowError, TextFrame if t.TYPE_CHECKING: from collections.abc import Sequence From 2b6bf882e04520da951cf4eee319fb29302e1ffd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:24:49 -0600 Subject: [PATCH 14/35] libtmux(textframe): Add pytest plugin with hooks and fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Auto-register TextFrame assertion hooks and snapshot fixture for downstream users who install libtmux[textframe]. what: - Move tests/textframe/plugin.py → src/libtmux/textframe/plugin.py - Add pytest_assertrepr_compare hook for rich diff output - Add textframe_snapshot fixture for downstream users - Export TextFrameExtension from __init__.py - Simplify tests/textframe/conftest.py (hooks now in plugin) --- src/libtmux/textframe/__init__.py | 3 +- src/libtmux/textframe/plugin.py | 152 ++++++++++++++++++++++++++++++ tests/textframe/conftest.py | 58 +----------- tests/textframe/plugin.py | 63 ------------- 4 files changed, 155 insertions(+), 121 deletions(-) create mode 100644 src/libtmux/textframe/plugin.py delete mode 100644 tests/textframe/plugin.py diff --git a/src/libtmux/textframe/__init__.py b/src/libtmux/textframe/__init__.py index d4f833c27..bd5c31144 100644 --- a/src/libtmux/textframe/__init__.py +++ b/src/libtmux/textframe/__init__.py @@ -3,5 +3,6 @@ from __future__ import annotations from libtmux.textframe.core import ContentOverflowError, TextFrame +from libtmux.textframe.plugin import TextFrameExtension -__all__ = ["ContentOverflowError", "TextFrame"] +__all__ = ["ContentOverflowError", "TextFrame", "TextFrameExtension"] diff --git a/src/libtmux/textframe/plugin.py b/src/libtmux/textframe/plugin.py new file mode 100644 index 000000000..917176526 --- /dev/null +++ b/src/libtmux/textframe/plugin.py @@ -0,0 +1,152 @@ +"""Syrupy snapshot extension and pytest hooks for TextFrame. + +This module provides: +- TextFrameExtension: A syrupy extension for .frame snapshot files +- pytest_assertrepr_compare: Rich assertion output for TextFrame comparisons +- textframe_snapshot: Pre-configured snapshot fixture + +When installed via `pip install libtmux[textframe]`, this plugin is +auto-discovered by pytest through the pytest11 entry point. +""" + +from __future__ import annotations + +import typing as t +from difflib import ndiff + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode + +from libtmux.textframe.core import ContentOverflowError, TextFrame + + +class TextFrameExtension(SingleFileSnapshotExtension): + """Single-file extension for TextFrame snapshots (.frame files). + + Each test snapshot is stored in its own .frame file, providing cleaner + git diffs compared to the multi-snapshot .ambr format. + + Notes + ----- + This extension serializes: + - TextFrame objects → their render() output + - ContentOverflowError → their overflow_visual attribute + - Other types → str() representation + """ + + _write_mode = WriteMode.TEXT + file_extension = "frame" + + def serialize( + self, + data: t.Any, + *, + exclude: t.Any = None, + include: t.Any = None, + matcher: t.Any = None, + ) -> str: + """Serialize data to ASCII frame representation. + + Parameters + ---------- + data : Any + The data to serialize. + exclude : Any + Properties to exclude (unused for TextFrame). + include : Any + Properties to include (unused for TextFrame). + matcher : Any + Custom matcher (unused for TextFrame). + + Returns + ------- + str + ASCII representation of the data. + """ + if isinstance(data, TextFrame): + return data.render() + if isinstance(data, ContentOverflowError): + return data.overflow_visual + return str(data) + + +# pytest hooks (auto-discovered via pytest11 entry point) + + +def pytest_assertrepr_compare( + config: pytest.Config, + op: str, + left: t.Any, + right: t.Any, +) -> list[str] | None: + """Provide rich assertion output for TextFrame comparisons. + + This hook provides detailed diff output when two TextFrame objects + are compared with ==, showing dimension mismatches and content diffs. + + Parameters + ---------- + config : pytest.Config + The pytest configuration object. + op : str + The comparison operator (e.g., "==", "!="). + left : Any + The left operand of the comparison. + right : Any + The right operand of the comparison. + + Returns + ------- + list[str] | None + List of explanation lines, or None to use default behavior. + """ + if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): + return None + if op != "==": + return None + + lines = ["TextFrame comparison failed:"] + + # Dimension mismatch + if left.content_width != right.content_width: + lines.append(f" width: {left.content_width} != {right.content_width}") + if left.content_height != right.content_height: + lines.append(f" height: {left.content_height} != {right.content_height}") + + # Content diff + left_render = left.render().splitlines() + right_render = right.render().splitlines() + if left_render != right_render: + lines.append("") + lines.append("Content diff:") + lines.extend(ndiff(right_render, left_render)) + + return lines + + +@pytest.fixture +def textframe_snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Snapshot fixture configured with TextFrameExtension. + + This fixture is auto-discovered when libtmux[textframe] is installed. + It provides a pre-configured snapshot for TextFrame objects. + + Parameters + ---------- + snapshot : SnapshotAssertion + The default syrupy snapshot fixture. + + Returns + ------- + SnapshotAssertion + Snapshot configured with TextFrame serialization. + + Examples + -------- + >>> def test_my_frame(textframe_snapshot): + ... frame = TextFrame(content_width=10, content_height=2) + ... frame.set_content(["hello", "world"]) + ... assert frame == textframe_snapshot + """ + return snapshot.use_extension(TextFrameExtension) diff --git a/tests/textframe/conftest.py b/tests/textframe/conftest.py index 09e06f073..fe57d3b42 100644 --- a/tests/textframe/conftest.py +++ b/tests/textframe/conftest.py @@ -2,66 +2,10 @@ from __future__ import annotations -import typing as t -from difflib import ndiff - import pytest from syrupy.assertion import SnapshotAssertion -from libtmux.textframe import TextFrame - -from .plugin import TextFrameExtension - - -def pytest_assertrepr_compare( - config: pytest.Config, - op: str, - left: t.Any, - right: t.Any, -) -> list[str] | None: - """Provide rich assertion output for TextFrame comparisons. - - This hook provides detailed diff output when two TextFrame objects - are compared with ==, showing dimension mismatches and content diffs. - - Parameters - ---------- - config : pytest.Config - The pytest configuration object. - op : str - The comparison operator (e.g., "==", "!="). - left : Any - The left operand of the comparison. - right : Any - The right operand of the comparison. - - Returns - ------- - list[str] | None - List of explanation lines, or None to use default behavior. - """ - if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): - return None - if op != "==": - return None - - lines = ["TextFrame comparison failed:"] - - # Dimension mismatch - if left.content_width != right.content_width: - lines.append(f" width: {left.content_width} != {right.content_width}") - if left.content_height != right.content_height: - lines.append(f" height: {left.content_height} != {right.content_height}") - - # Content diff - left_render = left.render().splitlines() - right_render = right.render().splitlines() - if left_render != right_render: - lines.append("") - lines.append("Content diff:") - lines.extend(ndiff(right_render, left_render)) - - return lines +from libtmux.textframe import TextFrameExtension @pytest.fixture diff --git a/tests/textframe/plugin.py b/tests/textframe/plugin.py deleted file mode 100644 index 6a2c7387a..000000000 --- a/tests/textframe/plugin.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Syrupy snapshot extension for TextFrame objects. - -This module provides a single-file snapshot extension that renders TextFrame -objects and ContentOverflowError exceptions as ASCII art in .frame files. -""" - -from __future__ import annotations - -import typing as t - -from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode - -from libtmux.textframe import ContentOverflowError, TextFrame - - -class TextFrameExtension(SingleFileSnapshotExtension): - """Single-file extension for TextFrame snapshots (.frame files). - - Each test snapshot is stored in its own .frame file, providing cleaner - git diffs compared to the multi-snapshot .ambr format. - - Notes - ----- - This extension serializes: - - TextFrame objects → their render() output - - ContentOverflowError → their overflow_visual attribute - - Other types → str() representation - """ - - _write_mode = WriteMode.TEXT - file_extension = "frame" - - def serialize( - self, - data: t.Any, - *, - exclude: t.Any = None, - include: t.Any = None, - matcher: t.Any = None, - ) -> str: - """Serialize data to ASCII frame representation. - - Parameters - ---------- - data : Any - The data to serialize. - exclude : Any - Properties to exclude (unused for TextFrame). - include : Any - Properties to include (unused for TextFrame). - matcher : Any - Custom matcher (unused for TextFrame). - - Returns - ------- - str - ASCII representation of the data. - """ - if isinstance(data, TextFrame): - return data.render() - if isinstance(data, ContentOverflowError): - return data.overflow_visual - return str(data) From 6e8c279ad5e202927b4b13f1c6457c1c746a1358 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:25:29 -0600 Subject: [PATCH 15/35] py(deps): Add textframe extras with syrupy dependency why: Allow opt-in installation of textframe pytest plugin. what: - Add [project.optional-dependencies] textframe = ["syrupy>=4.0.0"] - Add [project.entry-points.pytest11] libtmux-textframe entry point - Downstream users can now: pip install libtmux[textframe] --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8ad0f57d3..8d4c962d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,8 +100,12 @@ lint = [ "mypy", ] +[project.optional-dependencies] +textframe = ["syrupy>=4.0.0"] + [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +libtmux-textframe = "libtmux.textframe.plugin" [build-system] requires = ["hatchling"] From 9242c9892ac919c1481d20dc55acc3e3ea5ee8a1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:26:36 -0600 Subject: [PATCH 16/35] docs(textframe): Update for distributable plugin why: Document opt-in mechanism for downstream users. what: - Update import paths from tests/ to src/libtmux/textframe/ - Add installation section: pip install libtmux[textframe] - Document auto-discovered fixtures and hooks - Add Plugin Discovery section explaining pytest11 entry points - Update file paths table --- docs/internals/textframe.md | 92 +++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 24 deletions(-) diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md index 2f999567b..7c7384899 100644 --- a/docs/internals/textframe.md +++ b/docs/internals/textframe.md @@ -1,11 +1,30 @@ # TextFrame - ASCII Frame Simulator -:::{warning} -This is a testing utility in `tests/textframe/`. It is **not** part of the public API. -::: - TextFrame provides a fixed-size ASCII frame simulator for visualizing terminal content with overflow detection and diagnostic rendering. It integrates with [syrupy](https://github.com/tophat/syrupy) for snapshot testing and pytest for rich assertion output. +## Installation + +TextFrame is available as an optional extra: + +```bash +pip install libtmux[textframe] +``` + +This installs syrupy and registers the pytest plugin automatically. + +## Quick Start + +After installation, the `textframe_snapshot` fixture and `pytest_assertrepr_compare` hook are auto-discovered by pytest: + +```python +from libtmux.textframe import TextFrame + +def test_my_terminal_ui(textframe_snapshot): + frame = TextFrame(content_width=20, content_height=5) + frame.set_content(["Hello", "World"]) + assert frame == textframe_snapshot +``` + ## Overview TextFrame is designed for testing terminal UI components. It provides: @@ -20,7 +39,7 @@ TextFrame is designed for testing terminal UI components. It provides: ### TextFrame Dataclass ```python -from tests.textframe.core import TextFrame, ContentOverflowError +from libtmux.textframe import TextFrame, ContentOverflowError # Create a frame with fixed dimensions frame = TextFrame(content_width=10, content_height=2) @@ -79,7 +98,7 @@ TextFrame uses syrupy's `SingleFileSnapshotExtension` to store each snapshot in ### Extension Implementation ```python -# tests/textframe/plugin.py +# src/libtmux/textframe/plugin.py from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode class TextFrameExtension(SingleFileSnapshotExtension): @@ -100,36 +119,34 @@ Key design decisions: 2. **`_write_mode = WriteMode.TEXT`**: Stores snapshots as text (not binary) 3. **Custom serialization**: Renders TextFrame objects and ContentOverflowError exceptions as ASCII art -### Fixture Override Pattern +### Auto-Discovered Fixtures -The snapshot fixture is overridden in `conftest.py` using syrupy's `use_extension()` pattern: +When `libtmux[textframe]` is installed, the following fixture is available: ```python -# tests/textframe/conftest.py @pytest.fixture -def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: +def textframe_snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Snapshot fixture configured with TextFrameExtension.""" return snapshot.use_extension(TextFrameExtension) ``` -This pattern works because pytest fixtures that request themselves receive the parent scope's version. - ### Snapshot Directory Structure ``` -tests/textframe/__snapshots__/ - test_core/ +__snapshots__/ + test_module/ test_frame_rendering[basic_success].frame test_frame_rendering[overflow_width].frame test_frame_rendering[empty_frame].frame ... ``` -## Pure pytest Assertion Hook +## pytest Assertion Hook -For TextFrame-to-TextFrame comparisons (without syrupy), a `pytest_assertrepr_compare` hook provides rich diff output: +The `pytest_assertrepr_compare` hook provides rich diff output for TextFrame comparisons: ```python -# tests/textframe/conftest.py +# Auto-registered via pytest11 entry point def pytest_assertrepr_compare(config, op, left, right): if not isinstance(left, TextFrame) or not isinstance(right, TextFrame): return None @@ -159,6 +176,25 @@ This hook intercepts `assert frame1 == frame2` comparisons and shows: - Dimension mismatches (width/height) - Line-by-line diff using `difflib.ndiff` +## Plugin Discovery + +The textframe plugin is registered via pytest's entry point mechanism: + +```toml +# pyproject.toml +[project.entry-points.pytest11] +libtmux-textframe = "libtmux.textframe.plugin" + +[project.optional-dependencies] +textframe = ["syrupy>=4.0.0"] +``` + +When installed with `pip install libtmux[textframe]`: +1. syrupy is installed as a dependency +2. The pytest11 entry point is registered +3. pytest auto-discovers the plugin on startup +4. `textframe_snapshot` fixture and assertion hooks are available + ## Architecture Patterns ### From syrupy @@ -170,8 +206,8 @@ This hook intercepts `assert frame1 == frame2` comparisons and shows: ### From pytest - **`pytest_assertrepr_compare` hook**: Return `list[str]` for custom assertion output -- **Fixture override pattern**: Request same-named fixture to get parent scope's version -- **`ndiff` for diffs**: Character-level diff with `+`/`-` prefixes +- **pytest11 entry points**: Auto-discovery of installed plugins +- **Fixture auto-discovery**: Fixtures defined in plugins are globally available ### From CPython dataclasses @@ -183,8 +219,16 @@ This hook intercepts `assert frame1 == frame2` comparisons and shows: | File | Purpose | |------|---------| -| `tests/textframe/core.py` | `TextFrame` dataclass and `ContentOverflowError` | -| `tests/textframe/plugin.py` | Syrupy `TextFrameExtension` | -| `tests/textframe/conftest.py` | Fixture override and `pytest_assertrepr_compare` hook | -| `tests/textframe/test_core.py` | Parametrized tests with snapshot assertions | -| `tests/textframe/__snapshots__/test_core/*.frame` | Snapshot baselines | +| `src/libtmux/textframe/core.py` | `TextFrame` dataclass and `ContentOverflowError` | +| `src/libtmux/textframe/plugin.py` | Syrupy extension, pytest hooks, and fixtures | +| `src/libtmux/textframe/__init__.py` | Public API exports | + +## Public API + +```python +from libtmux.textframe import ( + TextFrame, # Core dataclass + ContentOverflowError, # Exception with visual diagnostic + TextFrameExtension, # Syrupy extension for custom usage +) +``` From 2cabba3e91207187bd8ff18b83deb94e0f2ea32d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:38:55 -0600 Subject: [PATCH 17/35] py(deps): Update lockfile for textframe extras why: Lockfile reflects new optional dependency. what: - Add syrupy to textframe extras in uv.lock --- uv.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/uv.lock b/uv.lock index 4450593f2..898356514 100644 --- a/uv.lock +++ b/uv.lock @@ -617,6 +617,11 @@ name = "libtmux" version = "0.56.0" source = { editable = "." } +[package.optional-dependencies] +textframe = [ + { name = "syrupy" }, +] + [package.dev-dependencies] coverage = [ { name = "codecov" }, @@ -665,6 +670,8 @@ testing = [ ] [package.metadata] +requires-dist = [{ name = "syrupy", marker = "extra == 'textframe'", specifier = ">=4.0.0" }] +provides-extras = ["textframe"] [package.metadata.requires-dev] coverage = [ From 18f567be227417ede77c0a226864622060817d05 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:41:38 -0600 Subject: [PATCH 18/35] docs(CHANGES): Document TextFrame features for 0.52.x (#613) why: Document new features for release notes. what: - TextFrame primitive for terminal UI testing - pytest assertion hook with rich diff output - syrupy snapshot extension with .frame files - Optional install via libtmux[textframe] --- CHANGES | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/CHANGES b/CHANGES index 4c9cbe8e9..6ea8fbe40 100644 --- a/CHANGES +++ b/CHANGES @@ -40,6 +40,71 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### New features + +#### TextFrame primitive (#613) + +New {class}`~libtmux.textframe.TextFrame` dataclass for testing terminal UI output. +Provides a fixed-size ASCII frame simulator with overflow detection - useful for +validating `capture_pane()` output and terminal rendering in tests. + +```python +from libtmux.textframe import TextFrame + +frame = TextFrame(content_width=10, content_height=2) +frame.set_content(["hello", "world"]) +print(frame.render()) +# +----------+ +# |hello | +# |world | +# +----------+ +``` + +**Features:** + +- Configurable dimensions with `content_width` and `content_height` +- Overflow handling: `overflow_behavior="error"` raises {class}`~libtmux.textframe.ContentOverflowError` + with visual diagnostic, `overflow_behavior="truncate"` clips content silently +- Dimension validation via `__post_init__` + +#### pytest assertion hook for TextFrame (#613) + +Rich assertion output when comparing {class}`~libtmux.textframe.TextFrame` objects. +Shows dimension mismatches and line-by-line content diffs using `difflib.ndiff`. + +``` +TextFrame comparison failed: + width: 20 != 10 +Content diff: +- +----------+ ++ +--------------------+ +``` + +#### syrupy snapshot extension for TextFrame (#613) + +{class}`~libtmux.textframe.TextFrameExtension` stores snapshots as `.frame` files - +one file per test for cleaner git diffs. + +**Installation:** + +```console +$ pip install libtmux[textframe] +``` + +**Usage:** + +```python +from libtmux.textframe import TextFrame + +def test_pane_output(textframe_snapshot): + frame = TextFrame(content_width=20, content_height=5) + frame.set_content(["Hello", "World"]) + assert frame == textframe_snapshot +``` + +The `textframe_snapshot` fixture and assertion hooks are auto-discovered via +pytest's `pytest11` entry point when the `textframe` extra is installed. + ## libtmux 0.56.0 (2026-05-10) ### What's new From 92dab52a1318108828beebe3a01ec12effdcf2ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:53:30 -0600 Subject: [PATCH 19/35] Pane(feat[capture_frame]): Add capture_frame() method why: Enable capturing pane content as TextFrame for visualization and snapshot testing. This bridges capture_pane() with the TextFrame dataclass for a more ergonomic testing workflow. what: - Add capture_frame() method that wraps capture_pane() - Default to pane dimensions when width/height not specified - Default to truncate mode for robustness in CI environments - Add comprehensive docstring with examples --- src/libtmux/pane.py | 89 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 7b9624afe..4d22a337b 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -32,6 +32,8 @@ import types from libtmux._internal.types import StrPath + from libtmux.textframe import TextFrame + from libtmux.textframe.core import OverflowBehavior from .server import Server from .session import Session @@ -496,6 +498,93 @@ def capture_pane( return None return proc.stdout + def capture_frame( + self, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + *, + content_width: int | None = None, + content_height: int | None = None, + overflow_behavior: OverflowBehavior = "truncate", + ) -> TextFrame: + """Capture pane content as a TextFrame. + + Combines :meth:`capture_pane` with :class:`~libtmux.textframe.TextFrame` + for visualization and snapshot testing. + + Parameters + ---------- + start : str | int, optional + Starting line number (same as :meth:`capture_pane`). + Zero is the first line of the visible pane. + Positive numbers are lines in the visible pane. + Negative numbers are lines in the history. + ``-`` is the start of the history. + Default: None + end : str | int, optional + Ending line number (same as :meth:`capture_pane`). + Zero is the first line of the visible pane. + Positive numbers are lines in the visible pane. + Negative numbers are lines in the history. + ``-`` is the end of the visible pane. + Default: None + content_width : int, optional + Frame width. Defaults to pane's current width. + content_height : int, optional + Frame height. Defaults to pane's current height. + overflow_behavior : OverflowBehavior, optional + How to handle content that exceeds frame dimensions. + Defaults to ``"truncate"`` since pane content may exceed + nominal dimensions during terminal transitions. + + Returns + ------- + :class:`~libtmux.textframe.TextFrame` + Frame containing captured pane content. + + Examples + -------- + >>> pane.send_keys('echo "Hello"', enter=True) + >>> import time; time.sleep(0.1) + >>> frame = pane.capture_frame(content_width=20, content_height=5) + >>> 'Hello' in frame.render() + True + + >>> print(frame.render()) # doctest: +SKIP + +--------------------+ + |$ echo "Hello" | + |Hello | + |$ | + | | + | | + +--------------------+ + """ + from libtmux.textframe import TextFrame as TextFrameClass + + # Capture content + lines = self.capture_pane(start=start, end=end) + + # Use pane dimensions if not specified + self.refresh() + width = ( + content_width if content_width is not None else int(self.pane_width or 80) + ) + height = ( + content_height + if content_height is not None + else int(self.pane_height or 24) + ) + + # Create and populate frame + frame = TextFrameClass( + content_width=width, + content_height=height, + overflow_behavior=overflow_behavior, + ) + frame.set_content(lines) + + return frame + def send_keys( self, cmd: str, From 4d738c1db458bf8506fc6f8d4ba789b4095a80bc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:56:15 -0600 Subject: [PATCH 20/35] tests(pane): Add capture_frame() integration tests why: Verify capture_frame() works with real tmux panes and integrates properly with syrupy snapshot testing. what: - Add 12 comprehensive tests using NamedTuple parametrization - Test basic usage, custom dimensions, overflow behavior - Demonstrate retry_until integration pattern --- tests/test_pane_capture_frame.py | 369 +++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 tests/test_pane_capture_frame.py diff --git a/tests/test_pane_capture_frame.py b/tests/test_pane_capture_frame.py new file mode 100644 index 000000000..1ef218738 --- /dev/null +++ b/tests/test_pane_capture_frame.py @@ -0,0 +1,369 @@ +"""Tests for Pane.capture_frame() method.""" + +from __future__ import annotations + +import shutil +import typing as t + +import pytest +from syrupy.assertion import SnapshotAssertion + +from libtmux.test.retry import retry_until +from libtmux.textframe import TextFrame, TextFrameExtension + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Override default snapshot fixture to use TextFrameExtension. + + Parameters + ---------- + snapshot : SnapshotAssertion + The default syrupy snapshot fixture. + + Returns + ------- + SnapshotAssertion + Snapshot configured with TextFrame serialization. + """ + return snapshot.use_extension(TextFrameExtension) + + +class CaptureFrameCase(t.NamedTuple): + """Test case for capture_frame() parametrized tests.""" + + test_id: str + content_to_send: str + content_width: int | None # None = use pane width + content_height: int | None # None = use pane height + overflow_behavior: t.Literal["error", "truncate"] + expected_in_frame: list[str] # Substrings expected in rendered frame + description: str + + +CAPTURE_FRAME_CASES: list[CaptureFrameCase] = [ + CaptureFrameCase( + test_id="basic_echo", + content_to_send='echo "hello"', + content_width=40, + content_height=10, + overflow_behavior="truncate", + expected_in_frame=["hello"], + description="Basic echo command output", + ), + CaptureFrameCase( + test_id="multiline_output", + content_to_send='printf "line1\\nline2\\nline3\\n"', + content_width=40, + content_height=10, + overflow_behavior="truncate", + expected_in_frame=["line1", "line2", "line3"], + description="Multi-line printf output", + ), + CaptureFrameCase( + test_id="custom_small_dimensions", + content_to_send='echo "test"', + content_width=20, + content_height=5, + overflow_behavior="truncate", + expected_in_frame=["test"], + description="Custom small frame dimensions", + ), + CaptureFrameCase( + test_id="truncate_long_line", + content_to_send='echo "' + "x" * 50 + '"', + content_width=15, + content_height=5, + overflow_behavior="truncate", + expected_in_frame=["xxxxxxxxxxxxxxx"], # Truncated to 15 chars + description="Long output truncated to frame width", + ), + CaptureFrameCase( + test_id="empty_pane", + content_to_send="", + content_width=20, + content_height=5, + overflow_behavior="truncate", + expected_in_frame=["$"], # Just shell prompt + description="Empty pane with just prompt", + ), +] + + +@pytest.mark.parametrize( + list(CaptureFrameCase._fields), + CAPTURE_FRAME_CASES, + ids=[case.test_id for case in CAPTURE_FRAME_CASES], +) +def test_capture_frame_parametrized( + test_id: str, + content_to_send: str, + content_width: int | None, + content_height: int | None, + overflow_behavior: t.Literal["error", "truncate"], + expected_in_frame: list[str], + description: str, + session: Session, +) -> None: + """Verify capture_frame() with various content and dimensions. + + Parameters + ---------- + test_id : str + Unique identifier for the test case. + content_to_send : str + Command to send to the pane. + content_width : int | None + Frame width (None = use pane width). + content_height : int | None + Frame height (None = use pane height). + overflow_behavior : OverflowBehavior + How to handle overflow. + expected_in_frame : list[str] + Substrings expected in the rendered frame. + description : str + Human-readable test description. + session : Session + pytest fixture providing tmux session. + """ + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name=f"capture_frame_{test_id}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send content if provided + if content_to_send: + pane.send_keys(content_to_send, literal=True, suppress_history=False) + + # Wait for command output to appear + def output_appeared() -> bool: + lines = pane.capture_pane() + content = "\n".join(lines) + # Check that at least one expected substring is present + return any(exp in content for exp in expected_in_frame) + + retry_until(output_appeared, 2, raises=True) + + # Capture frame with specified dimensions + frame = pane.capture_frame( + content_width=content_width, + content_height=content_height, + overflow_behavior=overflow_behavior, + ) + + # Verify frame type + assert isinstance(frame, TextFrame) + + # Verify dimensions + if content_width is not None: + assert frame.content_width == content_width + if content_height is not None: + assert frame.content_height == content_height + + # Verify expected content in rendered frame + rendered = frame.render() + for expected in expected_in_frame: + assert expected in rendered, f"Expected '{expected}' not found in frame" + + +def test_capture_frame_returns_textframe(session: Session) -> None: + """Verify capture_frame() returns a TextFrame instance.""" + pane = session.active_window.active_pane + assert pane is not None + + frame = pane.capture_frame(content_width=20, content_height=5) + + assert isinstance(frame, TextFrame) + assert frame.content_width == 20 + assert frame.content_height == 5 + + +def test_capture_frame_default_dimensions(session: Session) -> None: + """Verify capture_frame() uses pane dimensions by default.""" + pane = session.active_window.active_pane + assert pane is not None + pane.refresh() + + # Get actual pane dimensions + expected_width = int(pane.pane_width or 80) + expected_height = int(pane.pane_height or 24) + + # Capture without specifying dimensions + frame = pane.capture_frame() + + assert frame.content_width == expected_width + assert frame.content_height == expected_height + + +def test_capture_frame_with_start_end(session: Session) -> None: + """Verify capture_frame() works with start/end parameters.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_start_end", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send multiple lines + pane.send_keys('echo "line1"', enter=True) + pane.send_keys('echo "line2"', enter=True) + pane.send_keys('echo "line3"', enter=True) + + # Wait for all output + def all_lines_present() -> bool: + content = "\n".join(pane.capture_pane()) + return "line3" in content + + retry_until(all_lines_present, 2, raises=True) + + # Capture with start parameter (visible pane only) + frame = pane.capture_frame(start=0, content_width=40, content_height=10) + rendered = frame.render() + + # Should capture visible content + assert isinstance(frame, TextFrame) + assert "line" in rendered # At least some output + + +def test_capture_frame_overflow_truncate(session: Session) -> None: + """Verify capture_frame() truncates content when overflow_behavior='truncate'.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_truncate", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send long line + long_text = "x" * 100 + pane.send_keys(f'echo "{long_text}"', literal=True, suppress_history=False) + + def output_appeared() -> bool: + return "xxxx" in "\n".join(pane.capture_pane()) + + retry_until(output_appeared, 2, raises=True) + + # Capture with small width, truncate mode + frame = pane.capture_frame( + content_width=10, + content_height=5, + overflow_behavior="truncate", + ) + + # Should not raise, content should be truncated + assert isinstance(frame, TextFrame) + rendered = frame.render() + + # Frame should have the specified width (10 chars + borders) + lines = rendered.splitlines() + # Border line should be +----------+ (10 dashes) + assert lines[0] == "+----------+" + + +def test_capture_frame_snapshot(session: Session, snapshot: SnapshotAssertion) -> None: + """Verify capture_frame() output matches snapshot.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_snapshot", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send a predictable command + pane.send_keys('echo "Hello, TextFrame!"', literal=True, suppress_history=False) + + # Wait for output + def output_appeared() -> bool: + return "Hello, TextFrame!" in "\n".join(pane.capture_pane()) + + retry_until(output_appeared, 2, raises=True) + + # Capture as frame - use fixed dimensions for reproducible snapshot + frame = pane.capture_frame(content_width=30, content_height=5) + + # Compare against snapshot + assert frame == snapshot + + +def test_capture_frame_with_retry_pattern(session: Session) -> None: + """Demonstrate capture_frame() in retry_until pattern.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_retry", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Send command that produces multi-line output + pane.send_keys('for i in 1 2 3; do echo "line $i"; done', enter=True) + + # Use capture_frame in retry pattern + def all_lines_in_frame() -> bool: + frame = pane.capture_frame(content_width=40, content_height=10) + rendered = frame.render() + return all(f"line {i}" in rendered for i in [1, 2, 3]) + + # Should eventually pass + result = retry_until(all_lines_in_frame, 3, raises=True) + assert result is True + + +def test_capture_frame_preserves_content(session: Session) -> None: + """Verify capture_frame() content matches capture_pane() content.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name="capture_frame_content", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + pane.send_keys('echo "test content"', literal=True, suppress_history=False) + + def output_appeared() -> bool: + return "test content" in "\n".join(pane.capture_pane()) + + retry_until(output_appeared, 2, raises=True) + + # Capture both ways + pane_lines = pane.capture_pane() + frame = pane.capture_frame( + content_width=40, + content_height=len(pane_lines), + overflow_behavior="truncate", + ) + + # Frame content should contain the same lines (possibly truncated) + for line in pane_lines[:5]: # Check first few lines + # Truncated lines should match up to frame width + truncated = line[: frame.content_width] + if truncated.strip(): # Non-empty lines + assert truncated in frame.render() From 15ac3bb9c9b36aa18d3e2e525a1d7be7c840bd13 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:56:21 -0600 Subject: [PATCH 21/35] tests(pane): Add capture_frame snapshot baseline why: Baseline snapshot for capture_frame() visual regression testing. what: - Add test_capture_frame_snapshot.frame baseline --- .../test_capture_frame_snapshot.frame | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame new file mode 100644 index 000000000..48a6251fc --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot.frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ echo "Hello, TextFrame!" | +|Hello, TextFrame! | +|$ | +| | +| | ++------------------------------+ \ No newline at end of file From f972626ebd62abe27749547dc29d9cdf03d226d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 11:56:26 -0600 Subject: [PATCH 22/35] docs(textframe): Document capture_frame() integration why: Show users how to use capture_frame() for testing terminal output. what: - Add Pane.capture_frame() integration section - Document parameters with table - Explain design decisions (truncate default, refresh) - Add retry_until usage example --- CHANGES | 35 ++++++++++++------- docs/internals/textframe.md | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/CHANGES b/CHANGES index 6ea8fbe40..bee7c4d3b 100644 --- a/CHANGES +++ b/CHANGES @@ -72,27 +72,15 @@ print(frame.render()) Rich assertion output when comparing {class}`~libtmux.textframe.TextFrame` objects. Shows dimension mismatches and line-by-line content diffs using `difflib.ndiff`. -``` -TextFrame comparison failed: - width: 20 != 10 -Content diff: -- +----------+ -+ +--------------------+ -``` - #### syrupy snapshot extension for TextFrame (#613) -{class}`~libtmux.textframe.TextFrameExtension` stores snapshots as `.frame` files - +{class}`~libtmux.textframe.TextFrameExtension` stores snapshots as `.frame` files — one file per test for cleaner git diffs. -**Installation:** - ```console $ pip install libtmux[textframe] ``` -**Usage:** - ```python from libtmux.textframe import TextFrame @@ -105,6 +93,27 @@ def test_pane_output(textframe_snapshot): The `textframe_snapshot` fixture and assertion hooks are auto-discovered via pytest's `pytest11` entry point when the `textframe` extra is installed. +#### Pane.capture_frame() (#613) + +New {meth}`~libtmux.pane.Pane.capture_frame` method that wraps +{meth}`~libtmux.pane.Pane.capture_pane` and returns a +{class}`~libtmux.textframe.TextFrame` for visualization and snapshot testing. + +```python +def test_cli_output(pane, textframe_snapshot): + pane.send_keys("echo 'Hello'", enter=True) + + # Wait for output, then capture as frame + frame = pane.capture_frame(content_width=40, content_height=10) + assert frame == textframe_snapshot +``` + +**Features:** + +- Defaults to pane dimensions when `content_width` / `content_height` not specified +- Uses `overflow_behavior="truncate"` by default for CI robustness +- Accepts same `start` / `end` parameters as `capture_pane()` + ## libtmux 0.56.0 (2026-05-10) ### What's new diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md index 7c7384899..69bcecca2 100644 --- a/docs/internals/textframe.md +++ b/docs/internals/textframe.md @@ -223,6 +223,74 @@ When installed with `pip install libtmux[textframe]`: | `src/libtmux/textframe/plugin.py` | Syrupy extension, pytest hooks, and fixtures | | `src/libtmux/textframe/__init__.py` | Public API exports | +## Pane.capture_frame() Integration + +The `Pane.capture_frame()` method provides a high-level way to capture pane content as a TextFrame: + +```python +from libtmux.test.retry import retry_until + +def test_cli_output(pane, textframe_snapshot): + """Test CLI output with visual snapshot.""" + pane.send_keys("echo 'Hello, World!'", enter=True) + + # Wait for output to appear + def output_appeared(): + return "Hello" in "\n".join(pane.capture_pane()) + retry_until(output_appeared, 2, raises=True) + + # Capture as frame for snapshot comparison + frame = pane.capture_frame(content_width=40, content_height=10) + assert frame == textframe_snapshot +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `start` | `int \| "-" \| None` | `None` | Starting line (same as `capture_pane`) | +| `end` | `int \| "-" \| None` | `None` | Ending line (same as `capture_pane`) | +| `content_width` | `int \| None` | Pane width | Frame width in characters | +| `content_height` | `int \| None` | Pane height | Frame height in lines | +| `overflow_behavior` | `"error" \| "truncate"` | `"truncate"` | How to handle overflow | + +### Design Decisions + +**Why `overflow_behavior="truncate"` by default?** + +Pane content can exceed nominal dimensions during: +- Terminal resize transitions +- Shell startup (MOTD, prompts) +- ANSI escape sequences in output + +Using `truncate` avoids spurious test failures in CI environments. + +**Why does it call `self.refresh()`?** + +Pane dimensions can change (resize, zoom). `refresh()` ensures we use current values when `content_width` or `content_height` are not specified. + +### Using with retry_until + +For asynchronous terminal output, combine with `retry_until`: + +```python +from libtmux.test.retry import retry_until + +def test_async_output(session): + """Wait for output using capture_frame in retry loop.""" + window = session.new_window() + pane = window.active_pane + + pane.send_keys('for i in 1 2 3; do echo "line $i"; done', enter=True) + + def all_lines_present(): + frame = pane.capture_frame(content_width=40, content_height=10) + rendered = frame.render() + return all(f"line {i}" in rendered for i in [1, 2, 3]) + + retry_until(all_lines_present, 3, raises=True) +``` + ## Public API ```python From 992f0603cda86c37701a394f4142dc58fbe89a22 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 12:12:06 -0600 Subject: [PATCH 23/35] tests(pane): Add exhaustive capture_frame() snapshot tests why: Comprehensive visual regression testing for all capture_frame() variations. what: - Add SnapshotCase NamedTuple for parametrized snapshot testing - Add 18 snapshot test cases covering: - Dimension variations: prompt_only, wide/narrow/tall/short frames - start/end parameters: start=0, end=0, end="-", start_end_range - Truncation: width and height truncation - Special characters and edge cases - Use retry_until for robust async output handling --- tests/test_pane_capture_frame.py | 318 +++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/tests/test_pane_capture_frame.py b/tests/test_pane_capture_frame.py index 1ef218738..eecb4cf60 100644 --- a/tests/test_pane_capture_frame.py +++ b/tests/test_pane_capture_frame.py @@ -367,3 +367,321 @@ def output_appeared() -> bool: truncated = line[: frame.content_width] if truncated.strip(): # Non-empty lines assert truncated in frame.render() + + +# ============================================================================= +# Exhaustive Snapshot Tests +# ============================================================================= + + +class SnapshotCase(t.NamedTuple): + """Snapshot test case for exhaustive capture_frame() variations. + + This NamedTuple defines the parameters for parametrized snapshot tests + that cover all combinations of capture_frame() options. + + Attributes + ---------- + test_id : str + Unique identifier for the test case, used in snapshot filenames. + command : str + Shell command to execute (empty string for prompt-only tests). + content_width : int + Frame width in characters. + content_height : int + Frame height in lines. + start : t.Literal["-"] | int | None + Starting line for capture (None = default). + end : t.Literal["-"] | int | None + Ending line for capture (None = default). + overflow_behavior : t.Literal["error", "truncate"] + How to handle content exceeding frame dimensions. + wait_for : str + String to wait for before capturing (ensures output is ready). + """ + + test_id: str + command: str + content_width: int + content_height: int + start: t.Literal["-"] | int | None + end: t.Literal["-"] | int | None + overflow_behavior: t.Literal["error", "truncate"] + wait_for: str + + +SNAPSHOT_CASES: list[SnapshotCase] = [ + # --- Dimension Variations --- + SnapshotCase( + test_id="prompt_only", + command="", + content_width=20, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="$", + ), + SnapshotCase( + test_id="echo_simple", + command='echo "hello"', + content_width=25, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="hello", + ), + SnapshotCase( + test_id="echo_multiline", + command='printf "a\\nb\\nc\\n"', + content_width=20, + content_height=6, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="c", + ), + SnapshotCase( + test_id="wide_frame", + command='echo "test"', + content_width=60, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="test", + ), + SnapshotCase( + test_id="narrow_frame", + command='echo "test"', + content_width=10, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="test", + ), + SnapshotCase( + test_id="tall_frame", + command='echo "x"', + content_width=20, + content_height=10, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="x", + ), + SnapshotCase( + test_id="short_frame", + command='echo "x"', + content_width=20, + content_height=2, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="x", + ), + # --- Start/End Parameter Variations --- + SnapshotCase( + test_id="start_zero", + command='echo "line"', + content_width=30, + content_height=5, + start=0, + end=None, + overflow_behavior="truncate", + wait_for="line", + ), + SnapshotCase( + test_id="end_zero", + command='echo "line"', + content_width=30, + content_height=3, + start=None, + end=0, + overflow_behavior="truncate", + wait_for="line", + ), + SnapshotCase( + test_id="end_dash", + command='echo "line"', + content_width=30, + content_height=5, + start=None, + end="-", + overflow_behavior="truncate", + wait_for="line", + ), + SnapshotCase( + test_id="start_end_range", + command='printf "L1\\nL2\\nL3\\nL4\\n"', + content_width=30, + content_height=5, + start=0, + end=2, + overflow_behavior="truncate", + wait_for="L4", + ), + # --- Truncation Behavior --- + SnapshotCase( + test_id="truncate_width", + command='echo "' + "x" * 50 + '"', + content_width=15, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="xxxx", + ), + SnapshotCase( + test_id="truncate_height", + command='printf "L1\\nL2\\nL3\\nL4\\nL5\\nL6\\nL7\\nL8\\nL9\\nL10\\n"', + content_width=30, + content_height=3, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="L10", + ), + # --- Special Characters --- + SnapshotCase( + test_id="special_chars", + command='echo "!@#$%"', + content_width=25, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="!@#$%", + ), + SnapshotCase( + test_id="unicode_basic", + command='echo "cafe"', # Using ASCII to avoid shell encoding issues + content_width=25, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="cafe", + ), + # --- Edge Cases --- + SnapshotCase( + test_id="empty_lines", + command='printf "\\n\\n\\n"', + content_width=20, + content_height=6, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="$", + ), + SnapshotCase( + test_id="spaces_only", + command='echo " "', + content_width=20, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="$", + ), + SnapshotCase( + test_id="mixed_content", + command='echo "abc 123 !@#"', + content_width=30, + content_height=4, + start=None, + end=None, + overflow_behavior="truncate", + wait_for="abc 123 !@#", + ), +] + + +@pytest.mark.parametrize( + list(SnapshotCase._fields), + SNAPSHOT_CASES, + ids=[case.test_id for case in SNAPSHOT_CASES], +) +def test_capture_frame_snapshot_parametrized( + test_id: str, + command: str, + content_width: int, + content_height: int, + start: t.Literal["-"] | int | None, + end: t.Literal["-"] | int | None, + overflow_behavior: t.Literal["error", "truncate"], + wait_for: str, + session: Session, + snapshot: SnapshotAssertion, +) -> None: + """Exhaustive snapshot tests for capture_frame() parameter variations. + + This parametrized test covers all combinations of capture_frame() options + including dimensions, start/end parameters, truncation, special characters, + and edge cases. + + Parameters + ---------- + test_id : str + Unique identifier for the test case. + command : str + Shell command to execute (empty for prompt-only). + content_width : int + Frame width in characters. + content_height : int + Frame height in lines. + start : t.Literal["-"] | int | None + Starting line for capture. + end : t.Literal["-"] | int | None + Ending line for capture. + overflow_behavior : t.Literal["error", "truncate"] + How to handle overflow. + wait_for : str + String to wait for before capturing. + session : Session + pytest fixture providing tmux session. + snapshot : SnapshotAssertion + syrupy snapshot fixture with TextFrameExtension. + """ + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + attach=True, + window_name=f"snap_{test_id}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Wait for shell prompt to appear + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + # Send command if provided + if command: + pane.send_keys(command, literal=True, suppress_history=False) + + # Wait for expected content + if wait_for: + + def content_ready() -> bool: + return wait_for in "\n".join(pane.capture_pane()) + + retry_until(content_ready, 2, raises=True) + + # Capture frame with specified parameters + frame = pane.capture_frame( + start=start, + end=end, + content_width=content_width, + content_height=content_height, + overflow_behavior=overflow_behavior, + ) + + # Compare against snapshot + assert frame == snapshot From fe88a74400eab6f930c1d727d469c96407fa6836 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 12:12:13 -0600 Subject: [PATCH 24/35] tests(pane): Add capture_frame snapshot baselines why: Baseline snapshots for exhaustive visual regression testing. what: - Add 18 .frame snapshot files for parametrized test cases - Covers dimensions, start/end params, truncation, special chars --- CHANGES | 49 ++++++++++++++++++- ...napshot_parametrized[echo_multiline].frame | 8 +++ ...e_snapshot_parametrized[echo_simple].frame | 6 +++ ...e_snapshot_parametrized[empty_lines].frame | 8 +++ ...rame_snapshot_parametrized[end_dash].frame | 7 +++ ...rame_snapshot_parametrized[end_zero].frame | 5 ++ ...snapshot_parametrized[mixed_content].frame | 6 +++ ..._snapshot_parametrized[narrow_frame].frame | 5 ++ ...e_snapshot_parametrized[prompt_only].frame | 5 ++ ...e_snapshot_parametrized[short_frame].frame | 4 ++ ...e_snapshot_parametrized[spaces_only].frame | 6 +++ ...snapshot_parametrized[special_chars].frame | 6 +++ ...apshot_parametrized[start_end_range].frame | 7 +++ ...me_snapshot_parametrized[start_zero].frame | 7 +++ ...me_snapshot_parametrized[tall_frame].frame | 12 +++++ ...apshot_parametrized[truncate_height].frame | 5 ++ ...napshot_parametrized[truncate_width].frame | 6 +++ ...snapshot_parametrized[unicode_basic].frame | 6 +++ ...me_snapshot_parametrized[wide_frame].frame | 5 ++ 19 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame create mode 100644 tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame diff --git a/CHANGES b/CHANGES index bee7c4d3b..863fffa50 100644 --- a/CHANGES +++ b/CHANGES @@ -99,11 +99,56 @@ New {meth}`~libtmux.pane.Pane.capture_frame` method that wraps {meth}`~libtmux.pane.Pane.capture_pane` and returns a {class}`~libtmux.textframe.TextFrame` for visualization and snapshot testing. +**Basic usage:** + +```python +pane.send_keys('echo "Hello, TextFrame!"', enter=True) +frame = pane.capture_frame(content_width=30, content_height=5) +print(frame.render()) +# +------------------------------+ +# |$ echo "Hello, TextFrame!" | +# |Hello, TextFrame! | +# |$ | +# | | +# | | +# +------------------------------+ +``` + +**Multiline output:** + +```python +pane.send_keys('printf "a\\nb\\nc\\n"', enter=True) +frame = pane.capture_frame(content_width=20, content_height=6) +print(frame.render()) +# +--------------------+ +# |$ printf "a\nb\nc\n"| +# |a | +# |b | +# |c | +# |$ | +# | | +# +--------------------+ +``` + +**Truncation (long lines clipped to frame width):** + +```python +pane.send_keys('echo "' + "x" * 50 + '"', enter=True) +frame = pane.capture_frame(content_width=15, content_height=4) +print(frame.render()) +# +---------------+ +# |$ echo "xxxxxxx| +# |xxxxxxxxxxxxxxx| +# |$ | +# | | +# +---------------+ +``` + +**Snapshot testing:** + ```python def test_cli_output(pane, textframe_snapshot): pane.send_keys("echo 'Hello'", enter=True) - - # Wait for output, then capture as frame frame = pane.capture_frame(content_width=40, content_height=10) assert frame == textframe_snapshot ``` diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame new file mode 100644 index 000000000..d23c10d5e --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_multiline].frame @@ -0,0 +1,8 @@ ++--------------------+ +|$ printf "a\nb\nc\n"| +|a | +|b | +|c | +|$ | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame new file mode 100644 index 000000000..6e6b48dbd --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[echo_simple].frame @@ -0,0 +1,6 @@ ++-------------------------+ +|$ echo "hello" | +|hello | +|$ | +| | ++-------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame new file mode 100644 index 000000000..07fc5d743 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[empty_lines].frame @@ -0,0 +1,8 @@ ++--------------------+ +|$ printf "\n\n\n" | +| | +| | +| | +|$ | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame new file mode 100644 index 000000000..15a62ba31 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_dash].frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ echo "line" | +|line | +|$ | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame new file mode 100644 index 000000000..996b8b5cb --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[end_zero].frame @@ -0,0 +1,5 @@ ++------------------------------+ +|$ echo "line" | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame new file mode 100644 index 000000000..cb6870cdd --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[mixed_content].frame @@ -0,0 +1,6 @@ ++------------------------------+ +|$ echo "abc 123 !@#" | +|abc 123 !@# | +|$ | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame new file mode 100644 index 000000000..0a608abfd --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[narrow_frame].frame @@ -0,0 +1,5 @@ ++----------+ +|$ echo "te| +|test | +|$ | ++----------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame new file mode 100644 index 000000000..c8298bd59 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[prompt_only].frame @@ -0,0 +1,5 @@ ++--------------------+ +|$ | +| | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame new file mode 100644 index 000000000..71092ada4 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[short_frame].frame @@ -0,0 +1,4 @@ ++--------------------+ +|$ echo "x" | +|x | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame new file mode 100644 index 000000000..9bb6e1592 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[spaces_only].frame @@ -0,0 +1,6 @@ ++--------------------+ +|$ echo " " | +| | +|$ | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame new file mode 100644 index 000000000..18574aaae --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[special_chars].frame @@ -0,0 +1,6 @@ ++-------------------------+ +|$ echo "!@#$%" | +|!@#$% | +|$ | +| | ++-------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame new file mode 100644 index 000000000..dd79e6623 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_end_range].frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ printf "L1\nL2\nL3\nL4\n" | +|L1 | +|L2 | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame new file mode 100644 index 000000000..15a62ba31 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[start_zero].frame @@ -0,0 +1,7 @@ ++------------------------------+ +|$ echo "line" | +|line | +|$ | +| | +| | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame new file mode 100644 index 000000000..354fe9fb4 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[tall_frame].frame @@ -0,0 +1,12 @@ ++--------------------+ +|$ echo "x" | +|x | +|$ | +| | +| | +| | +| | +| | +| | +| | ++--------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame new file mode 100644 index 000000000..21b5dfc9a --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_height].frame @@ -0,0 +1,5 @@ ++------------------------------+ +|$ printf "L1\nL2\nL3\nL4\nL5\n| +|L1 | +|L2 | ++------------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame new file mode 100644 index 000000000..aeecfa7af --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[truncate_width].frame @@ -0,0 +1,6 @@ ++---------------+ +|$ echo "xxxxxxx| +|xxxxxxxxxxxxxxx| +|$ | +| | ++---------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame new file mode 100644 index 000000000..a018a9f32 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[unicode_basic].frame @@ -0,0 +1,6 @@ ++-------------------------+ +|$ echo "cafe" | +|cafe | +|$ | +| | ++-------------------------+ \ No newline at end of file diff --git a/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame new file mode 100644 index 000000000..71ee6aed9 --- /dev/null +++ b/tests/__snapshots__/test_pane_capture_frame/test_capture_frame_snapshot_parametrized[wide_frame].frame @@ -0,0 +1,5 @@ ++------------------------------------------------------------+ +|$ echo "test" | +|test | +|$ | ++------------------------------------------------------------+ \ No newline at end of file From f3def309b1a239e69cb3e64de10210b695315d73 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 13:57:47 -0600 Subject: [PATCH 25/35] Pane(docs[capture_frame]): Fix doctest to work without SKIP why: Enable doctest verification of capture_frame() output. what: - Create new pane with shell='sh' for predictable prompt - Remove # doctest: +SKIP since output is now deterministic - Follow established pattern from capture_pane() doctest --- src/libtmux/pane.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 4d22a337b..9f81c7523 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -544,13 +544,14 @@ def capture_frame( Examples -------- + >>> pane = window.split(shell='sh') >>> pane.send_keys('echo "Hello"', enter=True) >>> import time; time.sleep(0.1) >>> frame = pane.capture_frame(content_width=20, content_height=5) >>> 'Hello' in frame.render() True - >>> print(frame.render()) # doctest: +SKIP + >>> print(frame.render()) +--------------------+ |$ echo "Hello" | |Hello | From 649e5a4716bf924fd0ae6393ae2cc41502c816f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 16:09:35 -0600 Subject: [PATCH 26/35] Pane(feat[capture_frame]): Forward capture_pane() flags why: Allow users to control capture behavior when using capture_frame() for snapshot testing, such as capturing colored output or joining wrapped lines. what: - Add escape_sequences parameter for ANSI escape sequences - Add escape_non_printable parameter for octal escapes - Add join_wrapped parameter for joining wrapped lines - Add preserve_trailing parameter for trailing spaces - Add trim_trailing parameter with tmux 3.4+ version check - Forward all flags to capture_pane() call --- src/libtmux/pane.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 9f81c7523..5f2252a81 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -506,8 +506,13 @@ def capture_frame( content_width: int | None = None, content_height: int | None = None, overflow_behavior: OverflowBehavior = "truncate", + escape_sequences: bool = False, + escape_non_printable: bool = False, + join_wrapped: bool = False, + preserve_trailing: bool = False, + trim_trailing: bool = False, ) -> TextFrame: - """Capture pane content as a TextFrame. + r"""Capture pane content as a TextFrame. Combines :meth:`capture_pane` with :class:`~libtmux.textframe.TextFrame` for visualization and snapshot testing. @@ -536,6 +541,19 @@ def capture_frame( How to handle content that exceeds frame dimensions. Defaults to ``"truncate"`` since pane content may exceed nominal dimensions during terminal transitions. + escape_sequences : bool, optional + Include ANSI escape sequences for text and background attributes. + Useful for capturing colored output. Default: False + escape_non_printable : bool, optional + Escape non-printable characters as octal ``\\xxx`` format. + Default: False + join_wrapped : bool, optional + Join wrapped lines back together. Default: False + preserve_trailing : bool, optional + Preserve trailing spaces at each line's end. Default: False + trim_trailing : bool, optional + Trim trailing positions with no characters. + Requires tmux 3.4+. Default: False Returns ------- @@ -562,8 +580,16 @@ def capture_frame( """ from libtmux.textframe import TextFrame as TextFrameClass - # Capture content - lines = self.capture_pane(start=start, end=end) + # Capture content with all flags forwarded + lines = self.capture_pane( + start=start, + end=end, + escape_sequences=escape_sequences, + escape_non_printable=escape_non_printable, + join_wrapped=join_wrapped, + preserve_trailing=preserve_trailing, + trim_trailing=trim_trailing, + ) # Use pane dimensions if not specified self.refresh() From cc7a061eeebdec3ba9e15e093d562bf6b08bdb6d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 16:09:43 -0600 Subject: [PATCH 27/35] tests(pane): Add capture_frame() flag forwarding tests why: Verify that capture_frame() correctly forwards all capture_pane() flags for proper behavior in snapshot testing scenarios. what: - Add CaptureFrameFlagCase NamedTuple for parametrized tests - Add 4 test cases covering key flag behaviors - Test escape_sequences, join_wrapped, preserve_trailing flags - Verify flag absence behavior (no_escape_sequences) --- CHANGES | 2 + tests/test_pane_capture_frame.py | 178 +++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/CHANGES b/CHANGES index 863fffa50..35545ecf3 100644 --- a/CHANGES +++ b/CHANGES @@ -158,6 +158,8 @@ def test_cli_output(pane, textframe_snapshot): - Defaults to pane dimensions when `content_width` / `content_height` not specified - Uses `overflow_behavior="truncate"` by default for CI robustness - Accepts same `start` / `end` parameters as `capture_pane()` +- Forwards all `capture_pane()` flags: `escape_sequences`, `escape_non_printable`, + `join_wrapped`, `preserve_trailing`, `trim_trailing` ## libtmux 0.56.0 (2026-05-10) diff --git a/tests/test_pane_capture_frame.py b/tests/test_pane_capture_frame.py index eecb4cf60..43ba686e9 100644 --- a/tests/test_pane_capture_frame.py +++ b/tests/test_pane_capture_frame.py @@ -685,3 +685,181 @@ def content_ready() -> bool: # Compare against snapshot assert frame == snapshot + + +# ============================================================================= +# Flag Forwarding Tests +# ============================================================================= + + +class CaptureFrameFlagCase(t.NamedTuple): + """Test case for capture_frame() flag forwarding to capture_pane().""" + + test_id: str + command: str + escape_sequences: bool + escape_non_printable: bool + join_wrapped: bool + preserve_trailing: bool + trim_trailing: bool + expected_pattern: str | None + not_expected_pattern: str | None + min_tmux_version: str | None + + +CAPTURE_FRAME_FLAG_CASES: list[CaptureFrameFlagCase] = [ + CaptureFrameFlagCase( + test_id="escape_sequences_color", + command='printf "\\033[31mRED\\033[0m"', + escape_sequences=True, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"\x1b\[31m", + not_expected_pattern=None, + min_tmux_version=None, + ), + CaptureFrameFlagCase( + test_id="no_escape_sequences", + command='printf "\\033[31mRED\\033[0m"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"RED", + not_expected_pattern=r"\x1b\[", + min_tmux_version=None, + ), + CaptureFrameFlagCase( + test_id="join_wrapped_long_line", + command="printf '%s' \"$(seq 1 30 | tr -d '\\n')\"", + escape_sequences=False, + escape_non_printable=False, + join_wrapped=True, + preserve_trailing=False, + trim_trailing=False, + # With join_wrapped, wrapped lines are joined - verify contiguous sequence + expected_pattern=r"123456789101112131415161718192021222324252627282930", + not_expected_pattern=None, + min_tmux_version=None, + ), + CaptureFrameFlagCase( + test_id="preserve_trailing_spaces", + command='printf "text \\n"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=True, + trim_trailing=False, + expected_pattern=r"text ", + not_expected_pattern=None, + min_tmux_version=None, + ), +] + + +@pytest.mark.parametrize( + list(CaptureFrameFlagCase._fields), + CAPTURE_FRAME_FLAG_CASES, + ids=[case.test_id for case in CAPTURE_FRAME_FLAG_CASES], +) +def test_capture_frame_flag_forwarding( + test_id: str, + command: str, + escape_sequences: bool, + escape_non_printable: bool, + join_wrapped: bool, + preserve_trailing: bool, + trim_trailing: bool, + expected_pattern: str | None, + not_expected_pattern: str | None, + min_tmux_version: str | None, + session: Session, +) -> None: + """Test that capture_frame() correctly forwards flags to capture_pane(). + + Parameters + ---------- + test_id : str + Unique identifier for the test case. + command : str + Shell command to execute. + escape_sequences : bool + Include ANSI escape sequences. + escape_non_printable : bool + Escape non-printable characters. + join_wrapped : bool + Join wrapped lines. + preserve_trailing : bool + Preserve trailing spaces. + trim_trailing : bool + Trim trailing positions. + expected_pattern : str | None + Regex pattern expected in output. + not_expected_pattern : str | None + Regex pattern that should NOT be in output. + min_tmux_version : str | None + Minimum tmux version required. + session : Session + pytest fixture providing tmux session. + """ + import re + + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + env = shutil.which("env") + assert env is not None + + window = session.new_window( + attach=True, + window_name=f"flag_{test_id}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Wait for shell prompt + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + # Send command and wait for completion marker + marker = f"__DONE_{test_id}__" + pane.send_keys(f"{command}; echo {marker}", literal=True) + + def marker_ready() -> bool: + return marker in "\n".join(pane.capture_pane()) + + retry_until(marker_ready, 3, raises=True) + + # Capture frame with specified flags + frame = pane.capture_frame( + content_width=80, + content_height=24, + escape_sequences=escape_sequences, + escape_non_printable=escape_non_printable, + join_wrapped=join_wrapped, + preserve_trailing=preserve_trailing, + trim_trailing=trim_trailing, + ) + + # Get rendered content (without frame borders) + rendered = frame.render() + + # Verify expected pattern + if expected_pattern: + assert re.search(expected_pattern, rendered, re.DOTALL), ( + f"Expected pattern '{expected_pattern}' not found in:\n{rendered}" + ) + + # Verify not_expected pattern is absent + if not_expected_pattern: + assert not re.search(not_expected_pattern, rendered, re.DOTALL), ( + f"Unexpected pattern '{not_expected_pattern}' found in:\n{rendered}" + ) From e1c0c489f38537b15506ff77c9bf83e19e9a2e1f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 18:35:12 -0600 Subject: [PATCH 28/35] TextFrame(feat[display]): Add interactive curses viewer why: Enable interactive exploration of large frame content in terminal what: - Add display() method with TTY detection - Add _curses_display() with scrolling support - Navigation: arrows, WASD, vim keys (hjkl) - Page navigation: PgUp/PgDn, Home/End - Exit: q, Esc, Ctrl-C --- src/libtmux/textframe/core.py | 122 ++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/libtmux/textframe/core.py b/src/libtmux/textframe/core.py index e6e94056d..1190f7800 100644 --- a/src/libtmux/textframe/core.py +++ b/src/libtmux/textframe/core.py @@ -6,6 +6,9 @@ from __future__ import annotations +import contextlib +import curses +import sys import typing as t from dataclasses import dataclass, field @@ -189,3 +192,122 @@ def _draw_frame(self, lines: list[str], w: int, h: int) -> str: line = lines[r] if r < len(lines) else "" body.append(f"|{line.ljust(w, self.fill_char)}|") return "\n".join([border, *body, border]) + + def display(self) -> None: + """Display frame in interactive scrollable curses viewer. + + Opens a full-screen terminal viewer with scrolling support for + exploring large frame content interactively. + + Controls + -------- + Navigation: + - Arrow keys: Scroll up/down/left/right + - w/a/s/d: Scroll up/left/down/right + - k/h/j/l (vim): Scroll up/left/down/right + - PgUp/PgDn: Page up/down + - Home/End: Jump to top/bottom + + Exit: + - q: Quit + - Esc: Quit + - Ctrl-C: Quit + + Raises + ------ + RuntimeError + If stdout is not a TTY (not an interactive terminal). + + Examples + -------- + >>> pane = session.active_window.active_pane + >>> frame = pane.capture_frame() + >>> frame.display() # Opens interactive viewer # doctest: +SKIP + """ + if not sys.stdout.isatty(): + msg = "display() requires an interactive terminal" + raise RuntimeError(msg) + + curses.wrapper(self._curses_display) + + def _curses_display(self, stdscr: curses.window) -> None: + """Curses main loop for interactive display. + + Parameters + ---------- + stdscr : curses.window + The curses standard screen window. + """ + curses.curs_set(0) # Hide cursor + + # Render full frame once + rendered = self.render() + lines = rendered.split("\n") + + # Scroll state + scroll_y = 0 + scroll_x = 0 + + while True: + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + + # Calculate scroll bounds + max_scroll_y = max(0, len(lines) - max_y + 1) + max_scroll_x = max(0, max(len(line) for line in lines) - max_x) + + # Clamp scroll position + scroll_y = max(0, min(scroll_y, max_scroll_y)) + scroll_x = max(0, min(scroll_x, max_scroll_x)) + + # Draw visible portion + for i, line in enumerate(lines[scroll_y : scroll_y + max_y - 1]): + if i >= max_y - 1: + break + display_line = line[scroll_x : scroll_x + max_x] + with contextlib.suppress(curses.error): + stdscr.addstr(i, 0, display_line) + + # Status line + status = ( + f" [{scroll_y + 1}/{len(lines)}] " + f"{self.content_width}x{self.content_height} | q:quit " + ) + with contextlib.suppress(curses.error): + stdscr.addstr(max_y - 1, 0, status[: max_x - 1], curses.A_REVERSE) + + stdscr.refresh() + + # Handle input + try: + key = stdscr.getch() + except KeyboardInterrupt: + break + + # Exit keys + if key in (ord("q"), 27): # q or Esc + break + + # Vertical navigation + if key in (curses.KEY_UP, ord("w"), ord("k")): + scroll_y = max(0, scroll_y - 1) + elif key in (curses.KEY_DOWN, ord("s"), ord("j")): + scroll_y = min(max_scroll_y, scroll_y + 1) + + # Horizontal navigation + elif key in (curses.KEY_LEFT, ord("a"), ord("h")): + scroll_x = max(0, scroll_x - 1) + elif key in (curses.KEY_RIGHT, ord("d"), ord("l")): + scroll_x = min(max_scroll_x, scroll_x + 1) + + # Page navigation + elif key == curses.KEY_PPAGE: # Page Up + scroll_y = max(0, scroll_y - (max_y - 2)) + elif key == curses.KEY_NPAGE: # Page Down + scroll_y = min(max_scroll_y, scroll_y + (max_y - 2)) + + # Jump navigation + elif key == curses.KEY_HOME: + scroll_y = 0 + elif key == curses.KEY_END: + scroll_y = max_scroll_y From f3cc82bb442fa00e8f33008534d42527e671356d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 18:37:14 -0600 Subject: [PATCH 29/35] docs(textframe): Document display() method why: Enable users to discover interactive viewer feature what: - Add Interactive Display section with usage example - Document all keyboard controls in table format - Note TTY requirement and RuntimeError behavior --- CHANGES | 1 + docs/internals/textframe.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/CHANGES b/CHANGES index 35545ecf3..68b512631 100644 --- a/CHANGES +++ b/CHANGES @@ -66,6 +66,7 @@ print(frame.render()) - Overflow handling: `overflow_behavior="error"` raises {class}`~libtmux.textframe.ContentOverflowError` with visual diagnostic, `overflow_behavior="truncate"` clips content silently - Dimension validation via `__post_init__` +- Interactive curses viewer via `display()` for exploring large frames #### pytest assertion hook for TextFrame (#613) diff --git a/docs/internals/textframe.md b/docs/internals/textframe.md index 69bcecca2..f9e5f23b9 100644 --- a/docs/internals/textframe.md +++ b/docs/internals/textframe.md @@ -55,6 +55,30 @@ Output: +----------+ ``` +### Interactive Display + +For exploring large frames interactively, use `display()` to open a scrollable curses viewer: + +```python +frame = TextFrame(content_width=80, content_height=50) +frame.set_content(["line %d" % i for i in range(50)]) +frame.display() # Opens interactive viewer +``` + +**Controls:** + +| Key | Action | +|-----|--------| +| ↑/↓ or w/s or k/j | Scroll up/down | +| ←/→ or a/d or h/l | Scroll left/right | +| PgUp/PgDn | Page up/down | +| Home/End | Jump to top/bottom | +| q, Esc, Ctrl-C | Quit | + +The viewer shows a status bar at the bottom with scroll position, frame dimensions, and help text. + +**Note:** `display()` requires an interactive terminal (TTY). It raises `RuntimeError` if stdout is not a TTY (e.g., when piped or in CI environments). + ### Overflow Behavior TextFrame supports two overflow behaviors: From 819e7145281b144ce464baf92beac2a3f50ef85f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:34:00 -0600 Subject: [PATCH 30/35] TextFrame(fix[display]): Use shutil for terminal size detection why: curses KEY_RESIZE only fires on getch(), missing resize events when terminal is resized but no key is pressed what: - Replace stdscr.getmaxyx() with shutil.get_terminal_size() - Remove KEY_RESIZE handling (now redundant) This follows Rich's approach: query terminal size directly via ioctl(TIOCGWINSZ) on each loop iteration, which works reliably in tmux and other terminal multiplexers. --- src/libtmux/textframe/core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libtmux/textframe/core.py b/src/libtmux/textframe/core.py index 1190f7800..eccb40d21 100644 --- a/src/libtmux/textframe/core.py +++ b/src/libtmux/textframe/core.py @@ -8,6 +8,7 @@ import contextlib import curses +import shutil import sys import typing as t from dataclasses import dataclass, field @@ -250,7 +251,10 @@ def _curses_display(self, stdscr: curses.window) -> None: while True: stdscr.clear() - max_y, max_x = stdscr.getmaxyx() + + # Query terminal size directly (handles resize without signals) + term_size = shutil.get_terminal_size() + max_x, max_y = term_size.columns, term_size.lines # Calculate scroll bounds max_scroll_y = max(0, len(lines) - max_y + 1) From 5b148cc2bd1533934317053dcdf1e6acec51a572 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:34:06 -0600 Subject: [PATCH 31/35] tests(textframe): Add shutil terminal size detection test why: Verify display() uses shutil.get_terminal_size() for resize what: - Add test_terminal_resize_via_shutil test - Mock shutil.get_terminal_size to verify it's called --- tests/textframe/test_display.py | 152 ++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/textframe/test_display.py diff --git a/tests/textframe/test_display.py b/tests/textframe/test_display.py new file mode 100644 index 000000000..d0c96278d --- /dev/null +++ b/tests/textframe/test_display.py @@ -0,0 +1,152 @@ +"""Tests for TextFrame.display() interactive viewer.""" + +from __future__ import annotations + +import curses +import io +import os +import typing as t +from unittest.mock import MagicMock, patch + +import pytest + +from libtmux.textframe import TextFrame + + +class ExitKeyCase(t.NamedTuple): + """Test case for exit key handling.""" + + id: str + key: int | None + side_effect: type[BaseException] | None = None + + +EXIT_KEY_CASES: tuple[ExitKeyCase, ...] = ( + ExitKeyCase( + id="quit_on_q", + key=ord("q"), + ), + ExitKeyCase( + id="quit_on_escape", + key=27, + ), + ExitKeyCase( + id="quit_on_ctrl_c", + key=None, + side_effect=KeyboardInterrupt, + ), +) + + +@pytest.fixture +def mock_curses_env() -> t.Generator[None, None, None]: + """Mock curses module-level functions that require initscr().""" + with ( + patch("curses.curs_set"), + patch("curses.A_REVERSE", 0), + ): + yield + + +def test_display_raises_when_not_tty() -> None: + """Verify display() raises RuntimeError when stdout is not a TTY.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + with ( + patch("sys.stdout", new=io.StringIO()), + pytest.raises(RuntimeError, match="interactive terminal"), + ): + frame.display() + + +def test_display_calls_curses_wrapper_when_tty() -> None: + """Verify display() calls curses.wrapper when stdout is a TTY.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + with ( + patch("sys.stdout.isatty", return_value=True), + patch("curses.wrapper") as mock_wrapper, + ): + frame.display() + mock_wrapper.assert_called_once() + args = mock_wrapper.call_args[0] + assert args[0].__name__ == "_curses_display" + + +@pytest.mark.parametrize("case", EXIT_KEY_CASES, ids=lambda c: c.id) +def test_curses_display_exit_keys( + case: ExitKeyCase, + mock_curses_env: None, +) -> None: + """Verify viewer exits on various exit keys/events.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + mock_stdscr = MagicMock() + + if case.side_effect: + mock_stdscr.getch.side_effect = case.side_effect + else: + mock_stdscr.getch.return_value = case.key + + # Should exit cleanly without error + frame._curses_display(mock_stdscr) + mock_stdscr.clear.assert_called() + + +def test_curses_display_scroll_navigation(mock_curses_env: None) -> None: + """Verify scroll navigation works with arrow keys.""" + frame = TextFrame(content_width=10, content_height=10) + frame.set_content([f"line {i}" for i in range(10)]) + + mock_stdscr = MagicMock() + + # Simulate: down arrow, then quit + mock_stdscr.getch.side_effect = [curses.KEY_DOWN, ord("q")] + + frame._curses_display(mock_stdscr) + + # Verify multiple refresh cycles occurred (initial + after navigation) + assert mock_stdscr.refresh.call_count >= 2 + + +def test_curses_display_status_line(mock_curses_env: None) -> None: + """Verify status line shows position and dimensions.""" + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + mock_stdscr = MagicMock() + mock_stdscr.getch.return_value = ord("q") + + frame._curses_display(mock_stdscr) + + # Find the addstr call that contains status info + status_calls = [ + call + for call in mock_stdscr.addstr.call_args_list + if len(call[0]) >= 3 and "q:quit" in str(call[0][2]) + ] + assert len(status_calls) > 0, "Status line should be displayed" + + +def test_curses_display_uses_shutil_terminal_size(mock_curses_env: None) -> None: + """Verify terminal size is queried via shutil.get_terminal_size(). + + This approach works reliably in tmux/multiplexers because it directly + queries the terminal via ioctl(TIOCGWINSZ) on each loop iteration, + rather than relying on curses KEY_RESIZE events. + """ + frame = TextFrame(content_width=10, content_height=2) + frame.set_content(["hello", "world"]) + + mock_stdscr = MagicMock() + mock_stdscr.getch.return_value = ord("q") + + with patch( + "libtmux.textframe.core.shutil.get_terminal_size", + return_value=os.terminal_size((120, 40)), + ) as mock_get_size: + frame._curses_display(mock_stdscr) + mock_get_size.assert_called() From 25358fef223154e64162d9d38c15022ec4af9730 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:50:05 -0600 Subject: [PATCH 32/35] textframe(fix): Make TextFrameExtension import conditional why: Users without libtmux[textframe] get ImportError on capture_frame() what: - Wrap TextFrameExtension import in try/except ImportError - Only add to __all__ when syrupy is available - Core TextFrame functionality works without optional dependency --- src/libtmux/textframe/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libtmux/textframe/__init__.py b/src/libtmux/textframe/__init__.py index bd5c31144..a1331b362 100644 --- a/src/libtmux/textframe/__init__.py +++ b/src/libtmux/textframe/__init__.py @@ -3,6 +3,13 @@ from __future__ import annotations from libtmux.textframe.core import ContentOverflowError, TextFrame -from libtmux.textframe.plugin import TextFrameExtension -__all__ = ["ContentOverflowError", "TextFrame", "TextFrameExtension"] +__all__ = ["ContentOverflowError", "TextFrame"] + +# Conditionally export TextFrameExtension when syrupy is available +try: + from libtmux.textframe.plugin import TextFrameExtension + + __all__.append("TextFrameExtension") +except ImportError: + pass From 8e9b241487c8f91da0a38b38b81e2f2e7c810d2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:50:34 -0600 Subject: [PATCH 33/35] textframe(fix): Inherit ContentOverflowError from LibTmuxException why: Follow established exception pattern for libtmux exceptions what: - Add LibTmuxException as base class alongside ValueError - Matches pattern of AdjustmentDirectionRequiresAdjustment, etc. - Enables catching all libtmux exceptions with LibTmuxException --- src/libtmux/textframe/core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libtmux/textframe/core.py b/src/libtmux/textframe/core.py index eccb40d21..2118992c3 100644 --- a/src/libtmux/textframe/core.py +++ b/src/libtmux/textframe/core.py @@ -13,13 +13,15 @@ import typing as t from dataclasses import dataclass, field +from libtmux.exc import LibTmuxException + if t.TYPE_CHECKING: from collections.abc import Sequence OverflowBehavior = t.Literal["error", "truncate"] -class ContentOverflowError(ValueError): +class ContentOverflowError(LibTmuxException, ValueError): """Raised when content does not fit into the configured frame dimensions. Attributes From 3d3df3d408e0d08e78cfb1c99a3d19fdf51d6e80 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:51:08 -0600 Subject: [PATCH 34/35] textframe(style): Use namespace import for difflib why: Follow CLAUDE.md guideline for stdlib namespace imports what: - Change from difflib import ndiff to import difflib - Use difflib.ndiff() instead of ndiff() --- src/libtmux/textframe/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/textframe/plugin.py b/src/libtmux/textframe/plugin.py index 917176526..14eef48b1 100644 --- a/src/libtmux/textframe/plugin.py +++ b/src/libtmux/textframe/plugin.py @@ -11,8 +11,8 @@ from __future__ import annotations +import difflib import typing as t -from difflib import ndiff import pytest from syrupy.assertion import SnapshotAssertion @@ -120,7 +120,7 @@ def pytest_assertrepr_compare( if left_render != right_render: lines.append("") lines.append("Content diff:") - lines.extend(ndiff(right_render, left_render)) + lines.extend(difflib.ndiff(right_render, left_render)) return lines From 09817450bd5d40bd3938bbdba414e531dd68333d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 19:53:02 -0600 Subject: [PATCH 35/35] tests(textframe): Replace patch() with monkeypatch.setattr() why: Follow pytest best practices from CLAUDE.md guidelines what: - Use import unittest.mock namespace style - Replace patch() context managers with monkeypatch.setattr() - Document MagicMock necessity for curses window simulation --- tests/textframe/test_display.py | 74 ++++--- uv.lock | 377 +++++++++++++++----------------- 2 files changed, 218 insertions(+), 233 deletions(-) diff --git a/tests/textframe/test_display.py b/tests/textframe/test_display.py index d0c96278d..2b91bafc4 100644 --- a/tests/textframe/test_display.py +++ b/tests/textframe/test_display.py @@ -1,12 +1,19 @@ -"""Tests for TextFrame.display() interactive viewer.""" +"""Tests for TextFrame.display() interactive viewer. + +Note on MagicMock usage: These tests require MagicMock to create mock curses.window +objects with configurable return values and side effects. pytest's monkeypatch +fixture patches existing attributes but doesn't create mock objects with the +call tracking and behavior configuration needed for curses window simulation. +""" from __future__ import annotations import curses import io import os +import sys import typing as t -from unittest.mock import MagicMock, patch +import unittest.mock import pytest @@ -39,40 +46,38 @@ class ExitKeyCase(t.NamedTuple): @pytest.fixture -def mock_curses_env() -> t.Generator[None, None, None]: +def mock_curses_env(monkeypatch: pytest.MonkeyPatch) -> None: """Mock curses module-level functions that require initscr().""" - with ( - patch("curses.curs_set"), - patch("curses.A_REVERSE", 0), - ): - yield + monkeypatch.setattr(curses, "curs_set", lambda x: None) + monkeypatch.setattr(curses, "A_REVERSE", 0) -def test_display_raises_when_not_tty() -> None: +def test_display_raises_when_not_tty(monkeypatch: pytest.MonkeyPatch) -> None: """Verify display() raises RuntimeError when stdout is not a TTY.""" frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - with ( - patch("sys.stdout", new=io.StringIO()), - pytest.raises(RuntimeError, match="interactive terminal"), - ): + monkeypatch.setattr(sys, "stdout", io.StringIO()) + + with pytest.raises(RuntimeError, match="interactive terminal"): frame.display() -def test_display_calls_curses_wrapper_when_tty() -> None: +def test_display_calls_curses_wrapper_when_tty( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Verify display() calls curses.wrapper when stdout is a TTY.""" frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - with ( - patch("sys.stdout.isatty", return_value=True), - patch("curses.wrapper") as mock_wrapper, - ): - frame.display() - mock_wrapper.assert_called_once() - args = mock_wrapper.call_args[0] - assert args[0].__name__ == "_curses_display" + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + mock_wrapper = unittest.mock.MagicMock() + monkeypatch.setattr(curses, "wrapper", mock_wrapper) + + frame.display() + mock_wrapper.assert_called_once() + args = mock_wrapper.call_args[0] + assert args[0].__name__ == "_curses_display" @pytest.mark.parametrize("case", EXIT_KEY_CASES, ids=lambda c: c.id) @@ -84,7 +89,7 @@ def test_curses_display_exit_keys( frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() if case.side_effect: mock_stdscr.getch.side_effect = case.side_effect @@ -101,7 +106,7 @@ def test_curses_display_scroll_navigation(mock_curses_env: None) -> None: frame = TextFrame(content_width=10, content_height=10) frame.set_content([f"line {i}" for i in range(10)]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() # Simulate: down arrow, then quit mock_stdscr.getch.side_effect = [curses.KEY_DOWN, ord("q")] @@ -117,7 +122,7 @@ def test_curses_display_status_line(mock_curses_env: None) -> None: frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() mock_stdscr.getch.return_value = ord("q") frame._curses_display(mock_stdscr) @@ -131,7 +136,10 @@ def test_curses_display_status_line(mock_curses_env: None) -> None: assert len(status_calls) > 0, "Status line should be displayed" -def test_curses_display_uses_shutil_terminal_size(mock_curses_env: None) -> None: +def test_curses_display_uses_shutil_terminal_size( + mock_curses_env: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: """Verify terminal size is queried via shutil.get_terminal_size(). This approach works reliably in tmux/multiplexers because it directly @@ -141,12 +149,14 @@ def test_curses_display_uses_shutil_terminal_size(mock_curses_env: None) -> None frame = TextFrame(content_width=10, content_height=2) frame.set_content(["hello", "world"]) - mock_stdscr = MagicMock() + mock_stdscr = unittest.mock.MagicMock() mock_stdscr.getch.return_value = ord("q") - with patch( + mock_get_size = unittest.mock.MagicMock(return_value=os.terminal_size((120, 40))) + monkeypatch.setattr( "libtmux.textframe.core.shutil.get_terminal_size", - return_value=os.terminal_size((120, 40)), - ) as mock_get_size: - frame._curses_display(mock_stdscr) - mock_get_size.assert_called() + mock_get_size, + ) + + frame._curses_display(mock_stdscr) + mock_get_size.assert_called() diff --git a/uv.lock b/uv.lock index 898356514..56bad9383 100644 --- a/uv.lock +++ b/uv.lock @@ -64,44 +64,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "ast-serialize" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, - { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, - { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, - { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, - { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, - { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, - { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, - { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, - { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, - { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, - { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, - { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, - { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, -] - [[package]] name = "babel" version = "2.18.0" @@ -529,87 +491,87 @@ wheels = [ [[package]] name = "librt" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/18/827e5c1262a88c2602e86f99aee0f288ffea3280dbd2ff448858ef9dc6e9/librt-0.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dc99f9642100b86e5f6bb14cdc9970009e31a9ef7d64df6704b7018451524a3", size = 76461, upload-time = "2026-05-05T16:29:00.422Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/54254e30287f5a5abec6fef22d976987476e966be5fdff51fe8c2d5d73d1/librt-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8298cedfcfaff3790000bd057aaaa3df1b0ab54cf7b48eeab16184cbb1bc66b9", size = 79740, upload-time = "2026-05-05T16:29:01.926Z" }, - { url = "https://files.pythonhosted.org/packages/4c/20/e93264b52113669d98d3b63ff94d4ce0c4dd49ae0503f1788440a884e5f0/librt-0.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7dbe312dbf76468255b79a7ba311236fde620f2f7055fc09d421e31340314e", size = 243472, upload-time = "2026-05-05T16:29:03.373Z" }, - { url = "https://files.pythonhosted.org/packages/35/ad/34a5141178e8b18a4cfa45d1a0d523c84397e2abd5d06fea2d846da687e8/librt-0.10.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:56ed90c48c19249012dadfd79a1bc13bd5168ea60a70722d330a3a600c0b1852", size = 232073, upload-time = "2026-05-05T16:29:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/97/1f/67240e910cd9f9ab1498c1470738345fc29dce5dc9719db1e0e09d1e861f/librt-0.10.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d74ca0f4b2b09c117f913d4df01f6b934dff8a271096b35167d5264a31649f0", size = 256956, upload-time = "2026-05-05T16:29:06.516Z" }, - { url = "https://files.pythonhosted.org/packages/22/50/3a2b3482c27d607f6e8216d913c6bc592b9a2141d96990309452340a78e3/librt-0.10.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8eb2daa9375f93c0e55ff5e44a4bbe98f39e5fe52e1abf9c97acb67743b61bf8", size = 250593, upload-time = "2026-05-05T16:29:08.324Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1c/07dba133d79f93322fa17514062f1a2a50d6bdfb7baec4acf78193d7fad1/librt-0.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7b09b90e634e6dff57978cd358070046071e2b120501f10787aeb35425f504f6", size = 263582, upload-time = "2026-05-05T16:29:09.866Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ac/033f2c6d6ab0b48f15f02e5bf065521b11a51922806017f8b6274df30d69/librt-0.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2cf22fd379d60c739b800d4295ed34045f8b04aa8df9c12bd2f8f43f7fe672b7", size = 259307, upload-time = "2026-05-05T16:29:11.675Z" }, - { url = "https://files.pythonhosted.org/packages/6e/10/679046cd75d5a52c0104c890d8f69574ef4e619c683e59c15584d03a2457/librt-0.10.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:74c798793fcf29a84d442278ebe0bb1fff79fe58ac4106eeff7019cbba861423", size = 257342, upload-time = "2026-05-05T16:29:13.14Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d5/dbaac9c0884f78a53dda22b9ec92bb788e1400e762ed7623fa96928c8da5/librt-0.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc4f1573401e8dbe6c26511fe027620b0fb30ae9a7ab814e02e510626b8b5f9c", size = 280141, upload-time = "2026-05-05T16:29:14.922Z" }, - { url = "https://files.pythonhosted.org/packages/cc/81/71f18cf8eb340d9fda011498870910f6a8697aeb50833005d3d8107653fd/librt-0.10.0-cp310-cp310-win32.whl", hash = "sha256:e1428275f5fe3d4db6822e58d8b005a5b28ffca55e8433ebc051247fbe46429f", size = 62257, upload-time = "2026-05-05T16:29:16.226Z" }, - { url = "https://files.pythonhosted.org/packages/df/52/6bcebc2f870c4836bcb372be885fae7f17a1d25037d3a8250ef79fbe0124/librt-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:0708e9408f585b0f065081680583a577652099680ccf820c7538904322b679c3", size = 70321, upload-time = "2026-05-05T16:29:17.41Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a3/1472717d2325adacc8d335ba2e4078015c09d75b599f3cf48e967b3d306e/librt-0.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01b4500ca3a625450c032a9142a8e843923ce263fa8a92ad1b38927cabe2fe72", size = 76045, upload-time = "2026-05-05T16:29:18.731Z" }, - { url = "https://files.pythonhosted.org/packages/a6/31/bfe32355d4b369aef3d7aa442df663bb5558c2ffa2de286cb2956346bc24/librt-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6b7e42d1b3e300d20bfc87e72ffd62f0a92a2cb3c35f7bf90df90c9d2a49f74c", size = 79466, upload-time = "2026-05-05T16:29:20.052Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f1/83f8a2c715ba2cac9b7387a5a5cea25f717f7184320cfe48b36bed9c58e9/librt-0.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8ef7b8c61ce3a1b597cd3e15348ff1574325165c2e7ce09a718154cde2a7950", size = 242283, upload-time = "2026-05-05T16:29:21.596Z" }, - { url = "https://files.pythonhosted.org/packages/cc/94/c3a4ce94857f0004a542f86662806383611858f522722db58efaec0a1472/librt-0.10.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e73c84f72d1fa0d6eaa7a1930b436ba8d2c90c58d77bfabb09995a69ad35f6c0", size = 230735, upload-time = "2026-05-05T16:29:23.335Z" }, - { url = "https://files.pythonhosted.org/packages/d1/41/e962bb26c7728eb7b3a69e490d0c800fd9968a6970e390c1f18ddb56093d/librt-0.10.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9728cb98713bd862fb8f4fd6a642d1896c86058a41d77c70f3d5cee75e725275", size = 256606, upload-time = "2026-05-05T16:29:24.91Z" }, - { url = "https://files.pythonhosted.org/packages/66/3a/4e46a707b1ecc993fd691071623b9beab89703a63bd21cc7807e06c28209/librt-0.10.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:648b7e941d20acd72f9652115e0e53facd98156d61f9ebf7a812bdef8bdccea9", size = 249739, upload-time = "2026-05-05T16:29:26.648Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f5/dc5b7eb294656ad23d4ff4cf8514208d54fe1026b909d726a0dc026689c9/librt-0.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c3e33747c068e86a9007c20fdb777eb5ba8d3d19136d7812f88e69a713041b6f", size = 261414, upload-time = "2026-05-05T16:29:28.702Z" }, - { url = "https://files.pythonhosted.org/packages/58/e4/990ed8d12c7f114ac8f8ccd47f7d9bd9704ef61acfcb1df4a05047da7710/librt-0.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d509c745bf7e77d1107cf05e6abb249dc03fad13eb39f2286a49deedaeb2bcd7", size = 256614, upload-time = "2026-05-05T16:29:30.357Z" }, - { url = "https://files.pythonhosted.org/packages/60/eb/52d2726c7fb22818507dc3cc166c8f36dd4a4b68a7be67f12006ac8777c1/librt-0.10.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:786ad5a15e99d0e0e74f3adbeecc198a5ac58f340be07e984723d1e0074838de", size = 255144, upload-time = "2026-05-05T16:29:32.106Z" }, - { url = "https://files.pythonhosted.org/packages/bc/df/bd5591a78f7531fce4b6eb9962aadc6adc9560a01570442a884b6e554abe/librt-0.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:075582d877a97ee3d8e77bda3689dbe617b14f6469224a2d80b4b6c38e3951aa", size = 279121, upload-time = "2026-05-05T16:29:33.688Z" }, - { url = "https://files.pythonhosted.org/packages/fd/df/7c2b838dfc89a1762dd156d8b0c39848a7a2845d725a50be5a6e021fb8ba/librt-0.10.0-cp311-cp311-win32.whl", hash = "sha256:75ecdc3f5a90065aa2af2e574706c5495adc392520762dcf10b1aa716f0b8090", size = 62593, upload-time = "2026-05-05T16:29:35.152Z" }, - { url = "https://files.pythonhosted.org/packages/91/19/22ff572981049a9d436a083dbea1572d0f5dc068b7353637d2dd9977c8f1/librt-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:b6f6084884131d8a52cb9d7095ff2aa52c1e786d9fdaefab1fb4515415e9e083", size = 70914, upload-time = "2026-05-05T16:29:36.407Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/1697cc64f4a5c7e9bce55e99c6d234a346beaedaefcd1e2ca90dd285f98c/librt-0.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:0140bd62151160047e89b2730cb6f8506cdac5127baa1afb9231e4dd3fe7f681", size = 61176, upload-time = "2026-05-05T16:29:37.62Z" }, - { url = "https://files.pythonhosted.org/packages/12/8e/cbb5b6f6e45e65c10a42449a69eaccc44d73e6a081ea752fbc5221c6dc1c/librt-0.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b4b58a44b407e91f633dafee008de9ddea6aa2a555ed94929c099260910bd0ba", size = 77327, upload-time = "2026-05-05T16:29:38.919Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3d/8233cbee8e99e6a8992f02bfc2dec8d787509566a511d1fde2574ee7473f/librt-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:950b79b11762531bdf45a9df909d2f9a2a8445c70c88665c01d14c8511a27dc5", size = 79971, upload-time = "2026-05-05T16:29:40.96Z" }, - { url = "https://files.pythonhosted.org/packages/87/6f/5264b298cef2b72fc97d2dde56c66181eda35204bf5dcd1ed0c3d0a0a782/librt-0.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4538453f51be197633b425912c150e25b0667252d3741c53e8368176d98d9d37", size = 246559, upload-time = "2026-05-05T16:29:42.701Z" }, - { url = "https://files.pythonhosted.org/packages/07/7b/19b1b859cc60d5f99276cc2b3144d91556c6d1b1e4ebb50359696bebf7a8/librt-0.10.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:70b955f091beac93e994a0b7ec616934f63b3ea5c3d6d7af847562f935aceca7", size = 235216, upload-time = "2026-05-05T16:29:44.193Z" }, - { url = "https://files.pythonhosted.org/packages/6e/56/a2f40717142a8af46289f57874ef914353d8faccd5e4f8e594ab1e16e8c7/librt-0.10.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:483e685e06b6163728ba6c85d74315176be7190f432ec2a41226e5e14355d5f0", size = 263108, upload-time = "2026-05-05T16:29:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/67/ca/15c625c3bdc0167c01e04ef8878317e9713f3bfa788438342f7a94c7b22c/librt-0.10.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ac53d946a009d1a38c44a60812708c9458fb2a239a5f630d8e625571386650f", size = 255280, upload-time = "2026-05-05T16:29:48.087Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/ba301d571d9e05844e2435b73aba30bee77bb75ce155c9affcfd2173dd03/librt-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc8771c9fcf0ea894ca41fdc2abd83572c2fbda221f232d86e718614e57ff513", size = 268829, upload-time = "2026-05-05T16:29:49.628Z" }, - { url = "https://files.pythonhosted.org/packages/8b/60/af70e135bc1f1fe15dd3894b1e4bbefc7ecdf911749a925a39eb86ceb2a1/librt-0.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:70805dbc5257892ac572f86290a61e3c8d90224ecce1a8b2d1f7ed51965417f4", size = 262051, upload-time = "2026-05-05T16:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/83/c2/c8236eb8b421bac5a172ba208f965abaa89805da2a3fa112bdf1764caf8f/librt-0.10.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d3b4f300f7bcba6e2ff73fb8bef1898479e9772bfa2682998c636391633ec826", size = 264347, upload-time = "2026-05-05T16:29:53.013Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/15b6d32bc25dacd4a60886a683d8128d6219910c122202b995a40dd4f8d2/librt-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:943bc943f92f4fb3408fae62485c6a3ad68ce4f2ee205643a39641525c19a276", size = 286482, upload-time = "2026-05-05T16:29:54.675Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8e/b1b959bacd323eb4360579db992513e1406d1c6ef7edb57b5511fd0666fd/librt-0.10.0-cp312-cp312-win32.whl", hash = "sha256:6065c1a758fba1010b41401013903d3d5d2750eab425ddedd584abac31d0630e", size = 62955, upload-time = "2026-05-05T16:29:56.39Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4c/d4cd6e4b9fc24098e63cc85537d1b6689682aee96809c38f08072067cc2b/librt-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:d788ecbe208ab352dab0e105cc06057bf9a2fc7e58cabb0d751ad9e30062b9e2", size = 71191, upload-time = "2026-05-05T16:29:57.682Z" }, - { url = "https://files.pythonhosted.org/packages/2b/19/8641da1f63d24b92354a492f893c022d6b3a0df44e70c8eff49364613983/librt-0.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:6003d1f295bdba02656dc81308208fc060d0a51d8c0d0a6db70f7f3c57b9ba0a", size = 61432, upload-time = "2026-05-05T16:29:58.971Z" }, - { url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" }, - { url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" }, - { url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" }, - { url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" }, - { url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" }, - { url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" }, - { url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" }, - { url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" }, - { url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" }, - { url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" }, - { url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" }, - { url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" }, - { url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" }, - { url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" }, - { url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" }, - { url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" }, - { url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" }, - { url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" }, - { url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" }, +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/4a/c64265d71b84030174ff3ac2cd16d8b664072afab8c41fccd8e2ee5a6f8d/librt-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f8e12706dcb8ff6b3ed57514a19e45c49ad00bcd423e87b2b2e4b5f64578443", size = 67529, upload-time = "2026-04-09T16:04:27.373Z" }, + { url = "https://files.pythonhosted.org/packages/23/b1/30ca0b3a8bdac209a00145c66cf42e5e7da2cc056ffc6ebc5c7b430ddd34/librt-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e3dda8345307fd7306db0ed0cb109a63a2c85ba780eb9dc2d09b2049a931f9c", size = 70248, upload-time = "2026-04-09T16:04:28.758Z" }, + { url = "https://files.pythonhosted.org/packages/fa/fc/c6018dc181478d6ac5aa24a5846b8185101eb90894346db239eb3ea53209/librt-0.9.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:de7dac64e3eb832ffc7b840eb8f52f76420cde1b845be51b2a0f6b870890645e", size = 202184, upload-time = "2026-04-09T16:04:29.893Z" }, + { url = "https://files.pythonhosted.org/packages/bf/58/d69629f002203370ef41ea69ff71c49a2c618aec39b226ff49986ecd8623/librt-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22a904cbdb678f7cb348c90d543d3c52f581663d687992fee47fd566dcbf5285", size = 212926, upload-time = "2026-04-09T16:04:31.126Z" }, + { url = "https://files.pythonhosted.org/packages/cc/55/01d859f57824e42bd02465c77bec31fa5ef9d8c2bcee702ccf8ef1b9f508/librt-0.9.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224b9727eb8bc188bc3bcf29d969dba0cd61b01d9bac80c41575520cc4baabb2", size = 225664, upload-time = "2026-04-09T16:04:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/32f63ad0ef085a94a70315291efe1151a48b9947af12261882f8445b2a30/librt-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e94cbc6ad9a6aeea46d775cbb11f361022f778a9cc8cc90af653d3a594b057ce", size = 219534, upload-time = "2026-04-09T16:04:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5a/9d77111a183c885acf3b3b6e4c00f5b5b07b5817028226499a55f1fedc59/librt-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7bc30ad339f4e1a01d4917d645e522a0bc0030644d8973f6346397c93ba1503f", size = 227322, upload-time = "2026-04-09T16:04:34.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/05d700c93063753e12ab230b972002a3f8f3b9c95d8a980c2f646c8b6963/librt-0.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:56d65b583cf43b8cf4c8fbe1e1da20fa3076cc32a1149a141507af1062718236", size = 223407, upload-time = "2026-04-09T16:04:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/c0/26/26c3124823c67c987456977c683da9a27cc874befc194ddcead5f9988425/librt-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0a1be03168b2691ba61927e299b352a6315189199ca18a57b733f86cb3cc8d38", size = 221302, upload-time = "2026-04-09T16:04:37.62Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/c7cc2be5cf4ff7b017d948a789256288cb33a517687ff1995e72a7eea79f/librt-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63c12efcd160e1d14da11af0c46c0217473e1e0d2ae1acbccc83f561ea4c2a7b", size = 243893, upload-time = "2026-04-09T16:04:38.909Z" }, + { url = "https://files.pythonhosted.org/packages/62/d3/da553d37417a337d12660450535d5fd51373caffbedf6962173c87867246/librt-0.9.0-cp310-cp310-win32.whl", hash = "sha256:e9002e98dcb1c0a66723592520decd86238ddcef168b37ff6cfb559200b4b774", size = 55375, upload-time = "2026-04-09T16:04:40.148Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5a/46fa357bab8311b6442a83471591f2f9e5b15ecc1d2121a43725e0c529b8/librt-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9fcb461fbf70654a52a7cc670e606f04449e2374c199b1825f754e16dacfedd8", size = 62581, upload-time = "2026-04-09T16:04:41.452Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, + { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, + { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, + { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, + { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, + { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, + { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, + { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, + { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, + { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, + { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, + { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, + { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, + { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, + { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, + { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, + { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, + { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, + { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, ] [[package]] @@ -645,6 +607,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, + { name = "syrupy" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -695,6 +658,7 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a17" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a17" }, + { name = "syrupy" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -747,7 +711,7 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.2.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", @@ -756,9 +720,9 @@ resolution-markers = [ dependencies = [ { name = "mdurl", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] @@ -848,15 +812,15 @@ wheels = [ [[package]] name = "mdit-py-plugins" -version = "0.6.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/3d/e0e8d9d1cee04f758120915e2b2a3a07eb41f8cf4654b4734788a522bcd1/mdit_py_plugins-0.6.0.tar.gz", hash = "sha256:2436f14a7295837ac9228a36feeabda867c4abc488c8d019ad5c0bda88eee040", size = 56025, upload-time = "2026-05-07T12:20:42.295Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/d6/48f5b9e44e2e760855d7b489b1317cd7620e82dcb73197961e5cc1391348/mdit_py_plugins-0.6.0-py3-none-any.whl", hash = "sha256:f7e7a25d8b616fee99cb1e330da73451d11a8061baf39bb9663ab9ce0e005b90", size = 66655, upload-time = "2026-05-07T12:20:41.226Z" }, + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, ] [[package]] @@ -870,61 +834,60 @@ wheels = [ [[package]] name = "mypy" -version = "2.0.0" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/dc/7e6d49f04fca40b9dd5c752a51a432ffe67fb45200702bc9eee0cb4bbb26/mypy-2.0.0.tar.gz", hash = "sha256:1a9e3900ac5c40f1fe813506c7739da6e6f0eab2729067ebd94bfb0bbba53532", size = 3869036, upload-time = "2026-05-06T19:26:43.22Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/1e/9983d2d5b5d2dc3677177bcf0fa6b25185ecf750cc0559e02199625a31c5/mypy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:65d6f22d643bccaeb182d41d2a9f0990a05a871673c4ae3f97d4931eca0d2294", size = 14663140, upload-time = "2026-05-06T19:25:59.474Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/b4009c91d3ced13c8f406acf47bbe56365025cd21bf6585cd1e87375a708/mypy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:106650bce72114f43019bf72197296f51c2cd47adfa9d073ea2976c247a404c5", size = 13526733, upload-time = "2026-05-06T19:22:56.425Z" }, - { url = "https://files.pythonhosted.org/packages/f0/99/2403cb0ceeb1552f70e70e779e3d0713b24f84c7ca0e9e14b2b7bc684cf0/mypy-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c734b7eb89a4cc4ec347f8187ffa730e2b59693407bc93dcb878183037f80a17", size = 13951940, upload-time = "2026-05-06T19:24:43.45Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f7/4848a14c2667b6eb62841c9aeb7e1f6479613b1ef9a65564fe1f5518a35b/mypy-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd9e60388944d0f1432a2419ab938a78d5658df1d143a7172cfe1a197276cf49", size = 14833983, upload-time = "2026-05-06T19:23:16.827Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/c51831f9f1c6e46cbce765bd0a18981b84696e40bd1eea14e0a08494af44/mypy-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95e3890666c3be41af7a7179f4872341c08e90c161ba8e7a08a21f9be92c131", size = 15135591, upload-time = "2026-05-06T19:24:32.96Z" }, - { url = "https://files.pythonhosted.org/packages/40/7f/3c25e503a94f9ec18352464551bc6c506dee2bca93c6d0e0b5568eefc269/mypy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8e8709ce1b1046b8aad77a506dd01491157102dd727128c0b374b5025c7d769", size = 10983019, upload-time = "2026-05-06T19:20:30.942Z" }, - { url = "https://files.pythonhosted.org/packages/75/da/5cf833fd3b53fd4b5797e55dc16fb7efab16fddbc7205d49ff65b15d554e/mypy-2.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:0165968759c99ab79dc1a9f8aaec18e93a1bedcf7c13edd70e68dd3d5faf17cb", size = 9914165, upload-time = "2026-05-06T19:21:49.165Z" }, - { url = "https://files.pythonhosted.org/packages/8b/1e/268b81393b81d64683f670680215553e70ae92c55805915b3440080e05e4/mypy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17b7222e9fdfd352e61fb3131da117e55cc465f701ff232f1bd97a02bbad91f", size = 14580849, upload-time = "2026-05-06T19:23:06.567Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/d159a8002d9e5c44e59ece9d641a26956c89be5b6827f819d9a9dc678c65/mypy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0a61adea1a5ffc2d47a4dc4bb180d8103f477fc2a90a1cdcbb168c2cc6caff", size = 13444955, upload-time = "2026-05-06T19:25:11.982Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5d/3b28d5a2799591da0ee5490418e94497eaf5d701e42d8b001b5e17a9b3d6/mypy-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8578f857b519993d065e5805290b71467ebfae772407a5f57e823755e4fdb850", size = 13873124, upload-time = "2026-05-06T19:20:39.684Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/f40f723955617b814d5ddc1154d8938b77aaf6926c2dbf72846e8943a0b7/mypy-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33f668a37a650df60f7b825c1ac61e6baadd4ac3c89519e929badde58d28edf5", size = 14748822, upload-time = "2026-05-06T19:25:30.972Z" }, - { url = "https://files.pythonhosted.org/packages/d6/16/eded971224a483e422a141ffd580c00e1b919df8e529f06d03a4a987878c/mypy-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29ea6da86c8c5e9addd48fa6e624f467341b3814f54ded871b28980468686dea", size = 14992675, upload-time = "2026-05-06T19:23:34.511Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6a/1cbd7290f00b4dbaa4c4502e53ac05645ea635e4d1e3dcd42687c2fc39cd/mypy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:904baa0124ebbccf0c7ba94f722cf9186ee30478f5e5b11432ffc8929248ee55", size = 10983628, upload-time = "2026-05-06T19:26:39.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/3f/8caa9bcc2636cd512642050747466b695fa2540d7040544fd7ddb721d671/mypy-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:440165501295e523bf1e5d3e411b62b367b901c65610938e75f0e56ba0462461", size = 9906041, upload-time = "2026-05-06T19:24:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4b/f6cd12ef1eb63be1c342da3e8ca811d2280276177f6de4ef20cb2366d79b/mypy-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:660790551c988e69d8bf7a35c8b4149edeb22f4a339165702be843532e9dcdb5", size = 14756610, upload-time = "2026-05-06T19:26:19.221Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/67d09ca28bee21feaca264b2a680cf2d300bcc2071136ad064928324c843/mypy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a15bf92cd8781f8e72f69ffa7e30d1f434402d065ee1ecd5223ef2ef100f914", size = 13554270, upload-time = "2026-05-06T19:26:08.977Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/44718b5c6b1b5a27440ff2effe6a1be0fa2a190c0f4e2e21a83728416f95/mypy-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ff370b43d7def05bbcd2f5267f0bcda72dd6a552ef2ea9375b02d6fe06da270", size = 13924663, upload-time = "2026-05-06T19:21:24.932Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2b/bbb9cc5773f946846a7c340097e59bcf84095437dda0d56bb4f6cf1f6541/mypy-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37bd246590a018e5a11703b7b09c39d47ede3df5ba3fa863c5b8590b465beb01", size = 14946862, upload-time = "2026-05-06T19:24:23.023Z" }, - { url = "https://files.pythonhosted.org/packages/43/25/e9318566f443a5130b4ff0ad3367ee6c4c4c49ff083fe5214a7318c18282/mypy-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cce87e92214fac8bf8feb8a680d0c1b6fb748d50e9b57fbb13e4b1d83a3ed19b", size = 15175090, upload-time = "2026-05-06T19:26:28.794Z" }, - { url = "https://files.pythonhosted.org/packages/67/65/2ec28c834f21e164c33bc296a7db538ad50c74f83e517c7a0be95ff6de86/mypy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e19e9cb69b66a4141009d24898259914fa2b71d026de0b46edf9fafdbf4fd46e", size = 11052899, upload-time = "2026-05-06T19:25:39.084Z" }, - { url = "https://files.pythonhosted.org/packages/9e/72/d1ec625cfc9bd101c07a6834ef1f94e820296f8fdbad2eb03f50e0983f8c/mypy-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:b021614cb08d44785b025982163ec3c39c94bff766ead071fa9e82b4ef6f62cd", size = 9972935, upload-time = "2026-05-06T19:23:24.204Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c6/996a1e535e5d0d597c3b1460fc962733091f885f312e749350eb2ac10965/mypy-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ef5f581b61240d1cc629b12f8df6565ed6ffac0d82ed745eef7833222ab50b9", size = 14737259, upload-time = "2026-05-06T19:20:23.081Z" }, - { url = "https://files.pythonhosted.org/packages/94/c5/0f9460e26b77f434bd53f47d1ce32a3cd4580c92a5331fa5dfc059f9421a/mypy-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20e3470a165dbc249bdfbe8d1c5172727ef22688cffc279f8c3aa264ab9d4d9a", size = 13538377, upload-time = "2026-05-06T19:21:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/b2/3e/8ea2f8dd1e5c9c279fb3c28193bdb850adf4d3d8172880abad829eced609/mypy-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224ba142eee8b4d65d4db657cb1fc22abec30b135ded6ab297302ba1f62e505d", size = 13914264, upload-time = "2026-05-06T19:24:12.875Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/78bd3b8520f676acee9dab48ea71473e68f6d5cf14b59fbd800bea50a92b/mypy-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e879ad8a03908ff74d15e8a9b42bf049918e6798d52c011011f1873d0b5877e", size = 14926761, upload-time = "2026-05-06T19:20:12.846Z" }, - { url = "https://files.pythonhosted.org/packages/61/ef/b52fa340522da3d22e669117c3b83155c2660f7cdc035856958fbfffb224/mypy-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5c15bcbd18d6fe927cc55c459597a3517d69cc3123f067be3b020010e115e", size = 15157014, upload-time = "2026-05-06T19:25:49.78Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/dde7614250c6d017936c7aa3bb63b9b52c7cfd298d3f1be9be45f307870b/mypy-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1a068acd7c9fb77e9f8923f1556f2f49d6d7895821121b8d97fa5642b9c52f5", size = 11067049, upload-time = "2026-05-06T19:21:16.116Z" }, - { url = "https://files.pythonhosted.org/packages/27/ec/1d6af4830a94a285442db19caa02f160cc1a255e4f324eec5458e6c2bafb/mypy-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:ef9d96da1ddffbc21f27d3939319b6846d12393baa17c4d2f3e81e040e73ce2c", size = 9967903, upload-time = "2026-05-06T19:22:15.52Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/6fefe954207860aed6eeb91776795e64a257d3ce0360862288984ce121f5/mypy-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c918c64e8ce36557851b0347f84eb12f1965d3a06813c36df253eb0c0afd1d82", size = 14729633, upload-time = "2026-05-06T19:24:53.383Z" }, - { url = "https://files.pythonhosted.org/packages/23/d6/d336f5b820af189eb0390cce21de62d264c0a4e64713dfbe81bfc4fc7739/mypy-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:301f1a8ccc7d79b542ee218b28bb49443a83e194eb3d10da63ff1649e5aa5d34", size = 13559524, upload-time = "2026-05-06T19:22:24.906Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/d7bb54fde1770f0484e5fbdbdce37a41e95ed0a1cd493ec60ead111e356c/mypy-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdf4ef489d44ce350bac3fd699907834e551d4c934e9cc862ef201215ab1558d", size = 13936018, upload-time = "2026-05-06T19:25:02.992Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ba/5be51316b91e6a6bf6e3a8adb3de500e7e1fb5bf9491743b8cbc81a34a2c/mypy-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cde2d0989f912fc850890f727d0d76495e7a6c5bdd9912a1efdb64952b4398d", size = 14910712, upload-time = "2026-05-06T19:25:21.83Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/e2c8c3b373e20ebfb66e6c83a99027fd67df4ec43b08879f74e822d2dc4c/mypy-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdf05693c231a14fe37dbfce192a3a1372c26a833af4a80f550547742952e719", size = 15141499, upload-time = "2026-05-06T19:20:50.924Z" }, - { url = "https://files.pythonhosted.org/packages/12/36/07756f933e00416d912e35878cfcf89a593a3350a885691c0bb85ae0226a/mypy-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:73aee2da33a2237e66cbe84a94780e53599847e86bb3aa7b93e405e8cd9905f2", size = 11240511, upload-time = "2026-05-06T19:21:32.39Z" }, - { url = "https://files.pythonhosted.org/packages/70/05/79ac1f20f2397353f3845f7b8bb5d8006cda7c8ef9092f04f9de3c6135f2/mypy-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:1f6dcd8f39971f41edab2728c877c4ac8b50ad3c387ff2770423b79a05d23910", size = 10149336, upload-time = "2026-05-06T19:22:08.383Z" }, - { url = "https://files.pythonhosted.org/packages/53/e0/0db84e0ebbad6e99e566c68e4b465784f2a2294f7719e8db9d509ef23087/mypy-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a04e980b9275c76159da66c6e1723c7798306f9802b31bdaf9358d0c84030ce8", size = 15797362, upload-time = "2026-05-06T19:22:00.835Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a4/14cc0768164dd53bec48aa41a20270b18df9bf72aa5054278bf133608315/mypy-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:33f9cf4825469b2bc73c53ba55f6d9a9b4cdb60f9e6e228745581520f29b8771", size = 14635914, upload-time = "2026-05-06T19:23:43.675Z" }, - { url = "https://files.pythonhosted.org/packages/08/48/d866a3e23b4dc5974c77d9cf65a435bf22de01a84dd4620917950e233960/mypy-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191675c3c7dc2a5c7722a035a6909c277f14046c5e4e02aa5fbf65f8524f08ad", size = 15270866, upload-time = "2026-05-06T19:22:34.756Z" }, - { url = "https://files.pythonhosted.org/packages/71/eb/de9ef94958eb2078a6b908ceb247757dc384d3a238d3bd6ed7d81de5eaf8/mypy-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3d26c4321a3b06fc9f04c741e0733af693f82d823f8e64e47b2e63b7f19fa84", size = 16093131, upload-time = "2026-05-06T19:23:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/ad/07/0ab2c1a9d26e90942612724cbd5788f16b7810c5dd39bfcf79286c6c4524/mypy-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bbcbc4d5917ca6ce12de70e051de7f533e3bf92d548b41a38a2232a6fe356525", size = 16330685, upload-time = "2026-05-06T19:21:42.037Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/46f85d1371a5be642dad263828118ae1efd536d91d8bd2000c68acff3920/mypy-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:dbc6ba6d40572ae49268531565793a8f07eac7fc65ad76d482c9b4c8765b6043", size = 12752017, upload-time = "2026-05-06T19:22:44.002Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e6/94ca48800cac19eb28a58188a768aaec0d16cac0f373915f073058ab0855/mypy-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:77926029dfcb7e1a3ecb0acb2ddbb24ca36be03f7d623e1759ad5376be8f6c01", size = 10527097, upload-time = "2026-05-06T19:20:58.973Z" }, - { url = "https://files.pythonhosted.org/packages/5c/14/fd0694aa594d6e9f9fd16ce821be2eff295197a273262ef56ddcc1388d68/mypy-2.0.0-py3-none-any.whl", hash = "sha256:8a92b2be3146b4fa1f062af7eb05574cbf3e6eb8e1f14704af1075423144e4e5", size = 2673434, upload-time = "2026-05-06T19:26:32.856Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, ] [[package]] @@ -967,7 +930,7 @@ resolution-markers = [ dependencies = [ { name = "docutils", marker = "python_full_version >= '3.11'" }, { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, { name = "pyyaml", marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -979,20 +942,20 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] name = "pathspec" -version = "1.1.1" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/17/9c3094b822982b9f1ea666d8580ce59000f61f87c1663556fb72031ad9ec/pathspec-1.1.0.tar.gz", hash = "sha256:f5d7c555da02fd8dde3e4a2354b6aba817a89112fa8f333f7917a2a4834dd080", size = 133918, upload-time = "2026-04-23T01:46:22.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" }, ] [[package]] @@ -1198,27 +1161,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] [[package]] @@ -1617,6 +1580,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "syrupy" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/b0/24bca682d6a6337854be37f242d116cceeda9942571d5804c44bc1bdd427/syrupy-5.1.0.tar.gz", hash = "sha256:df543c7aa50d3cf1246e83d58fe490afe5f7dab7b41e74ecc0d8d23ae19bd4b8", size = 50495, upload-time = "2026-01-25T14:53:06.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/70/cf880c3b95a6034ef673e74b369941b42315c01f1554a5637a4f8b911009/syrupy-5.1.0-py3-none-any.whl", hash = "sha256:95162d2b05e61ed3e13f117b88dfab7c58bd6f90e66ebbf918e8a77114ad51c5", size = 51658, upload-time = "2026-01-25T14:53:05.105Z" }, +] + [[package]] name = "tomli" version = "2.4.1"