Skip to content

Snapshot / frame dataclass #370

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Tracked by #584
tony opened this issue May 13, 2022 · 0 comments
Open
Tracked by #584

Snapshot / frame dataclass #370

tony opened this issue May 13, 2022 · 0 comments

Comments

@tony
Copy link
Member

tony commented May 13, 2022

libtmux:

The Snapshot.

class Snapshot:

    created_at: datetime.utcnow()
    id: tmux_id

    def refresh_from_cli():
        # return new object with new created_at
class Session(Snapshot):
    pass


class Window(Snapshot):
    pass

acronyms: Frame, tmux object

This is a state of tmux at a certain time.

You can know, based on time, the likelihood a certain object is stale. What it means is that you can audit a terminal and trail it, you can record it, you can print the terminal to text. You can export the session, window, pane's contents to a file. You can assert that certain text exists in a pane, window, session, server.

A snapshot could be pickable. It could be serializable in various other formats

This can open doors to new ways of interacting with the terminal not hitherto conceived

Appendix

Proposal

Goal: Provide frozen, read-only, hierarchical snapshots of tmux objects:

  • ServerSnapshot (immutable server)
  • SessionSnapshot (immutable session)
  • WindowSnapshot (immutable window)
  • PaneSnapshot (immutable pane)

Key Requirements:

  1. Preserve Type Information: Mirror fields from Server, Session, Window, Pane.
  2. Immutable / Read-Only: Prevent modifications or tmux command execution.
  3. Accurate Object Graph: Retain parent-child references (server → sessions → windows → panes).
  4. Minimize Overhead: Avoid copying unnecessary data.
  5. Optional Filtering: Provide a way to selectively keep or discard certain sessions/windows/panes in snapshots.
  6. Serialization: Offer a convenient way to convert snapshots to Python dictionaries (or JSON), avoiding circular references.

Design Highlights

  1. Inheritance: Each snapshot class inherits from the corresponding tmux object class (e.g., PaneSnapshot(Pane)) so that existing code can still perform type checks like isinstance(snapshot, Pane).
  2. Frozen Dataclasses: Using @dataclass(frozen=True) ensures immutability. Once created, attributes cannot be changed.
  3. Method Overrides: Methods that perform tmux commands (cmd, capture_pane, etc.) are overridden to either:
    • Return cached data (e.g., pre-captured pane contents), or
    • Raise NotImplementedError if the action would require writing or sending commands to tmux.
  4. Parent-Child Links: PaneSnapshot has a reference to its WindowSnapshot, WindowSnapshot has a reference to its SessionSnapshot, etc. These references let you traverse the entire snapshot hierarchy from any node.
  5. Filtering: A filter_snapshot function can traverse the snapshot hierarchy and produce a new, pruned snapshot that keeps only the objects that satisfy a user-supplied predicate (e.g., only “active” windows).

Below is the unified code that brings these elements together.


Complete Dataclass Snapshot Implementation

from __future__ import annotations
import datetime
import typing as t
from dataclasses import dataclass, field, fields
import copy

# Assume these come from libtmux or a similar library
from libtmux.server import Server
from libtmux.session import Session
from libtmux.window import Window
from libtmux.pane import Pane


@dataclass(frozen=True)
class PaneSnapshot(Pane):
    """
    Immutable snapshot of a Pane. Inherits from Pane so that:
    1) It's recognized as a Pane type.
    2) We can easily copy fields from the original Pane.
    """
    # Snapshot-specific fields
    pane_content: t.Optional[t.List[str]] = None
    created_at: datetime.datetime = field(
        default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
    )
    window_snapshot: t.Optional[WindowSnapshot] = None  # Link back to parent window

    def cmd(self, *args, **kwargs):
        """Prevent executing tmux commands on a snapshot."""
        raise NotImplementedError("PaneSnapshot is read-only and cannot execute tmux commands")

    def capture_pane(self, *args, **kwargs):
        """Return the pre-captured content instead of hitting tmux."""
        return self.pane_content or []

    @property
    def window(self) -> t.Optional[WindowSnapshot]:
        """Return the WindowSnapshot link, rather than a live Window."""
        return self.window_snapshot

    @classmethod
    def from_pane(
        cls, 
        pane: Pane, 
        *, 
        capture_content: bool = True,
        window_snapshot: t.Optional[WindowSnapshot] = None
    ) -> PaneSnapshot:
        """
        Factory method to create a PaneSnapshot from a live Pane.
        
        capture_content=True to fetch the current text from the pane
        window_snapshot    to link this pane back to a parent WindowSnapshot
        """
        # Try capturing the pane’s content
        pane_content = None
        if capture_content:
            try:
                pane_content = pane.capture_pane()
            except Exception:
                pass  # If capturing fails, leave it None
        
        # Gather fields from the parent Pane class
        # We exclude 'window' to avoid pulling in a live reference
        field_values = {}
        for f in fields(pane.__class__):
            if f.name not in ["window", "server"]:
                if hasattr(pane, f.name):
                    field_values[f.name] = copy.deepcopy(getattr(pane, f.name))
        
        # Add snapshot-specific fields
        field_values["pane_content"] = pane_content
        field_values["window_snapshot"] = window_snapshot
        
        return cls(**field_values)


