Skip to content

Commit 031a837

Browse files
committed
!squash more
1 parent 74dc3b9 commit 031a837

File tree

2 files changed

+289
-1
lines changed

2 files changed

+289
-1
lines changed

src/libtmux/snapshot.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import dataclasses
66
import datetime
7+
import json
78
import typing as t
9+
from abc import ABC, abstractmethod
810

911
from typing_extensions import Self
1012

@@ -16,6 +18,147 @@
1618
from libtmux.pane import Pane
1719

1820

21+
class SnapshotOutputAdapter(ABC):
22+
"""Base class for snapshot output adapters.
23+
24+
This class defines the interface for converting a PaneSnapshot
25+
into different output formats.
26+
"""
27+
28+
@abstractmethod
29+
def format(self, snapshot: PaneSnapshot) -> str:
30+
"""Format the snapshot for output.
31+
32+
Parameters
33+
----------
34+
snapshot : PaneSnapshot
35+
The snapshot to format
36+
37+
Returns
38+
-------
39+
str
40+
The formatted output
41+
"""
42+
43+
44+
class TerminalOutputAdapter(SnapshotOutputAdapter):
45+
"""Format snapshot for terminal output with ANSI colors."""
46+
47+
def format(self, snapshot: PaneSnapshot) -> str:
48+
"""Format snapshot with ANSI colors for terminal display.
49+
50+
Parameters
51+
----------
52+
snapshot : PaneSnapshot
53+
The snapshot to format
54+
55+
Returns
56+
-------
57+
str
58+
ANSI-colored terminal output
59+
"""
60+
header = (
61+
f"\033[1;34m=== Pane Snapshot ===\033[0m\n"
62+
f"\033[1;36mPane:\033[0m {snapshot.pane_id}\n"
63+
f"\033[1;36mWindow:\033[0m {snapshot.window_id}\n"
64+
f"\033[1;36mSession:\033[0m {snapshot.session_id}\n"
65+
f"\033[1;36mServer:\033[0m {snapshot.server_name}\n"
66+
f"\033[1;36mTimestamp:\033[0m {snapshot.timestamp.isoformat()}\n"
67+
f"\033[1;33m=== Content ===\033[0m\n"
68+
)
69+
return header + snapshot.content_str
70+
71+
72+
class CLIOutputAdapter(SnapshotOutputAdapter):
73+
"""Format snapshot for plain text CLI output."""
74+
75+
def format(self, snapshot: PaneSnapshot) -> str:
76+
"""Format snapshot as plain text.
77+
78+
Parameters
79+
----------
80+
snapshot : PaneSnapshot
81+
The snapshot to format
82+
83+
Returns
84+
-------
85+
str
86+
Plain text output suitable for CLI
87+
"""
88+
header = (
89+
f"=== Pane Snapshot ===\n"
90+
f"Pane: {snapshot.pane_id}\n"
91+
f"Window: {snapshot.window_id}\n"
92+
f"Session: {snapshot.session_id}\n"
93+
f"Server: {snapshot.server_name}\n"
94+
f"Timestamp: {snapshot.timestamp.isoformat()}\n"
95+
f"=== Content ===\n"
96+
)
97+
return header + snapshot.content_str
98+
99+
100+
class PytestDiffAdapter(SnapshotOutputAdapter):
101+
"""Format snapshot for pytest assertion diffs."""
102+
103+
def format(self, snapshot: PaneSnapshot) -> str:
104+
"""Format snapshot for optimal pytest diff output.
105+
106+
Parameters
107+
----------
108+
snapshot : PaneSnapshot
109+
The snapshot to format
110+
111+
Returns
112+
-------
113+
str
114+
Pytest-friendly diff output
115+
"""
116+
lines = [
117+
"PaneSnapshot(",
118+
f" pane_id={snapshot.pane_id!r},",
119+
f" window_id={snapshot.window_id!r},",
120+
f" session_id={snapshot.session_id!r},",
121+
f" server_name={snapshot.server_name!r},",
122+
f" timestamp={snapshot.timestamp.isoformat()!r},",
123+
" content=[",
124+
*(f" {line!r}," for line in snapshot.content),
125+
" ],",
126+
" metadata={",
127+
*(f" {k!r}: {v!r}," for k, v in sorted(snapshot.metadata.items())),
128+
" },",
129+
")",
130+
]
131+
return "\n".join(lines)
132+
133+
134+
class SyrupySnapshotAdapter(SnapshotOutputAdapter):
135+
"""Format snapshot for syrupy snapshot testing."""
136+
137+
def format(self, snapshot: PaneSnapshot) -> str:
138+
"""Format snapshot for syrupy compatibility.
139+
140+
Parameters
141+
----------
142+
snapshot : PaneSnapshot
143+
The snapshot to format
144+
145+
Returns
146+
-------
147+
str
148+
JSON-serialized snapshot data
149+
"""
150+
data = {
151+
"pane_id": snapshot.pane_id,
152+
"window_id": snapshot.window_id,
153+
"session_id": snapshot.session_id,
154+
"server_name": snapshot.server_name,
155+
"timestamp": snapshot.timestamp.isoformat(),
156+
"content": snapshot.content,
157+
"metadata": snapshot.metadata,
158+
}
159+
return json.dumps(data, indent=2, sort_keys=True)
160+
161+
19162
@dataclasses.dataclass(frozen=True)
20163
class PaneSnapshot:
21164
"""A frozen snapshot of a pane's state at a point in time.
@@ -92,6 +235,25 @@ def from_pane(
92235
metadata=metadata,
93236
)
94237

238+
def format(self, adapter: SnapshotOutputAdapter | None = None) -> str:
239+
"""Format the snapshot using the specified adapter.
240+
241+
If no adapter is provided, uses the default string representation.
242+
243+
Parameters
244+
----------
245+
adapter : SnapshotOutputAdapter | None
246+
The adapter to use for formatting
247+
248+
Returns
249+
-------
250+
str
251+
The formatted output
252+
"""
253+
if adapter is None:
254+
return str(self)
255+
return adapter.format(self)
256+
95257
def __str__(self) -> str:
96258
"""Return a string representation of the snapshot.
97259

