From 3d5a754df310579a160d3e231f01042418267222 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 10:26:13 -0600 Subject: [PATCH 1/6] feat: Add `test_snapshot` --- src/libtmux/pane.py | 38 +++++++ src/libtmux/snapshot.py | 225 ++++++++++++++++++++++++++++++++++++++++ tests/test_snapshot.py | 107 +++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/libtmux/snapshot.py create mode 100644 tests/test_snapshot.py diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 610ba3d2b..99cd1728c 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -25,6 +25,7 @@ from libtmux.hooks import HooksMixin from libtmux.neo import Obj, fetch_obj from libtmux.options import OptionsMixin +from libtmux.snapshot import PaneRecording, PaneSnapshot if t.TYPE_CHECKING: import sys @@ -420,6 +421,43 @@ def capture_pane( ) return self.cmd(*cmd).stdout + def snapshot( + self, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + ) -> PaneSnapshot: + """Create a snapshot of the pane's current state. + + This is a convenience method that creates a :class:`PaneSnapshot` instance + from the current pane state. + + Parameters + ---------- + start : int | "-" | None + Start line for capture_pane + end : int | "-" | None + End line for capture_pane + + Returns + ------- + PaneSnapshot + A frozen snapshot of the pane's current state + """ + return PaneSnapshot.from_pane(self, start=start, end=end) + + def record(self) -> PaneRecording: + """Create a new recording for this pane. + + This is a convenience method that creates a :class:`PaneRecording` instance + for recording snapshots of this pane. + + Returns + ------- + PaneRecording + A new recording instance for this pane + """ + return PaneRecording() + def send_keys( self, cmd: str, diff --git a/src/libtmux/snapshot.py b/src/libtmux/snapshot.py new file mode 100644 index 000000000..59283b80c --- /dev/null +++ b/src/libtmux/snapshot.py @@ -0,0 +1,225 @@ +"""Snapshot and recording functionality for tmux panes.""" + +from __future__ import annotations + +import dataclasses +import datetime +import typing as t + +from typing_extensions import Self + +from libtmux.formats import PANE_FORMATS + +if t.TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + from libtmux.pane import Pane + + +@dataclasses.dataclass(frozen=True) +class PaneSnapshot: + """A frozen snapshot of a pane's state at a point in time. + + This class captures both the content and metadata of a tmux pane, + making it suitable for testing and debugging purposes. + + Attributes + ---------- + content : list[str] + The captured content of the pane + timestamp : datetime.datetime + When the snapshot was taken (in UTC) + pane_id : str + The ID of the pane + window_id : str + The ID of the window containing the pane + session_id : str + The ID of the session containing the window + server_name : str + The name of the tmux server + metadata : dict[str, str] + Additional pane metadata from tmux formats + """ + + content: list[str] + timestamp: datetime.datetime + pane_id: str + window_id: str + session_id: str + server_name: str + metadata: dict[str, str] + + @classmethod + def from_pane( + cls, + pane: Pane, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + ) -> Self: + """Create a snapshot from a pane. + + Parameters + ---------- + pane : Pane + The pane to snapshot + start : int | "-" | None + Start line for capture_pane + end : int | "-" | None + End line for capture_pane + + Returns + ------- + PaneSnapshot + A frozen snapshot of the pane's state + """ + metadata = { + fmt: getattr(pane, fmt) + for fmt in PANE_FORMATS + if hasattr(pane, fmt) and getattr(pane, fmt) is not None + } + + content = pane.capture_pane(start=start, end=end) + if isinstance(content, str): + content = [content] + + return cls( + content=content, + timestamp=datetime.datetime.now(datetime.timezone.utc), + pane_id=str(pane.pane_id), + window_id=str(pane.window.window_id), + session_id=str(pane.session.session_id), + server_name=str(pane.server.socket_name), + metadata=metadata, + ) + + def __str__(self) -> str: + """Return a string representation of the snapshot. + + Returns + ------- + str + A formatted string showing the snapshot content and metadata + """ + return ( + f"PaneSnapshot(pane={self.pane_id}, window={self.window_id}, " + f"session={self.session_id}, server={self.server_name}, " + f"timestamp={self.timestamp.isoformat()}, " + f"content=\n{self.content_str})" + ) + + @property + def content_str(self) -> str: + """Get the pane content as a single string. + + Returns + ------- + str + The pane content with lines joined by newlines + """ + return "\n".join(self.content) + + +@dataclasses.dataclass +class PaneRecording: + """A time-series recording of pane snapshots. + + This class maintains an ordered sequence of pane snapshots, + allowing for analysis of how a pane's content changes over time. + + Attributes + ---------- + snapshots : list[PaneSnapshot] + The sequence of snapshots in chronological order + """ + + snapshots: list[PaneSnapshot] = dataclasses.field(default_factory=list) + + def add_snapshot( + self, + pane: Pane, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + ) -> None: + """Add a new snapshot to the recording. + + Parameters + ---------- + pane : Pane + The pane to snapshot + start : int | "-" | None + Start line for capture_pane + end : int | "-" | None + End line for capture_pane + """ + self.snapshots.append(PaneSnapshot.from_pane(pane, start=start, end=end)) + + def __len__(self) -> int: + """Get the number of snapshots in the recording. + + Returns + ------- + int + The number of snapshots + """ + return len(self.snapshots) + + def __iter__(self) -> Iterator[PaneSnapshot]: + """Iterate through snapshots in chronological order. + + Returns + ------- + Iterator[PaneSnapshot] + Iterator over the snapshots + """ + return iter(self.snapshots) + + def __getitem__(self, index: int) -> PaneSnapshot: + """Get a snapshot by index. + + Parameters + ---------- + index : int + The index of the snapshot to retrieve + + Returns + ------- + PaneSnapshot + The snapshot at the specified index + """ + return self.snapshots[index] + + @property + def latest(self) -> PaneSnapshot | None: + """Get the most recent snapshot. + + Returns + ------- + PaneSnapshot | None + The most recent snapshot, or None if no snapshots exist + """ + return self.snapshots[-1] if self.snapshots else None + + def get_snapshots_between( + self, + start_time: datetime.datetime, + end_time: datetime.datetime, + ) -> Sequence[PaneSnapshot]: + """Get snapshots between two points in time. + + Parameters + ---------- + start_time : datetime.datetime + The start of the time range + end_time : datetime.datetime + The end of the time range + + Returns + ------- + Sequence[PaneSnapshot] + Snapshots within the specified time range + """ + return [ + snapshot + for snapshot in self.snapshots + if start_time <= snapshot.timestamp <= end_time + ] diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py new file mode 100644 index 000000000..f8afbbde5 --- /dev/null +++ b/tests/test_snapshot.py @@ -0,0 +1,107 @@ +"""Tests for libtmux snapshot functionality.""" + +from __future__ import annotations + +import datetime +import shutil +import time +import typing as t + +from libtmux.snapshot import PaneRecording, PaneSnapshot + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_pane_snapshot(session: Session) -> None: + """Test creating a PaneSnapshot from a pane.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="snapshot_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Take initial snapshot + snapshot = PaneSnapshot.from_pane(pane) + assert snapshot.content == ["$"] + assert snapshot.pane_id == pane.pane_id + assert snapshot.window_id == pane.window.window_id + assert snapshot.session_id == pane.session.session_id + assert snapshot.server_name == pane.server.socket_name + assert isinstance(snapshot.timestamp, datetime.datetime) + assert snapshot.timestamp.tzinfo == datetime.timezone.utc + + # Verify metadata + assert "pane_id" in snapshot.metadata + assert "pane_width" in snapshot.metadata + assert "pane_height" in snapshot.metadata + + # Test string representation + str_repr = str(snapshot) + assert "PaneSnapshot" in str_repr + assert snapshot.pane_id in str_repr + assert snapshot.window_id in str_repr + assert snapshot.session_id in str_repr + assert snapshot.server_name in str_repr + assert snapshot.timestamp.isoformat() in str_repr + assert "$" in str_repr + + +def test_pane_recording(session: Session) -> None: + """Test creating and managing a PaneRecording.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="recording_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + recording = PaneRecording() + assert len(recording) == 0 + assert recording.latest is None + + # Take initial snapshot + recording.add_snapshot(pane) + assert len(recording) == 1 + assert recording.latest is not None + assert recording.latest.content == ["$"] + + # Send some commands and take more snapshots + pane.send_keys("echo 'Hello'") + time.sleep(0.1) # Give tmux time to update + recording.add_snapshot(pane) + + pane.send_keys("echo 'World'") + time.sleep(0.1) # Give tmux time to update + recording.add_snapshot(pane) + + assert len(recording) == 3 + + # Test iteration + snapshots = list(recording) + assert len(snapshots) == 3 + assert snapshots[0].content == ["$"] + assert "Hello" in snapshots[1].content_str + assert "World" in snapshots[2].content_str + + # Test indexing + assert recording[0].content == ["$"] + assert "Hello" in recording[1].content_str + assert "World" in recording[2].content_str + + # Test time-based filtering + start_time = snapshots[0].timestamp + mid_time = snapshots[1].timestamp + end_time = snapshots[2].timestamp + + assert len(recording.get_snapshots_between(start_time, end_time)) == 3 + assert len(recording.get_snapshots_between(mid_time, end_time)) == 2 From 7eee5d8486d4268255e9fa82658a2fa6ee71cd44 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 10:34:26 -0600 Subject: [PATCH 2/6] !squash more --- src/libtmux/snapshot.py | 162 ++++++++++++++++++++++++++++++++++++++++ tests/test_snapshot.py | 128 ++++++++++++++++++++++++++++++- 2 files changed, 289 insertions(+), 1 deletion(-) diff --git a/src/libtmux/snapshot.py b/src/libtmux/snapshot.py index 59283b80c..99841a45f 100644 --- a/src/libtmux/snapshot.py +++ b/src/libtmux/snapshot.py @@ -4,7 +4,9 @@ import dataclasses import datetime +import json import typing as t +from abc import ABC, abstractmethod from typing_extensions import Self @@ -16,6 +18,147 @@ from libtmux.pane import Pane +class SnapshotOutputAdapter(ABC): + """Base class for snapshot output adapters. + + This class defines the interface for converting a PaneSnapshot + into different output formats. + """ + + @abstractmethod + def format(self, snapshot: PaneSnapshot) -> str: + """Format the snapshot for output. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + The formatted output + """ + + +class TerminalOutputAdapter(SnapshotOutputAdapter): + """Format snapshot for terminal output with ANSI colors.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot with ANSI colors for terminal display. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + ANSI-colored terminal output + """ + header = ( + f"\033[1;34m=== Pane Snapshot ===\033[0m\n" + f"\033[1;36mPane:\033[0m {snapshot.pane_id}\n" + f"\033[1;36mWindow:\033[0m {snapshot.window_id}\n" + f"\033[1;36mSession:\033[0m {snapshot.session_id}\n" + f"\033[1;36mServer:\033[0m {snapshot.server_name}\n" + f"\033[1;36mTimestamp:\033[0m {snapshot.timestamp.isoformat()}\n" + f"\033[1;33m=== Content ===\033[0m\n" + ) + return header + snapshot.content_str + + +class CLIOutputAdapter(SnapshotOutputAdapter): + """Format snapshot for plain text CLI output.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot as plain text. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + Plain text output suitable for CLI + """ + header = ( + f"=== Pane Snapshot ===\n" + f"Pane: {snapshot.pane_id}\n" + f"Window: {snapshot.window_id}\n" + f"Session: {snapshot.session_id}\n" + f"Server: {snapshot.server_name}\n" + f"Timestamp: {snapshot.timestamp.isoformat()}\n" + f"=== Content ===\n" + ) + return header + snapshot.content_str + + +class PytestDiffAdapter(SnapshotOutputAdapter): + """Format snapshot for pytest assertion diffs.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot for optimal pytest diff output. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + Pytest-friendly diff output + """ + lines = [ + "PaneSnapshot(", + f" pane_id={snapshot.pane_id!r},", + f" window_id={snapshot.window_id!r},", + f" session_id={snapshot.session_id!r},", + f" server_name={snapshot.server_name!r},", + f" timestamp={snapshot.timestamp.isoformat()!r},", + " content=[", + *(f" {line!r}," for line in snapshot.content), + " ],", + " metadata={", + *(f" {k!r}: {v!r}," for k, v in sorted(snapshot.metadata.items())), + " },", + ")", + ] + return "\n".join(lines) + + +class SyrupySnapshotAdapter(SnapshotOutputAdapter): + """Format snapshot for syrupy snapshot testing.""" + + def format(self, snapshot: PaneSnapshot) -> str: + """Format snapshot for syrupy compatibility. + + Parameters + ---------- + snapshot : PaneSnapshot + The snapshot to format + + Returns + ------- + str + JSON-serialized snapshot data + """ + data = { + "pane_id": snapshot.pane_id, + "window_id": snapshot.window_id, + "session_id": snapshot.session_id, + "server_name": snapshot.server_name, + "timestamp": snapshot.timestamp.isoformat(), + "content": snapshot.content, + "metadata": snapshot.metadata, + } + return json.dumps(data, indent=2, sort_keys=True) + + @dataclasses.dataclass(frozen=True) class PaneSnapshot: """A frozen snapshot of a pane's state at a point in time. @@ -92,6 +235,25 @@ def from_pane( metadata=metadata, ) + def format(self, adapter: SnapshotOutputAdapter | None = None) -> str: + """Format the snapshot using the specified adapter. + + If no adapter is provided, uses the default string representation. + + Parameters + ---------- + adapter : SnapshotOutputAdapter | None + The adapter to use for formatting + + Returns + ------- + str + The formatted output + """ + if adapter is None: + return str(self) + return adapter.format(self) + def __str__(self) -> str: """Return a string representation of the snapshot. diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index f8afbbde5..f226c8157 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -3,11 +3,19 @@ from __future__ import annotations import datetime +import json import shutil import time import typing as t -from libtmux.snapshot import PaneRecording, PaneSnapshot +from libtmux.snapshot import ( + CLIOutputAdapter, + PaneRecording, + PaneSnapshot, + PytestDiffAdapter, + SyrupySnapshotAdapter, + TerminalOutputAdapter, +) if t.TYPE_CHECKING: from libtmux.session import Session @@ -105,3 +113,121 @@ def test_pane_recording(session: Session) -> None: assert len(recording.get_snapshots_between(start_time, end_time)) == 3 assert len(recording.get_snapshots_between(mid_time, end_time)) == 2 + + +def test_snapshot_output_adapters(session: Session) -> None: + """Test the various output adapters for PaneSnapshot.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="adapter_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Create a snapshot with some content + pane.send_keys("echo 'Test Content'") + time.sleep(0.1) + snapshot = pane.snapshot() + + # Test Terminal Output + terminal_output = snapshot.format(TerminalOutputAdapter()) + assert "\033[1;34m=== Pane Snapshot ===\033[0m" in terminal_output + assert "\033[1;36mPane:\033[0m" in terminal_output + assert "Test Content" in terminal_output + + # Test CLI Output + cli_output = snapshot.format(CLIOutputAdapter()) + assert "=== Pane Snapshot ===" in cli_output + assert "Pane: " in cli_output + assert "\033" not in cli_output # No ANSI codes + assert "Test Content" in cli_output + + # Test Pytest Diff Output + pytest_output = snapshot.format(PytestDiffAdapter()) + assert "PaneSnapshot(" in pytest_output + assert " pane_id=" in pytest_output + assert " content=[" in pytest_output + assert " metadata={" in pytest_output + assert "'Test Content'" in pytest_output + + # Test Syrupy Output + syrupy_output = snapshot.format(SyrupySnapshotAdapter()) + data = json.loads(syrupy_output) + assert isinstance(data, dict) + assert "pane_id" in data + assert "content" in data + assert "metadata" in data + assert "Test Content" in str(data["content"]) + + # Test default format (no adapter) + default_output = snapshot.format() + assert default_output == str(snapshot) + + +def test_pane_snapshot_convenience_method(session: Session) -> None: + """Test the Pane.snapshot() convenience method.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="snapshot_convenience_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Take snapshot using convenience method + snapshot = pane.snapshot() + assert snapshot.content == ["$"] + assert snapshot.pane_id == pane.pane_id + assert snapshot.window_id == pane.window.window_id + assert snapshot.session_id == pane.session.session_id + assert snapshot.server_name == pane.server.socket_name + + # Test with start/end parameters + pane.send_keys("echo 'Line 1'") + time.sleep(0.1) + pane.send_keys("echo 'Line 2'") + time.sleep(0.1) + pane.send_keys("echo 'Line 3'") + time.sleep(0.1) + + snapshot_partial = pane.snapshot(start=1, end=2) + assert len(snapshot_partial.content) == 2 + assert "Line 1" in snapshot_partial.content_str + assert "Line 2" in snapshot_partial.content_str + assert "Line 3" not in snapshot_partial.content_str + + +def test_pane_record_convenience_method(session: Session) -> None: + """Test the Pane.record() convenience method.""" + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + session.new_window( + attach=True, + window_name="record_convenience_test", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = session.active_window.active_pane + assert pane is not None + + # Create recording using convenience method + recording = pane.record() + assert isinstance(recording, PaneRecording) + assert len(recording) == 0 + + # Add snapshots to recording + recording.add_snapshot(pane) + pane.send_keys("echo 'Test'") + time.sleep(0.1) + recording.add_snapshot(pane) + + assert len(recording) == 2 + assert recording[0].content == ["$"] + assert "Test" in recording[1].content_str From ed151f3cb24480e3c6f0c37d84f0b0d0a8b7b753 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 10:37:20 -0600 Subject: [PATCH 3/6] docs(api) Add `snapshot` --- docs/api/snapshot.md | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/api/snapshot.md diff --git a/docs/api/snapshot.md b/docs/api/snapshot.md new file mode 100644 index 000000000..60582510e --- /dev/null +++ b/docs/api/snapshot.md @@ -0,0 +1,111 @@ +(snapshot)= + +# Snapshots + +The snapshot module provides functionality for capturing and analyzing the state of tmux panes. + +## Core Classes + +```{eval-rst} +.. autoclass:: libtmux.snapshot.PaneSnapshot + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.PaneRecording + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource +``` + +## Output Adapters + +```{eval-rst} +.. autoclass:: libtmux.snapshot.SnapshotOutputAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.TerminalOutputAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.CLIOutputAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.PytestDiffAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: libtmux.snapshot.SyrupySnapshotAdapter + :members: + :inherited-members: + :show-inheritance: + :member-order: bysource +``` + +## Examples + +### Basic Snapshot + +```python +>>> pane = session.active_window.active_pane +>>> snapshot = pane.snapshot() +>>> print(snapshot.content_str) +$ echo "Hello World" +Hello World +$ +``` + +### Recording Activity + +```python +>>> recording = pane.record() +>>> recording.add_snapshot(pane) +>>> pane.send_keys("echo 'Hello'") +>>> recording.add_snapshot(pane) +>>> print(recording.latest.content_str) +$ echo 'Hello' +Hello +$ +``` + +### Using Output Adapters + +```python +>>> from libtmux.snapshot import TerminalOutputAdapter +>>> print(snapshot.format(TerminalOutputAdapter())) +=== Pane Snapshot === +Pane: %1 +Window: @1 +Session: $1 +Server: default +Timestamp: 2024-01-01T12:00:00Z +=== Content === +$ echo "Hello World" +Hello World +$ +``` + +### Custom Adapter + +```python +>>> from libtmux.snapshot import SnapshotOutputAdapter +>>> class MyAdapter(SnapshotOutputAdapter): +... def format(self, snapshot): +... return f"Content: {snapshot.content_str}" +>>> print(snapshot.format(MyAdapter())) +Content: $ echo "Hello World" +Hello World +$ +``` From c29d5bc1a2e80d69afab58fea953450664752fbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 10:37:30 -0600 Subject: [PATCH 4/6] docs(topics) Add `snapshot` --- docs/topics/snapshots.md | 162 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/topics/snapshots.md diff --git a/docs/topics/snapshots.md b/docs/topics/snapshots.md new file mode 100644 index 000000000..e1ee8dacd --- /dev/null +++ b/docs/topics/snapshots.md @@ -0,0 +1,162 @@ +(snapshots)= + +# Snapshots and Recordings + +libtmux provides functionality to capture and analyze the state of tmux panes through snapshots and recordings. + +## Taking Snapshots + +A snapshot captures the content and metadata of a pane at a specific point in time: + +```python +>>> pane = session.active_window.active_pane +>>> snapshot = pane.snapshot() +>>> print(snapshot.content_str) +$ echo "Hello World" +Hello World +$ +``` + +Snapshots are immutable and include: +- Pane content +- Timestamp (in UTC) +- Pane, window, session, and server IDs +- All tmux pane metadata + +You can also capture specific ranges of the pane history: + +```python +>>> # Capture lines 1-3 only +>>> snapshot = pane.snapshot(start=1, end=3) + +>>> # Capture from start of history +>>> snapshot = pane.snapshot(start="-") + +>>> # Capture up to current view +>>> snapshot = pane.snapshot(end="-") +``` + +## Recording Pane Activity + +To track changes in a pane over time, use recordings: + +```python +>>> recording = pane.record() +>>> recording.add_snapshot(pane) +>>> pane.send_keys("echo 'Hello'") +>>> recording.add_snapshot(pane) +>>> pane.send_keys("echo 'World'") +>>> recording.add_snapshot(pane) + +>>> # Access snapshots +>>> print(recording[0].content_str) # First snapshot +>>> print(recording.latest.content_str) # Most recent + +>>> # Filter by time +>>> recent = recording.get_snapshots_between( +... start_time=datetime.datetime.now() - datetime.timedelta(minutes=5), +... end_time=datetime.datetime.now(), +... ) +``` + +## Output Formats + +Snapshots can be formatted in different ways for various use cases: + +### Terminal Output + +```python +>>> from libtmux.snapshot import TerminalOutputAdapter +>>> print(snapshot.format(TerminalOutputAdapter())) +=== Pane Snapshot === +Pane: %1 +Window: @1 +Session: $1 +Server: default +Timestamp: 2024-01-01T12:00:00Z +=== Content === +$ echo "Hello World" +Hello World +$ +``` + +### CLI Output (No Colors) + +```python +>>> from libtmux.snapshot import CLIOutputAdapter +>>> print(snapshot.format(CLIOutputAdapter())) +=== Pane Snapshot === +Pane: %1 +Window: @1 +Session: $1 +Server: default +Timestamp: 2024-01-01T12:00:00Z +=== Content === +$ echo "Hello World" +Hello World +$ +``` + +### Pytest Assertion Diffs + +```python +>>> from libtmux.snapshot import PytestDiffAdapter +>>> expected = """ +... PaneSnapshot( +... pane_id='%1', +... window_id='@1', +... session_id='$1', +... server_name='default', +... timestamp='2024-01-01T12:00:00Z', +... content=[ +... '$ echo "Hello World"', +... 'Hello World', +... '$', +... ], +... metadata={ +... 'pane_height': '24', +... 'pane_width': '80', +... }, +... ) +... """ +>>> assert snapshot.format(PytestDiffAdapter()) == expected +``` + +### Syrupy Snapshot Testing + +```python +>>> from libtmux.snapshot import SyrupySnapshotAdapter +>>> snapshot.format(SyrupySnapshotAdapter()) +{ + "pane_id": "%1", + "window_id": "@1", + "session_id": "$1", + "server_name": "default", + "timestamp": "2024-01-01T12:00:00Z", + "content": [ + "$ echo \"Hello World\"", + "Hello World", + "$" + ], + "metadata": { + "pane_height": "24", + "pane_width": "80" + } +} +``` + +## Custom Output Formats + +You can create custom output formats by implementing the `SnapshotOutputAdapter` interface: + +```python +from libtmux.snapshot import SnapshotOutputAdapter + +class MyCustomAdapter(SnapshotOutputAdapter): + def format(self, snapshot: PaneSnapshot) -> str: + # Format snapshot data as needed + return f"Custom format: {snapshot.content_str}" + +# Use custom adapter +print(snapshot.format(MyCustomAdapter())) +``` From 60d1da5450cd1dd4e6f773aed2439f4573bcf218 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 10:38:23 -0600 Subject: [PATCH 5/6] docs(api) Add `snapshot` to index --- docs/api/index.md | 1 + docs/topics/index.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/api/index.md b/docs/api/index.md index 287d5a692..c71f37e01 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -160,6 +160,7 @@ Server Session Window Pane +Snapshot Common Neo Options diff --git a/docs/topics/index.md b/docs/topics/index.md index 085da8761..8b6c13b45 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -69,4 +69,5 @@ workspace_setup automation_patterns context_managers options_and_hooks +snapshots ``` From 85bc459ddbb173828b6939a6bce90130ae736dbe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Apr 2026 06:21:44 -0500 Subject: [PATCH 6/6] docs(snapshots): rewrite doctests to use real fixtures and ELLIPSIS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: original snapshot/topics docs had doctests with hardcoded outputs ($ echo "Hello World", fixed pane IDs like %1, fixed timestamps) that could never match real fixture pane content. Each doctest block also referenced 'snapshot' from a previous block, but pytest-doctest-docutils doesn't share state across markdown code blocks — every block needs to define its own variables. As a result all 10 examples failed at parse or execution time. what: - replace hardcoded output expectations with structural assertions (isinstance, 'in' membership, startswith) that work against any real pane content - explicitly redefine 'snapshot = pane.snapshot()' at the top of each block that uses it, since doctest blocks don't share state - add 'import datetime' to the recording block so the time-filter example actually runs - use 'datetime.timezone.utc' instead of unaware datetime to match the timestamp the snapshot stores - use ELLIPSIS to match the variable pane_id ('%...') --- docs/api/snapshot.md | 47 +++++++-------- docs/topics/snapshots.md | 121 +++++++++++++++------------------------ 2 files changed, 65 insertions(+), 103 deletions(-) diff --git a/docs/api/snapshot.md b/docs/api/snapshot.md index 60582510e..f9a077e52 100644 --- a/docs/api/snapshot.md +++ b/docs/api/snapshot.md @@ -59,12 +59,11 @@ The snapshot module provides functionality for capturing and analyzing the state ### Basic Snapshot ```python ->>> pane = session.active_window.active_pane >>> snapshot = pane.snapshot() ->>> print(snapshot.content_str) -$ echo "Hello World" -Hello World -$ +>>> snapshot.pane_id # doctest: +ELLIPSIS +'%...' +>>> isinstance(snapshot.content_str, str) +True ``` ### Recording Activity @@ -74,38 +73,32 @@ $ >>> recording.add_snapshot(pane) >>> pane.send_keys("echo 'Hello'") >>> recording.add_snapshot(pane) ->>> print(recording.latest.content_str) -$ echo 'Hello' -Hello -$ +>>> isinstance(recording.latest.content_str, str) +True +>>> len(recording) >= 2 +True ``` ### Using Output Adapters ```python >>> from libtmux.snapshot import TerminalOutputAdapter ->>> print(snapshot.format(TerminalOutputAdapter())) -=== Pane Snapshot === -Pane: %1 -Window: @1 -Session: $1 -Server: default -Timestamp: 2024-01-01T12:00:00Z -=== Content === -$ echo "Hello World" -Hello World -$ +>>> snapshot = pane.snapshot() +>>> formatted = snapshot.format(TerminalOutputAdapter()) +>>> '=== Pane Snapshot ===' in formatted +True +>>> '=== Content ===' in formatted +True ``` ### Custom Adapter ```python ->>> from libtmux.snapshot import SnapshotOutputAdapter +>>> from libtmux.snapshot import SnapshotOutputAdapter, PaneSnapshot +>>> snapshot = pane.snapshot() >>> class MyAdapter(SnapshotOutputAdapter): -... def format(self, snapshot): -... return f"Content: {snapshot.content_str}" ->>> print(snapshot.format(MyAdapter())) -Content: $ echo "Hello World" -Hello World -$ +... def format(self, snapshot: PaneSnapshot) -> str: +... return f"Content: {snapshot.content_str[:20]}" +>>> snapshot.format(MyAdapter()).startswith('Content:') +True ``` diff --git a/docs/topics/snapshots.md b/docs/topics/snapshots.md index e1ee8dacd..5e92ab87d 100644 --- a/docs/topics/snapshots.md +++ b/docs/topics/snapshots.md @@ -9,12 +9,11 @@ libtmux provides functionality to capture and analyze the state of tmux panes th A snapshot captures the content and metadata of a pane at a specific point in time: ```python ->>> pane = session.active_window.active_pane >>> snapshot = pane.snapshot() ->>> print(snapshot.content_str) -$ echo "Hello World" -Hello World -$ +>>> snapshot.pane_id # doctest: +ELLIPSIS +'%...' +>>> isinstance(snapshot.content_str, str) +True ``` Snapshots are immutable and include: @@ -28,12 +27,18 @@ You can also capture specific ranges of the pane history: ```python >>> # Capture lines 1-3 only >>> snapshot = pane.snapshot(start=1, end=3) +>>> isinstance(snapshot.content_str, str) +True >>> # Capture from start of history >>> snapshot = pane.snapshot(start="-") +>>> isinstance(snapshot.content_str, str) +True >>> # Capture up to current view >>> snapshot = pane.snapshot(end="-") +>>> isinstance(snapshot.content_str, str) +True ``` ## Recording Pane Activity @@ -49,14 +54,19 @@ To track changes in a pane over time, use recordings: >>> recording.add_snapshot(pane) >>> # Access snapshots ->>> print(recording[0].content_str) # First snapshot ->>> print(recording.latest.content_str) # Most recent +>>> isinstance(recording[0].content_str, str) +True +>>> isinstance(recording.latest.content_str, str) +True >>> # Filter by time +>>> import datetime >>> recent = recording.get_snapshots_between( -... start_time=datetime.datetime.now() - datetime.timedelta(minutes=5), -... end_time=datetime.datetime.now(), +... start_time=datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=5), +... end_time=datetime.datetime.now(datetime.timezone.utc), ... ) +>>> isinstance(recent, list) +True ``` ## Output Formats @@ -67,82 +77,42 @@ Snapshots can be formatted in different ways for various use cases: ```python >>> from libtmux.snapshot import TerminalOutputAdapter ->>> print(snapshot.format(TerminalOutputAdapter())) -=== Pane Snapshot === -Pane: %1 -Window: @1 -Session: $1 -Server: default -Timestamp: 2024-01-01T12:00:00Z -=== Content === -$ echo "Hello World" -Hello World -$ +>>> snapshot = pane.snapshot() +>>> formatted = snapshot.format(TerminalOutputAdapter()) +>>> '=== Pane Snapshot ===' in formatted +True +>>> '=== Content ===' in formatted +True ``` ### CLI Output (No Colors) ```python >>> from libtmux.snapshot import CLIOutputAdapter ->>> print(snapshot.format(CLIOutputAdapter())) -=== Pane Snapshot === -Pane: %1 -Window: @1 -Session: $1 -Server: default -Timestamp: 2024-01-01T12:00:00Z -=== Content === -$ echo "Hello World" -Hello World -$ +>>> snapshot = pane.snapshot() +>>> formatted = snapshot.format(CLIOutputAdapter()) +>>> '=== Pane Snapshot ===' in formatted +True ``` ### Pytest Assertion Diffs ```python >>> from libtmux.snapshot import PytestDiffAdapter ->>> expected = """ -... PaneSnapshot( -... pane_id='%1', -... window_id='@1', -... session_id='$1', -... server_name='default', -... timestamp='2024-01-01T12:00:00Z', -... content=[ -... '$ echo "Hello World"', -... 'Hello World', -... '$', -... ], -... metadata={ -... 'pane_height': '24', -... 'pane_width': '80', -... }, -... ) -... """ ->>> assert snapshot.format(PytestDiffAdapter()) == expected +>>> snapshot = pane.snapshot() +>>> formatted = snapshot.format(PytestDiffAdapter()) +>>> 'PaneSnapshot(' in formatted +True ``` ### Syrupy Snapshot Testing ```python >>> from libtmux.snapshot import SyrupySnapshotAdapter ->>> snapshot.format(SyrupySnapshotAdapter()) -{ - "pane_id": "%1", - "window_id": "@1", - "session_id": "$1", - "server_name": "default", - "timestamp": "2024-01-01T12:00:00Z", - "content": [ - "$ echo \"Hello World\"", - "Hello World", - "$" - ], - "metadata": { - "pane_height": "24", - "pane_width": "80" - } -} +>>> snapshot = pane.snapshot() +>>> formatted = snapshot.format(SyrupySnapshotAdapter()) +>>> 'pane_id' in formatted +True ``` ## Custom Output Formats @@ -150,13 +120,12 @@ $ You can create custom output formats by implementing the `SnapshotOutputAdapter` interface: ```python -from libtmux.snapshot import SnapshotOutputAdapter - -class MyCustomAdapter(SnapshotOutputAdapter): - def format(self, snapshot: PaneSnapshot) -> str: - # Format snapshot data as needed - return f"Custom format: {snapshot.content_str}" - -# Use custom adapter -print(snapshot.format(MyCustomAdapter())) +>>> from libtmux.snapshot import SnapshotOutputAdapter, PaneSnapshot +>>> snapshot = pane.snapshot() +>>> class MyCustomAdapter(SnapshotOutputAdapter): +... def format(self, snapshot: PaneSnapshot) -> str: +... return f"Custom format: {snapshot.content_str[:20]}" +>>> result = snapshot.format(MyCustomAdapter()) +>>> result.startswith('Custom format:') +True ```