diff --git a/CHANGES b/CHANGES index eba6e7efa..52af0a9b8 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,118 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +libtmux 0.57.0 closes 14 wrapper gaps surfaced by downstream adoption of +0.56's typed API. The release introduces {class}`~libtmux.Client` as a +first-class object, broadens {meth}`~libtmux.Pane.send_keys` to support +tmux's flag-only invocations, exposes tmux's C-side ``-f`` filter on +typed listing methods, threads originating subcommand context through +{exc}`~libtmux.exc.LibTmuxException`, and adds typed access to ~45 +additional format tokens across {class}`~libtmux.Pane`, +{class}`~libtmux.Window`, and {class}`~libtmux.Session`. + +### What's new + +#### `Client` object and `Server.clients` accessor (#670) + +New {class}`~libtmux.Client` dataclass and +{attr}`~libtmux.Server.clients` property expose tmux's twelve +``client_*`` format tokens with the same typed-ORM ergonomics already in +place for Session/Window/Pane. Reads like ``client.client_readonly``, +``client.client_session``, and ``client.client_termtype`` work directly +on attached clients instead of forcing callers down to +{meth}`~libtmux.Server.cmd`. + +#### `Server.display_message` and `Window.display_message` (#670) + +{meth}`~libtmux.Server.display_message` and +{meth}`~libtmux.Window.display_message` join the existing +{meth}`~libtmux.Pane.display_message`. Server reads (``#{version}``, +``#{socket_path}``) work without a pane handle; window reads +(``#{window_zoomed_flag}``, ``#{window_active_clients_list}``) auto-bind +to the window's id. + +#### C-side filter on typed listing methods (#670) + +{meth}`~libtmux.Server.search_panes`, +{meth}`~libtmux.Server.search_windows`, +{meth}`~libtmux.Server.search_sessions`, and the Session/Window +analogues take a ``filter=`` kwarg routed to tmux's ``-f`` flag. tmux +evaluates the predicate and drops non-matching objects before any +Python instance is constructed. + +#### `Pane.send_keys(cmd=None, …)` flag-only invocation (#670) + +{meth}`~libtmux.Pane.send_keys` accepts ``cmd=None`` together with +``reset=True`` or ``repeat=N`` to invoke tmux's flag-only ``send-keys +-R`` / ``send-keys -N `` form without any trailing key argument. + +#### `Server.list_buffers(format_string=, filter=)` (#670) + +{meth}`~libtmux.Server.list_buffers` gains ``format_string`` (``-F``) +and ``filter`` (``-f``) kwargs. Callers can project a chosen template +(e.g. ``"#{buffer_name}"``) or push a buffer-name match expression into +tmux's format engine. + +#### `Server.run_shell(cwd=, show_stderr=)` (#670) + +{meth}`~libtmux.Server.run_shell` gains ``cwd`` (``-c``) to set the +shell command's working directory and ``show_stderr`` (``-E``) to merge +the command's stderr into the captured output. + +#### `Pane.capture_pane(pending=True)` (#670) + +{meth}`~libtmux.Pane.capture_pane` gains a ``pending`` kwarg. When set, +the wrapper emits tmux's ``-P`` to return the bytes tmux has read from +the pane but not yet committed to the terminal — output that begins an +incomplete escape sequence and is still pending the parser's ground +state. Useful for diagnosing programs whose output stalls mid-sequence. + +#### Subcommand-tagged exceptions (#670) + +{exc}`~libtmux.exc.LibTmuxException` takes an optional ``subcommand`` +attribute. When set, ``str(exc)`` prefixes the originating tmux command +name (e.g. ``"last-window: no such window"``), giving downstream +consumers a stable way to dispatch on which tmux command produced the +error. {func}`~libtmux.common.raise_if_stderr` is the helper every +wrapper uses to populate it. + +#### Typed format-token fields with scope and version gating (#670) + +{class}`~libtmux.Pane`, {class}`~libtmux.Window`, {class}`~libtmux.Session`, +and {class}`~libtmux.Client` now declare typed dataclass fields for the +scope-relevant tokens from tmux's ``format_table[]`` — covering pane +state (``pane_dead``, ``pane_in_mode``, ``pane_marked``, +``pane_synchronized``, ``pane_path``, ``pane_pipe`` …), window state +(``window_zoomed_flag``, ``window_silence_flag``, ``window_flags`` …), +session state (``session_marked``, ``session_active``, +``session_silence_flag`` …), and the client view (``client_session``, +``client_readonly``, ``client_termtype``, ``client_theme`` …). + +The ``-F`` template libtmux sends to each ``list-*`` subcommand is now +**scope-aware** and **version-aware**: ``list-clients`` emits the +``client_*`` tokens but never ``pane_*`` ones; tokens introduced in +tmux 3.4 / 3.5 / 3.6 are suppressed on tmux 3.2a; eight forward-looking +tokens from tmux master (``pane_zoomed_flag``, ``pane_floating_flag``, +``pane_flags``, ``pane_pb_state``, ``pane_pb_progress``, +``pane_pipe_pid``, ``synchronized_output_flag``, ``bracket_paste_flag``) +are declared on the dataclass but only hydrate once tmux 3.7 ships. +Tokens the running tmux doesn't recognize stay ``None`` on the typed +surface — no crash, no warning. + +### Fixes + +- {meth}`~libtmux.Pane.reset` now clears pane scrollback. In 0.56.0 the + history clear silently no-op'd, leaving the scrollback intact (#650). +- {meth}`~libtmux.Server.display_message`, + {meth}`~libtmux.Window.display_message`, and + {meth}`~libtmux.Pane.display_message` raise + {exc}`~libtmux.exc.LibTmuxException` when tmux reports an error, + matching the rest of the typed wrappers (#670). + +### Documentation + +- New API page: {doc}`api/libtmux.client`. + ## libtmux 0.56.0 (2026-05-10) libtmux 0.56.0 is the tmux command-parity release. It adds more than diff --git a/conftest.py b/conftest.py index fd7620e96..88a2656d2 100644 --- a/conftest.py +++ b/conftest.py @@ -18,6 +18,7 @@ from _pytest.doctest import DoctestItem from libtmux._internal.control_mode import ControlMode +from libtmux.client import Client from libtmux.pane import Pane from libtmux.pytest_plugin import USING_ZSH from libtmux.server import Server @@ -42,6 +43,7 @@ def add_doctest_fixtures( doctest_namespace["Session"] = Session doctest_namespace["Window"] = Window doctest_namespace["Pane"] = Pane + doctest_namespace["Client"] = Client doctest_namespace["server"] = request.getfixturevalue("server") doctest_namespace["Server"] = request.getfixturevalue("TestServer") session: Session = request.getfixturevalue("session") diff --git a/docs/api/index.md b/docs/api/index.md index 287d5a692..bf23783de 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -5,7 +5,8 @@ # API Reference libtmux's public API mirrors tmux's object hierarchy: -`Server` → `Session` → `Window` → `Pane` +`Server` → `Session` → `Window` → `Pane`. Attached terminals show up as +`Client` objects accessed off the server. ## What do you want to do? @@ -67,6 +68,12 @@ Manages panes, layouts, and window operations. Terminal instance. Send keys and capture output. ::: +:::{grid-item-card} Client +:link: libtmux.client +:link-type: doc +Attached terminal. Read read-only state, theme, termtype. +::: + :::: ## Supporting Modules @@ -160,6 +167,7 @@ Server Session Window Pane +Client Common Neo Options diff --git a/docs/api/libtmux.client.md b/docs/api/libtmux.client.md new file mode 100644 index 000000000..6bc88bec7 --- /dev/null +++ b/docs/api/libtmux.client.md @@ -0,0 +1,16 @@ +(clients)= + +# Clients + +- Attached terminals connected to a tmux server +- Each client has its own view of the active session, window, and pane +- Identified by ``client_name`` (the path or label tmux assigns at attach time) + +```{eval-rst} +.. autoclass:: libtmux.Client + :members: + :inherited-members: + :private-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/src/libtmux/__init__.py b/src/libtmux/__init__.py index e8ee94308..c99fde4bd 100644 --- a/src/libtmux/__init__.py +++ b/src/libtmux/__init__.py @@ -14,6 +14,7 @@ __title__, __version__, ) +from .client import Client from .pane import Pane from .server import Server from .session import Session @@ -22,6 +23,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = ( + "Client", "Pane", "Server", "Session", diff --git a/src/libtmux/client.py b/src/libtmux/client.py new file mode 100644 index 000000000..792cfe502 --- /dev/null +++ b/src/libtmux/client.py @@ -0,0 +1,81 @@ +"""Pythonization of the :term:`tmux(1)` client. + +libtmux.client +~~~~~~~~~~~~~~ + +""" + +from __future__ import annotations + +import dataclasses +import logging +import typing as t + +from libtmux.neo import Obj, fetch_obj + +if t.TYPE_CHECKING: + from libtmux.server import Server + + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass() +class Client(Obj): + """:term:`tmux(1)` :term:`Client` [client_manual]_. + + A tmux client is an attached terminal. The same tmux server can have + multiple clients attached simultaneously (e.g. ``$ tmux attach`` from + several terminals) and each receives its own view of the active + session, window, and pane. + + Parameters + ---------- + server : :class:`Server` + + Examples + -------- + >>> with control_mode() as ctl: + ... attached = [ + ... c + ... for c in server.clients + ... if c.client_name == ctl.client_name + ... ] + >>> bool(attached) + True + + >>> with control_mode() as ctl: + ... client = server.clients.get(client_name=ctl.client_name) + ... client.client_readonly in {"0", "1"} + True + + References + ---------- + .. [client_manual] tmux client. openbsd manpage for TMUX(1). + "tmux supports multiple attached clients. Each client has its + own keymap, view of the session, and message log." + + https://man.openbsd.org/tmux.1#DESCRIPTION. Accessed 2026. + """ + + server: Server + + def refresh(self) -> None: + """Refresh client attributes from tmux.""" + assert isinstance(self.client_name, str) + return super()._refresh( + obj_key="client_name", + obj_id=self.client_name, + list_cmd="list-clients", + ) + + @classmethod + def from_client_name(cls, server: Server, client_name: str) -> Client: + """Create Client from an existing client_name.""" + client = fetch_obj( + obj_key="client_name", + obj_id=client_name, + list_cmd="list-clients", + server=server, + ) + return cls(server=server, **client) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 69f077b99..b1a10664c 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,6 +7,7 @@ from __future__ import annotations +import functools import logging import re import shlex @@ -241,6 +242,41 @@ def getenv(self, name: str) -> str | bool | None: return opts_dict.get(name) +def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: + """Raise :exc:`LibTmuxException` tagged with the tmux subcommand on stderr. + + Centralizes the ``if proc.stderr: raise exc.LibTmuxException(proc.stderr)`` + pattern scattered across the wrappers. Tags the exception with the + originating tmux subcommand so downstream consumers (e.g. libtmux-mcp's + ``handle_tool_errors``) keep the "which tmux command failed" context. + + Parameters + ---------- + proc : :class:`tmux_cmd` + Result of a :meth:`Server.cmd` / :meth:`Session.cmd` / etc. call. + subcommand : str + The tmux subcommand the wrapper invoked, e.g. ``"last-window"``, + ``"swap-pane"``. Surfaces in ``str(exc)`` as a ``": …"`` + prefix. + + Raises + ------ + :exc:`LibTmuxException` + When ``proc.stderr`` is non-empty. + + Examples + -------- + >>> from libtmux.common import raise_if_stderr + >>> from libtmux import exc + >>> proc = session.cmd("display-message", "-p", "#{session_id}") + >>> raise_if_stderr(proc, "display-message") # no stderr → no raise + + .. versionadded:: 0.57 + """ + if proc.stderr: + raise exc.LibTmuxException(proc.stderr, subcommand=subcommand) + + class tmux_cmd: """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. @@ -338,6 +374,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) +@functools.cache def get_version(tmux_bin: str | None = None) -> LooseVersion: """Return tmux version. @@ -358,6 +395,15 @@ def get_version(tmux_bin: str | None = None) -> LooseVersion: :class:`distutils.version.LooseVersion` tmux version according to *tmux_bin* if provided, otherwise the system tmux from :func:`shutil.which` + + Notes + ----- + Memoized via :func:`functools.cache`, keyed on the *tmux_bin* argument + (``None`` is a distinct key from any explicit path). The cache is sticky + across ``PATH`` changes and on-disk binary swaps when *tmux_bin* is + ``None`` or the same path string — call ``get_version.cache_clear()`` to + invalidate. Tests that monkey-patch :class:`tmux_cmd` should call + ``cache_clear()`` before asserting parsed-version behavior. """ proc = tmux_cmd("-V", tmux_bin=tmux_bin) if proc.stderr: diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 6a47b247c..0a0c8228c 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -16,7 +16,34 @@ class LibTmuxException(Exception): - """Base Exception for libtmux Errors.""" + """Base Exception for libtmux Errors. + + Parameters + ---------- + *args : object + Forwarded to :class:`Exception`. + subcommand : str, optional + The tmux subcommand that produced this error (e.g. ``"last-window"``). + When set, :meth:`__str__` formats as ``": "`` so + downstream consumers see which tmux command failed. + + .. versionadded:: 0.57 + """ + + def __init__( + self, + *args: object, + subcommand: str | None = None, + ) -> None: + super().__init__(*args) + self.subcommand = subcommand + + def __str__(self) -> str: + """Render with optional ``": …"`` prefix.""" + base = super().__str__() + if self.subcommand is None: + return base + return f"{self.subcommand}: {base}" class DeprecatedError(LibTmuxException): diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 6085dd494..6d1aeea35 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -10,11 +10,12 @@ from collections.abc import Iterable from libtmux import exc -from libtmux.common import tmux_cmd +from libtmux._compat import LooseVersion +from libtmux.common import get_version, raise_if_stderr, tmux_cmd from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: - ListCmd = t.Literal["list-sessions", "list-windows", "list-panes"] + ListCmd = t.Literal["list-sessions", "list-windows", "list-panes", "list-clients"] ListExtraArgs = Iterable[str] | None from libtmux.server import Server @@ -26,6 +27,200 @@ OutputsRaw = list[OutputRaw] +SCOPES_BY_LIST_CMD: dict[str, frozenset[str]] = { + "list-sessions": frozenset({"universal", "session", "window", "pane"}), + "list-windows": frozenset({"universal", "session", "window", "pane"}), + "list-panes": frozenset({"universal", "session", "window", "pane"}), + "list-clients": frozenset({"universal", "session", "window", "pane", "client"}), +} +"""Format-token scopes a given tmux ``list-*`` subcommand can resolve. + +A token whose scope is in the set is safe to include in that subcommand's +``-F`` template. A token whose scope is *outside* the set may be unknown to +the format engine in that context, or in older tmux releases trigger a +server-side fault — exclude it from the format string. + +The cascade is asymmetric: tmux's ``format_defaults`` (``format.c``) fills +deeper context downward — ``c->session`` → ``s->curw`` → ``wl->window->active`` +— so every ``list-*`` subcommand admits its own scope plus every scope +reachable *downward*. ``client`` scope is the exception: it appears only in +``list-clients`` because no reverse cascade exists and emitting ``client_*`` +in a non-client context crashed tmux 3.2a CI (commit 342ff5f5). +""" + + +FIELD_VERSION: dict[str, str] = { + # Post-3.2a additions (verified against tmux's format.c at each gated + # release tag, e.g. https://github.com/tmux/tmux/blob/3.6a/format.c). + "pane_unseen_changes": "3.4", + "pane_key_mode": "3.5", + "session_active": "3.6", + "session_activity_flag": "3.6", + "session_alert": "3.6", + "session_bell_flag": "3.6", + "session_silence_flag": "3.6", + "client_theme": "3.6", + # Forward-looking tokens registered in tmux master post-3.6a. Gated + # to 3.7 so they're absent on every released tmux. Once tmux 3.7 ships, + # they'll hydrate automatically. + "bracket_paste_flag": "3.7", + "pane_flags": "3.7", + "pane_floating_flag": "3.7", + "pane_pb_progress": "3.7", + "pane_pb_state": "3.7", + "pane_pipe_pid": "3.7", + "pane_zoomed_flag": "3.7", + "synchronized_output_flag": "3.7", +} +"""Minimum tmux version that registers each format token. + +Field names absent from this dict default to ``"3.2a"`` (always-safe within +the supported tmux range). Entries here represent tokens added after 3.2a +that need explicit gating to keep the ``-F`` template compatible with older +tmux versions. +""" + + +# Field-name prefixes that map to a single format-token scope. Resolved by +# :func:`_token_scope`. Order matters: longer prefixes win (e.g. +# ``copy_cursor_`` is a runtime token, not a generic ``copy_`` one). +_SCOPE_PREFIXES: tuple[tuple[str, str], ...] = ( + ("copy_cursor_", "event"), + ("pane_", "pane"), + ("window_", "window"), + ("session_", "session"), + ("client_", "client"), + ("buffer_", "buffer"), + ("mouse_", "event"), + ("cursor_", "event"), + ("selection_", "event"), + ("scroll_", "event"), + ("popup_", "event"), +) + +# Per-token scope overrides for fields whose name doesn't follow the prefix +# convention. Verified against the corresponding ``format_cb_*`` in tmux's +# ``format.c`` (which context the callback dereferences — wp, wl, s, or c). +_SCOPE_OVERRIDES: dict[str, str] = { + "bracket_paste_flag": "pane", # ft->wp->screen MODE_BRACKETPASTE + "synchronized_output_flag": "pane", # ft->wp->base MODE_SYNC + "cursor_x": "pane", # ft->wp->base.cx + "cursor_y": "pane", # ft->wp->base.cy + "cursor_flag": "pane", # ft->wp->base.mode + "cursor_character": "pane", # ft->wp + "mouse_all_flag": "pane", # ft->wp->base.mode MODE_MOUSE_ALL + "mouse_any_flag": "pane", # ft->wp->base.mode ALL_MOUSE_MODES + "mouse_button_flag": "pane", # ft->wp->base.mode MODE_MOUSE_BUTTON + "mouse_sgr_flag": "pane", # ft->wp->base.mode MODE_MOUSE_SGR + "mouse_standard_flag": "pane", # ft->wp->base.mode MODE_MOUSE_STANDARD + "scroll_region_lower": "pane", # ft->wp->base.rlower + "scroll_region_upper": "pane", # ft->wp->base.rupper + "alternate_saved_x": "pane", # ft->wp->base.saved_cx + "alternate_saved_y": "pane", # ft->wp->base.saved_cy + "history_bytes": "pane", # ft->wp + "history_limit": "pane", # ft->wp->base.grid->hlimit + "history_size": "pane", # ft->wp->base.grid->hsize + "insert_flag": "pane", # ft->wp->base.mode MODE_INSERT + "keypad_cursor_flag": "pane", # ft->wp->base.mode MODE_KCURSOR + "keypad_flag": "pane", # ft->wp->base.mode MODE_KKEYPAD + "origin_flag": "pane", # ft->wp->base.mode MODE_ORIGIN + "wrap_flag": "pane", # ft->wp->base.mode MODE_WRAP + "active_window_index": "session", # ft->s->curw->idx + "last_window_index": "session", # ft->s +} + + +# Standalone tokens not captured by the prefix table. These are genuinely +# server-wide or parse-context tokens whose callbacks resolve without a +# session/window/pane/client (most are ``__unused`` ft or read globals). +# Pane- and session-scoped standalones are routed via :data:`_SCOPE_OVERRIDES`. +_UNIVERSAL_TOKENS: frozenset[str] = frozenset( + { + "command_list_alias", + "command_list_name", + "command_list_usage", + "config_files", + "current_file", + "host", + "host_short", + "line", + "next_session_id", + "pid", + "search_match", + "socket_path", + "start_time", + "uid", + "user", + "version", + } +) + + +def _token_scope(field_name: str) -> str: + """Resolve a format token's scope from its name. + + Returns ``"universal"`` for cross-scope tokens (e.g. ``version``, + ``socket_path``, ``host``). Returns ``"event"`` for runtime-only tokens + that never appear in a ``list-*`` output (mouse, cursor, selection, + popup). Returns ``"pane"`` / ``"window"`` / ``"session"`` / ``"client"`` + / ``"buffer"`` for scope-prefixed tokens. + + Examples + -------- + >>> from libtmux.neo import _token_scope + >>> _token_scope("pane_id") + 'pane' + >>> _token_scope("window_zoomed_flag") + 'window' + >>> _token_scope("client_name") + 'client' + >>> _token_scope("version") + 'universal' + >>> _token_scope("mouse_x") + 'event' + + Tokens whose name doesn't carry a scope prefix can still be scope-gated + via :data:`_SCOPE_OVERRIDES` (verified against tmux's ``format_cb_*``). + The override also corrects prefix-misclassified tokens — e.g. + ``mouse_all_flag`` is a per-pane mode bit, not a runtime mouse event: + + >>> _token_scope("synchronized_output_flag") + 'pane' + >>> _token_scope("mouse_all_flag") + 'pane' + >>> _token_scope("active_window_index") + 'session' + """ + override = _SCOPE_OVERRIDES.get(field_name) + if override is not None: + return override + for prefix, scope in _SCOPE_PREFIXES: + if field_name.startswith(prefix): + return scope + if field_name in _UNIVERSAL_TOKENS: + return "universal" + return "universal" + + +def _normalize_tmux_version(version: str) -> LooseVersion: + """Convert a tmux version string into a comparable :class:`LooseVersion`. + + tmux master is reported as ``"master"`` (or e.g. ``"3.6a-master"``); + treat it as larger than any tagged release. + + Examples + -------- + >>> from libtmux.neo import _normalize_tmux_version + >>> _normalize_tmux_version("3.6a") < _normalize_tmux_version("master") + True + >>> _normalize_tmux_version("3.2a") < _normalize_tmux_version("3.6a") + True + """ + if "master" in version.lower(): + return LooseVersion("99.0") + return LooseVersion(version) + + @dataclasses.dataclass() class Obj: """Dataclass of generic tmux object.""" @@ -35,21 +230,34 @@ class Obj: active_window_index: str | None = None alternate_saved_x: str | None = None alternate_saved_y: str | None = None + bracket_paste_flag: str | None = None buffer_name: str | None = None buffer_sample: str | None = None buffer_size: str | None = None + client_activity: str | None = None client_cell_height: str | None = None client_cell_width: str | None = None + client_control_mode: str | None = None + client_created: str | None = None client_discarded: str | None = None client_flags: str | None = None client_height: str | None = None client_key_table: str | None = None + client_last_session: str | None = None + client_mode_format: str | None = None client_name: str | None = None client_pid: str | None = None + client_prefix: str | None = None + client_readonly: str | None = None + client_session: str | None = None + client_termfeatures: str | None = None client_termname: str | None = None + client_termtype: str | None = None + client_theme: str | None = None client_tty: str | None = None client_uid: str | None = None client_user: str | None = None + client_utf8: str | None = None client_width: str | None = None client_written: str | None = None command_list_alias: str | None = None @@ -89,24 +297,43 @@ class Obj: pane_bottom: str | None = None pane_current_command: str | None = None pane_current_path: str | None = None + pane_dead: str | None = None pane_dead_signal: str | None = None pane_dead_status: str | None = None pane_dead_time: str | None = None pane_fg: str | None = None + pane_flags: str | None = None + pane_floating_flag: str | None = None + pane_format: str | None = None pane_height: str | None = None pane_id: str | None = None + pane_in_mode: str | None = None pane_index: str | None = None + pane_input_off: str | None = None + pane_key_mode: str | None = None + pane_last: str | None = None pane_left: str | None = None + pane_marked: str | None = None + pane_marked_set: str | None = None + pane_mode: str | None = None + pane_path: str | None = None + pane_pb_progress: str | None = None + pane_pb_state: str | None = None pane_pid: str | None = None + pane_pipe: str | None = None + pane_pipe_pid: str | None = None pane_right: str | None = None pane_search_string: str | None = None pane_start_command: str | None = None pane_start_path: str | None = None + pane_synchronized: str | None = None pane_tabs: str | None = None pane_title: str | None = None pane_top: str | None = None pane_tty: str | None = None + pane_unseen_changes: str | None = None pane_width: str | None = None + pane_zoomed_flag: str | None = None pid: str | None = None scroll_position: str | None = None scroll_region_lower: str | None = None @@ -116,35 +343,56 @@ class Obj: selection_end_y: str | None = None selection_start_x: str | None = None selection_start_y: str | None = None + session_active: str | None = None session_activity: str | None = None + session_activity_flag: str | None = None + session_alert: str | None = None session_alerts: str | None = None session_attached: str | None = None session_attached_list: str | None = None + session_bell_flag: str | None = None session_created: str | None = None + session_format: str | None = None session_group: str | None = None session_group_attached: str | None = None + session_group_attached_list: str | None = None session_group_list: str | None = None + session_group_many_attached: str | None = None session_group_size: str | None = None + session_grouped: str | None = None session_id: str | None = None session_last_attached: str | None = None + session_many_attached: str | None = None + session_marked: str | None = None session_name: str | None = None session_path: str | None = None + session_silence_flag: str | None = None session_stack: str | None = None session_windows: str | None = None socket_path: str | None = None start_time: str | None = None + synchronized_output_flag: str | None = None uid: str | None = None user: str | None = None version: str | None = None window_active: str | None = None # Not detected by script window_active_clients: str | None = None + window_active_clients_list: str | None = None window_active_sessions: str | None = None + window_active_sessions_list: str | None = None window_activity: str | None = None + window_activity_flag: str | None = None + window_bell_flag: str | None = None + window_bigger: str | None = None window_cell_height: str | None = None window_cell_width: str | None = None + window_end_flag: str | None = None + window_flags: str | None = None + window_format: str | None = None window_height: str | None = None window_id: str | None = None window_index: str | None = None + window_last_flag: str | None = None window_layout: str | None = None window_linked: str | None = None window_linked_sessions: str | None = None @@ -155,8 +403,12 @@ class Obj: window_offset_y: str | None = None window_panes: str | None = None window_raw_flags: str | None = None + window_silence_flag: str | None = None window_stack_index: str | None = None + window_start_flag: str | None = None + window_visible_layout: str | None = None window_width: str | None = None + window_zoomed_flag: str | None = None wrap_flag: str | None = None def _refresh( @@ -181,40 +433,102 @@ def _refresh( @functools.cache -def get_output_format() -> tuple[tuple[str, ...], str]: - """Return field names and tmux format string for all Obj fields. +def get_output_format( + list_cmd: str = "list-panes", + tmux_version: str = "3.2a", +) -> tuple[tuple[str, ...], str]: + """Return field names and tmux format string filtered by scope and version. + + Only emits tokens whose scope is reachable from *list_cmd* (per + :data:`SCOPES_BY_LIST_CMD`) and whose minimum tmux version (per + :data:`FIELD_VERSION`) is at or below *tmux_version*. Runtime-only + tokens (``mouse_*``, ``cursor_*``, popups) are excluded from every + ``list-*`` template — they only resolve in event-time format contexts. - Excludes the ``server`` field, which is a Python object reference - rather than a tmux format variable. + Parameters + ---------- + list_cmd : str + The tmux list subcommand the format string is being built for. + Determines which token scopes are reachable. + tmux_version : str + The live tmux version. Used to gate post-3.2a tokens. Defaults to + ``"3.2a"`` (the project's minimum) for safe fallback when the + caller can't yet detect the version. Returns ------- tuple[tuple[str, ...], str] - A tuple of (field_names, tmux_format_string). + A tuple of (field_names, tmux_format_string) restricted to tokens + the given *list_cmd* and *tmux_version* can resolve. Examples -------- >>> from libtmux.neo import get_output_format - >>> fields, fmt = get_output_format() + >>> fields, fmt = get_output_format("list-sessions", "3.6a") >>> 'session_id' in fields True + >>> 'pane_id' in fields # downward cascade via format_defaults + True + >>> 'client_name' in fields # upward not allowed + False >>> 'server' in fields False - """ - # Exclude 'server' - it's a Python object, not a tmux format variable - formats = tuple(f for f in Obj.__dataclass_fields__ if f != "server") - tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] - return formats, "".join(tmux_formats) + Pane scope picks up window and session tokens too: + + >>> fields, _ = get_output_format("list-panes", "3.6a") + >>> all(t in fields for t in ('pane_id', 'window_id', 'session_id')) + True + + ``list-clients`` adds client scope on top of the downward cascade: + + >>> fields, _ = get_output_format("list-clients", "3.6a") + >>> 'client_name' in fields + True + >>> 'pane_id' in fields + True + """ + allowed_scopes = SCOPES_BY_LIST_CMD.get( + list_cmd, + frozenset({"universal", "session", "window", "pane"}), + ) + live_ver = _normalize_tmux_version(tmux_version) + + formats: list[str] = [] + for f in Obj.__dataclass_fields__: + if f == "server": + continue + if _token_scope(f) not in allowed_scopes: + continue + min_v = FIELD_VERSION.get(f) + if min_v is not None and _normalize_tmux_version(min_v) > live_ver: + continue + formats.append(f) + + tmux_format = "".join(f"#{{{n}}}{FORMAT_SEPARATOR}" for n in formats) + return tuple(formats), tmux_format + + +def parse_output( + output: str, + list_cmd: str = "list-panes", + tmux_version: str = "3.2a", +) -> OutputRaw: + """Parse a tmux ``-F`` line into a dict keyed by Obj field name. -def parse_output(output: str) -> OutputRaw: - """Parse tmux output formatted with get_output_format() into a dict. + The (*list_cmd*, *tmux_version*) pair must match what was passed to + :func:`get_output_format` when the ``-F`` template was built — + otherwise the field order won't line up with the split values. Parameters ---------- output : str - Raw tmux output produced with the format string from + Raw tmux output line produced with a template from :func:`get_output_format`. + list_cmd : str + Same value passed to :func:`get_output_format`. + tmux_version : str + Same value passed to :func:`get_output_format`. Returns ------- @@ -225,16 +539,20 @@ def parse_output(output: str) -> OutputRaw: -------- >>> from libtmux.neo import get_output_format, parse_output >>> from libtmux.formats import FORMAT_SEPARATOR - >>> fields, fmt = get_output_format() + >>> fields, fmt = get_output_format("list-sessions", "3.6a") >>> values = [''] * len(fields) >>> values[fields.index('session_id')] = '$1' - >>> result = parse_output(FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR) + >>> result = parse_output( + ... FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR, + ... list_cmd="list-sessions", + ... tmux_version="3.6a", + ... ) >>> result['session_id'] '$1' - >>> 'buffer_sample' in result + >>> 'pane_id' in result False """ - formats, _ = get_output_format() + formats, _ = get_output_format(list_cmd, tmux_version) values = output.split(FORMAT_SEPARATOR) # Remove the trailing empty string from the split @@ -249,6 +567,7 @@ def fetch_objs( server: Server, list_cmd: ListCmd, list_extra_args: ListExtraArgs = None, + filter: str | None = None, # noqa: A002 ) -> OutputsRaw: """Fetch a listing of raw data from a tmux command. @@ -265,6 +584,13 @@ def fetch_objs( list_extra_args : ListExtraArgs, optional Extra arguments appended to the tmux command (e.g. ``("-a",)`` for all windows/panes, or ``["-t", session_id]`` to filter). + filter : str, optional + Filter expression evaluated by tmux's format engine (``-f`` flag). + Objects for which the expanded expression is "false" (empty string, + "0") are omitted from the result. Pushes filtering into tmux's C + code instead of Python post-processing. + + .. versionadded:: 0.57 Returns ------- @@ -288,7 +614,8 @@ def fetch_objs( >>> 'session_id' in objs[0] True """ - _fields, format_string = get_output_format() + tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + _fields, format_string = get_output_format(list_cmd, tmux_version) cmd_args: list[str | int] = [] @@ -305,6 +632,9 @@ def fetch_objs( if list_extra_args is not None and isinstance(list_extra_args, Iterable): tmux_cmds.extend(list(list_extra_args)) + if filter is not None: + tmux_cmds.extend(["-f", filter]) + tmux_cmds.append(f"-F{format_string}") cmd_str: str | None = None @@ -324,10 +654,9 @@ def fetch_objs( tmux_bin=server.tmux_bin, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, list_cmd) - outputs = [parse_output(line) for line in proc.stdout] + outputs = [parse_output(line, list_cmd, tmux_version) for line in proc.stdout] if logger.isEnabledFor(logging.DEBUG): if cmd_str is None: diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 7b9624afe..3bbcb59a4 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -14,7 +14,7 @@ import warnings from libtmux import exc -from libtmux.common import has_gte_version, tmux_cmd +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, @@ -311,8 +311,7 @@ def resize( proc = self.cmd("resize-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "resize-pane") self.refresh() return self @@ -331,6 +330,7 @@ def capture_pane( alternate_screen: bool = ..., quiet: bool = ..., mode_screen: bool = ..., + pending: bool = ..., to_buffer: str, ) -> None: ... @@ -348,6 +348,7 @@ def capture_pane( alternate_screen: bool = ..., quiet: bool = ..., mode_screen: bool = ..., + pending: bool = ..., to_buffer: None = ..., ) -> list[str]: ... @@ -364,6 +365,7 @@ def capture_pane( alternate_screen: bool = False, quiet: bool = False, mode_screen: bool = False, + pending: bool = False, to_buffer: str | None = None, ) -> list[str] | None: r"""Capture text from pane. @@ -426,6 +428,18 @@ def capture_pane( Default: False .. versionadded:: 0.56 + pending : bool, optional + Capture *pending output* — the bytes tmux has read from the + pane but not yet committed to the terminal (``-P`` flag). + These are bytes that begin an incomplete escape sequence + and are still pending the parser's ground state (tmux's + ``input_pending()`` / ``since_ground`` buffer), distinct + from the default capture (the pane's screen history). + Useful for diagnosing programs whose output stalls + mid-sequence. + Default: False + + .. versionadded:: 0.57 to_buffer : str, optional Write the capture into the named tmux buffer (``-b`` flag) instead of returning it. When set, ``-p`` is omitted and @@ -491,6 +505,8 @@ def capture_pane( "mode_screen requires tmux 3.6+, ignoring", stacklevel=2, ) + if pending: + cmd.append("-P") proc = self.cmd(*cmd) if to_buffer is not None: return None @@ -498,7 +514,7 @@ def capture_pane( def send_keys( self, - cmd: str, + cmd: str | None = None, enter: bool | None = True, suppress_history: bool | None = False, literal: bool | None = False, @@ -515,10 +531,21 @@ def send_keys( A leading space character is added to cmd to avoid polluting the user's history. + When ``cmd`` is omitted (``None``), the wrapper emits a flag-only + invocation — useful with ``reset=True`` or ``repeat=N`` to invoke + tmux's deliberate ``count == 0`` branch in ``cmd-send-keys.c`` that + runs the flag effect without sending any keys. In flag-only mode, + ``enter`` is forced ``False`` (no keys → no Enter). + Parameters ---------- - cmd : str - Text or input into pane + cmd : str | None, optional + Text or input into pane. ``None`` for flag-only invocation + (requires ``reset``, ``repeat``, or ``copy_mode_cmd`` to be set). + + .. versionchanged:: 0.57 + + Now optional. ``None`` triggers tmux's flag-only path. enter : bool, optional Send enter after sending the input, default True. suppress_history : bool, optional @@ -559,6 +586,12 @@ def send_keys( .. versionadded:: 0.56 + Raises + ------ + ValueError + If ``cmd`` is ``None`` and no flag-only path is selected + (``reset``, ``repeat``, or ``copy_mode_cmd``). + Examples -------- >>> pane = window.split(shell='sh') @@ -574,6 +607,10 @@ def send_keys( $ echo "Hello world" Hello world $ + + Flag-only invocation — reset terminal state without sending any keys: + + >>> pane.send_keys(reset=True) """ prefix = " " if suppress_history else "" @@ -615,6 +652,18 @@ def send_keys( if copy_mode_cmd is not None: tmux_args += ("-X",) self.cmd("send-keys", *tmux_args, copy_mode_cmd) + elif cmd is None: + # Flag-only path — tmux's cmd-send-keys.c:223-225 explicitly + # supports count == 0 when -R or -N is set, returning + # CMD_RETURN_NORMAL without sending keys. + if not reset and repeat is None: + msg = ( + "send_keys(cmd=None) requires at least one of: " + "reset=True, repeat=N, copy_mode_cmd=..." + ) + raise ValueError(msg) + self.cmd("send-keys", *tmux_args) + return else: self.cmd("send-keys", *tmux_args, prefix + cmd) @@ -767,6 +816,7 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) + raise_if_stderr(proc, "display-message") if get_text: return proc.stdout @@ -826,8 +876,7 @@ def kill( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-pane") extra: dict[str, str] = { "tmux_subcommand": "kill-pane", @@ -941,8 +990,7 @@ def select( proc = self.cmd("select-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-pane") self.refresh() @@ -1198,8 +1246,7 @@ def set_title(self, title: str) -> Pane: 'my-title' """ proc = self.cmd("select-pane", "-T", title) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-pane") self.refresh() return self @@ -1410,8 +1457,7 @@ def display_popup( proc = self.cmd("display-popup", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "display-popup") def paste_buffer( self, @@ -1462,8 +1508,7 @@ def paste_buffer( proc = self.cmd("paste-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "paste-buffer") def pipe( self, @@ -1510,8 +1555,7 @@ def pipe( proc = self.cmd("pipe-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "pipe-pane") def copy_mode( self, @@ -1576,8 +1620,7 @@ def copy_mode( proc = self.cmd("copy-mode", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "copy-mode") def clock_mode(self) -> None: """Enter clock mode via ``$ tmux clock-mode``. @@ -1586,8 +1629,7 @@ def clock_mode(self) -> None: """ proc = self.cmd("clock-mode") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "clock-mode") def display_panes( self, @@ -1621,8 +1663,7 @@ def display_panes( proc = self.server.cmd("display-panes", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "display-panes") def choose_buffer(self) -> None: """Enter buffer chooser via ``$ tmux choose-buffer``. @@ -1631,8 +1672,7 @@ def choose_buffer(self) -> None: """ proc = self.cmd("choose-buffer") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "choose-buffer") def choose_client(self) -> None: """Enter client chooser via ``$ tmux choose-client``. @@ -1641,8 +1681,7 @@ def choose_client(self) -> None: """ proc = self.cmd("choose-client") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "choose-client") def choose_tree( self, @@ -1704,8 +1743,7 @@ def choose_tree( proc = self.cmd("choose-tree", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "choose-tree") def customize_mode(self) -> None: """Enter customize mode via ``$ tmux customize-mode``. @@ -1714,8 +1752,7 @@ def customize_mode(self) -> None: """ proc = self.cmd("customize-mode") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "customize-mode") def find_window( self, @@ -1771,8 +1808,7 @@ def find_window( proc = self.cmd("find-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "find-window") def send_prefix(self, *, secondary: bool | None = None) -> None: """Send the prefix key to the pane via ``$ tmux send-prefix``. @@ -1793,8 +1829,7 @@ def send_prefix(self, *, secondary: bool | None = None) -> None: proc = self.cmd("send-prefix", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "send-prefix") def respawn( self, @@ -1841,8 +1876,7 @@ def respawn( proc = self.cmd("respawn-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "respawn-pane") def move( self, @@ -1913,8 +1947,7 @@ def move( # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("move-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "move-pane") def join( self, @@ -1985,8 +2018,7 @@ def join( # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("join-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "join-pane") def break_pane( self, @@ -2028,8 +2060,7 @@ def break_pane( # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("break-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "break-pane") window_id = proc.stdout[0].strip() @@ -2109,8 +2140,7 @@ def swap( proc = self.cmd("swap-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "swap-pane") def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: """Clear pane history buffer via ``$ tmux clear-history``. @@ -2139,8 +2169,7 @@ def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: proc = self.cmd("clear-history", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "clear-history") def clear(self) -> Pane: """Clear pane.""" @@ -2148,8 +2177,21 @@ def clear(self) -> Pane: return self def reset(self) -> Pane: - """Reset and clear pane history.""" - self.cmd("send-keys", r"-R \; clear-history") + r"""Reset terminal state and clear pane history. + + Issues ``send-keys -R`` to reset the pane's terminal state, then + ``clear-history`` to drop its scrollback. + + Examples + -------- + >>> pane.send_keys('echo "for the history"') + >>> 'for the history' in '\n'.join(pane.capture_pane(start=-100, end=-1)) + True + >>> pane.reset() + Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + """ + self.cmd("send-keys", "-R") + self.cmd("clear-history") return self # diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c0ad1dc50..b82349687 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -17,7 +17,8 @@ from libtmux import exc from libtmux._internal.query_list import QueryList -from libtmux.common import has_gte_version, tmux_cmd +from libtmux.client import Client +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -419,8 +420,7 @@ def kill_session(self, target_session: str | int) -> Server: """ proc = self.cmd("kill-session", target=target_session) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-session") return self @@ -432,8 +432,10 @@ def run_shell( delay: str | None = None, as_tmux_command: bool | None = None, target_pane: str | None = None, + cwd: StrPath | None = None, + show_stderr: bool | None = None, ) -> list[str] | None: - """Execute a shell command via ``$ tmux run-shell``. + r"""Execute a shell command via ``$ tmux run-shell``. Parameters ---------- @@ -448,6 +450,25 @@ def run_shell( (``-C`` flag). target_pane : str, optional Target pane for output (``-t`` flag). + cwd : str or PathLike, optional + Start directory for the shell command (``-c`` flag). When + omitted, tmux uses the target client's current working + directory. Requires tmux 3.4+; on older tmux a warning is + emitted and the kwarg is ignored. + + Note: tmux's ``-c`` is a *start directory*, not subprocess + semantics. If ``chdir(cwd)`` fails, tmux falls back to the + user's home directory, then to ``/``, rather than raising + — unlike Python's ``subprocess.Popen(cwd=)`` which errors + on a failed chdir. + + .. versionadded:: 0.57 + show_stderr : bool, optional + Combine the command's stderr into the captured output stream + (``-E`` flag, maps to ``JOB_SHOWSTDERR``). Requires tmux 3.6+; + on older tmux a warning is emitted and the kwarg is ignored. + + .. versionadded:: 0.57 Returns ------- @@ -475,12 +496,29 @@ def run_shell( if target_pane is not None: tmux_args += ("-t", target_pane) + if cwd is not None: + if has_gte_version("3.4", tmux_bin=self.tmux_bin): + tmux_args += ("-c", str(cwd)) + else: + warnings.warn( + "cwd requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if show_stderr: + if has_gte_version("3.6", tmux_bin=self.tmux_bin): + tmux_args += ("-E",) + else: + warnings.warn( + "show_stderr requires tmux 3.6+, ignoring", + stacklevel=2, + ) + tmux_args += (command,) proc = self.cmd("run-shell", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "run-shell") if background: return None @@ -528,8 +566,7 @@ def wait_for( proc = self.cmd("wait-for", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "wait-for") def bind_key( self, @@ -575,8 +612,7 @@ def bind_key( proc = self.cmd("bind-key", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "bind-key") def unbind_key( self, @@ -620,8 +656,7 @@ def unbind_key( proc = self.cmd("unbind-key", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "unbind-key") def list_keys( self, @@ -653,8 +688,7 @@ def list_keys( proc = self.cmd("list-keys", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "list-keys") return proc.stdout @@ -684,8 +718,7 @@ def list_commands(self, *, command_name: str | None = None) -> list[str]: proc = self.cmd("list-commands", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "list-commands") return proc.stdout @@ -699,8 +732,7 @@ def lock_server(self) -> None: """ proc = self.cmd("lock-server") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "lock-server") def server_access( self, @@ -771,8 +803,7 @@ def server_access( proc = self.cmd("server-access", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "server-access") if list_access: return proc.stdout @@ -800,8 +831,7 @@ def refresh_client(self, *, target_client: str | None = None) -> None: proc = self.cmd("refresh-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "refresh-client") def suspend_client(self, *, target_client: str | None = None) -> None: """Suspend a client via ``$ tmux suspend-client``. @@ -825,8 +855,7 @@ def suspend_client(self, *, target_client: str | None = None) -> None: proc = self.cmd("suspend-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "suspend-client") def lock_client(self, *, target_client: str | None = None) -> None: """Lock a client via ``$ tmux lock-client``. @@ -850,8 +879,7 @@ def lock_client(self, *, target_client: str | None = None) -> None: proc = self.cmd("lock-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "lock-client") def detach_client( self, @@ -900,8 +928,7 @@ def detach_client( proc = self.cmd("detach-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "detach-client") def detach_all_clients( self, @@ -948,8 +975,7 @@ def detach_all_clients( proc = self.cmd("detach-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "detach-client") def confirm_before( self, @@ -1035,8 +1061,7 @@ def confirm_before( proc = self.cmd("confirm-before", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "confirm-before") def command_prompt( self, @@ -1168,8 +1193,7 @@ def command_prompt( proc = self.cmd("command-prompt", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "command-prompt") def display_menu( self, @@ -1321,8 +1345,7 @@ def display_menu( proc = self.cmd("display-menu", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "display-menu") def start_server(self) -> None: """Start the tmux server via ``$ tmux start-server``. @@ -1334,8 +1357,7 @@ def start_server(self) -> None: """ proc = self.cmd("start-server") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "start-server") def show_messages( self, @@ -1390,11 +1412,170 @@ def show_messages( proc = self.cmd("show-messages", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "show-messages") return proc.stdout + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[True], + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> list[str]: ... + + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[False] = ..., + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> None: ... + + def display_message( + self, + cmd: str, + get_text: bool = False, + *, + format_string: str | None = None, + all_formats: bool | None = None, + verbose: bool | None = None, + no_expand: bool | None = None, + target_client: str | None = None, + delay: int | None = None, + notify: bool | None = None, + ) -> list[str] | None: + """Display message at server scope via ``$ tmux display-message``. + + Like :meth:`Pane.display_message` but without ``-t `` injection. + tmux's ``cmd-display-message`` entry uses ``CMD_FIND_CANFAIL`` so the + target is optional; server-scoped format reads (``#{version}``, + ``#{socket_path}``, ``#{pid}``) resolve without a specific pane handle. + + With no client attached and ``target_client`` omitted, the status-line + path (``get_text=False``) raises ``no current client``. Use + ``get_text=True`` for headless reads, or pair with + :class:`~libtmux._internal.control_mode.ControlMode`. + + Parameters + ---------- + cmd : str + Format string to display or evaluate (e.g. ``"#{version}"``). + Pass ``""`` together with ``all_formats=True`` to dump every + variable. + + .. versionadded:: 0.57 + get_text : bool, optional + Return tmux's stdout instead of rendering to the status line + (``-p`` flag). + + .. versionadded:: 0.57 + format_string : str, optional + Alternative format template (``-F`` flag). + + .. versionadded:: 0.57 + all_formats : bool, optional + List all format variables (``-a`` flag). + + .. versionadded:: 0.57 + verbose : bool, optional + Show format variable types (``-v`` flag). + + .. versionadded:: 0.57 + no_expand : bool, optional + Output the literal string without format expansion (``-l`` flag). + Requires tmux 3.4+. + + .. versionadded:: 0.57 + target_client : str, optional + Target client (``-c`` flag). + + .. versionadded:: 0.57 + delay : int, optional + Display time in milliseconds (``-d`` flag). + + .. versionadded:: 0.57 + notify : bool, optional + Do not wait for input (``-N`` flag). + + .. versionadded:: 0.57 + + Returns + ------- + list[str] | None + Message output if ``get_text`` is True, otherwise ``None``. + + Examples + -------- + Read tmux version without needing a pane handle: + + >>> result = server.display_message("#{version}", get_text=True) + >>> isinstance(result, list) and len(result) == 1 + True + + Dump every format variable: + + >>> result = server.display_message("", get_text=True, all_formats=True) + >>> any("session_name=" in line for line in result) + True + """ + tmux_args: tuple[str, ...] = () + + if get_text: + tmux_args += ("-p",) + + if all_formats: + tmux_args += ("-a",) + + if verbose: + tmux_args += ("-v",) + + if no_expand: + if has_gte_version("3.4", tmux_bin=self.tmux_bin): + tmux_args += ("-l",) + else: + warnings.warn( + "no_expand requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if notify: + tmux_args += ("-N",) + + if target_client is not None: + tmux_args += ("-c", target_client) + + if delay is not None: + tmux_args += ("-d", str(delay)) + + if format_string is not None: + tmux_args += ("-F", format_string) + + if cmd: + tmux_args += (cmd,) + + proc = self.cmd("display-message", *tmux_args) + raise_if_stderr(proc, "display-message") + + if get_text: + return proc.stdout + + return None + def show_prompt_history( self, *, @@ -1434,8 +1615,7 @@ def show_prompt_history( proc = self.cmd("show-prompt-history", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "show-prompt-history") return proc.stdout @@ -1469,8 +1649,7 @@ def clear_prompt_history( proc = self.cmd("clear-prompt-history", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "clear-prompt-history") def set_buffer( self, @@ -1508,8 +1687,7 @@ def set_buffer( proc = self.cmd("set-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "set-buffer") def show_buffer(self, *, buffer_name: str | None = None) -> str: """Show content of a paste buffer via ``$ tmux show-buffer``. @@ -1537,8 +1715,7 @@ def show_buffer(self, *, buffer_name: str | None = None) -> str: proc = self.cmd("show-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "show-buffer") return "\n".join(proc.stdout) @@ -1563,8 +1740,7 @@ def delete_buffer(self, *, buffer_name: str | None = None) -> None: proc = self.cmd("delete-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "delete-buffer") def save_buffer( self, @@ -1603,8 +1779,7 @@ def save_buffer( proc = self.cmd("save-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "save-buffer") def load_buffer( self, @@ -1637,28 +1812,80 @@ def load_buffer( proc = self.cmd("load-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "load-buffer") - def list_buffers(self) -> list[str]: + def list_buffers( + self, + *, + format_string: str | None = None, + filter: str | None = None, # noqa: A002 + ) -> list[str]: """List paste buffers via ``$ tmux list-buffers``. + Without arguments returns tmux's default template + (``name: N bytes: "sample"``) — kept for backward compatibility. + Pass *format_string* to project a specific tmux format, or *filter* + to push a format-expression predicate into tmux's C-side evaluation + (avoids parsing the default template in Python). + + Parameters + ---------- + format_string : str, optional + Output template (``-F`` flag). Example: ``"#{buffer_name}"`` for + raw names only. + + .. versionadded:: 0.57 + filter : str, optional + Filter expression evaluated by tmux's format engine (``-f`` flag). + Buffers for which the expanded expression is "false" (empty, 0) + are omitted. Example: ``"#{m:libtmux_mcp_*,#{buffer_name}}"``. + + Note: this kwarg shadows the Python builtin ``filter`` by design — + it mirrors tmux's own flag name (``-f filter``) for grep-friendly + symmetry between the wrapper and the tmux manual. + + .. versionadded:: 0.57 + Returns ------- list[str] - Raw output lines from list-buffers. + Raw output lines. Examples -------- + Default template (backward-compatible): + >>> server.set_buffer('buf_data') >>> result = server.list_buffers() >>> len(result) >= 1 True + + Project just the names: + + >>> server.set_buffer('hello', buffer_name='gap6_demo') + >>> 'gap6_demo' in server.list_buffers(format_string='#{buffer_name}') + True + + Filter via tmux's format engine: + + >>> matches = server.list_buffers( + ... format_string='#{buffer_name}', + ... filter='#{m:gap6_*,#{buffer_name}}', + ... ) + >>> 'gap6_demo' in matches + True """ - proc = self.cmd("list-buffers") + tmux_args: tuple[str, ...] = () - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + if format_string is not None: + tmux_args += ("-F", format_string) + + if filter is not None: + tmux_args += ("-f", filter) + + proc = self.cmd("list-buffers", *tmux_args) + + raise_if_stderr(proc, "list-buffers") return proc.stdout @@ -1707,8 +1934,7 @@ def if_shell( proc = self.cmd("if-shell", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "if-shell") def source_file( self, @@ -1753,8 +1979,7 @@ def source_file( proc = self.cmd("source-file", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "source-file") def list_clients(self) -> list[str]: """List connected clients via ``$ tmux list-clients``. @@ -1771,8 +1996,7 @@ def list_clients(self) -> list[str]: """ proc = self.cmd("list-clients") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "list-clients") return proc.stdout @@ -1792,8 +2016,7 @@ def switch_client(self, target_session: str) -> None: proc = self.cmd("switch-client", target=target_session) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "switch-client") def attach_session(self, target_session: str | None = None) -> None: """Attach tmux session. @@ -1810,8 +2033,7 @@ def attach_session(self, target_session: str | None = None) -> None: session_check_name(target_session) proc = self.cmd("attach-session", target=target_session) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "attach-session") def new_session( self, @@ -1919,8 +2141,7 @@ def new_session( if self.has_session(session_name): if kill_session: proc = self.cmd("kill-session", target=session_name) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-session") logger.info( "existing session killed", extra={ @@ -1991,8 +2212,7 @@ def new_session( proc = self.cmd("new-session", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "new-session") session_stdout = proc.stdout[0] @@ -2075,6 +2295,166 @@ def panes(self) -> QueryList[Pane]: return QueryList(panes) + @property + def clients(self) -> QueryList[Client]: + """Clients attached to this tmux server. + + Each attached terminal is a separate :class:`Client`. ``server.clients`` + returns the typed view; ``client.client_readonly``, ``client.client_termtype``, + ``client.client_session`` etc. read tmux's ``client_*`` format tokens. + + Returns + ------- + :class:`~libtmux._internal.query_list.QueryList` of :class:`Client` + + Examples + -------- + >>> with control_mode() as ctl: + ... names = [c.client_name for c in server.clients] + ... ctl.client_name in names + True + """ + clients: list[Client] = [] + try: + for obj in fetch_objs(list_cmd="list-clients", server=self): + clients.append(Client(server=self, **obj)) # noqa: PERF401 + except Exception: + pass + + return QueryList(clients) + + def search_sessions( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Session]: + """Sessions, optionally filtered by tmux's C-side format predicate. + + Like :attr:`Server.sessions` but adds an optional ``filter`` kwarg + that is plumbed through to ``$ tmux list-sessions -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). Sessions for which the + expanded expression is "false" are omitted by tmux itself before + any Python object is built. + + .. versionadded:: 0.57 + + Returns + ------- + :class:`~libtmux._internal.query_list.QueryList` of :class:`Session` + + Examples + -------- + >>> server.new_session(session_name='gap7_alpha') + Session($... gap7_alpha) + >>> server.new_session(session_name='other_beta') + Session($... other_beta) + >>> matches = server.search_sessions(filter='#{m:gap7_*,#{session_name}}') + >>> [s.session_name for s in matches] + ['gap7_alpha'] + """ + sessions: list[Session] = [] + + try: + for obj in fetch_objs( + list_cmd="list-sessions", + server=self, + filter=filter, + ): + sessions.append(Session(server=self, **obj)) # noqa: PERF401 + except Exception: + pass + + return QueryList(sessions) + + def search_windows( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Window]: + """All windows across sessions, optionally filtered by tmux's C-side predicate. + + Like :attr:`Server.windows` but with a ``filter`` kwarg plumbed to + ``$ tmux list-windows -a -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> sess = server.new_session(session_name='gap7_win_demo') + >>> _ = sess.new_window(window_name='gap7_target') + >>> _ = sess.new_window(window_name='other_window') + >>> matches = server.search_windows(filter='#{m:gap7_*,#{window_name}}') + >>> any(w.window_name == 'gap7_target' for w in matches) + True + >>> any(w.window_name == 'other_window' for w in matches) + False + """ + windows: list[Window] = [ + Window(server=self, **obj) + for obj in fetch_objs( + list_cmd="list-windows", + list_extra_args=("-a",), + server=self, + filter=filter, + ) + ] + + return QueryList(windows) + + def search_panes( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Pane]: + """All panes across the server, optionally filtered by tmux's C-side predicate. + + Like :attr:`Server.panes` but with a ``filter`` kwarg plumbed to + ``$ tmux list-panes -a -f ``. This is the typed entry point + for the fast-path libtmux-mcp uses for ``search_panes``: tmux drops + non-matching panes before any Python object is constructed. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). Example: + ``'#{m:%5,#{pane_id}}'`` (id match) or + ``'#{C/i:libtmux,#{pane_current_command}}'`` (case-insensitive + substring on the current command). + + .. versionadded:: 0.57 + + Examples + -------- + >>> sess = server.new_session(session_name='gap7_pane_demo') + >>> window = sess.active_window + >>> target_pane = window.split() + >>> matches = server.search_panes( + ... filter=f'#{{m:{target_pane.pane_id},#{{pane_id}}}}' + ... ) + >>> [p.pane_id for p in matches] == [target_pane.pane_id] + True + """ + panes: list[Pane] = [ + Pane(server=self, **obj) + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=("-a",), + server=self, + filter=filter, + ) + ] + + return QueryList(panes) + # # Dunder # diff --git a/src/libtmux/session.py b/src/libtmux/session.py index daeb2593b..dca283bce 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -13,7 +13,7 @@ import typing as t from libtmux._internal.query_list import QueryList -from libtmux.common import tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import WINDOW_DIRECTION_FLAG_MAP, OptionScope, WindowDirection from libtmux.formats import FORMAT_SEPARATOR from libtmux.hooks import HooksMixin @@ -193,6 +193,83 @@ def panes(self) -> QueryList[Pane]: return QueryList(panes) + def search_windows( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Window]: + """Windows in this session, optionally filtered by tmux's C-side predicate. + + Like :attr:`Session.windows` but with a ``filter`` kwarg plumbed to + ``$ tmux list-windows -t -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> _ = session.new_window(window_name='gap7s_target') + >>> _ = session.new_window(window_name='other_window') + >>> matches = session.search_windows(filter='#{m:gap7s_*,#{window_name}}') + >>> [w.window_name for w in matches] + ['gap7s_target'] + """ + windows: list[Window] = [ + Window(server=self.server, **obj) + for obj in fetch_objs( + list_cmd="list-windows", + list_extra_args=["-t", str(self.session_id)], + server=self.server, + filter=filter, + ) + if obj.get("session_id") == self.session_id + ] + + return QueryList(windows) + + def search_panes( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Pane]: + """Panes in this session, optionally filtered by tmux's C-side predicate. + + Like :attr:`Session.panes` but with a ``filter`` kwarg plumbed to + ``$ tmux list-panes -s -t -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> target_pane = session.active_window.split() + >>> matches = session.search_panes( + ... filter=f'#{{m:{target_pane.pane_id},#{{pane_id}}}}' + ... ) + >>> [p.pane_id for p in matches] == [target_pane.pane_id] + True + """ + panes: list[Pane] = [ + Pane(server=self.server, **obj) + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=["-s", "-t", str(self.session_id)], + server=self.server, + filter=filter, + ) + if obj.get("session_id") == self.session_id + ] + + return QueryList(panes) + # # Command # @@ -252,8 +329,7 @@ def lock_session(self) -> None: """ proc = self.cmd("lock-session") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "lock-session") def detach_client( self, @@ -292,8 +368,7 @@ def detach_client( proc = self.server.cmd("detach-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "detach-client") def last_window(self) -> Window: """Select the last (previously selected) window. @@ -314,8 +389,7 @@ def last_window(self) -> Window: """ proc = self.cmd("last-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "last-window") return self.active_window @@ -337,8 +411,7 @@ def next_window(self) -> Window: """ proc = self.cmd("next-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "next-window") return self.active_window @@ -360,8 +433,7 @@ def previous_window(self) -> Window: """ proc = self.cmd("previous-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "previous-window") return self.active_window @@ -391,8 +463,7 @@ def select_window(self, target_window: str | int) -> Window: proc = self.cmd("select-window", target=target) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-window") return self.active_window @@ -446,8 +517,7 @@ def attach( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "attach-session") return self @@ -512,8 +582,7 @@ def kill( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-session") msg = "other sessions killed" if all_except else "session killed" extra: dict[str, str] = { @@ -534,8 +603,7 @@ def switch_client(self) -> Session: """ proc = self.cmd("switch-client") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "switch-client") return self @@ -555,8 +623,7 @@ def rename_session(self, new_name: str) -> Session: proc = self.cmd("rename-session", new_name) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "rename-session") self.refresh() @@ -708,8 +775,7 @@ def new_window( cmd = self.cmd("new-window", *window_args, target=target) - if cmd.stderr: - raise exc.LibTmuxException(cmd.stderr) + raise_if_stderr(cmd, "new-window") window_output = cmd.stdout[0] @@ -761,8 +827,7 @@ def kill_window(self, target_window: str | int | None = None) -> None: proc = self.cmd("kill-window", target=target) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-window") extra: dict[str, str] = { "tmux_subcommand": "kill-window", diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 1e6afd062..3d9147811 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -15,7 +15,7 @@ import warnings from libtmux._internal.query_list import QueryList -from libtmux.common import tmux_cmd +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import ( RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, OptionScope, @@ -197,6 +197,45 @@ def panes(self) -> QueryList[Pane]: return QueryList(panes) + def search_panes( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Pane]: + """Panes in this window, optionally filtered by tmux's C-side predicate. + + Like :attr:`Window.panes` but with a ``filter`` kwarg plumbed to + ``$ tmux list-panes -t -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> target_pane = window.split() + >>> matches = window.search_panes( + ... filter=f'#{{m:{target_pane.pane_id},#{{pane_id}}}}' + ... ) + >>> [p.pane_id for p in matches] == [target_pane.pane_id] + True + """ + panes: list[Pane] = [ + Pane(server=self.server, **obj) + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=["-t", str(self.window_id)], + server=self.server, + filter=filter, + ) + if obj.get("window_id") == self.window_id + ] + + return QueryList(panes) + """ Commands (pane-scoped) """ @@ -262,8 +301,7 @@ def select_pane(self, target_pane: str | int) -> Pane | None: else: proc = self.cmd("select-pane", target=target_pane) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-pane") return self.active_pane @@ -404,8 +442,7 @@ def resize( proc = self.cmd("resize-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "resize-window") self.refresh() return self @@ -460,8 +497,7 @@ def last_pane( proc = self.cmd("last-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "last-pane") return self.active_pane @@ -549,8 +585,7 @@ def select_layout( proc = self.cmd(*cmd) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-layout") return self @@ -564,8 +599,7 @@ def next_layout(self) -> Window: """ proc = self.cmd("next-layout") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "next-layout") return self @@ -579,8 +613,7 @@ def previous_layout(self) -> Window: """ proc = self.cmd("previous-layout") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "previous-layout") return self @@ -647,8 +680,7 @@ def link( proc = self.server.cmd("link-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "link-window") def unlink(self, *, kill_if_last: bool | None = None) -> None: """Unlink this window from the current session via ``$ tmux unlink-window``. @@ -673,8 +705,7 @@ def unlink(self, *, kill_if_last: bool | None = None) -> None: proc = self.cmd("unlink-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "unlink-window") def rotate( self, @@ -719,8 +750,7 @@ def rotate( proc = self.cmd("rotate-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "rotate-window") return self @@ -769,8 +799,7 @@ def respawn( proc = self.cmd("respawn-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "respawn-window") def swap( self, @@ -809,13 +838,168 @@ def swap( proc = self.cmd("swap-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "swap-window") self.refresh() if isinstance(target, Window): target.refresh() + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[True], + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> list[str]: ... + + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[False] = ..., + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> None: ... + + def display_message( + self, + cmd: str, + get_text: bool = False, + *, + format_string: str | None = None, + all_formats: bool | None = None, + verbose: bool | None = None, + no_expand: bool | None = None, + target_client: str | None = None, + delay: int | None = None, + notify: bool | None = None, + ) -> list[str] | None: + """Display message at window scope via ``$ tmux display-message``. + + Like :meth:`Pane.display_message` but auto-injects ``-t @`` + instead of a pane id. Window-scoped format reads such as + ``#{window_zoomed_flag}`` or ``#{window_active_clients}`` no longer + require dropping to :meth:`Window.cmd`. + + Parameters + ---------- + cmd : str + Format string to display or evaluate (e.g. + ``"#{window_zoomed_flag}"``). + + .. versionadded:: 0.57 + get_text : bool, optional + Return tmux's stdout instead of rendering to the status line + (``-p`` flag). + + .. versionadded:: 0.57 + format_string : str, optional + Alternative format template (``-F`` flag). + + .. versionadded:: 0.57 + all_formats : bool, optional + List all format variables (``-a`` flag). + + .. versionadded:: 0.57 + verbose : bool, optional + Show format variable types (``-v`` flag). + + .. versionadded:: 0.57 + no_expand : bool, optional + Output the literal string without format expansion (``-l`` flag). + Requires tmux 3.4+. + + .. versionadded:: 0.57 + target_client : str, optional + Target client (``-c`` flag). + + .. versionadded:: 0.57 + delay : int, optional + Display time in milliseconds (``-d`` flag). + + .. versionadded:: 0.57 + notify : bool, optional + Do not wait for input (``-N`` flag). + + .. versionadded:: 0.57 + + Returns + ------- + list[str] | None + Message output if ``get_text`` is True, otherwise ``None``. + + Examples + -------- + Read the window's id format: + + >>> result = window.display_message("#{window_id}", get_text=True) + >>> result[0].startswith("@") + True + + Check zoom state (a common gap-#670 use case): + + >>> result = window.display_message( + ... "#{window_zoomed_flag}", get_text=True + ... ) + >>> result[0] in {"0", "1"} + True + """ + tmux_args: tuple[str, ...] = () + + if get_text: + tmux_args += ("-p",) + + if all_formats: + tmux_args += ("-a",) + + if verbose: + tmux_args += ("-v",) + + if no_expand: + if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + tmux_args += ("-l",) + else: + warnings.warn( + "no_expand requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if notify: + tmux_args += ("-N",) + + if target_client is not None: + tmux_args += ("-c", target_client) + + if delay is not None: + tmux_args += ("-d", str(delay)) + + if format_string is not None: + tmux_args += ("-F", format_string) + + if cmd: + tmux_args += (cmd,) + + proc = self.cmd("display-message", *tmux_args) + raise_if_stderr(proc, "display-message") + + if get_text: + return proc.stdout + + return None + def rename_window(self, new_name: str) -> Window: """Rename window. @@ -839,8 +1023,7 @@ def rename_window(self, new_name: str) -> Window: lex.whitespace_split = False proc = self.cmd("rename-window", new_name) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "rename-window") self.window_name = new_name self.refresh() @@ -906,8 +1089,7 @@ def kill( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-window") msg = "other windows killed" if all_except else "window killed" extra: dict[str, str] = { @@ -998,8 +1180,7 @@ def move_window( target=f"{session}:{destination}", ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "move-window") self.refresh() @@ -1088,8 +1269,7 @@ def select(self) -> Window: """ proc = self.cmd("select-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-window") self.refresh() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..6b8a98369 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""Test-suite-wide fixtures for libtmux's own tests.""" + +from __future__ import annotations + +import pytest + +from libtmux.common import get_version + + +@pytest.fixture(autouse=True) +def _clear_get_version_cache() -> None: + """Flush get_version's @functools.cache before each test. + + Several tests in test_common.py and legacy_api/test_common.py + monkey-patch libtmux.common.tmux_cmd then call get_version() to + assert parsed-version behavior. With memoization, a prior test's + cached result would mask the mock — this fixture guarantees a + fresh subprocess lookup per test. + """ + get_version.cache_clear() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 000000000..312574b65 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,81 @@ +"""Tests for libtmux Client object.""" + +from __future__ import annotations + +import typing as t + +from libtmux.client import Client + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def test_server_clients_returns_querylist( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``Server.clients`` lists every attached tmux client as a :class:`Client`.""" + with control_mode(): + clients = server.clients + assert len(clients) >= 1 + for client in clients: + assert isinstance(client, Client) + assert client.client_name is not None + + +def test_client_session_reports_attached_session( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.client_session`` reports the session this client is attached to.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.client_session == session.session_name + + +def test_client_readonly_default_zero( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """A non-readonly attached client reports ``client_readonly == "0"``.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.client_readonly == "0" + + +def test_client_refresh_rehydrates_fields( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``Client.refresh()`` repopulates fields from tmux's live state.""" + with control_mode() as ctl: + client = Client.from_client_name(server=server, client_name=ctl.client_name) + assert client.client_name == ctl.client_name + + # Stash and clear a field, then refresh: it must come back. + original_pid = client.client_pid + client.client_pid = None + client.refresh() + assert client.client_pid == original_pid + + +def test_clients_property_hydrates_cross_scope( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``Server.clients`` hydrates the client's active session/window/pane. + + Exercises the ``list-clients`` path. tmux's ``format_defaults`` cascades + via ``c->session`` → ``s->curw`` → ``wl->window->active``, so a Client + object must surface ``session_id``, ``window_id``, and ``pane_id``. + """ + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.session_id is not None + assert client.window_id is not None + assert client.pane_id is not None diff --git a/tests/test_common.py b/tests/test_common.py index f9c6c0968..bbb58006e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -39,6 +39,130 @@ def test_has_version() -> None: assert has_version(str(get_version())) +def test_get_version_is_memoized_for_same_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two calls with the same tmux_bin fork tmux -V once. + + Validates the @functools.cache contract: identical-arg calls hit the + cache after the first miss. + """ + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + v1 = get_version() + v2 = get_version() + + assert v1 == v2 + assert call_count["n"] == 1 + + +def test_get_version_cache_keyed_by_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Different tmux_bin args cache independently; same arg revisits hit.""" + call_count = {"n": 0} + versions = {"/path/a/tmux": "tmux 3.4", "/path/b/tmux": "tmux 3.6a"} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc(versions[kwargs["tmux_bin"]]) + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + a1 = get_version(tmux_bin="/path/a/tmux") + b1 = get_version(tmux_bin="/path/b/tmux") + a2 = get_version(tmux_bin="/path/a/tmux") + + assert a1 != b1 + assert a1 == a2 + assert call_count["n"] == 2 # /a once, /b once, /a hits cache + + +def test_get_version_cache_clear_invalidates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cache_clear() forces a fresh subprocess on the next call.""" + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + get_version() + get_version() + assert call_count["n"] == 1 + + get_version.cache_clear() + get_version() + assert call_count["n"] == 2 + + +def test_get_version_binary_swap_requires_explicit_cache_clear( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Documents the sticky-cache trap when tmux_bin=None and PATH changes. + + Simulates a user upgrading tmux mid-process: two consecutive + ``get_version()`` calls with ``tmux_bin=None`` see different + underlying binaries, but the cache pins the first answer. The + escape hatch is ``get_version.cache_clear()`` — this test asserts + the trap is real and the escape hatch works. + """ + versions = ["tmux 3.2a", "tmux 3.6a"] + call_count = {"n": 0} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + proc = _MockProc(versions[call_count["n"]]) + call_count["n"] += 1 + return proc + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + first = get_version() + assert str(first) == "3.2" + + # "Binary swap" — PATH changed, but cache is sticky. + second = get_version() + assert str(second) == "3.2" # Stale: still the cached 3.2a. + assert call_count["n"] == 1 # No fresh subprocess. + + # Escape hatch. + get_version.cache_clear() + third = get_version() + assert str(third) == "3.6" # Fresh lookup. + assert call_count["n"] == 2 + + def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: """Verify raises if tmux command not found.""" monkeypatch.setenv("PATH", "") @@ -48,7 +172,7 @@ def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: def test_tmux_cmd_unicode(session: Session) -> None: """Verify tmux commands with unicode.""" - session.cmd("new-window", "-t", 3, "-n", "юникод", "-F", "Ελληνικά") + session.cmd("new-window", "-n", "юникод", "-F", "Ελληνικά", target=3) class SessionCheckName(t.NamedTuple): @@ -526,3 +650,45 @@ def test_tmux_cmd_pre_execution_logging( ] assert len(running_records) > 0 assert "list-sessions" in running_records[0].tmux_cmd + + +def test_libtmux_exception_subcommand_default_none() -> None: + """Backward-compat: existing call sites (no kwarg) get subcommand=None.""" + err = exc.LibTmuxException(["no last window"]) + assert err.subcommand is None + # str(err) reproduces only the args, preserving pre-0.57 shape. + assert "no last window" in str(err) + assert not str(err).startswith(":") + + +def test_libtmux_exception_subcommand_tags_str() -> None: + """When ``subcommand`` is set, str(exc) prefixes ``": …"``.""" + err = exc.LibTmuxException(["no last window"], subcommand="last-window") + assert err.subcommand == "last-window" + assert str(err).startswith("last-window:") + assert "no last window" in str(err) + + +def test_raise_if_stderr_no_stderr_is_noop(session: libtmux.Session) -> None: + """``raise_if_stderr`` returns silently when proc.stderr is empty.""" + from libtmux.common import raise_if_stderr + + proc = session.cmd("display-message", "-p", "#{version}") + raise_if_stderr(proc, "display-message") # must not raise + + +def test_raise_if_stderr_raises_with_subcommand_tag( + session: libtmux.Session, +) -> None: + """``raise_if_stderr`` raises ``LibTmuxException`` tagged with subcommand.""" + from libtmux.common import raise_if_stderr + + # Provoke a tmux stderr: ask list-clients with a non-existent target. + proc = session.server.cmd("list-clients", "-t", "$nonexistent_session_id_for_test") + assert proc.stderr # sanity check the fixture + + with pytest.raises(exc.LibTmuxException) as excinfo: + raise_if_stderr(proc, "list-clients") + + assert excinfo.value.subcommand == "list-clients" + assert str(excinfo.value).startswith("list-clients:") diff --git a/tests/test_pane.py b/tests/test_pane.py index 3eeff4625..6cb0bf21f 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -547,6 +547,167 @@ def test_send_keys_flags( assert not_expected_in_capture not in contents +def test_send_keys_flag_only_reset_emits_clean_argv( + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """``send_keys(reset=True)`` (no positional) emits ``send-keys -R`` only. + + tmux's flag-only path (``cmd-send-keys.c:223-225``) supports ``-R`` and + ``-N`` without any trailing key argument; ``cmd=None`` routes through + that path so the emitted argv has no spurious empty string. + """ + pane = session.active_window.active_pane + assert pane is not None + + captured: list[tuple[str, ...]] = [] + real_cmd = pane.cmd + + def fake_cmd(cmd_name: str, *args: t.Any, **kw: t.Any) -> t.Any: + captured.append((cmd_name, *(str(a) for a in args))) + return real_cmd(cmd_name, *args, **kw) + + monkeypatch.setattr(pane, "cmd", fake_cmd) + + pane.send_keys(reset=True) + + send_keys_calls = [c for c in captured if c[0] == "send-keys"] + assert send_keys_calls == [("send-keys", "-R")] + + +def test_send_keys_flag_only_repeat_emits_dash_N( + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """``send_keys(repeat=3)`` flag-only emits ``send-keys -N 3`` only.""" + pane = session.active_window.active_pane + assert pane is not None + + captured: list[tuple[str, ...]] = [] + real_cmd = pane.cmd + + def fake_cmd(cmd_name: str, *args: t.Any, **kw: t.Any) -> t.Any: + captured.append((cmd_name, *(str(a) for a in args))) + return real_cmd(cmd_name, *args, **kw) + + monkeypatch.setattr(pane, "cmd", fake_cmd) + + pane.send_keys(repeat=3, reset=True) + + send_keys_calls = [c for c in captured if c[0] == "send-keys"] + assert send_keys_calls == [("send-keys", "-R", "-N", "3")] + + +def test_send_keys_flag_only_requires_a_flag(session: Session) -> None: + """``send_keys()`` with neither ``cmd`` nor a flag raises ValueError.""" + pane = session.active_window.active_pane + assert pane is not None + + with pytest.raises(ValueError, match="requires at least one of"): + pane.send_keys() + + +PANE_FORMAT_FIELDS = ( + "pane_dead", + "pane_format", + "pane_in_mode", + "pane_input_off", + "pane_key_mode", + "pane_last", + "pane_marked", + "pane_marked_set", + "pane_mode", + "pane_path", + "pane_pipe", + "pane_synchronized", + "pane_unseen_changes", +) + + +@pytest.mark.parametrize("field_name", PANE_FORMAT_FIELDS) +def test_pane_format_field_declared_and_hydrated( + field_name: str, + session: Session, +) -> None: + """Tmux's pane-scope format tokens hydrate onto the typed ``Pane`` object. + + Verifies each registered ``pane_*`` token from tmux's ``format_table[]`` + has a corresponding typed field on the ``Obj`` dataclass and that + ``refresh()`` populates it. Older tmux releases that don't recognize a + token expand it to the empty string, so the field reads as ``None``. + """ + pane = session.active_window.active_pane + assert pane is not None + + # Field must be declared on the dataclass. + assert field_name in pane.__dataclass_fields__ + + pane.refresh() + value = getattr(pane, field_name) + assert value is None or isinstance(value, str) + + +def test_pane_synchronized_reflects_window_state(session: Session) -> None: + """``pane.pane_synchronized`` flips when synchronize-panes toggles.""" + window = session.active_window + window.split() + pane = window.active_pane + assert pane is not None + + window.set_option("synchronize-panes", "on") + pane.refresh() + assert pane.pane_synchronized == "1" + + window.set_option("synchronize-panes", "off") + pane.refresh() + assert pane.pane_synchronized == "0" + + +PANE_SCOPE_OVERRIDE_FIELDS = ( + "cursor_x", + "cursor_y", + "cursor_flag", + "mouse_all_flag", + "mouse_any_flag", + "mouse_button_flag", + "mouse_sgr_flag", + "mouse_standard_flag", + "scroll_region_lower", + "scroll_region_upper", + "alternate_saved_x", + "alternate_saved_y", + "history_bytes", + "history_limit", + "history_size", + "insert_flag", + "keypad_cursor_flag", + "keypad_flag", + "origin_flag", + "wrap_flag", +) + + +@pytest.mark.parametrize("field_name", PANE_SCOPE_OVERRIDE_FIELDS) +def test_pane_scope_override_field_hydrates( + field_name: str, + session: Session, +) -> None: + """Per-token scope overrides admit each token into list-panes -F. + + These tokens' callbacks all dereference ``ft->wp`` in tmux's + ``format.c`` (verified across tmux 3.2a through master), so the value + must hydrate to a string on every supported tmux version. A ``None`` + here indicates the scope gate excluded the token from the format + string, which is the regression class these overrides prevent. + """ + pane = session.active_window.active_pane + assert pane is not None + pane.refresh() + value = getattr(pane, field_name) + assert value is not None, f"{field_name} should hydrate via list-panes" + assert isinstance(value, str) + + def test_select_pane_direction(session: Session) -> None: """Test Pane.select() with direction flags.""" window = session.new_window(window_name="test_select_dir") @@ -717,6 +878,18 @@ def test_display_message_flags( assert expected_in_output in output +def test_display_message_raises_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``.""" + pane = session.active_window.active_pane + assert pane is not None + + with pytest.raises(exc.LibTmuxException) as excinfo: + pane.display_message("x", get_text=True, format_string="#{pane_id}") + + assert excinfo.value.subcommand == "display-message" + assert "only one of -F or argument" in str(excinfo.value) + + def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" from libtmux.common import has_gte_version @@ -1280,3 +1453,43 @@ def test_clear_history(session: Session) -> None: history = pane.capture_pane(start=-100) # After clearing, scrollback history should be much shorter assert len(history) <= 30 # reasonable bound after clear + + +def test_pane_reset_clears_history_and_sends_reset(session: Session) -> None: + """Pane.reset() runs both ``send-keys -R`` and ``clear-history`` (#650). + + Populates scrollback, calls ``reset()``, then verifies the markers are + gone — proving ``clear-history`` actually ran. + """ + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_reset_650", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) + + # Populate scrollback. + for n in range(5): + pane.send_keys(f"echo reset_marker_{n}", enter=True) + retry_until( + lambda: "reset_marker_4" in "\n".join(pane.capture_pane()), + 3, + raises=True, + ) + + # Sanity-check that the history is non-trivial pre-reset. + pre = pane.capture_pane(start=-100) + assert any("reset_marker_" in line for line in pre) + + result = pane.reset() + assert result is pane + + # After reset, scrollback should be empty — the old code left the markers + # behind because clear-history never executed. + post = pane.capture_pane(start=-100) + assert not any("reset_marker_" in line for line in post) diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5858cbd45..5111a0670 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -525,3 +525,45 @@ def test_capture_pane_to_buffer(session: Session) -> None: contents = session.server.cmd("show-buffer", "-b", "cap_test_buf").stdout assert any("BUFFER_CAPTURE_MARKER" in line for line in contents) session.server.cmd("delete-buffer", "-b", "cap_test_buf") + + +def test_capture_pane_pending_emits_dash_P( + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """``capture_pane(pending=True)`` emits the tmux ``-P`` flag. + + ``-P`` captures pending input — bytes tmux has buffered for the pane + but the program hasn't consumed yet. The argv assertion guarantees the + wrapper routes the kwarg to tmux's flag; whether tmux returns bytes + for any given pane depends on live input pressure and isn't reliably + testable in isolation. + """ + pane = session.active_window.active_pane + assert pane is not None + + captured: list[tuple[str, ...]] = [] + real_cmd = pane.cmd + + def fake_cmd(cmd_name: str, *args: t.Any, **kw: t.Any) -> t.Any: + captured.append((cmd_name, *(str(a) for a in args))) + return real_cmd(cmd_name, *args, **kw) + + monkeypatch.setattr(pane, "cmd", fake_cmd) + + pane.capture_pane(pending=True) + + capture_calls = [c for c in captured if c[0] == "capture-pane"] + assert capture_calls + assert "-P" in capture_calls[0] + # -p (stdout) stays on because to_buffer is None. + assert "-p" in capture_calls[0] + + +def test_capture_pane_pending_returns_list(session: Session) -> None: + """``capture_pane(pending=True)`` returns a list (possibly empty).""" + pane = session.active_window.active_pane + assert pane is not None + + result = pane.capture_pane(pending=True) + assert isinstance(result, list) diff --git a/tests/test_server.py b/tests/test_server.py index 8e9976f18..b76a33739 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -117,6 +117,38 @@ def test_new_session_returns_populated_session(server: Server) -> None: assert session.pane_id is not None +def test_sessions_property_hydrates_cross_scope(server: Server) -> None: + """Server.sessions hydrates active window/pane via tmux's format_defaults cascade. + + Distinct from ``test_new_session_returns_populated_session``: that test + exercises ``new-session -P -F`` (list-panes scope). This one exercises + the ``list-sessions`` path used by the ``.sessions`` property, which + must hydrate downward-cascade tokens (tmux ``format.c:format_defaults`` + walks ``s->curw`` and ``wl->window->active``). + """ + server.new_session(session_name="hydration_cascade_sessions") + fetched = server.sessions.get(session_name="hydration_cascade_sessions") + assert fetched is not None + assert fetched.window_id is not None + assert fetched.pane_id is not None + assert fetched.pane_current_command is not None + + +def test_windows_property_hydrates_active_pane( + server: Server, + session: Session, +) -> None: + """Server.windows hydrates each window's active pane via the cascade. + + Exercises the ``list-windows -a`` path used by the ``.windows`` property. + """ + session.new_window(window_name="hydration_cascade_windows") + fetched = server.windows.get(window_name="hydration_cascade_windows") + assert fetched is not None + assert fetched.pane_id is not None + assert fetched.pane_current_command is not None + + def test_new_session_no_name(server: Server) -> None: """Server.new_session works with no name.""" first_session = server.new_session() @@ -1019,6 +1051,73 @@ def test_run_shell_background(server: Server) -> None: assert result is None +def test_run_shell_cwd(server: Server, tmp_path: pathlib.Path) -> None: + """``cwd=`` sets the working directory for the shell command.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.5"): + pytest.skip("run-shell stdout passthrough requires tmux 3.5+") + + server.new_session(session_name="run_shell_cwd_test") + result = server.run_shell("pwd", cwd=tmp_path) + assert result is not None + assert any(str(tmp_path) in line for line in result) + + +def test_run_shell_show_stderr(server: Server) -> None: + """``show_stderr=True`` captures the command's stderr into the output.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.6"): + pytest.skip("run-shell -E (JOB_SHOWSTDERR) requires tmux 3.6+") + + server.new_session(session_name="run_shell_stderr_test") + result = server.run_shell( + "sh -c 'echo to_stdout; echo to_stderr >&2'", + show_stderr=True, + ) + assert result is not None + joined = "\n".join(result) + assert "to_stdout" in joined + assert "to_stderr" in joined + + +def test_run_shell_cwd_warns_on_old_tmux( + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """``cwd=`` emits a warning and skips ``-c`` on tmux <3.4. + + Simulates older tmux by patching ``has_gte_version`` in the + :mod:`libtmux.server` module namespace (where it's bound at + import time). + """ + import libtmux.server + + monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + server.new_session(session_name="run_shell_cwd_warn_test") + with pytest.warns(UserWarning, match="cwd requires tmux 3.4+"): + server.run_shell("true", cwd=tmp_path) + + +def test_run_shell_show_stderr_warns_on_old_tmux( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``show_stderr=True`` emits a warning and skips ``-E`` on tmux <3.6. + + Simulates older tmux by patching ``has_gte_version`` in the + :mod:`libtmux.server` module namespace. + """ + import libtmux.server + + monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + server.new_session(session_name="run_shell_stderr_warn_test") + with pytest.warns(UserWarning, match="show_stderr requires tmux 3.6+"): + server.run_shell("true", show_stderr=True) + + class BufferCase(t.NamedTuple): """Test case for buffer operations.""" @@ -1144,6 +1243,73 @@ def test_list_buffers(server: Server) -> None: assert len(result) >= 2 +def test_list_buffers_format_returns_raw_names(server: Server) -> None: + """``format_string`` projects raw names instead of the default template.""" + server.new_session(session_name="buf_format") + server.set_buffer("payload_a", buffer_name="fmt_a") + server.set_buffer("payload_b", buffer_name="fmt_b") + + names = server.list_buffers(format_string="#{buffer_name}") + + assert "fmt_a" in names + assert "fmt_b" in names + # Default template would contain "bytes:" — raw projection must not. + assert not any("bytes:" in line for line in names) + + +def test_list_buffers_filter_pushes_predicate_into_tmux(server: Server) -> None: + """``filter=`` pushes the match into tmux's format engine (-f flag). + + Only names matching the predicate come back from tmux; no Python-side + post-filter is needed. + """ + server.new_session(session_name="buf_filter") + server.set_buffer("keep_me", buffer_name="gap6match_alpha") + server.set_buffer("keep_me", buffer_name="gap6match_beta") + server.set_buffer("drop_me", buffer_name="gap6miss_one") + + matches = server.list_buffers( + format_string="#{buffer_name}", + filter="#{m:gap6match_*,#{buffer_name}}", + ) + + assert sorted(matches) == ["gap6match_alpha", "gap6match_beta"] + + +def test_server_search_sessions_filter(server: Server) -> None: + """``Server.list_sessions(filter=...)`` returns only matching sessions.""" + server.new_session(session_name="gap7_keep_alpha") + server.new_session(session_name="gap7_keep_beta") + server.new_session(session_name="other_drop") + + matches = server.search_sessions(filter="#{m:gap7_*,#{session_name}}") + names = sorted(s.session_name for s in matches if s.session_name) + assert names == ["gap7_keep_alpha", "gap7_keep_beta"] + + +def test_server_search_windows_filter(server: Server) -> None: + """``Server.list_windows(filter=...)`` returns only matching windows.""" + sess = server.new_session(session_name="gap7_win_demo") + sess.new_window(window_name="gap7_target") + sess.new_window(window_name="other_window") + + matches = server.search_windows(filter="#{m:gap7_*,#{window_name}}") + names = sorted(w.window_name for w in matches if w.window_name) + # Catch-all base window starts at name 'gap7_win_demo' (matches gap7_*), + # so we expect both the original and the new gap7_target. + assert "gap7_target" in names + assert "other_window" not in names + + +def test_server_search_panes_filter_by_id(server: Server) -> None: + """``Server.list_panes(filter=...)`` returns only the pane id we asked for.""" + sess = server.new_session(session_name="gap7_pane_demo") + target = sess.active_window.split() + + matches = server.search_panes(filter=f"#{{m:{target.pane_id},#{{pane_id}}}}") + assert [p.pane_id for p in matches] == [target.pane_id] + + def test_if_shell_true(server: Server) -> None: """Test Server.if_shell() with true condition.""" server.new_session(session_name="ifshell_test") @@ -1292,3 +1458,146 @@ def test_new_session_client_flags( client_flags="no-output", ) assert session.session_name == "flags_test" + + +class ServerDisplayMessageCase(t.NamedTuple): + """Test case for Server.display_message() flag variations.""" + + test_id: str + cmd: str + kwargs: dict[str, t.Any] + expected_in_output: str | None + min_tmux_version: str | None + + +SERVER_DISPLAY_MESSAGE_CASES: list[ServerDisplayMessageCase] = [ + ServerDisplayMessageCase( + test_id="version", + cmd="#{version}", + kwargs={"get_text": True}, + expected_in_output=".", + min_tmux_version=None, + ), + ServerDisplayMessageCase( + test_id="socket_path_format_string", + cmd="", + kwargs={"get_text": True, "format_string": "#{socket_path}"}, + expected_in_output="/", + min_tmux_version=None, + ), + ServerDisplayMessageCase( + test_id="all_formats", + cmd="", + kwargs={"get_text": True, "all_formats": True}, + expected_in_output="session_name", + min_tmux_version=None, + ), + ServerDisplayMessageCase( + test_id="no_expand_literal", + cmd="#{version}", + kwargs={"get_text": True, "no_expand": True}, + expected_in_output="#{version}", + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(ServerDisplayMessageCase._fields), + SERVER_DISPLAY_MESSAGE_CASES, + ids=[c.test_id for c in SERVER_DISPLAY_MESSAGE_CASES], +) +def test_server_display_message_flags( + test_id: str, + cmd: str, + kwargs: dict[str, t.Any], + expected_in_output: str | None, + min_tmux_version: str | None, + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Server.display_message() resolves server-scoped formats without a pane. + + tmux dispatches ``display-message -p`` output through a client; the wrapper + omits ``-t `` but still needs a client to receive stdout. The + headless test environment provides one via :class:`ControlMode`. + + Skipped on tmux 3.2a: ``display-message -p -c `` + returns empty stdout on that release (output dispatch via a control-mode + client was unreliable until later versions). + """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip( + "display-message -p via control-mode client unreliable on tmux 3.2a" + ) + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + with control_mode() as ctl: + call_kwargs = dict(kwargs) + call_kwargs.setdefault("target_client", ctl.client_name) + result = server.display_message(cmd, **call_kwargs) + + if expected_in_output is not None: + assert result is not None + output = "\n".join(result) + assert expected_in_output in output + + +def test_server_display_message_no_text_returns_none( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Without ``get_text=True`` the call renders to status line and returns None.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip( + "display-message via control-mode client unreliable on tmux 3.2a", + ) + + with control_mode() as ctl: + result = server.display_message( + "hi from libtmux", target_client=ctl.client_name + ) + assert result is None + + +def test_server_display_message_target_client( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``target_client`` is plumbed through as ``-c``; get_text=True returns stdout.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip( + "display-message -p via control-mode client unreliable on tmux 3.2a" + ) + + with control_mode() as ctl: + result = server.display_message( + "#{version}", get_text=True, target_client=ctl.client_name + ) + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].strip() != "" + + +def test_server_display_message_raises_on_tmux_error( + server: Server, + session: Session, +) -> None: + """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``. + + Passing ``cmd`` and ``format_string`` together is rejected by tmux's + argument parser with stderr ``only one of -F or argument must be + given``. The wrapper must propagate that, not silently return ``[]``. + """ + with pytest.raises(exc.LibTmuxException) as excinfo: + server.display_message("x", get_text=True, format_string="#{version}") + + assert excinfo.value.subcommand == "display-message" + assert "only one of -F or argument" in str(excinfo.value) diff --git a/tests/test_session.py b/tests/test_session.py index 4586dddf3..34a9dfd36 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -671,3 +671,90 @@ def test_new_window_select_existing(session: Session) -> None: select_existing=True, ) assert w.window_name == "selexist_new" + + +def test_session_last_window_exception_tags_subcommand(session: Session) -> None: + """Wrapper raises tag LibTmuxException with the originating tmux subcommand. + + Calling ``last_window()`` on a session with only one window and no prior + selection makes tmux error. The raised exception carries + ``.subcommand == "last-window"`` and ``str(exc)`` prefixes the + subcommand so downstream consumers see which tmux command failed. + """ + with pytest.raises(exc.LibTmuxException) as excinfo: + session.last_window() + assert excinfo.value.subcommand == "last-window" + assert str(excinfo.value).startswith("last-window:") + + +def test_session_search_windows_filter(session: Session) -> None: + """``Session.list_windows(filter=...)`` filters via tmux's C-side -f flag.""" + session.new_window(window_name="gap7s_keep") + session.new_window(window_name="other_drop") + + matches = session.search_windows(filter="#{m:gap7s_*,#{window_name}}") + names = sorted(w.window_name for w in matches if w.window_name) + assert names == ["gap7s_keep"] + + +def test_session_search_panes_filter_by_id(session: Session) -> None: + """``Session.list_panes(filter=...)`` projects only matching pane ids.""" + window = session.active_window + target = window.split() + + matches = session.search_panes( + filter=f"#{{m:{target.pane_id},#{{pane_id}}}}", + ) + assert [p.pane_id for p in matches] == [target.pane_id] + + +SESSION_FORMAT_FIELDS = ( + "session_active", + "session_activity_flag", + "session_alert", + "session_bell_flag", + "session_format", + "session_group_attached_list", + "session_group_many_attached", + "session_grouped", + "session_many_attached", + "session_marked", + "session_silence_flag", +) + + +@pytest.mark.parametrize("field_name", SESSION_FORMAT_FIELDS) +def test_session_format_field_declared_and_hydrated( + field_name: str, + session: Session, +) -> None: + """Tmux's session-scope format tokens hydrate onto the typed ``Session``.""" + assert field_name in session.__dataclass_fields__ + + session.refresh() + value = getattr(session, field_name) + assert value is None or isinstance(value, str) + + +SESSION_SCOPE_OVERRIDE_FIELDS = ( + "active_window_index", + "last_window_index", +) + + +@pytest.mark.parametrize("field_name", SESSION_SCOPE_OVERRIDE_FIELDS) +def test_session_scope_override_field_hydrates( + field_name: str, + session: Session, +) -> None: + """Session-scope overrides admit each token into list-sessions -F. + + Callbacks dereference ``ft->s`` in tmux's ``format.c`` (verified + across tmux 3.2a through master). A ``None`` here indicates the + scope gate excluded the token from the format string, which is + the regression class these overrides prevent. + """ + session.refresh() + value = getattr(session, field_name) + assert value is not None, f"{field_name} should hydrate via list-sessions" + assert isinstance(value, str) diff --git a/tests/test_window.py b/tests/test_window.py index a6627634b..7c500e24e 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1058,3 +1058,202 @@ def test_move_window_no_select(session: Session) -> None: w2.move_window(destination="99", no_select=True) session.refresh() assert session.active_window.window_id == w1.window_id + + +class WindowDisplayMessageCase(t.NamedTuple): + """Test case for Window.display_message() flag variations.""" + + test_id: str + cmd: str + kwargs: dict[str, t.Any] + expected_in_output: str | None + min_tmux_version: str | None + + +WINDOW_DISPLAY_MESSAGE_CASES: list[WindowDisplayMessageCase] = [ + WindowDisplayMessageCase( + test_id="window_id", + cmd="#{window_id}", + kwargs={"get_text": True}, + expected_in_output="@", + min_tmux_version=None, + ), + WindowDisplayMessageCase( + test_id="window_index_via_format_string", + cmd="", + kwargs={"get_text": True, "format_string": "#{window_index}"}, + # pytest plugin sets `base-index 1` (pytest_plugin.py:110), so the + # first window in a fresh session is index 1, not 0. + expected_in_output="1", + min_tmux_version=None, + ), + WindowDisplayMessageCase( + test_id="zoomed_flag_default_zero", + cmd="#{window_zoomed_flag}", + kwargs={"get_text": True}, + expected_in_output="0", + min_tmux_version=None, + ), + WindowDisplayMessageCase( + test_id="no_expand_literal", + cmd="#{window_id}", + kwargs={"get_text": True, "no_expand": True}, + expected_in_output="#{window_id}", + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(WindowDisplayMessageCase._fields), + WINDOW_DISPLAY_MESSAGE_CASES, + ids=[c.test_id for c in WINDOW_DISPLAY_MESSAGE_CASES], +) +def test_window_display_message_flags( + test_id: str, + cmd: str, + kwargs: dict[str, t.Any], + expected_in_output: str | None, + min_tmux_version: str | None, + session: Session, +) -> None: + """Window.display_message() resolves window-scoped formats.""" + 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}+") + + window = session.active_window + result = window.display_message(cmd, **kwargs) + + if expected_in_output is not None: + assert result is not None + output = "\n".join(result) + assert expected_in_output in output + + +def test_window_display_message_no_text_returns_none( + session: Session, +) -> None: + """Without ``get_text=True`` the call renders to status line and returns None.""" + window = session.active_window + result = window.display_message("hi from libtmux") + assert result is None + + +def test_window_display_message_target_client( + control_mode: t.Callable[..., t.Any], + session: Session, +) -> None: + """``target_client`` is plumbed through as ``-c``.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip( + "display-message -p via control-mode client unreliable on tmux 3.2a" + ) + + window = session.active_window + with control_mode() as ctl: + result = window.display_message( + "#{window_id}", get_text=True, target_client=ctl.client_name + ) + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].startswith("@") + + +def test_window_display_message_raises_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``.""" + window = session.active_window + with pytest.raises(exc.LibTmuxException) as excinfo: + window.display_message("x", get_text=True, format_string="#{window_id}") + + assert excinfo.value.subcommand == "display-message" + assert "only one of -F or argument" in str(excinfo.value) + + +def test_window_zoomed_flag_field_toggle(session: Session) -> None: + """``window.window_zoomed_flag`` reflects tmux's zoom state across refresh. + + ``refresh()`` repopulates the field after each ``resize-pane -Z`` toggle: + the flag reads ``"0"`` for an un-zoomed window and ``"1"`` once a pane + has been zoomed. + """ + window = session.active_window + # Need at least two panes for zoom to mean anything. + window.split() + window.refresh() + assert window.window_zoomed_flag == "0" + + pane = window.active_pane + assert pane is not None + pane.resize(zoom=True) + window.refresh() + assert window.window_zoomed_flag == "1" + + pane.resize(zoom=True) + window.refresh() + assert window.window_zoomed_flag == "0" + + +def test_window_search_panes_filter_by_id(session: Session) -> None: + """``Window.search_panes(filter=...)`` returns only the matching pane id.""" + window = session.active_window + target = window.split() + + matches = window.search_panes(filter=f"#{{m:{target.pane_id},#{{pane_id}}}}") + assert [p.pane_id for p in matches] == [target.pane_id] + + +def test_window_search_panes_no_filter_equivalent_to_property( + session: Session, +) -> None: + """``search_panes()`` with no filter matches the existing ``panes`` property.""" + window = session.active_window + window.split() + + from_property = sorted(p.pane_id for p in window.panes if p.pane_id) + from_method = sorted(p.pane_id for p in window.search_panes() if p.pane_id) + assert from_property == from_method + + +WINDOW_FORMAT_FIELDS = ( + "window_active_clients_list", + "window_active_sessions_list", + "window_activity_flag", + "window_bell_flag", + "window_bigger", + "window_end_flag", + "window_flags", + "window_format", + "window_last_flag", + "window_silence_flag", + "window_start_flag", + "window_visible_layout", +) + + +@pytest.mark.parametrize("field_name", WINDOW_FORMAT_FIELDS) +def test_window_format_field_declared_and_hydrated( + field_name: str, + session: Session, +) -> None: + """Tmux's window-scope format tokens hydrate onto the typed ``Window``.""" + window = session.active_window + assert field_name in window.__dataclass_fields__ + + window.refresh() + value = getattr(window, field_name) + assert value is None or isinstance(value, str) + + +def test_window_flags_field_returns_string(session: Session) -> None: + """``window_flags`` summarizes window state and reads as a string. + + The value is often empty for an idle window; the contract is that the + field hydrates as a string (never ``None``) once tmux has populated it. + """ + window = session.active_window + window.refresh() + assert isinstance(window.window_flags, str)