Skip to content
Merged
96 changes: 94 additions & 2 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,47 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Breaking changes

#### Query errors join the libtmux exception hierarchy (#718)

{exc}`~libtmux.exc.ObjectDoesNotExist` and
{exc}`~libtmux.exc.MultipleObjectsReturned` now subclass
{exc}`~libtmux.exc.LibTmuxException`. Because
{exc}`~libtmux.exc.TmuxObjectDoesNotExist` inherits from
{exc}`~libtmux.exc.ObjectDoesNotExist`, it now falls under
{exc}`~libtmux.exc.LibTmuxException` as well. Previously, a lookup-specific
handler could follow the common libtmux handler:

```python
from libtmux import exc

try:
pane = server.panes.get(pane_id="%0")
except exc.LibTmuxException:
pane = None
except exc.ObjectDoesNotExist:
pane = None
```

Put the more specific handler first:

```python
from libtmux import exc

try:
pane = server.panes.get(pane_id="%0")
except exc.ObjectDoesNotExist:
pane = None
except exc.LibTmuxException:
pane = None
```

Blanket retry policies for {exc}`~libtmux.exc.LibTmuxException` should exclude
{exc}`~libtmux.exc.ObjectDoesNotExist` and
{exc}`~libtmux.exc.MultipleObjectsReturned`: they report deterministic missing
or ambiguous lookups, not transient tmux failures.

### What's new

#### Find where you are running (#714)
Expand All @@ -67,15 +108,32 @@ See {ref}`self-location` for the whole story — why the session id tmux exports
goes stale, the window that *contains* you versus the one in front of you, and
how to test code that locates itself.

#### Ask a window which sessions hold it (#718)

{attr}`~libtmux.Window.linked_sessions` lists every
{class}`~libtmux.Session` from which a window is reachable. A typical window
returns one session; a linked or grouped window can return several, with each
session appearing once even when it holds the same window at multiple indexes.

Use {attr}`~libtmux.Window.linked_sessions` when you need every holder.
{attr}`~libtmux.Window.session` follows the `session_id` recorded on that
{class}`~libtmux.Window` instance; use
{meth}`~libtmux.Window.from_window_id` when you need tmux to resolve a window id
afresh.
See {ref}`winlinks` for the relationship between sessions, indexes, and shared
windows.

### Fixes

#### Linked windows resolve to the session tmux would use (#713)

A window can be linked into several sessions at once, and libtmux used to pick
one of them by accident — whichever sorted last by name. So
{meth}`Window.from_window_id() <libtmux.Window.from_window_id>`,
{meth}`Pane.from_pane_id() <libtmux.Pane.from_pane_id>` and both `refresh()`
methods could change their answer when you renamed a session.
{meth}`Pane.from_pane_id() <libtmux.Pane.from_pane_id>`,
{meth}`Window.refresh() <libtmux.Window.refresh>` and
{meth}`Pane.refresh() <libtmux.Pane.refresh>` could change their answer when you
renamed a session.

libtmux now lets tmux resolve the object, so a window reports the session tmux
itself would act on. For a window in a single session — the ordinary case —
Expand All @@ -85,6 +143,40 @@ sessions holding it, where before it was stable but arbitrary. The lookups also
got cheaper: they list one window's panes or one session's windows instead of
scanning the whole server.

#### A window linked twice now reports tmux's index (#718)

tmux lets one session link the same window at multiple indexes.
{meth}`Window.from_window_id() <libtmux.Window.from_window_id>` and
{meth}`Window.refresh() <libtmux.Window.refresh>` now give
{attr}`Window.window_index <libtmux.Window.window_index>` the index tmux would
choose: the current link when that window is current, otherwise the
lowest-indexed link. Windows held at one index are unaffected.

#### Missing and ambiguous lookups explain what happened (#718)

{meth}`QueryList.get() <libtmux._internal.query_list.QueryList.get>` now names
both the lookup and its missing or ambiguous outcome. A missing
`pane_id="%99"` raises {exc}`~libtmux.exc.ObjectDoesNotExist` with
`No objects found: pane_id='%99'`; two matches for `pane_id="%0"` raise
{exc}`~libtmux.exc.MultipleObjectsReturned` with
`Multiple objects returned (2): pane_id='%0'`.

