diff --git a/CHANGES b/CHANGES index eb0ac5620..923ffb8c2 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,14 @@ $ 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. ### Documentation #### Cleaner `from_env` examples (#719) @@ -113,6 +121,52 @@ Blanket retry policies for {exc}`~libtmux.exc.LibTmuxException` should exclude {exc}`~libtmux.exc.MultipleObjectsReturned`: they report deterministic missing or ambiguous lookups, not transient tmux failures. +### Breaking changes + +#### `LibTmuxException` string form gains a subcommand prefix (#670) + +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 #### Find where you are running (#714) @@ -332,6 +386,17 @@ unaffected. ## libtmux 0.58.1 (2026-06-16) +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. libtmux 0.58.1 restores compatibility with pytest 9.1. The bundled pytest plugin no longer aborts at import time, so projects that rely on libtmux's fixtures can move to the latest pytest without their test @@ -552,6 +617,12 @@ Caveat: tmux silently expands a malformed filter expression to empty, which it treats as false — a typo looks identical to "no matches". Verify filter syntax against the FORMATS section of ``tmux(1)``. +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 (#670) #### `Pane.send_keys(cmd=None, …)` flag-only invocation (#672) {meth}`~libtmux.Pane.send_keys` accepts ``cmd=None`` together with @@ -562,6 +633,10 @@ 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. and ``filter`` (``-f``) kwargs. Callers can ask tmux to return selected fields (e.g. ``"#{buffer_name}"``) or only buffers matching an expression — same bad-filter caveat as the ``search_*`` methods. @@ -574,6 +649,7 @@ 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)` (#670) #### `Pane.capture_pane(pending=True)` (#672) {meth}`~libtmux.Pane.capture_pane` gains a ``pending`` kwarg that @@ -581,6 +657,40 @@ returns bytes tmux has read from the pane but not yet committed to the terminal — useful for diagnosing programs whose output stalls mid-sequence. +#### Subcommand-tagged exceptions (#670) + +{exc}`~libtmux.exc.LibTmuxException` takes an optional ``subcommand`` +attribute. When set, ``str(exc)`` prefixes the originating tmux +command name (e.g. ``"last-window: no such window"``), giving +downstream consumers a stable way to dispatch on which tmux command +produced the error. {func}`~libtmux.common.raise_if_stderr` is the +shared helper most commands use to populate it. + +#### Scope-aware format-token retrieval (#670) + +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. #### More format-token fields on tmux objects (#672) libtmux now asks each ``list-*`` subcommand for the format tokens that make @@ -603,10 +713,29 @@ pane." See {ref}`format-tokens` for details. - {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). - New {ref}`format-tokens` topic explains why some fields describe an active child object, such as ``session.pane_id`` reflecting the active pane in the session's current window (#672). diff --git a/MIGRATION b/MIGRATION index cbcf307f2..5ec2c0b72 100644 --- a/MIGRATION +++ b/MIGRATION @@ -295,6 +295,118 @@ with warnings.catch_warnings(): result = pane.display_message("#{pane_id}", get_text=True) ``` +## libtmux 0.57.0: Subcommand-tagged exceptions (#670) + +### `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) ### New unified options API diff --git a/tests/test_pane.py b/tests/test_pane.py index 416032ce6..4059c0c26 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -613,6 +613,7 @@ def test_send_keys_flag_only_requires_a_flag(session: Session) -> None: "pane_format", "pane_in_mode", "pane_input_off", + "pane_key_mode", "pane_last", "pane_marked", "pane_marked_set", @@ -620,6 +621,7 @@ def test_send_keys_flag_only_requires_a_flag(session: Session) -> None: "pane_path", "pane_pipe", "pane_synchronized", + "pane_unseen_changes", ) @@ -646,6 +648,39 @@ def test_pane_format_field_declared_and_hydrated( 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 diff --git a/tests/test_session.py b/tests/test_session.py index f7d95e4cc..6b7adb77c 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -709,12 +709,17 @@ def test_session_search_panes_filter_by_id(session: Session) -> None: 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", )