diff --git a/CHANGES b/CHANGES index eba6e7efa..5a6775b17 100644 --- a/CHANGES +++ b/CHANGES @@ -45,10 +45,203 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +libtmux 0.57.0 broadens tmux support. It introduces +{class}`~libtmux.Client` as a first-class object, threads tmux's +C-side ``-f`` filter through the typed listing methods so callers can +push predicates into the tmux server, and adds typed access to many +more format tokens — all scope- and version-gated so they're safe on +every supported tmux version. Subcommand context now flows through +{exc}`~libtmux.exc.LibTmuxException`, making it easier for downstream +tools to dispatch on which tmux command produced an error. + +### Breaking changes + +#### `LibTmuxException` string form gains a subcommand prefix (#672) + +When {exc}`~libtmux.exc.LibTmuxException` is raised from one of the +typed command wrappers, ``str(exc)`` now begins with the originating +tmux subcommand name followed by ``": "``. For example, an error from +{meth}`~libtmux.Session.last_window` used to render as ``"can't find +window"`` and now renders as ``"last-window: can't find window"``. + +The wrapped stderr is unchanged — ``exc.args[0]`` still holds the raw +tmux output, and the new {attr}`~libtmux.exc.LibTmuxException.subcommand` +attribute exposes the tmux subcommand name as a separate field. +{func}`~libtmux.common.raise_if_stderr` is the shared helper that +populates both. + +This is a serialization-format change: code that pattern-matches on +``str(exc)`` exactly or anchors a regex with ``^`` against the old +shape will no longer match. + +```python +# Before +try: + session.last_window() +except LibTmuxException as exc: + if str(exc) == "can't find window": + ... + +# After — dispatch on the typed attribute +try: + session.last_window() +except LibTmuxException as exc: + if exc.subcommand == "last-window": + ... + +# Or — match against the raw stderr without the prefix +try: + session.last_window() +except LibTmuxException as exc: + if exc.args and exc.args[0] == "can't find window": + ... +``` + +Substring matches (``"can't find" in str(exc)``) and unanchored +``re.search`` patterns continue to work unchanged. + +### What's new + +#### `Client` object and `Server.clients` accessor (#672) + +New {class}`~libtmux.Client` dataclass and +{attr}`~libtmux.Server.clients` property bring typed-ORM ergonomics +to tmux's attached-client model. Reads like ``client.client_readonly`` +and ``client.client_session`` work directly on the client instead of +forcing callers down to {meth}`~libtmux.Server.cmd`. + +Note that ``client.session_id`` / ``client.window_id`` / +``client.pane_id`` reflect the client's currently attached view at +hydration time — {meth}`~libtmux.Client.refresh` re-reads them after +the client switches focus. ``client.client_name`` is the client's +stable identifier. + +For typed access to the live attachment, use +{attr}`~libtmux.Client.attached_session`, +{attr}`~libtmux.Client.attached_window`, and +{attr}`~libtmux.Client.attached_pane`. Each property re-reads the +client from ``list-clients`` before resolving; if tmux no longer +reports that ``client_name``, such as after the client detaches, the +property returns ``None``. Otherwise, the returned +{class}`~libtmux.Session` / {class}`~libtmux.Window` / +{class}`~libtmux.Pane` reflects where the client is attached *now*, +not where it was when the {class}`~libtmux.Client` was constructed. +Direct {meth}`~libtmux.Client.refresh` and +{meth}`~libtmux.Client.from_client_name` calls still surface missing +client lookup errors. + +#### `Server.display_message` and `Window.display_message` (#672) + +{meth}`~libtmux.Server.display_message` and +{meth}`~libtmux.Window.display_message` join the existing +{meth}`~libtmux.Pane.display_message`. Server reads like +``#{version}`` and ``#{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 (#672) + +{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. + +Caveat: tmux silently expands a malformed predicate to empty, which +the format engine treats as false — a typo looks identical to "no +matches". Verify predicate syntax against the FORMATS section of +``tmux(1)``. + +#### `Pane.send_keys(cmd=None, …)` flag-only invocation (#672) + +{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=)` (#672) + +{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 — same bad-filter caveat as the +``search_*`` methods. + +#### `Server.run_shell(cwd=, show_stderr=)` (#672) + +{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. Both kwargs are +version-gated; older tmux warns and ignores the flag instead of +erroring. + +#### `Pane.capture_pane(pending=True)` (#672) + +{meth}`~libtmux.Pane.capture_pane` gains a ``pending`` kwarg that +returns bytes tmux has read from the pane but not yet committed to +the terminal — useful for diagnosing programs whose output stalls +mid-sequence. + +#### Scope-aware format-token retrieval (#672) + +The ``-F`` template libtmux sends to each ``list-*`` subcommand is now +scope- and version-aware. tmux's format engine cascades context +downward from client → session → current window → active pane, so a +``Session`` row hydrates active-window and active-pane fields via that +cascade, and a ``Client`` row likewise hydrates the client's attached +session, window, and active pane. ``client_*`` tokens resolve only +under ``list-clients`` because tmux has no reverse cascade. Tokens +introduced after tmux 3.2a are gated through ``FIELD_VERSION`` so the +format string stays compatible with the project's minimum supported +tmux. Tokens the running tmux doesn't recognize stay ``None`` on the +typed surface — no crash, no warning. + +{class}`~libtmux.Pane`, {class}`~libtmux.Window`, +{class}`~libtmux.Session`, and {class}`~libtmux.Client` declare typed +dataclass fields for the scope-relevant tokens that ship in tmux 3.2a, +including 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`` …), and the client view +(``client_session``, ``client_readonly``, ``client_termtype`` …). Typed +fields for tokens tmux added in 3.4 / 3.5 / 3.6 and the forward-looking +set from tmux master will land in a follow-up shipment once those +releases can be validated end-to-end. + +### 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` surface tmux stderr via + :func:`warnings.warn` instead of silently returning ``[]``. tmux uses + stderr for both genuine errors and informational messages on some + versions, so the wrappers warn rather than raise; callers that want + to escalate can wrap the call in :func:`warnings.catch_warnings` with + ``filterwarnings("error")`` (#672). +- {attr}`~libtmux.Server.clients` and + {meth}`~libtmux.Server.search_sessions` propagate tmux errors + rather than silently returning an empty + {class}`~libtmux._internal.query_list.QueryList`. A genuine + list-clients or list-sessions failure now surfaces instead of + looking identical to "no clients" or "filter matched nothing" + (#672). + +### Documentation + +- New API page: {doc}`api/libtmux.client`. +- {class}`~libtmux.neo.Obj`'s class docstring documents the + downward-cascade resolution target so readers know that, for + example, ``session.pane_id`` is the session's *current window's + active* pane — not "the session's pane" (#672). + ## libtmux 0.56.0 (2026-05-10) libtmux 0.56.0 is the tmux command-parity release. It adds more than -50 wrappers across {class}`~libtmux.Server`, {class}`~libtmux.Session`, +50 commands across {class}`~libtmux.Server`, {class}`~libtmux.Session`, {class}`~libtmux.Window`, and {class}`~libtmux.Pane`, filling in many commands that previously required raw {meth}`~libtmux.Server.cmd` calls. It also adds attached-client test support so interactive tmux commands can be @@ -58,7 +251,7 @@ covered in headless test suites. #### Interactive tmux commands are now scriptable (#653) -libtmux now exposes Python wrappers for tmux commands that normally depend on +libtmux now exposes Python commands for tmux that normally depend on an attached client: {meth}`~libtmux.Pane.display_popup`, {meth}`~libtmux.Server.display_menu`, {meth}`~libtmux.Server.command_prompt`, @@ -80,7 +273,7 @@ The `detach-client` API is split by the same scopes tmux actually honors: `tmux detach-client -a [-t ]`. This keeps each method to one tmux flag group and one subprocess call. -#### tmux buffer I/O has first-class wrappers (#653) +#### tmux buffer I/O has first-class support (#653) Named tmux buffers can now be used from libtmux without hand-built commands. {meth}`~libtmux.Server.set_buffer`, @@ -94,7 +287,7 @@ inter-process handoff workflows. #### Server commands cover key bindings, clients, shell execution, and access (#653) -{class}`~libtmux.Server` gains wrappers for key-binding inspection and mutation +{class}`~libtmux.Server` gains support for key-binding inspection and mutation ({meth}`~libtmux.Server.bind_key`, {meth}`~libtmux.Server.unbind_key`, {meth}`~libtmux.Server.list_keys`, @@ -137,7 +330,7 @@ Navigation helpers fill in the surrounding topology: {meth}`~libtmux.Session.next_window`, and {meth}`~libtmux.Session.previous_window`. -#### Existing wrappers expose more tmux flags (#653) +#### Improvements (#653) Several established methods now surface tmux flags that were previously only available by dropping to raw commands. Highlights include @@ -196,7 +389,7 @@ old hardcoded socket paths. Fixes #664. #### tmux 3.7 is within the known-version range (#653) -{data}`~libtmux.common.TMUX_MAX_VERSION` is now `"3.7"`, enabling wrappers and +{data}`~libtmux.common.TMUX_MAX_VERSION` is now `"3.7"`, enabling support and tests for version-gated tmux 3.7 flags such as {meth}`~libtmux.Server.command_prompt` `bspace_exit` and {meth}`~libtmux.Server.show_messages` `terminals` / `jobs`. Installations on diff --git a/MIGRATION b/MIGRATION index 123b0cdcf..713b85e14 100644 --- a/MIGRATION +++ b/MIGRATION @@ -109,9 +109,121 @@ sections below for detailed migration examples and code samples. ## Upcoming Release + _Detailed migration steps for the next version will be posted here._ + - +## libtmux 0.57.0: Subcommand-tagged exceptions (#672) + +### `LibTmuxException` `str()` gains a subcommand prefix + +When {exc}`~libtmux.exc.LibTmuxException` is raised from one of the +typed command wrappers, ``str(exc)`` now starts with the originating +tmux subcommand name followed by ``": "``. The wrapped stderr is +unchanged — ``exc.args[0]`` still holds the raw tmux output, and the +new {attr}`~libtmux.exc.LibTmuxException.subcommand` attribute exposes +the tmux subcommand name on its own. + +**Who is affected:** code that pattern-matches `str(exc)` exactly, +anchors a regex with `^` against the previous shape, or hashes the +stringified exception. Substring containment (`"can't find" in +str(exc)`) and unanchored `re.search` patterns continue to match +unchanged. + +**Before (0.56.x and earlier):** + +```python +try: + session.last_window() +except LibTmuxException as exc: + if str(exc) == "can't find window": + handle_missing_last_window() +``` + +**After (0.57.0+) — dispatch on the typed attribute:** + +```python +try: + session.last_window() +except LibTmuxException as exc: + if exc.subcommand == "last-window": + handle_missing_last_window() +``` + +**Or — match against the raw stderr in `exc.args[0]`:** + +```python +try: + session.last_window() +except LibTmuxException as exc: + if exc.args and exc.args[0] == "can't find window": + handle_missing_last_window() +``` + +### `Client.session_id` / `window_id` / `pane_id` are snapshots, not identity + +{class}`~libtmux.Client` is new in 0.57.0, so this isn't a behavior +change — but new users of {attr}`~libtmux.Server.clients` should know +that ``client.session_id``, ``client.window_id``, and +``client.pane_id`` are hydrated from tmux's downward format cascade +(``c->session`` → ``s->curw`` → ``wl->window->active``) at the moment +the {class}`~libtmux.Client` was built. They go stale as soon as the +client switches sessions, changes window, or detaches. + +For typed access that reflects the client's *live* attachment, use +{attr}`~libtmux.Client.attached_session`, +{attr}`~libtmux.Client.attached_window`, and +{attr}`~libtmux.Client.attached_pane`: + +```python +# Snapshot (cheap, may be stale) +client = server.clients.get(client_name=ctl.client_name) +session_id = client.session_id # str captured at hydration time + +# Live (re-reads list-clients; returns None if tmux no longer reports the client) +attached = client.attached_session # libtmux.Session | None +window = client.attached_window # libtmux.Window | None +pane = client.attached_pane # libtmux.Pane | None +``` + +The ``client.client_name`` field (typically the tty path on Unix) is +the client's *stable* identifier and does not have this caveat. The +``attached_*`` properties translate a missing ``list-clients`` row into +``None`` for convenience; direct {meth}`~libtmux.Client.refresh` and +{meth}`~libtmux.Client.from_client_name` calls still raise when that +client row is gone. + +### `Pane.reset` now dispatches through `self.server.cmd` + +{meth}`~libtmux.Pane.reset` now bundles ``send-keys -R`` and +``clear-history`` into a single tmux IPC routed through +``self.server.cmd`` (with an explicit ``-t `` on both +subcommands) rather than calling ``self.cmd`` twice. This closes a race +where output written to the pane between the two IPCs could land in the +scrollback that the second call then cleared. + +**Who is affected:** test fixtures and downstream code that intercepts +``Pane.cmd`` (for example with ``unittest.mock.patch.object(pane, +"cmd")``) will no longer observe ``reset()``'s tmux invocation. Patch +``Server.cmd`` instead, or assert on the resulting pane state directly. + +### `Server.display_message` / `Window.display_message` / `Pane.display_message` warn instead of raise + +The three ``display_message`` wrappers now report tmux stderr via +:func:`warnings.warn` rather than raising +{exc}`~libtmux.exc.LibTmuxException`. tmux uses stderr for both genuine +errors and informational messages, and the right escalation depends on +tmux version and call shape; the wrappers default to warning so callers +can decide. To escalate to an exception, wrap the call in +:func:`warnings.catch_warnings` with ``filterwarnings("error")``: + +```python +import warnings + +with warnings.catch_warnings(): + warnings.filterwarnings("error", category=UserWarning) + result = pane.display_message("#{pane_id}", get_text=True) +``` ## libtmux 0.50.0: Unified Options and Hooks API (#516) 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..bae611d0b --- /dev/null +++ b/docs/api/libtmux.client.md @@ -0,0 +1,16 @@ +(api-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/docs/topics/architecture.md b/docs/topics/architecture.md index df25834d8..82ebbd648 100644 --- a/docs/topics/architecture.md +++ b/docs/topics/architecture.md @@ -14,21 +14,28 @@ libtmux mirrors tmux's object hierarchy as a typed Python ORM: ``` Server -└── Session - └── Window - └── Pane +├── Session +│ └── Window +│ └── Pane +└── Client (attached view) ``` | Object | Child | Parent | |--------|-------|--------| -| {class}`~libtmux.server.Server` | {class}`~libtmux.session.Session` | None | +| {class}`~libtmux.server.Server` | {class}`~libtmux.session.Session`, {class}`~libtmux.client.Client` | None | | {class}`~libtmux.session.Session` | {class}`~libtmux.window.Window` | {class}`~libtmux.server.Server` | | {class}`~libtmux.window.Window` | {class}`~libtmux.pane.Pane` | {class}`~libtmux.session.Session` | | {class}`~libtmux.pane.Pane` | None | {class}`~libtmux.window.Window` | +| {class}`~libtmux.client.Client` | None | {class}`~libtmux.server.Server` | {class}`~libtmux.common.TmuxRelationalObject` acts as the base container connecting these relationships. +{class}`~libtmux.Client` is a *view*, not part of the ownership chain: +each attached terminal points at a Session/Window/Pane it is currently +displaying, but is not owned by them. See {ref}`clients` for the view- +vs-identity distinction. + ## Internal Identifiers tmux assigns unique IDs to sessions, windows, and panes. libtmux uses @@ -50,6 +57,7 @@ Each level wraps tmux commands and format queries: - {class}`~libtmux.session.Session` — manages windows within a session - {class}`~libtmux.window.Window` — manages panes, handles layouts - {class}`~libtmux.pane.Pane` — terminal instance, sends keys and captures output +- {class}`~libtmux.client.Client` — attached terminal viewing a session, window, and pane ## Data Flow @@ -67,6 +75,7 @@ Each level wraps tmux commands and format queries: | {mod}`libtmux.session` | Session operations | | {mod}`libtmux.window` | Window operations and pane management | | {mod}`libtmux.pane` | Pane I/O and capture | +| {mod}`libtmux.client` | Attached-client view and live-attachment lookup | | {mod}`libtmux.common` | Base classes, command execution | | {mod}`libtmux.neo` | Modern dataclass-based query interface | | {mod}`libtmux.constants` | Format string constants | diff --git a/docs/topics/clients.md b/docs/topics/clients.md new file mode 100644 index 000000000..9a514ed11 --- /dev/null +++ b/docs/topics/clients.md @@ -0,0 +1,114 @@ +(clients)= + +# Clients + +A tmux {term}`Client` is an attached terminal — the side of the tmux +connection a user sees. The same tmux server can host many clients at +once (one per `$ tmux attach` from different terminals), and each +client has its own view of the active session, window, and pane. + +{class}`~libtmux.Client` is the libtmux dataclass for that attached +terminal. It sits outside the +{class}`~libtmux.server.Server` → {class}`~libtmux.session.Session` → +{class}`~libtmux.window.Window` → {class}`~libtmux.pane.Pane` +ownership hierarchy: a client *points at* a Session/Window/Pane it is +currently viewing, but is not owned by them. + +## View, not identity + +The fields that look like foreign keys — +{attr}`~libtmux.neo.Obj.client_session`, +{attr}`~libtmux.neo.Obj.session_id`, +{attr}`~libtmux.neo.Obj.window_id`, +{attr}`~libtmux.neo.Obj.pane_id` — are snapshots of where the client +was attached at the moment libtmux hydrated the dataclass. They go +stale the instant the user runs `switch-client`, `select-window`, or +`select-pane`. The client's *identity* is its +{attr}`~libtmux.neo.Obj.client_name` (the tty path on Unix), which is +stable for the lifetime of the attachment. + +| Field | What it is | Stable? | +|-------|------------|---------| +| `client_name` | tty path tmux assigned at attach time | Yes — identity | +| `session_id` / `window_id` / `pane_id` | the client's *attached view* at hydration time | No — snapshot | +| `client_session` | session name of the same attached view | No — snapshot | +| `client_pid` / `client_tty` / `client_user` | terminal-level facts | Yes — identity-adjacent | + +This distinction is documented in the warning block on +{class}`~libtmux.Client` itself. + +## Live attachment with `attached_*` + +When you want the *current* attachment — not the snapshot — use the +three live properties. Each calls +{meth}`~libtmux.Client.refresh` to re-read the client from +`list-clients`, then resolves the typed Session/Window/Pane: + +```python +>>> with control_mode() as ctl: +... client = server.clients.get(client_name=ctl.client_name) +... attached = client.attached_session +>>> attached is not None +True +``` + +{attr}`~libtmux.Client.attached_window` follows the client's attached +session to its +{attr}`~libtmux.session.Session.active_window`, and +{attr}`~libtmux.Client.attached_pane` follows that window to its +{attr}`~libtmux.window.Window.active_pane`. The three properties chain, +so reading {attr}`~libtmux.Client.attached_pane` does one +`list-clients` refresh plus two cheap typed lookups. + +```python +>>> with control_mode() as ctl: +... client = server.clients.get(client_name=ctl.client_name) +... pane = client.attached_pane +>>> pane is None or pane.pane_id.startswith('%') +True +``` + +## Iterating attached clients + +{attr}`~libtmux.Server.clients` returns a +{class}`~libtmux._internal.query_list.QueryList` of every client tmux +reports through `list-clients`. Filter or `get()` it the same way as +{attr}`~libtmux.Server.sessions`: + +```python +>>> with control_mode() as ctl: +... attached = [ +... c +... for c in server.clients +... if c.client_name == ctl.client_name +... ] +>>> bool(attached) +True +``` + +For tmux-server-side filtering (no Python-side iteration), use +{meth}`~libtmux.Server.search_sessions`-style predicate strings via +the `-f` flag — but note that `list-clients` only accepts a single +filter and exposes a narrower token vocabulary than sessions/windows. +See {ref}`c-side-filtering` for the predicate syntax. + +## When `attached_*` returns `None` + +The properties return `None` when: + +- the snapshot `session_id` is empty (e.g. the client is at the tmux + command prompt rather than viewing a session), +- the snapshot `session_id` no longer names a live session (the + session was killed between hydration and access), or +- the client has detached and `list-clients` no longer reports it. + +Calling {meth}`~libtmux.Client.refresh` directly still raises +{exc}`~libtmux.exc.TmuxObjectDoesNotExist` on a detached client; the +`attached_*` properties catch that case and return `None` so callers +can branch on truthiness without a `try`/`except`. + +## See also + +- {doc}`/api/libtmux.client` — autodoc reference +- {ref}`about` — where `Client` fits in the overall object model +- {ref}`c-side-filtering` — tmux-side filtering for `Server.clients` diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md index bb71f81b7..8ade4978e 100644 --- a/docs/topics/filtering.md +++ b/docs/topics/filtering.md @@ -258,6 +258,124 @@ True >>> w3.kill() ``` +(c-side-filtering)= + +## C-Side Filtering with `search_*()` + +`QueryList.filter()` runs in Python *after* tmux has returned every +row. For large servers, or when you only need a handful of matches, +push the predicate down to tmux instead. Every level of the hierarchy +ships a `search_*()` method that compiles a format predicate and runs +it inside the tmux server: + +| Caller | Method | Underlying tmux | +|--------|--------|-----------------| +| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_sessions` | `tmux list-sessions -f ` | +| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_windows` | `tmux list-windows -a -f ` | +| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_panes` | `tmux list-panes -a -f ` | +| {class}`~libtmux.Session` | {meth}`~libtmux.Session.search_windows` | `tmux list-windows -t $sess -f ` | +| {class}`~libtmux.Session` | {meth}`~libtmux.Session.search_panes` | `tmux list-panes -s -t $sess -f ` | +| {class}`~libtmux.Window` | {meth}`~libtmux.Window.search_panes` | `tmux list-panes -t @win -f ` | + +The {meth}`~libtmux.Server.list_buffers` method also accepts a `filter=` +kwarg with the same semantics. + +There is no `search_clients()` method; filter clients via the +{attr}`~libtmux.Server.clients` accessor and Python-side +{meth}`~libtmux._internal.query_list.QueryList.filter`. Pushing a +client-side predicate to tmux is rarely a hot path — a server's client +count is bounded by attached terminals, not by session/window/pane +fan-out. + +### Python-side vs. C-side + +| | `.filter()` | `.search_*()` | +|-|-------------|---------------| +| Where | Python (after fetch) | tmux server (before fetch) | +| Predicate vocabulary | libtmux's lookup operators (`__contains`, `__regex`, etc.) | tmux's [FORMATS](https://man.openbsd.org/tmux.1#FORMATS) grammar | +| Round trips | one (full list, then filter in memory) | one (tmux returns only matches) | +| Best for | rich Python predicates, set membership, post-fetch composition | exact/glob matches over many rows | +| Stability | every libtmux version supports it | requires tmux ≥ 3.2 (≥ 3.4 for `list-clients -f`) | + +Both are valid; pick on data volume and predicate shape. + +### Predicate syntax + +tmux's filter language is the same one used in `-F` templates. Three +shapes cover most use cases: + +```python +>>> # Match by glob +>>> s_alpha = server.new_session(session_name='alpha-1') +>>> s_beta = server.new_session(session_name='beta-1') +>>> alphas = server.search_sessions(filter='#{m:alpha-*,#{session_name}}') +>>> [s.session_name for s in alphas] +['alpha-1'] + +>>> # Match by equality +>>> exact = server.search_sessions( +... filter='#{==:#{session_name},alpha-1}' +... ) +>>> [s.session_name for s in exact] +['alpha-1'] + +>>> # Clean up +>>> s_alpha.kill() +>>> s_beta.kill() +``` + +`#{e:...}` evaluates an arithmetic expression; `#{?cond,a,b}` is the +conditional form. See `man tmux` for the full grammar. + +### The silent zero-match trap + +A malformed predicate is the single biggest footgun. tmux expands an +unclosed `#{...}` or an unknown format token to an empty string, +which the filter engine evaluates as "false" — every row is filtered +out and **no stderr is emitted**. A bad filter is indistinguishable +from a filter that genuinely matched nothing. + +If `search_*()` returns empty unexpectedly: + +1. Replace the predicate with `#{m:*,#{session_name}}` (or the + equivalent for windows/panes). If that returns rows, the issue is + predicate syntax, not data. +2. Expand the predicate standalone via + {meth}`~libtmux.Server.display_message` to see what tmux actually + produced: + + ```python + >>> result = server.display_message( + ... '#{m:alpha-*,alpha-1}', get_text=True + ... ) + >>> result[0] + '1' + ``` + + A non-`1`, non-empty result tells you the predicate is parsing as + text, not as a boolean. + +3. Cross-check the token name against the FORMATS section of + `tmux(1)` and against the version gate (see {ref}`format-tokens`). + +### When to prefer which + +Use `search_*()` when: + +- you have hundreds or thousands of windows/panes and only want a few, +- your predicate is a glob (`m:`) or equality check (`==:`), +- you're already in tmux-format thinking (writing `#{...}` for a + status-line template, for example). + +Use `.filter()` when: + +- your predicate needs Python types you can't express in tmux format + (set membership, complex regex, computed values from outside tmux), +- you're chaining multiple filters and prefer composing in Python, +- you want predictable, version-independent semantics. + ## API Reference -See {class}`~libtmux._internal.query_list.QueryList` for the complete API. +See {class}`~libtmux._internal.query_list.QueryList` for the complete +QueryList API, and each `search_*()` method for the C-side filter +contract. diff --git a/docs/topics/format-tokens.md b/docs/topics/format-tokens.md new file mode 100644 index 000000000..c32af7c3f --- /dev/null +++ b/docs/topics/format-tokens.md @@ -0,0 +1,130 @@ +(format-tokens)= + +# Format-Token Fields + +Every libtmux object — {class}`~libtmux.Server`, +{class}`~libtmux.Session`, {class}`~libtmux.Window`, +{class}`~libtmux.Pane`, {class}`~libtmux.Client` — exposes a flat set +of typed string attributes named after tmux's +[FORMATS](https://man.openbsd.org/tmux.1#FORMATS) tokens +(`pane_id`, `window_zoomed_flag`, `client_theme`, etc.). These are +declared once on {class}`libtmux.neo.Obj`, and the same dataclass +backs every concrete object — which is why +`pane.pane_id`, `pane.window_id`, and `pane.session_id` all work on a +single {class}`~libtmux.Pane` instance. + +Two gates decide which fields actually hold a value on a given object: + +1. **Scope** — which tmux struct field the token's format-callback + dereferences. A `pane_*` token reads `ft->wp`, a `session_*` token + reads `ft->s`, and so on. +2. **Version** — which tmux release first registered the token in + `format.c`'s static table. + +If either gate excludes a token, libtmux leaves the field at `None` +rather than risking a server-side fault on an older tmux. + +## Why a field is `None` + +A typed field is `None` for one of three reasons: + +- **Not yet introduced.** Older tmux doesn't know the token at all. + {attr}`~libtmux.Pane.pane_dead_signal` is `None` on tmux 3.2a because + the token landed in 3.3. +- **Wrong scope for this object.** A {class}`~libtmux.Client` row only + emits client-scope tokens directly; cross-scope tokens reach it via + the cascade described below, but `buffer_*` tokens never do. +- **Live-only token.** Some tokens (`mouse_*`, `cursor_*`, + `selection_*`) only resolve inside a live event context (key + binding, copy-mode, popup) — never in a `list-*` snapshot. libtmux + excludes them from every `-F` template. + +The version map for post-3.2a tokens is small and stable. The +following are the tokens libtmux currently gates: + +| Added in | Tokens | +|----------|--------| +| 3.3 | `pane_dead_signal`, `pane_dead_time` | +| 3.4 | `pane_unseen_changes` | +| 3.5 | `pane_key_mode` | +| 3.6 | `session_active`, `session_activity_flag`, `session_alert`, `session_bell_flag`, `session_silence_flag`, `client_theme` | +| 3.7 (forward) | `bracket_paste_flag`, `pane_flags`, `pane_floating_flag`, `pane_pb_progress`, `pane_pb_state`, `pane_pipe_pid`, `pane_zoomed_flag`, `synchronized_output_flag` | + +Everything not listed above is safe on every supported tmux (≥ 3.2a). + +## The downward cascade + +tmux fills its format context downward when a query specifies a +parent: `c->session` then `s->curw` then `wl->window->active`. That's +why pane-scope tokens have meaningful values on a session row — +they resolve to the session's current window's active pane. + +```python +>>> session = server.new_session() +>>> session.pane_id == session.active_window.active_pane.pane_id +True +>>> session.window_id == session.active_window.window_id +True +``` + +The cascade is **one-way**. A {class}`~libtmux.Pane` carries +`window_*` and `session_*` because the parent fills in for the child, +but a {class}`~libtmux.Session` does not carry `client_*` — tmux has +no reverse cascade for clients. The `client_*` tokens only hydrate on +{class}`~libtmux.Client` rows (returned by +{attr}`~libtmux.Server.clients`, which queries `list-clients`). + +If you treat `session.pane_id` as "the session's pane id" (rather +than "the active pane of the session's current window") you will be +surprised when the active window changes. That distinction is called +out in {class}`libtmux.neo.Obj`'s docstring. + +## Inspecting which fields apply + +Use {func}`libtmux.neo.get_output_format` to ask, for a given +`list-*` subcommand and tmux version, exactly which tokens libtmux +will emit in the `-F` template: + +```python +>>> from libtmux.neo import get_output_format +>>> fields, _ = get_output_format("list-sessions", "3.6a") +>>> 'session_id' in fields +True +>>> 'pane_id' in fields # via downward cascade +True +>>> 'client_name' in fields # client scope is the cascade exception +False +``` + +For `list-clients`, the gate widens to include `client_*` plus every +downward-cascadable token: + +```python +>>> from libtmux.neo import get_output_format +>>> fields, _ = get_output_format("list-clients", "3.6a") +>>> all(t in fields for t in ("client_name", "session_id", "pane_id")) +True +``` + +The result is memoized per `(list_cmd, tmux_version)` pair, so +repeated calls are free. + +## Tmux version detection + +libtmux detects the live tmux version via +{func}`libtmux.common.get_version` and passes it through to +`get_output_format` whenever it builds a `-F` template. The result +is cached for the process lifetime; if you're swapping the `tmux` +binary mid-test, call +`libtmux.common.get_version.cache_clear()` to invalidate. + +The {ref}`project` page tracks the project's minimum tmux version +(currently 3.2a); see {doc}`/project/compatibility` for the full +matrix. + +## See also + +- {class}`libtmux.neo.Obj` — the dataclass that declares every field +- {func}`libtmux.neo.get_output_format` — the scope+version gate +- {ref}`clients` — Client is the cascade exception +- {doc}`/project/compatibility` — supported tmux versions diff --git a/docs/topics/index.md b/docs/topics/index.md index 085da8761..7e6c38a74 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -53,6 +53,18 @@ Automatic cleanup with temporary sessions and windows. Get and set tmux options and hooks. ::: +:::{grid-item-card} Clients +:link: clients +:link-type: doc +Attached terminals, live-attachment lookup, and the view-vs-identity model. +::: + +:::{grid-item-card} Format-Token Fields +:link: format-tokens +:link-type: doc +Scope- and version-gated typed fields on every libtmux object. +::: + :::: ```{toctree} @@ -69,4 +81,6 @@ workspace_setup automation_patterns context_managers options_and_hooks +clients +format-tokens ``` diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md index 5163023f2..1005fe510 100644 --- a/docs/topics/pane_interaction.md +++ b/docs/topics/pane_interaction.md @@ -95,6 +95,23 @@ saved in shell history): >>> time.sleep(0.1) ``` +### Flag-only invocation + +When you want to invoke `send-keys` only for its flags — resetting the +pane or repeating a key — pass `cmd=None`: + +```python +>>> # Repeat the last key 5 times (-N 5) +>>> pane.send_keys(cmd=None, repeat=5) + +>>> # Reset the pane to default state (-R) +>>> pane.send_keys(cmd=None, reset=True) +``` + +`cmd=None` requires at least one of `reset=True`, `repeat=N`, or +`copy_mode_cmd=...`; calling it with no flag raises `ValueError` to +prevent silent no-ops. + ## Capturing Output The {meth}`~libtmux.Pane.capture_pane` method captures text from a pane's buffer. @@ -194,12 +211,31 @@ True | `join_wrapped` | `-J` | Join wrapped lines back together | | `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | | `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | +| `pending` | `-P` | Dump the unprocessed input buffer instead of the screen | :::{note} The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, a warning is issued and the flag is ignored. ::: +### Capturing the pending input buffer + +Use `pending=True` to dump bytes tmux has buffered in its parser but +not yet committed to the pane's terminal — input the tmux process read +from the pane's PTY but hasn't fed through its escape-sequence parser +into the visible screen. Useful for inspecting partial control +sequences mid-write. + +```python +>>> pending = pane.capture_pane(pending=True) +>>> isinstance(pending, list) +True +``` + +`pending=True` is mutually exclusive with the line-range and screen-mode +flags (`start`, `end`, `escape_sequences`, etc.) — tmux ignores them when +`-P` is set. + ## Waiting for Output A common pattern in automation is waiting for a command to complete. diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index 214bbc861..cd5b3fee1 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -138,6 +138,8 @@ True libtmux collections support Django-style filtering with `filter()` and `get()`. For comprehensive coverage of all lookup operators, see {ref}`querylist-filtering`. +For tmux-server-side predicates (compiled and executed inside tmux, fewer round +trips on large servers), see {ref}`c-side-filtering`. ### Basic Filtering 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..87fbd3398 --- /dev/null +++ b/src/libtmux/client.py @@ -0,0 +1,230 @@ +"""Pythonization of the :term:`tmux(1)` client. + +libtmux.client +~~~~~~~~~~~~~~ + +""" + +from __future__ import annotations + +import dataclasses +import logging +import typing as t + +from libtmux import exc +from libtmux.neo import Obj, fetch_obj + +if t.TYPE_CHECKING: + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + + +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. + + .. warning:: + + ``client_session``, ``session_id``, ``window_id`` and + ``pane_id`` are snapshots of the client's *currently attached + view* at the moment the dataclass is hydrated — not stable + identity for the client. When the client switches sessions via + ``switch-client``, moves focus via ``select-window`` / + ``select-pane``, or detaches, these fields go stale until + :meth:`refresh` re-reads them from ``list-clients``. The + ``client_name`` (tty path on Unix) is the client's stable + identity. + + Prefer :meth:`attached_session`, :meth:`attached_window`, and + :meth:`attached_pane` for typed access — each re-reads the + client's current attachment before returning, so the + :class:`Session` / :class:`Window` / :class:`Pane` you get + back reflects where the client is attached *now*, not where + it was when this :class:`Client` was constructed. If tmux no + longer reports this ``client_name`` through ``list-clients``, + these properties return ``None``. + + Each property re-reads tmux on every access. Code that needs + all three — session, window, and pane — should call + :meth:`_resolve_attached` once instead, which shares a single + ``list-clients`` refresh across the triple and returns + ``(session, window, pane)`` together. + + 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. + + Raises + ------ + ValueError + When ``client_name`` is unset. Surfaces a clear error under + ``python -O``, where an ``assert`` would be stripped. + """ + if self.client_name is None: + msg = "Client must have a client_name to refresh" + raise ValueError(msg) + 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) + + # + # Computed properties + # + @property + def attached_session(self) -> Session | None: + """Return the :class:`Session` this client is currently attached to. + + Re-reads the client from ``list-clients`` before resolving, so the + returned :class:`Session` reflects the client's *live* attachment + rather than the snapshot captured when this :class:`Client` was + hydrated. Returns ``None`` when tmux no longer reports this + ``client_name`` through ``list-clients``, when the client is not + attached to any session, or when the snapshot ``session_id`` no + longer names a live session. + + Examples + -------- + >>> with control_mode() as ctl: + ... client = server.clients.get(client_name=ctl.client_name) + ... attached = client.attached_session + >>> attached is not None + True + """ + try: + self.refresh() + except exc.TmuxObjectDoesNotExist: + return None + if self.session_id is None: + return None + return self.server.sessions.get( + session_id=self.session_id, + default=None, + ) + + @property + def attached_window(self) -> Window | None: + """Return the :class:`Window` this client is currently viewing. + + Re-reads the client from ``list-clients``, looks up its attached + session, and returns that session's :attr:`~libtmux.Session.active_window`. + Returns ``None`` when no live attached session can be resolved. + """ + session = self.attached_session + if session is None: + return None + return session.active_window + + @property + def attached_pane(self) -> Pane | None: + """Return the :class:`Pane` this client is currently viewing. + + Re-reads the client from ``list-clients``, looks up its attached + session's current window, and returns that window's + :attr:`~libtmux.Window.active_pane`. Returns ``None`` when no + live attached session or active pane can be resolved. + """ + window = self.attached_window + if window is None: + return None + return window.active_pane + + def _resolve_attached( + self, + ) -> tuple[Session | None, Window | None, Pane | None]: + """Resolve live attachment with a single ``list-clients`` refresh. + + The three :attr:`attached_session` / :attr:`attached_window` / + :attr:`attached_pane` properties each trigger their own + ``list-clients`` refresh — calling them in sequence costs three + roundtrips for one conceptual "where is this client attached *now*" + read. ``_resolve_attached`` shares a single refresh across the + triple and returns the three values together. + + Returns + ------- + tuple[Session | None, Window | None, Pane | None] + * ``(None, None, None)`` when tmux no longer reports this + ``client_name`` through ``list-clients``, when the client + snapshot has no ``session_id``, or when the snapshot + ``session_id`` no longer names a live session. + * ``(session, None, None)`` when the attached session has no + active window. + * ``(session, window, pane)`` for a fully-resolved live + attachment. ``pane`` is ``None`` only when the active + window has no active pane. + + :exc:`~libtmux.exc.MultipleActiveWindows` propagates rather than + collapsing to ``(session, None, None)`` — multiple active windows + in one session indicates a tmux invariant violation that callers + should surface, not absent attachment. + """ + try: + self.refresh() + except exc.TmuxObjectDoesNotExist: + return None, None, None + if self.session_id is None: + return None, None, None + session = self.server.sessions.get( + session_id=self.session_id, + default=None, + ) + if session is None: + return None, None, None + try: + window = session.active_window + except exc.NoActiveWindow: + return session, None, None + return session, window, window.active_pane diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 69f077b99..d05a0b2ea 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,44 @@ 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( + "\n".join(proc.stderr), + subcommand=subcommand, + ) + + class tmux_cmd: """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. @@ -338,6 +377,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 +398,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..f91068f9f 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,30 +27,318 @@ 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_dead_signal": "3.3", + "pane_dead_time": "3.3", + "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). +# Entries are added by the scope-gate fix commits as misclassifications are +# discovered. +_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 registered in tmux's ``format.c`` static table (the +# default tree of ``format_cb_*`` callbacks). They resolve in every +# ``list-*`` subcommand because their callbacks read process- or server- +# global state rather than dereferencing ``ft->c``, ``ft->s``, ``ft->wl``, +# or ``ft->wp``. Pane- and session-scoped standalones are routed via +# :data:`_SCOPE_OVERRIDES`; context-only tokens (registered outside +# ``format.c`` for a specific subcommand or mode) are routed via +# :data:`_CONTEXT_ONLY_TOKENS`. +_UNIVERSAL_TOKENS: frozenset[str] = frozenset( + { + "config_files", + "host", + "host_short", + "line", + "next_session_id", + "pid", + "socket_path", + "start_time", + "uid", + "user", + "version", + } +) + +# Tokens declared on :class:`Obj` whose callbacks are registered *outside* +# ``format.c``'s static table — i.e. they only resolve in a specific +# command context, not via ``format_defaults`` for any ``list-*`` +# subcommand: +# +# - ``command_list_alias`` / ``command_list_name`` / ``command_list_usage`` +# → ``cmd-list-commands.c`` (the ``list-commands`` subcommand only). +# - ``search_match`` → ``window-copy.c`` (copy-mode pane formats only). +# - ``current_file`` → ``cfg.c`` (config parse context only). +# +# Emitting these in a ``list-sessions/windows/panes/clients/buffers`` ``-F`` +# template is harmless (tmux renders unknown tokens to empty), but it +# misleads readers of the ``-F`` string about what the format engine will +# resolve in that scope. Routed to the ``"context"`` scope, which is +# explicitly excluded from every :data:`SCOPES_BY_LIST_CMD` entry. +_CONTEXT_ONLY_TOKENS: frozenset[str] = frozenset( + { + "command_list_alias", + "command_list_name", + "command_list_usage", + "current_file", + "search_match", + } +) + + +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 ``"context"`` for tokens registered outside + ``format.c``'s static table (only resolve in a specific command or + mode context). Returns ``"pane"`` / ``"window"`` / ``"session"`` / + ``"client"`` / ``"buffer"`` for scope-prefixed tokens. + + Fields that don't match any prefix, override, or known-token table + fall back to ``"unknown"``. ``"unknown"`` is intentionally absent from + every :data:`SCOPES_BY_LIST_CMD` entry, so an unclassified field is + excluded from every ``list-*`` ``-F`` template — preventing a future + untracked field from being silently emitted under a list command + where it might crash older tmux. Add such a field to + :data:`_SCOPE_OVERRIDES` (or the appropriate prefix / known-token + table) to admit it. + + 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("mouse_all_flag") + 'pane' + >>> _token_scope("active_window_index") + 'session' + >>> _token_scope("synchronized_output_flag") + 'pane' + + Context-only tokens (registered outside ``format.c``'s static table) + route to the ``"context"`` scope and are excluded from every + ``list-*`` ``-F`` template: + + >>> _token_scope("command_list_alias") + 'context' + >>> _token_scope("search_match") + 'context' + + Unclassified tokens fall back to ``"unknown"``, also excluded from + every list command: + + >>> _token_scope("libtmux_test_nonexistent_token") + 'unknown' + """ + 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 _CONTEXT_ONLY_TOKENS: + return "context" + if field_name in _UNIVERSAL_TOKENS: + return "universal" + return "unknown" + + +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.""" + """Dataclass of generic tmux object. + + Notes + ----- + Cross-scope fields hydrate via tmux's ``format_defaults`` downward + cascade (``format.c``: ``c->session`` → ``s->curw`` → + ``wl->window->active``), not via reverse lookup. The practical + consequence: + + - On a :class:`~libtmux.session.Session` row (``list-sessions``), + every ``pane_*`` and ``window_*`` field resolves to the + session's current window's **active pane** — *not* "the + session's pane" (no such thing). ``session.active_window.window_id`` + and ``session.active_window.active_pane.pane_id`` are the + canonical accessors for the same values. + - On a :class:`~libtmux.window.Window` row (``list-windows``), + every ``pane_*`` field resolves to that window's active pane. + - On a :class:`~libtmux.client.Client` row (``list-clients``), + every ``session_*``, ``window_*``, and ``pane_*`` field resolves + via the client's attached session's current window's active + pane. + + A reader who treats ``session.pane_id`` as the literal session's + pane id (rather than "active pane of this session's current + window") will be surprised when the active window changes. + """ server: Server 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 +378,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 +424,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 +484,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 +514,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 -def parse_output(output: str) -> OutputRaw: - """Parse tmux output formatted with get_output_format() into a dict. + ``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. + + 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 +620,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 +648,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 +665,23 @@ 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. + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the format + engine evaluates as "false" — every row is suppressed and no + stderr is emitted. A bad filter is indistinguishable from + "filter matched nothing"; verify predicate syntax against the + FORMATS section of ``tmux(1)``. See :ref:`c-side-filtering` + for the typed wrappers that share this caveat. + + .. versionadded:: 0.57 Returns ------- @@ -288,7 +705,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 +723,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 +745,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..05dc577b0 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) @@ -721,6 +770,15 @@ def display_message( ------- list[str] | None Message output if get_text is True, otherwise None. + + Notes + ----- + Stderr from tmux is reported via :func:`warnings.warn`, not raised. + Callers that want to escalate to an exception can wrap the call in + :func:`warnings.catch_warnings` with ``filterwarnings("error")``. + + .. versionchanged:: 0.57 + Reports stderr via :func:`warnings.warn` instead of raising. """ tmux_args: tuple[str, ...] = () @@ -767,6 +825,11 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) + if proc.stderr: + warnings.warn( + f"display-message: {'; '.join(proc.stderr)}", + stacklevel=2, + ) if get_text: return proc.stdout @@ -826,8 +889,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 +1003,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 +1259,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 +1470,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 +1521,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 +1568,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 +1633,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 +1642,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 +1676,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 +1685,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 +1694,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 +1756,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 +1765,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 +1821,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 +1842,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 +1889,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 +1960,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 +2031,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 +2073,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 +2153,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 +2182,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 +2190,30 @@ 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. + + Sends ``send-keys -R`` and ``clear-history`` to the pane in a + single tmux IPC so no pane output can land in the freshly-cleared + grid (and scroll into history under ``scroll-on-clear``) between + the terminal-state reset and the history clear. Both subcommands + carry an explicit ``-t`` so the ``;`` separator can't leave + ``clear-history`` routed to tmux's cmdq default pane. + + Examples + -------- + >>> pane.reset() + Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + """ + self.server.cmd( + "send-keys", + "-t", + self.pane_id, + "-R", + ";", + "clear-history", + "-t", + self.pane_id, + ) return self # diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c0ad1dc50..c763885d8 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 get_version, 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,185 @@ 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``) issues a ``no current client`` warning. Use + ``get_text=True`` for headless reads, or pair with + :class:`~libtmux._internal.control_mode.ControlMode`. + + Notes + ----- + Stderr from tmux is reported via :func:`warnings.warn`, not raised. + tmux uses stderr for both genuine errors and informational messages, + and the right escalation depends on tmux version and call shape. + Callers that want to escalate to an exception can wrap the call in + :func:`warnings.catch_warnings` with ``filterwarnings("error")``. + + .. versionchanged:: 0.57 + Reports stderr via :func:`warnings.warn` instead of raising. + + 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) + if proc.stderr: + warnings.warn( + f"display-message: {'; '.join(proc.stderr)}", + stacklevel=2, + ) + + if get_text: + return proc.stdout + + return None + def show_prompt_history( self, *, @@ -1434,8 +1630,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 +1664,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 +1702,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 +1730,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 +1755,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 +1794,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 +1827,90 @@ 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. + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + + .. 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 +1959,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 +2004,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 +2021,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 +2041,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 +2058,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 +2166,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={ @@ -1947,7 +2193,8 @@ def new_session( del os.environ["TMUX"] try: - _fields, format_string = get_output_format() + tmux_version = str(get_version(tmux_bin=self.tmux_bin)) + _fields, format_string = get_output_format("list-sessions", tmux_version) tmux_args: tuple[str | int, ...] = ( "-P", @@ -1991,8 +2238,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] @@ -2000,7 +2246,7 @@ def new_session( if env: os.environ["TMUX"] = env - session_data = parse_output(session_stdout) + session_data = parse_output(session_stdout, "list-sessions", tmux_version) session = Session(server=self, **session_data) @@ -2075,6 +2321,211 @@ 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] = [ + Client(server=self, **obj) + for obj in fetch_objs(list_cmd="list-clients", server=self) + ] + 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. + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + + .. versionadded:: 0.57 + + Returns + ------- + :class:`~libtmux._internal.query_list.QueryList` of :class:`Session` + + See Also + -------- + :attr:`Server.sessions` : unfiltered :class:`QueryList` of every + session (Python-side ``.filter()`` runs against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + + 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] = [ + Session(server=self, **obj) + for obj in fetch_objs( + list_cmd="list-sessions", + server=self, + filter=filter, + ) + ] + 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). + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + + .. versionadded:: 0.57 + + See Also + -------- + :attr:`Server.windows` : unfiltered :class:`QueryList` of every + window across every session (Python-side ``.filter()`` runs + against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + + 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). + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + + .. versionadded:: 0.57 + + See Also + -------- + :attr:`Server.panes` : unfiltered :class:`QueryList` of every + pane (Python-side ``.filter()`` runs against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + + 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..d5e36a021 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,119 @@ 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). + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + + .. versionadded:: 0.57 + + See Also + -------- + :attr:`Session.windows` : unfiltered :class:`QueryList` of every + window in this session (Python-side ``.filter()`` runs + against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + + 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). + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + + .. versionadded:: 0.57 + + See Also + -------- + :attr:`Session.panes` : unfiltered :class:`QueryList` of every + pane in this session (Python-side ``.filter()`` runs against + this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + + 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 +365,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 +404,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 +425,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 +447,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 +469,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 +499,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 +553,7 @@ def attach( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "attach-session") return self @@ -512,8 +618,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 +639,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 +659,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 +811,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 +863,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..4e36cafed 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,63 @@ 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). + + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + + .. versionadded:: 0.57 + + See Also + -------- + :attr:`Window.panes` : unfiltered :class:`QueryList` of every + pane in this window (Python-side ``.filter()`` runs against + this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + + 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 +319,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 +460,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 +515,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 +603,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 +617,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 +631,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 +698,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 +723,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 +768,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 +817,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 +856,181 @@ 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 + + Notes + ----- + Stderr from tmux is reported via :func:`warnings.warn`, not raised. + Callers that want to escalate to an exception can wrap the call in + :func:`warnings.catch_warnings` with ``filterwarnings("error")``. + + .. versionchanged:: 0.57 + Reports stderr via :func:`warnings.warn` instead of raising. + """ + 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) + if proc.stderr: + warnings.warn( + f"display-message: {'; '.join(proc.stderr)}", + stacklevel=2, + ) + + if get_text: + return proc.stdout + + return None + def rename_window(self, new_name: str) -> Window: """Rename window. @@ -839,8 +1054,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 +1120,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 +1211,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 +1300,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..8f79ae35a --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,254 @@ +"""Tests for libtmux Client object.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.client import Client +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.window import Window + +if t.TYPE_CHECKING: + from libtmux.server import Server + + +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``, AND those values must match the client's attached + session's current window's active pane — not arbitrary values. + """ + 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 + + attached_session = server.sessions.get(session_id=client.session_id) + assert attached_session is not None + active_pane = attached_session.active_window.active_pane + assert active_pane is not None + assert client.window_id == attached_session.active_window.window_id + assert client.pane_id == active_pane.pane_id + + +def test_client_attached_session_returns_typed_session( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.attached_session`` resolves to the live :class:`Session`.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + attached = client.attached_session + assert isinstance(attached, Session) + assert attached.session_id == session.session_id + + +def test_client_attached_window_tracks_active_window( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.attached_window`` reflects the live active window. + + Selects a freshly created window after hydrating the client, then + asserts the property reports the new selection — proves the + property re-reads rather than returning the snapshot. + """ + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + snapshot_window_id = client.window_id + + new_window = session.new_window(window_name="attached_window_probe") + assert new_window.window_index is not None + session.select_window(new_window.window_index) + + attached = client.attached_window + assert isinstance(attached, Window) + assert attached.window_id == new_window.window_id + assert attached.window_id != snapshot_window_id + + +def test_client_attached_pane_tracks_active_pane( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.attached_pane`` reflects the active pane in the active window.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + attached = client.attached_pane + assert isinstance(attached, Pane) + assert attached.pane_id == session.active_window.active_pane.pane_id # type: ignore[union-attr] + + +def test_client_attached_properties_return_none_after_detach( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``attached_*`` returns ``None`` after the client leaves ``list-clients``.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + assert client.attached_session is not None + assert client.attached_window is not None + assert client.attached_pane is not None + + assert client.attached_session is None + assert client.attached_window is None + assert client.attached_pane is None + + +def test_client_refresh_raises_when_client_name_is_none(server: Server) -> None: + """``Client.refresh()`` raises ``ValueError`` when ``client_name`` is unset. + + The previous ``assert isinstance(...)`` stripped under ``python -O`` and + let ``None`` flow into ``_refresh``, surfacing as a confusing downstream + error. The explicit raise keeps the failure mode loud regardless of + optimization level. + """ + client = Client(server=server) + assert client.client_name is None + + with pytest.raises(ValueError, match="client_name"): + client.refresh() + + +def test_resolve_attached_returns_full_triple_for_live_client( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``_resolve_attached`` returns ``(session, window, pane)`` for a live client.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + resolved_session, resolved_window, resolved_pane = client._resolve_attached() + + assert resolved_session is not None + assert resolved_session.session_id == session.session_id + assert resolved_window is not None + assert resolved_pane is not None + + +def test_resolve_attached_returns_none_triple_after_detach( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``_resolve_attached`` returns ``(None, None, None)`` after detach. + + Once tmux no longer reports this ``client_name``, the refresh raises + ``TmuxObjectDoesNotExist`` internally and the helper returns the + none-triple — matching :attr:`attached_session` / etc.'s contract for + a stale client name. + """ + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + # Client has detached at this point. + resolved = client._resolve_attached() + assert resolved == (None, None, None) + + +def test_resolve_attached_catches_no_active_window( + control_mode: t.Callable[..., t.Any], + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_resolve_attached`` returns ``(session, None, None)`` on NoActiveWindow. + + Patches the live session's ``active_window`` to raise + :exc:`~libtmux.exc.NoActiveWindow` (a state tmux normally prevents + but the helper still has to handle gracefully), and asserts the + helper falls back to the no-active-window triple rather than + propagating. + """ + from libtmux import exc as libtmux_exc + from libtmux.session import Session as SessionCls + + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + def raise_no_active_window(self: SessionCls) -> Window: + raise libtmux_exc.NoActiveWindow + + monkeypatch.setattr( + SessionCls, "active_window", property(raise_no_active_window) + ) + + resolved_session, resolved_window, resolved_pane = client._resolve_attached() + assert resolved_session is not None + assert resolved_window is None + assert resolved_pane is None diff --git a/tests/test_common.py b/tests/test_common.py index f9c6c0968..59f926eaa 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,66 @@ 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:") + + +def test_raise_if_stderr_str_shape_exact(session: libtmux.Session) -> None: + """Lock down ``str(exc)`` and ``exc.args[0]`` against future drift. + + The breaking-change documentation promises a flat string in + ``str(exc)`` and a flat string in ``exc.args[0]``. If a future change + re-introduces a list-shaped ``proc.stderr`` into ``LibTmuxException``, + this test catches it where ``startswith`` / substring matches won't. + """ + from libtmux.common import raise_if_stderr + + proc = session.cmd("last-window") + assert proc.stderr == ["no last window"] + + with pytest.raises(exc.LibTmuxException) as excinfo: + raise_if_stderr(proc, "last-window") + + assert str(excinfo.value) == "last-window: no last window" + assert excinfo.value.args == ("no last window",) + assert excinfo.value.subcommand == "last-window" diff --git a/tests/test_neo.py b/tests/test_neo.py new file mode 100644 index 000000000..437ede47e --- /dev/null +++ b/tests/test_neo.py @@ -0,0 +1,150 @@ +"""Tests for libtmux.neo scope+version gated -F template builder. + +These tests exercise :func:`libtmux.neo.get_output_format` and +:func:`libtmux.neo._token_scope` directly — pure-Python unit tests that +don't need a live tmux server. Scope and version classifications were +verified against tmux's ``format.c`` (see commit messages on +``parity-pt-2``). +""" + +from __future__ import annotations + +import pytest + +from libtmux.neo import ( + _CONTEXT_ONLY_TOKENS, + FIELD_VERSION, + SCOPES_BY_LIST_CMD, + Obj, + _token_scope, + get_output_format, +) + + +def test_pane_dead_signal_gated_to_3_3() -> None: + """``pane_dead_signal`` first registered in tmux 3.3. + + The format-table entry sits in ``format.c`` from commit a3d92093 + ("Add remain-on-exit-format"), first tagged in tmux 3.3. Emitting + it on tmux 3.2a hydrates the field with the literal ``#{...}`` + text rather than an empty value, which downstream code interprets + as a live signal — a real footgun. + """ + assert FIELD_VERSION["pane_dead_signal"] == "3.3" + fields_old, _ = get_output_format("list-panes", "3.2a") + assert "pane_dead_signal" not in fields_old + fields_new, _ = get_output_format("list-panes", "3.3") + assert "pane_dead_signal" in fields_new + + +def test_pane_dead_time_gated_to_3_3() -> None: + """``pane_dead_time`` first registered in tmux 3.3. + + Same provenance as ``pane_dead_signal`` — both tokens shipped in + the same upstream commit, so they share a version floor. + """ + assert FIELD_VERSION["pane_dead_time"] == "3.3" + fields_old, _ = get_output_format("list-panes", "3.2a") + assert "pane_dead_time" not in fields_old + fields_new, _ = get_output_format("list-panes", "3.3") + assert "pane_dead_time" in fields_new + + +def test_field_version_keys_are_obj_fields() -> None: + """Every gated field name must exist on :class:`libtmux.neo.Obj`. + + A typo in ``FIELD_VERSION`` would silently no-op; this catches + drift between the dataclass and the version-gate table. + """ + from libtmux.neo import Obj + + obj_fields = set(Obj.__dataclass_fields__) + for token in FIELD_VERSION: + assert token in obj_fields, ( + f"{token!r} in FIELD_VERSION but not declared on Obj" + ) + + +def test_scopes_by_list_cmd_downward_cascade() -> None: + """Every ``list-*`` admits universal+session+window+pane scopes. + + ``list-clients`` additionally admits ``client`` scope. This pins + the cascade asymmetry documented at ``neo.py`` SCOPES_BY_LIST_CMD. + """ + for cmd, scopes in SCOPES_BY_LIST_CMD.items(): + assert {"universal", "session", "window", "pane"} <= scopes, ( + f"{cmd} should admit the downward-cascade core scopes" + ) + assert "client" in SCOPES_BY_LIST_CMD["list-clients"] + assert "client" not in SCOPES_BY_LIST_CMD["list-sessions"] + assert "client" not in SCOPES_BY_LIST_CMD["list-windows"] + assert "client" not in SCOPES_BY_LIST_CMD["list-panes"] + + +@pytest.mark.parametrize("token", sorted(_CONTEXT_ONLY_TOKENS)) +def test_context_only_token_scope(token: str) -> None: + """Tokens registered outside ``format.c`` route to the ``context`` scope. + + ``command_list_*`` is only registered by ``cmd-list-commands.c``; + ``search_match`` by ``window-copy.c``; ``current_file`` by ``cfg.c``. + None resolve via ``format_defaults`` for any ``list-*``, so they + should not land in the universal bucket where they'd be emitted + in every ``-F`` template. + """ + assert _token_scope(token) == "context" + + +@pytest.mark.parametrize("list_cmd", sorted(SCOPES_BY_LIST_CMD)) +def test_context_scope_excluded_from_every_list_cmd(list_cmd: str) -> None: + """``"context"`` is excluded from every ``SCOPES_BY_LIST_CMD`` entry. + + The exclusion is the structural guarantee that context-only tokens + don't drift into any ``-F`` template. If a future change accidentally + admits ``"context"`` for a list subcommand, this test catches it. + """ + assert "context" not in SCOPES_BY_LIST_CMD[list_cmd] + + +@pytest.mark.parametrize("token", sorted(_CONTEXT_ONLY_TOKENS)) +def test_context_tokens_absent_from_every_list_cmd_template(token: str) -> None: + """Context-only tokens never appear in any ``list-*`` ``-F`` template.""" + for list_cmd in SCOPES_BY_LIST_CMD: + fields, _ = get_output_format(list_cmd, "3.6a") + assert token not in fields, ( + f"{token!r} (context-only) leaked into {list_cmd} template" + ) + + +def test_token_scope_unknown_for_unclassified_field() -> None: + """``_token_scope`` returns ``"unknown"`` for any unrecognized field. + + ``"unknown"`` must be absent from every :data:`SCOPES_BY_LIST_CMD` + entry, so a future field added without classification is silently + excluded from every ``-F`` template rather than emitted under a list + command where it might crash older tmux. + """ + assert _token_scope("libtmux_test_nonexistent_token") == "unknown" + for allowed in SCOPES_BY_LIST_CMD.values(): + assert "unknown" not in allowed + + +def test_every_obj_field_classifies_to_known_scope() -> None: + """Every declared ``Obj`` field must classify to a known scope. + + Adding a new field without a matching prefix / override / + known-token table entry would silently exclude it from every + ``list-*`` template (via the fail-closed default). This test + surfaces that misclassification as a deterministic failure rather + than a runtime hydration-as-None. + """ + unclassified: list[str] = [] + for name in Obj.__dataclass_fields__: + if name == "server": + continue + if _token_scope(name) == "unknown": + unclassified.append(name) + assert not unclassified, ( + "Obj fields with no scope classification " + "(add them to _SCOPE_OVERRIDES, _SCOPE_PREFIXES, " + f"_UNIVERSAL_TOKENS, or _CONTEXT_ONLY_TOKENS): {unclassified}" + ) diff --git a/tests/test_pane.py b/tests/test_pane.py index 3eeff4625..483fc3a9e 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -16,6 +16,7 @@ if t.TYPE_CHECKING: from libtmux._internal.types import StrPath + from libtmux.pane import Pane from libtmux.session import Session logger = logging.getLogger(__name__) @@ -547,6 +548,200 @@ 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) + + +NEW_TMUX_FORMAT_FIELDS = ( + # Pane-scope tokens registered in tmux master post-3.6a. + "pane_zoomed_flag", + "pane_floating_flag", + "pane_flags", + "pane_pb_state", + "pane_pb_progress", + "pane_pipe_pid", + # Server-scope tokens. + "synchronized_output_flag", + "bracket_paste_flag", +) + + +@pytest.mark.parametrize("field_name", NEW_TMUX_FORMAT_FIELDS) +def test_obj_declares_post_3_6a_field(field_name: str, session: Session) -> None: + """Tmux's post-3.6a format tokens have typed slots on ``Obj``. + + Older tmux releases that don't recognize these tokens expand them to + the empty string, so the wrapper hydrates the rest of the dataclass + normally and the new fields stay ``None``. When the user upgrades + tmux, ``refresh()`` populates the fields automatically — no library + update required. + """ + pane = session.active_window.active_pane + assert pane is not None + 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 +912,15 @@ def test_display_message_flags( assert expected_in_output in output +def test_display_message_warns_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as a :class:`UserWarning`.""" + pane = session.active_window.active_pane + assert pane is not None + + with pytest.warns(UserWarning, match="only one of -F or argument"): + pane.display_message("x", get_text=True, format_string="#{pane_id}") + + def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" from libtmux.common import has_gte_version @@ -1280,3 +1484,109 @@ 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) + + +def test_pane_reset_targets_non_active_pane(session: Session) -> None: + """Pane.reset() clears the target pane, not tmux's cmdq default. + + Regresses the bundled-IPC fix for the race-and-misroute combination: + the previous two-call form raced under busy pane writers, and a naïve + one-call form (``send-keys -R ; clear-history`` with one ``-t``) would + route ``clear-history`` to tmux's default pane because the ``;`` + separator doesn't propagate ``-t`` across subcommands. The fix passes + ``-t`` on both subcommands, so reset() must clear the *target* pane's + scrollback while leaving any active sibling pane untouched. + """ + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_reset_targets", + window_shell=f"{env} PS1='$ ' sh", + ) + # `attach=False` keeps the original pane active; the newly-split pane + # is the non-active one. Call reset() on the non-active pane so a + # missing -t on clear-history would route to the active sibling + # instead. + active_sibling = window.active_pane + assert active_sibling is not None + target = window.split( + shell=f"{env} PS1='$ ' sh", + attach=False, + ) + assert target.pane_id != active_sibling.pane_id + + window.refresh() + assert window.active_pane is not None + assert window.active_pane.pane_id == active_sibling.pane_id + + # Push enough output through both panes to accumulate scrollback. + # `seq 1 200` scrolls past the default 24-row visible region. + def _history_size(pane: Pane) -> int: + line = pane.cmd("display-message", "-p", "#{history_size}").stdout[0] + return int(line) + + retry_until( + lambda: "$" in "\n".join(active_sibling.capture_pane()), + 2, + raises=True, + ) + active_sibling.send_keys("seq 1 200", enter=True) + retry_until(lambda: _history_size(active_sibling) > 0, 3, raises=True) + + retry_until(lambda: "$" in "\n".join(target.capture_pane()), 2, raises=True) + target.send_keys("seq 1 200", enter=True) + retry_until(lambda: _history_size(target) > 0, 3, raises=True) + + target_pre = _history_size(target) + sibling_pre = _history_size(active_sibling) + assert target_pre > 0 + assert sibling_pre > 0 + + target.reset() + + # Target pane: history cleared. + assert _history_size(target) == 0 + # Active sibling pane: untouched. (Under a missing-target clear-history, + # this would have been wiped because it is the active pane.) + assert _history_size(active_sibling) == sibling_pre 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..cc14a35f2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -117,6 +117,50 @@ 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``). + + Pins the cascade *target*: ``session.window_id`` / + ``session.pane_id`` must equal the session's current window's + active pane — not any arbitrary pane in the session. A regression + that hydrated to the wrong window or pane would still pass a + "not None" check; this assertion catches that drift. + """ + new_session = 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 == new_session.active_window.window_id + active_pane = new_session.active_window.active_pane + assert active_pane is not None + assert fetched.pane_id == active_pane.pane_id + 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. The cascade resolves to the window's active pane, so the + hydrated ``pane_id`` must equal ``window.active_pane.pane_id``. + """ + new_window = session.new_window(window_name="hydration_cascade_windows") + fetched = server.windows.get(window_name="hydration_cascade_windows") + assert fetched is not None + active_pane = new_window.active_pane + assert active_pane is not None + assert fetched.pane_id == active_pane.pane_id + 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 +1063,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 +1255,113 @@ 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_server_clients_propagates_errors( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.clients`` re-raises tmux errors instead of swallowing them. + + The wrapper used to ``except Exception: pass`` and return an empty + QueryList on any failure, masking real tmux errors as "no clients". + A genuine failure should surface so callers can react. + """ + sentinel = exc.LibTmuxException("simulated list-clients failure") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.LibTmuxException, match="simulated list-clients failure"): + server.clients # noqa: B018 + + +def test_server_search_sessions_propagates_errors( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.search_sessions`` re-raises tmux errors instead of swallowing them. + + Mirrors the clients-propagation contract: errors are surfaced, not + buried as an empty QueryList that's indistinguishable from "filter + matched nothing". + """ + sentinel = exc.LibTmuxException("simulated list-sessions failure") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.LibTmuxException, match="simulated list-sessions failure"): + server.search_sessions(filter="#{m:keep_*,#{session_name}}") + + def test_if_shell_true(server: Server) -> None: """Test Server.if_shell() with true condition.""" server.new_session(session_name="ifshell_test") @@ -1292,3 +1510,137 @@ 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.""" + 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_warns_on_tmux_error( + server: Server, + session: Session, +) -> None: + """Tmux stderr on ``display-message`` surfaces as a :class:`UserWarning`. + + 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 surface that to the caller without silently + swallowing it. + """ + with pytest.warns(UserWarning, match="only one of -F or argument"): + server.display_message("x", get_text=True, format_string="#{version}") 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..c826b0ca5 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1058,3 +1058,199 @@ 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_warns_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as a :class:`UserWarning`.""" + window = session.active_window + with pytest.warns(UserWarning, match="only one of -F or argument"): + window.display_message("x", get_text=True, format_string="#{window_id}") + + +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)