### Documentation

#### Understand winlinks in server-wide listings (#718)

{ref}`winlinks` explains that
{attr}`Server.windows <libtmux.Server.windows>` and
{attr}`Server.panes <libtmux.Server.panes>` enumerate {term}`winlinks <winlink>`
— the session, index, and window relationships tmux stores — rather than
deduplicating windows. A shared window can therefore appear once for each path
by which a session reaches it.

Read those rows when you need their session and index context. For a unique
lookup by id, use {meth}`Pane.from_pane_id() <libtmux.Pane.from_pane_id>` or
{meth}`Window.from_window_id() <libtmux.Window.from_window_id>`; use
{attr}`~libtmux.Window.linked_sessions` when you need every holding session.

## libtmux 0.61.0 (2026-07-04)

libtmux 0.61.0 hardens support for the tmux 3.7 patch line. It fixes
Expand Down
65 changes: 65 additions & 0 deletions MIGRATION
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,71 @@ sections below for detailed migration examples and code samples.
_Detailed migration steps for the next version will be posted here._
<!-- END PLACEHOLDER - ADD NEW MIGRATION ENTRIES BELOW THIS LINE -->

## libtmux 0.62.x: Query exceptions join the hierarchy (#718)

{exc}`~libtmux.exc.ObjectDoesNotExist` and
{exc}`~libtmux.exc.MultipleObjectsReturned` now subclass
{exc}`~libtmux.exc.LibTmuxException`.
{exc}`~libtmux.exc.TmuxObjectDoesNotExist` already subclasses
{exc}`~libtmux.exc.ObjectDoesNotExist`, so it follows the same hierarchy.

Before 0.62, a broad libtmux handler could precede lookup-specific handlers
because the exception families were separate:

```python
from libtmux import exc

try:
pane = server.panes.get(pane_id="%0")
pane.refresh()
except exc.LibTmuxException:
retry_tmux_operation()
except exc.TmuxObjectDoesNotExist:
handle_disappeared_pane()
except exc.ObjectDoesNotExist:
handle_missing_pane()
except exc.MultipleObjectsReturned:
handle_ambiguous_pane()
```

Starting with 0.62, order the handlers from most specific to most general so
the broad clause does not intercept lookup outcomes:

```python
from libtmux import exc

try:
pane = server.panes.get(pane_id="%0")
pane.refresh()
except exc.TmuxObjectDoesNotExist:
handle_disappeared_pane()
except exc.ObjectDoesNotExist:
handle_missing_pane()
except exc.MultipleObjectsReturned:
handle_ambiguous_pane()
except exc.LibTmuxException:
retry_tmux_operation()
```

Blanket retry predicates for {exc}`~libtmux.exc.LibTmuxException` now also
match deterministic missing and ambiguous lookups. Exclude both lookup
exceptions explicitly:

```python
def should_retry(error: Exception) -> bool:
lookup_errors = (
exc.ObjectDoesNotExist,
exc.MultipleObjectsReturned,
)
return isinstance(error, exc.LibTmuxException) and not isinstance(
error,
lookup_errors,
)
```

The {exc}`~libtmux.exc.ObjectDoesNotExist` exclusion also covers
{exc}`~libtmux.exc.TmuxObjectDoesNotExist`.

## libtmux 0.57.0: Subcommand-tagged exceptions (#672)

### `LibTmuxException` `str()` gains a subcommand prefix
Expand Down
12 changes: 12 additions & 0 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ Pane

a pseudoterminal.

winlink
The link between a {term}`session` and a {term}`window`: the triple
``(session, index, window)``.

A window does not live *in* one session; a session holds *links* to
windows, each at an index. ``link-window`` adds another link to the same
window, so one window can be reachable from several sessions -- and even
from one session at two indexes.

This is what tmux enumerates: a row of ``list-windows`` or
``list-panes -a`` names a winlink, not a window. See {ref}`winlinks`.