tests/test_snapshot.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@
33
from __future__ import annotations
44

55
import datetime
6+
import json
67
import shutil
78
import time
89
import typing as t
910

10-
from libtmux.snapshot import PaneRecording, PaneSnapshot
11+
from libtmux.snapshot import (
12+
CLIOutputAdapter,
13+
PaneRecording,
14+
PaneSnapshot,
15+
PytestDiffAdapter,
16+
SyrupySnapshotAdapter,
17+
TerminalOutputAdapter,
18+
)
1119

1220
if t.TYPE_CHECKING:
1321
from libtmux.session import Session
@@ -105,3 +113,121 @@ def test_pane_recording(session: Session) -> None:
105113

106114
assert len(recording.get_snapshots_between(start_time, end_time)) == 3
107115
assert len(recording.get_snapshots_between(mid_time, end_time)) == 2
116+
117+
118+
def test_snapshot_output_adapters(session: Session) -> None:
119+
"""Test the various output adapters for PaneSnapshot."""
120+
env = shutil.which("env")
121+
assert env is not None, "Cannot find usable `env` in PATH."
122+
123+
session.new_window(
124+
attach=True,
125+
window_name="adapter_test",
126+
window_shell=f"{env} PS1='$ ' sh",
127+
)
128+
pane = session.active_window.active_pane
129+
assert pane is not None
130+
131+
# Create a snapshot with some content
132+
pane.send_keys("echo 'Test Content'")
133+
time.sleep(0.1)
134+
snapshot = pane.snapshot()
135+
136+
# Test Terminal Output
137+
terminal_output = snapshot.format(TerminalOutputAdapter())
138+
assert "\033[1;34m=== Pane Snapshot ===\033[0m" in terminal_output
139+
assert "\033[1;36mPane:\033[0m" in terminal_output
140+
assert "Test Content" in terminal_output
141+
142+
# Test CLI Output
143+
cli_output = snapshot.format(CLIOutputAdapter())
144+
assert "=== Pane Snapshot ===" in cli_output
145+
assert "Pane: " in cli_output
146+
assert "\033" not in cli_output # No ANSI codes
147+
assert "Test Content" in cli_output
148+
149+
# Test Pytest Diff Output
150+
pytest_output = snapshot.format(PytestDiffAdapter())
151+
assert "PaneSnapshot(" in pytest_output
152+
assert " pane_id=" in pytest_output
153+
assert " content=[" in pytest_output
154+
assert " metadata={" in pytest_output
155+
assert "'Test Content'" in pytest_output
156+
157+
# Test Syrupy Output
158+
syrupy_output = snapshot.format(SyrupySnapshotAdapter())
159+
data = json.loads(syrupy_output)
160+
assert isinstance(data, dict)
161+
assert "pane_id" in data
162+
assert "content" in data
163+
assert "metadata" in data
164+
assert "Test Content" in str(data["content"])
165+
166+
# Test default format (no adapter)
167+
default_output = snapshot.format()
168+
assert default_output == str(snapshot)
169+
170+
171+
def test_pane_snapshot_convenience_method(session: Session) -> None:
172+
"""Test the Pane.snapshot() convenience method."""
173+
env = shutil.which("env")
174+
assert env is not None, "Cannot find usable `env` in PATH."
175+
176+
session.new_window(
177+
attach=True,
178+
window_name="snapshot_convenience_test",
179+
window_shell=f"{env} PS1='$ ' sh",
180+
)
181+
pane = session.active_window.active_pane
182+
assert pane is not None
183+
184+
# Take snapshot using convenience method
185+
snapshot = pane.snapshot()
186+
assert snapshot.content == ["$"]
187+
assert snapshot.pane_id == pane.pane_id
188+
assert snapshot.window_id == pane.window.window_id
189+
assert snapshot.session_id == pane.session.session_id
190+
assert snapshot.server_name == pane.server.socket_name
191+
192+
# Test with start/end parameters
193+
pane.send_keys("echo 'Line 1'")
194+
time.sleep(0.1)
195+
pane.send_keys("echo 'Line 2'")
196+
time.sleep(0.1)
197+
pane.send_keys("echo 'Line 3'")
198+
time.sleep(0.1)
199+
200+
snapshot_partial = pane.snapshot(start=1, end=2)
201+
assert len(snapshot_partial.content) == 2
202+
assert "Line 1" in snapshot_partial.content_str
203+
assert "Line 2" in snapshot_partial.content_str
204+
assert "Line 3" not in snapshot_partial.content_str
205+
206+
207+
def test_pane_record_convenience_method(session: Session) -> None:
208+
"""Test the Pane.record() convenience method."""
209+
env = shutil.which("env")
210+
assert env is not None, "Cannot find usable `env` in PATH."
211+
212+
session.new_window(
213+
attach=True,
214+
window_name="record_convenience_test",
215+
window_shell=f"{env} PS1='$ ' sh",
216+
)
217+
pane = session.active_window.active_pane
218+
assert pane is not None
219+
220+
# Create recording using convenience method
221+
recording = pane.record()
222+
assert isinstance(recording, PaneRecording)
223+
assert len(recording) == 0
224+
225+
# Add snapshots to recording
226+
recording.add_snapshot(pane)
227+
pane.send_keys("echo 'Test'")
228+
time.sleep(0.1)
229+
recording.add_snapshot(pane)
230+
231+
assert len(recording) == 2
232+
assert recording[0].content == ["$"]
233+
assert "Test" in recording[1].content_str

0 commit comments

Comments
 (0)