@dataclass(frozen=True)
class WindowSnapshot(Window):
    """
    Immutable snapshot of a Window.
    """
    created_at: datetime.datetime = field(
        default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
    )
    panes_snapshot: t.List[PaneSnapshot] = field(default_factory=list)
    session_snapshot: t.Optional[SessionSnapshot] = None  # Link back to parent session

    def cmd(self, *args, **kwargs):
        raise NotImplementedError("WindowSnapshot is read-only and cannot execute tmux commands")

    @property
    def panes(self) -> t.List[PaneSnapshot]:
        """Return the snapshot list of panes."""
        return self.panes_snapshot

    @property
    def session(self) -> t.Optional[SessionSnapshot]:
        """Return the SessionSnapshot link, rather than a live Session."""
        return self.session_snapshot

    @classmethod
    def from_window(
        cls, 
        window: Window, 
        *, 
        include_panes: bool = True,
        session_snapshot: t.Optional[SessionSnapshot] = None
    ) -> WindowSnapshot:
        """
        Create a snapshot from a live Window.
        
        include_panes=True     to also snapshot all the window's panes
        session_snapshot       to link this window back to a parent SessionSnapshot
        """
        field_values = {}
        for f in fields(window.__class__):
            if f.name not in ["session", "server", "panes"]:
                if hasattr(window, f.name):
                    field_values[f.name] = copy.deepcopy(getattr(window, f.name))

        # Construct the WindowSnapshot (initially without panes)
        snapshot = cls(
            **field_values,
            session_snapshot=session_snapshot,
            panes_snapshot=[]
        )

        # If requested, snapshot all panes. Then fix back-references.
        if include_panes:
            all_panes = []
            for pane in window.panes:
                pane_snapshot = PaneSnapshot.from_pane(
                    pane,
                    capture_content=True,
                    window_snapshot=snapshot
                )
                all_panes.append(pane_snapshot)

            object.__setattr__(snapshot, "panes_snapshot", all_panes)

        return snapshot


@dataclass(frozen=True)
class SessionSnapshot(Session):
    """
    Immutable snapshot of a Session.
    """
    created_at: datetime.datetime = field(
        default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
    )
    windows_snapshot: t.List[WindowSnapshot] = field(default_factory=list)
    server_snapshot: t.Optional[ServerSnapshot] = None  # Link back to parent server

    def cmd(self, *args, **kwargs):
        raise NotImplementedError("SessionSnapshot is read-only and cannot execute tmux commands")

    @property
    def windows(self) -> t.List[WindowSnapshot]:
        """Return the snapshot list of windows."""
        return self.windows_snapshot

    @property
    def server(self) -> t.Optional[ServerSnapshot]:
        """Return the ServerSnapshot link, rather than a live Server."""
        return self.server_snapshot

    @classmethod
    def from_session(
        cls, 
        session: Session,
        *, 
        include_windows: bool = True,
        server_snapshot: t.Optional[ServerSnapshot] = None
    ) -> SessionSnapshot:
        """
        Create a snapshot from a live Session.
        
        include_windows=True   to also snapshot all the session's windows
        server_snapshot        to link this session back to a parent ServerSnapshot
        """
        field_values = {}
        for f in fields(session.__class__):
            if f.name not in ["server", "windows"]:
                if hasattr(session, f.name):
                    field_values[f.name] = copy.deepcopy(getattr(session, f.name))

        # Construct the SessionSnapshot (initially without windows)
        snapshot = cls(
            **field_values,
            windows_snapshot=[],
            server_snapshot=server_snapshot
        )

        # If requested, snapshot all windows. Then fix back-references.
        if include_windows:
            all_windows = []
            for window in session.windows:
                window_snapshot = WindowSnapshot.from_window(
                    window,
                    include_panes=True,
                    session_snapshot=snapshot
                )
                all_windows.append(window_snapshot)

            object.__setattr__(snapshot, "windows_snapshot", all_windows)

        return snapshot


