Skip to content

Commit 069842f

Browse files
committed
feat: Add test_snapshot
1 parent 65c15f3 commit 069842f

File tree

3 files changed

+370
-0
lines changed

3 files changed

+370
-0
lines changed

src/libtmux/pane.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from libtmux.formats import FORMAT_SEPARATOR
2424
from libtmux.neo import Obj, fetch_obj
25+
from libtmux.snapshot import PaneRecording, PaneSnapshot
2526

2627
from . import exc
2728

@@ -342,6 +343,43 @@ def capture_pane(
342343
cmd.extend(["-E", str(end)])
343344
return self.cmd(*cmd).stdout
344345

346+
def snapshot(
347+
self,
348+
start: t.Literal["-"] | int | None = None,
349+
end: t.Literal["-"] | int | None = None,
350+
) -> PaneSnapshot:
351+
"""Create a snapshot of the pane's current state.
352+
353+
This is a convenience method that creates a :class:`PaneSnapshot` instance
354+
from the current pane state.
355+
356+
Parameters
357+
----------
358+
start : int | "-" | None
359+
Start line for capture_pane
360+
end : int | "-" | None
361+
End line for capture_pane
362+
363+
Returns
364+
-------
365+
PaneSnapshot
366+
A frozen snapshot of the pane's current state
367+
"""
368+
return PaneSnapshot.from_pane(self, start=start, end=end)
369+
370+
def record(self) -> PaneRecording:
371+
"""Create a new recording for this pane.
372+
373+
This is a convenience method that creates a :class:`PaneRecording` instance
374+
for recording snapshots of this pane.
375+
376+
Returns
377+
-------
378+
PaneRecording
379+
A new recording instance for this pane
380+
"""
381+
return PaneRecording()
382+
345383
def send_keys(
346384
self,
347385
cmd: str,

src/libtmux/snapshot.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""Snapshot and recording functionality for tmux panes."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import datetime
7+
import typing as t
8+
9+
from typing_extensions import Self
10+
11+
from libtmux.formats import PANE_FORMATS
12+
13+
if t.TYPE_CHECKING:
14+
from collections.abc import Iterator, Sequence
15+
16+
from libtmux.pane import Pane
17+
18+
19+
@dataclasses.dataclass(frozen=True)
20+
class PaneSnapshot:
21+
"""A frozen snapshot of a pane's state at a point in time.
22+
23+
This class captures both the content and metadata of a tmux pane,
24+
making it suitable for testing and debugging purposes.
25+
26+
Attributes
27+
----------
28+
content : list[str]
29+
The captured content of the pane
30+
timestamp : datetime.datetime
31+
When the snapshot was taken (in UTC)
32+
pane_id : str
33+
The ID of the pane
34+
window_id : str
35+
The ID of the window containing the pane
36+
session_id : str
37+
The ID of the session containing the window
38+
server_name : str
39+
The name of the tmux server
40+
metadata : dict[str, str]
41+
Additional pane metadata from tmux formats
42+
"""
43+
44+
content: list[str]
45+
timestamp: datetime.datetime
46+
pane_id: str
47+
window_id: str
48+
session_id: str
49+
server_name: str
50+
metadata: dict[str, str]
51+
52+
@classmethod
53+
def from_pane(
54+
cls,
55+
pane: Pane,
56+
start: t.Literal["-"] | int | None = None,
57+
end: t.Literal["-"] | int | None = None,
58+
) -> Self:
59+
"""Create a snapshot from a pane.
60+
61+
Parameters
62+
----------
63+
pane : Pane
64+
The pane to snapshot
65+
start : int | "-" | None
66+
Start line for capture_pane
67+
end : int | "-" | None
68+
End line for capture_pane
69+
70+
Returns
71+
-------
72+
PaneSnapshot
73+
A frozen snapshot of the pane's state
74+
"""
75+
metadata = {
76+
fmt: getattr(pane, fmt)
77+
for fmt in PANE_FORMATS
78+
if hasattr(pane, fmt) and getattr(pane, fmt) is not None
79+
}
80+
81+
content = pane.capture_pane(start=start, end=end)
82+
if isinstance(content, str):
83+
content = [content]
84+
85+
return cls(
86+
content=content,
87+
timestamp=datetime.datetime.now(datetime.timezone.utc),
88+
pane_id=str(pane.pane_id),
89+
window_id=str(pane.window.window_id),
90+
session_id=str(pane.session.session_id),
91+
server_name=str(pane.server.socket_name),
92+
metadata=metadata,
93+
)
94+
95+
def __str__(self) -> str:
96+
"""Return a string representation of the snapshot.
97+
98+
Returns
99+
-------
100+
str
101+
A formatted string showing the snapshot content and metadata
102+
"""
103+
return (
104+
f"PaneSnapshot(pane={self.pane_id}, window={self.window_id}, "
105+
f"session={self.session_id}, server={self.server_name}, "
106+
f"timestamp={self.timestamp.isoformat()}, "
107+
f"content=\n{self.content_str})"
108+
)
109+
110+
@property
111+
def content_str(self) -> str:
112+
"""Get the pane content as a single string.
113+
114+
Returns
115+
-------
116+
str
117+
The pane content with lines joined by newlines
118+
"""
119+
return "\n".join(self.content)
120+
121+
122+
@dataclasses.dataclass
123+
class PaneRecording:
124+
"""A time-series recording of pane snapshots.
125+
126+
This class maintains an ordered sequence of pane snapshots,
127+
allowing for analysis of how a pane's content changes over time.
128+
129+
Attributes
130+
----------
131+
snapshots : list[PaneSnapshot]
132+
The sequence of snapshots in chronological order
133+
"""
134+
135+
snapshots: list[PaneSnapshot] = dataclasses.field(default_factory=list)
136+
137+
def add_snapshot(
138+
self,
139+
pane: Pane,
140+
start: t.Literal["-"] | int | None = None,
141+
end: t.Literal["-"] | int | None = None,
142+
) -> None:
143+
"""Add a new snapshot to the recording.
144+
145+
Parameters
146+
----------
147+
pane : Pane
148+
The pane to snapshot
149+
start : int | "-" | None
150+
Start line for capture_pane
151+
end : int | "-" | None
152+
End line for capture_pane
153+
"""
154+
self.snapshots.append(PaneSnapshot.from_pane(pane, start=start, end=end))
155+
156+
def __len__(self) -> int:
157+
"""Get the number of snapshots in the recording.
158+
159+
Returns
160+
-------
161+
int
162+
The number of snapshots
163+
"""
164+
return len(self.snapshots)
165+
166+
def __iter__(self) -> Iterator[PaneSnapshot]:
167+
"""Iterate through snapshots in chronological order.
168+
169+
Returns
170+
-------
171+
Iterator[PaneSnapshot]
172+
Iterator over the snapshots
173+
"""
174+
return iter(self.snapshots)
175+
176+
def __getitem__(self, index: int) -> PaneSnapshot:
177+
"""Get a snapshot by index.
178+
179+
Parameters
180+
----------
181+
index : int
182+
The index of the snapshot to retrieve
183+
184+
Returns
185+
-------
186+
PaneSnapshot
187+
The snapshot at the specified index
188+
"""
189+
return self.snapshots[index]
190+
191+
@property
192+
def latest(self) -> PaneSnapshot | None:
193+
"""Get the most recent snapshot.
194+
195+
Returns
196+
-------
197+
PaneSnapshot | None
198+
The most recent snapshot, or None if no snapshots exist
199+
"""
200+
return self.snapshots[-1] if self.snapshots else None
201+
202+
def get_snapshots_between(
203+
self,
204+
start_time: datetime.datetime,
205+
end_time: datetime.datetime,
206+
) -> Sequence[PaneSnapshot]:
207+
"""Get snapshots between two points in time.
208+
209+
Parameters
210+
----------
211+
start_time : datetime.datetime
212+
The start of the time range
213+
end_time : datetime.datetime
214+
The end of the time range
215+
216+
Returns
217+
-------
218+
Sequence[PaneSnapshot]
219+
Snapshots within the specified time range
220+
"""
221+
return [
222+
snapshot
223+
for snapshot in self.snapshots
224+
if start_time <= snapshot.timestamp <= end_time
225+
]

tests/test_snapshot.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Tests for libtmux snapshot functionality."""
2+
3+
from __future__ import annotations
4+
5+
import datetime
6+
import shutil
7+
import time
8+
import typing as t
9+
10+
from libtmux.snapshot import PaneRecording, PaneSnapshot
11+
12+
if t.TYPE_CHECKING:
13+
from libtmux.session import Session
14+
15+
16+
def test_pane_snapshot(session: Session) -> None:
17+
"""Test creating a PaneSnapshot from a pane."""
18+
env = shutil.which("env")
19+
assert env is not None, "Cannot find usable `env` in PATH."
20+
21+
session.new_window(
22+
attach=True,
23+
window_name="snapshot_test",
24+
window_shell=f"{env} PS1='$ ' sh",
25+
)
26+
pane = session.active_window.active_pane
27+
assert pane is not None
28+
29+
# Take initial snapshot
30+
snapshot = PaneSnapshot.from_pane(pane)
31+
assert snapshot.content == ["$"]
32+
assert snapshot.pane_id == pane.pane_id
33+
assert snapshot.window_id == pane.window.window_id
34+
assert snapshot.session_id == pane.session.session_id
35+
assert snapshot.server_name == pane.server.socket_name
36+
assert isinstance(snapshot.timestamp, datetime.datetime)
37+
assert snapshot.timestamp.tzinfo == datetime.timezone.utc
38+
39+
# Verify metadata
40+
assert "pane_id" in snapshot.metadata
41+
assert "pane_width" in snapshot.metadata
42+
assert "pane_height" in snapshot.metadata
43+
44+
# Test string representation
45+
str_repr = str(snapshot)
46+
assert "PaneSnapshot" in str_repr
47+
assert snapshot.pane_id in str_repr
48+
assert snapshot.window_id in str_repr
49+
assert snapshot.session_id in str_repr
50+
assert snapshot.server_name in str_repr
51+
assert snapshot.timestamp.isoformat() in str_repr
52+
assert "$" in str_repr
53+
54+
55+
def test_pane_recording(session: Session) -> None:
56+
"""Test creating and managing a PaneRecording."""
57+
env = shutil.which("env")
58+
assert env is not None, "Cannot find usable `env` in PATH."
59+
60+
session.new_window(
61+
attach=True,
62+
window_name="recording_test",
63+
window_shell=f"{env} PS1='$ ' sh",
64+
)
65+
pane = session.active_window.active_pane
66+
assert pane is not None
67+
68+
recording = PaneRecording()
69+
assert len(recording) == 0
70+
assert recording.latest is None
71+
72+
# Take initial snapshot
73+
recording.add_snapshot(pane)
74+
assert len(recording) == 1
75+
assert recording.latest is not None
76+
assert recording.latest.content == ["$"]
77+
78+
# Send some commands and take more snapshots
79+
pane.send_keys("echo 'Hello'")
80+
time.sleep(0.1) # Give tmux time to update
81+
recording.add_snapshot(pane)
82+
83+
pane.send_keys("echo 'World'")
84+
time.sleep(0.1) # Give tmux time to update
85+
recording.add_snapshot(pane)
86+
87+
assert len(recording) == 3
88+
89+
# Test iteration
90+
snapshots = list(recording)
91+
assert len(snapshots) == 3
92+
assert snapshots[0].content == ["$"]
93+
assert "Hello" in snapshots[1].content_str
94+
assert "World" in snapshots[2].content_str
95+
96+
# Test indexing
97+
assert recording[0].content == ["$"]
98+
assert "Hello" in recording[1].content_str
99+
assert "World" in recording[2].content_str
100+
101+
# Test time-based filtering
102+
start_time = snapshots[0].timestamp
103+
mid_time = snapshots[1].timestamp
104+
end_time = snapshots[2].timestamp
105+
106+
assert len(recording.get_snapshots_between(start_time, end_time)) == 3
107+
assert len(recording.get_snapshots_between(mid_time, end_time)) == 2

0 commit comments

Comments
 (0)