Target
A target, cited in the manual as ``[-t target]`` can be a session,
window or pane.
Expand Down
116 changes: 114 additions & 2 deletions docs/topics/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,20 @@ Window(@... ..., Session($... ...))
`get()` insists on exactly one result. If the query matches the wrong number of
objects, it raises:

- {exc}`~libtmux._internal.query_list.ObjectDoesNotExist` - no matching object found
- {exc}`~libtmux._internal.query_list.MultipleObjectsReturned` - more than one object matches
- {exc}`~libtmux.exc.ObjectDoesNotExist` - no matching object found
- {exc}`~libtmux.exc.MultipleObjectsReturned` - more than one object matches

Both are {exc}`~libtmux.exc.LibTmuxException`s, so one `except` clause catches
either, and both say what they went looking for:

```python
>>> from libtmux import exc
>>> try:
... session.windows.get(window_name="nonexistent")
... except exc.LibTmuxException as e:
... print(e)
No objects found: window_name='nonexistent'
```

Pass a `default` to get a fallback value back instead of an exception:

Expand All @@ -115,6 +127,106 @@ Pass a `default` to get a fallback value back instead of an exception:
True
```

A `default` stands in for an object that is *absent*, so it does not apply when
a query is merely ambiguous — {exc}`~libtmux.exc.MultipleObjectsReturned` is
raised whether or not you passed one. Handing back one of several equally valid
matches is how you end up driving the wrong pane. The next section is about the
one case where an ambiguous match is routine rather than a mistake.

(winlinks)=

## When one window is in two sessions

A server-wide collection — {attr}`server.windows <libtmux.Server.windows>` and
{attr}`server.panes <libtmux.Server.panes>` — does not enumerate windows. It
enumerates {term}`winlinks <winlink>`: the `(session, index, window)` edges tmux
actually stores. Nearly always there is exactly one edge per window and you never
notice the difference.

Sharing a window adds edges. `link-window` does it explicitly, and a grouped
session (`tmux new-session -t existing`, the mechanism behind [tmuxp](https://tmuxp.git-pull.com/)'s session
groups) does it for every window at once. The window is then genuinely reachable
from each session that links it, and a server-wide listing reports it once per
edge:

```python
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )

>>> len(server.windows.filter(window_id=shared.window_id))
2
```

Two rows for one `window_id` is not a miscount — it is the shape of the data.
The window is in both sessions, and a listing that collapsed the rows would be
throwing away the very fact you need.

The consequence is that a *point lookup* against a server-wide collection can be
ambiguous, and says so rather than guessing:

```python
>>> from libtmux import exc
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )

>>> try:
... server.windows.get(window_id=shared.window_id)
... except exc.MultipleObjectsReturned as e:
... print(e) # doctest: +ELLIPSIS
Multiple objects returned (2): window_id='@...'
```

### Which sessions hold it?

{attr}`Window.linked_sessions <libtmux.Window.linked_sessions>` answers directly,
listing each holding session once however many indexes it links the window at:

```python
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )

>>> sorted(s.session_name for s in shared.linked_sessions)
['guest', 'home']
```

### Just fetch the object

When you have an id and want the object, don't scan a listing for it — name it.
{meth}`Pane.from_pane_id <libtmux.Pane.from_pane_id>` and
{meth}`Window.from_window_id <libtmux.Window.from_window_id>` hand the id to tmux
with a `-t` target, and tmux always resolves it to exactly one object — the same
one it would act on if you typed the command yourself. They cannot be ambiguous,
so they are the right tool for a lookup by id:

```python
>>> from libtmux.window import Window
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )

>>> Window.from_window_id(server, shared.window_id).window_id == shared.window_id
True
```

Reserve the server-wide collections for what they are good at — sweeping the
whole server — and reach for them with {meth}`~libtmux._internal.query_list.QueryList.filter`,
which is happy to return two rows, rather than `get()`, which is not.

## Chaining filters

You can stack conditions two ways, and both narrow with AND. Pass several
Expand Down
Loading
Loading