@dataclass(frozen=True)
class ServerSnapshot(Server):
    """
    Immutable snapshot of a Server.
    """
    created_at: datetime.datetime = field(
        default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
    )
    sessions_snapshot: t.List[SessionSnapshot] = field(default_factory=list)

    def cmd(self, *args, **kwargs):
        raise NotImplementedError("ServerSnapshot is read-only and cannot execute tmux commands")

    @property
    def sessions(self) -> t.List[SessionSnapshot]:
        """Return the snapshot list of sessions."""
        return self.sessions_snapshot

    @classmethod
    def from_server(cls, server: Server, *, include_sessions: bool = True) -> ServerSnapshot:
        """
        Create a snapshot from a live Server.
        
        include_sessions=True  to also snapshot all the server's sessions
        """
        field_values = {}
        for f in fields(server.__class__):
            if f.name not in ["sessions"]:
                if hasattr(server, f.name):
                    field_values[f.name] = copy.deepcopy(getattr(server, f.name))

        # Construct the ServerSnapshot (initially without sessions)
        snapshot = cls(
            **field_values,
            sessions_snapshot=[]
        )

        # If requested, snapshot all sessions. Then fix back-references.
        if include_sessions:
            all_sessions = []
            for session in server.sessions:
                session_snapshot = SessionSnapshot.from_session(
                    session,
                    include_windows=True,
                    server_snapshot=snapshot
                )
                all_sessions.append(session_snapshot)

            object.__setattr__(snapshot, "sessions_snapshot", all_sessions)

        return snapshot


# -----------------------------
# Filtering Utilities
# -----------------------------

def filter_snapshot(snapshot, filter_func) -> t.Union[
    ServerSnapshot, 
    SessionSnapshot, 
    WindowSnapshot, 
    PaneSnapshot, 
    None
]:
    """
    Recursively filter snapshots based on a user-supplied function.

    filter_func(obj) should return True if the object should be retained; 
    False if it should be pruned entirely.

    Returns a new snapshot with references updated, or None if everything is filtered out.
    """
    # Server level
    if isinstance(snapshot, ServerSnapshot):
        filtered_sessions = []
        for sess in snapshot.sessions_snapshot:
            if filter_func(sess):
                new_sess = filter_snapshot(sess, filter_func)
                if new_sess is not None:
                    filtered_sessions.append(new_sess)

        # If the server itself fails the filter, discard entirely
        if not filter_func(snapshot) and not filtered_sessions:
            return None

        # Create a copy with filtered sessions
        result = copy.deepcopy(snapshot)
        object.__setattr__(result, "sessions_snapshot", filtered_sessions)

        # Fix the back-reference from sessions to server
        for sess_snap in filtered_sessions:
            object.__setattr__(sess_snap, "server_snapshot", result)
        return result

    # Session level
    if isinstance(snapshot, SessionSnapshot):
        filtered_windows = []
        for w in snapshot.windows_snapshot:
            if filter_func(w):
                new_w = filter_snapshot(w, filter_func)
                if new_w is not None:
                    filtered_windows.append(new_w)

        if not filter_func(snapshot) and not filtered_windows:
            return None

        result = copy.deepcopy(snapshot)
        object.__setattr__(result, "windows_snapshot", filtered_windows)

        # Fix the back-reference from windows to session
        for w_snap in filtered_windows:
            object.__setattr__(w_snap, "session_snapshot", result)
        return result

    # Window level
    if isinstance(snapshot, WindowSnapshot):
        filtered_panes = []
        for p in snapshot.panes_snapshot:
            if filter_func(p):
                filtered_panes.append(p)  # Pane is leaf-level except for reference to window

        if not filter_func(snapshot) and not filtered_panes:
            return None

        result = copy.deepcopy(snapshot)
        object.__setattr__(result, "panes_snapshot", filtered_panes)

        # Fix the back-reference from panes to window
        for p_snap in filtered_panes:
            object.__setattr__(p_snap, "window_snapshot", result)
        return result

    # Pane level
    if isinstance(snapshot, PaneSnapshot):
        if filter_func(snapshot):
            return snapshot
        else:
            return None

    # Unrecognized type → pass through or None
    return snapshot if filter_func(snapshot) else None


# -----------------------------
# Serialization Utility
# -----------------------------

def snapshot_to_dict(snapshot) -> dict:
    """
    Recursively convert a snapshot into a dictionary,
    avoiding circular references (server->session->server, etc.).
    """
    # Base case: For non-snapshot objects, just return them directly
    # (In practice, this is rarely triggered, so we focus on known classes.)
    if not isinstance(snapshot, (ServerSnapshot, SessionSnapshot, WindowSnapshot, PaneSnapshot)):
        return snapshot

    result = {}
    for f in fields(snapshot):
        name = f.name

        # If this is a parent reference field, skip it to avoid cycles
        if name in ["server_snapshot", "session_snapshot", "window_snapshot"]:
            continue

        value = getattr(snapshot, name)

        # Recurse on lists
        if isinstance(value, list):
            result[name] = [snapshot_to_dict(item) for item in value]
        else:
            # Recurse on single items
            if isinstance(value, (ServerSnapshot, SessionSnapshot, WindowSnapshot, PaneSnapshot)):
                result[name] = snapshot_to_dict(value)
            else:
                result[name] = value

    return result


# -----------------------------
# Example Usage
# -----------------------------

def snapshot_active_only(server: Server) -> ServerSnapshot:
    """
    Create a server snapshot that keeps only 'active' sessions, windows, and panes.
    
    For example, if an item has the attribute .active = True, we keep it;
    otherwise we prune it from the snapshot.
    """
    full_snapshot = ServerSnapshot.from_server(server)

    def is_active(obj):
        return bool(getattr(obj, "active", False))

    filtered = filter_snapshot(full_snapshot, is_active)
    if filtered is None:
        raise ValueError("No active objects found!")
    return filtered

Key Features in This Unified Dataclass Approach

  1. Inheritance from Tmux Classes
    Each *Snapshot class extends the corresponding live class (PaneSnapshot(Pane), etc.). This allows direct compatibility with code that expects an instance of Pane, Window, etc.

  2. True Immutability
    Using @dataclass(frozen=True) ensures that once the snapshot is constructed, its fields cannot be modified. We use object.__setattr__ only during construction (to fill child references after the object is created).

  3. Optional Hierarchical Construction

    • ServerSnapshot.from_server(...) can recursively build session, window, and pane snapshots in one call.
    • Similarly, SessionSnapshot.from_session(...) can skip or include windows.
    • This makes snapshot creation flexible depending on the user’s needs.
  4. Filtering
    The filter_snapshot function demonstrates how to prune snapshots based on a predicate function.

    • Example usage: filter out non-active sessions, or remove certain windows by name, etc.
    • The function returns a new snapshot graph (or None if everything is filtered out).
  5. Serialization

    • snapshot_to_dict(...) recursively converts snapshots to dictionaries, skipping parent references to avoid circular loops.
    • The resulting dictionary can be serialized to JSON, YAML, or any other format.

Summary of Why This Approach Works Well

  1. No External Dependencies: Pure Python dataclasses, which is lighter than introducing a library like Pydantic.
  2. Consistent with Existing Code: Inheriting from the original classes provides a natural migration path if you already have Server, Session, Window, and Pane objects in play.
  3. Safe Read-Only API: All tmux commands are disabled, ensuring your snapshots won’t accidentally mutate or command a live tmux session.
  4. Flexible Filtering: You can tailor which objects remain in your snapshots.
  5. Clarity: The from_* factory methods highlight exactly how data is copied from the live object to the snapshot.

If your main focus is maximum performance with no overhead for validation or complex features, then this dataclass-based approach is sufficient. If you ever need advanced validation or dynamic model creation, you could consider Pydantic, but that remains optional.


References

Use cases like filtering, partial snapshots, or specialized serialization are straightforward to layer onto these vanilla dataclasses with minimal boilerplate.

@tony tony added this to tmuxp May 13, 2022
@tony tony mentioned this issue May 17, 2022
@tony tony mentioned this issue Jan 6, 2024
3 tasks
@tony tony moved this to In Progress in tmuxp Feb 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In Progress
Development

No branches or pull requests

1 participant