diff --git a/AGENTS.md b/AGENTS.md index de323f3ea..c94a20de9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -561,6 +561,23 @@ libtmux uses tmux's format system extensively: - Use refresh methods (e.g., `session.refresh()`) to update object state - Alternative: use `neo.py` query interface for fresh data +### List-returning accessors: empty by default on tmux errors + +`Server.sessions`, `Server.clients`, and `Server.attached_sessions` +return an empty `QueryList` when tmux's underlying list command fails +for any reason — no running daemon, a missing socket, a permission +error, a subprocess crash. This is a deliberate API contract: +list-shaped accessors are lenient by default. Callers that need to +distinguish "no rows" from "tmux unreachable" use the explicit +`Server.is_alive()` or `Server.raise_if_dead()` primitives. + +When adding a new list-returning accessor, follow this convention. If +a future feature genuinely benefits from loud-failure semantics, expose +it as a scoped opt-in (e.g. a `Server.raise_server_errors()` context +manager) rather than changing the default contract of an existing +accessor or hard-coding raise-on-tmux-error into a new one. +Empty-on-tmux-error stays the default; raise is opt-in. + ## References - Documentation: https://libtmux.git-pull.com/ diff --git a/CHANGES b/CHANGES index d15d14ea6..848bd0488 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,37 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +Restores the "lenient-by-default" behavior for +{attr}`~libtmux.Server.sessions` and {attr}`~libtmux.Server.clients` that +was changed in 0.57.0. + +### Behavioral Changes + +#### Lenient `Server.sessions` and `Server.clients` accessors + +{attr}`~libtmux.Server.sessions` and {attr}`~libtmux.Server.clients` once +again return an empty {class}`~libtmux._internal.query_list.QueryList` +when tmux fails for any reason (permission errors, subprocess crashes, +etc.). + +This reverts a change in 0.57.0 that made these accessors raise +{exc}`~libtmux.exc.LibTmuxException`. The restored behavior matches the +contract {attr}`~libtmux.Server.sessions` has followed since 0.17.0. + +- **Upgrade path:** Code that added ``try/except LibTmuxException`` blocks around + these accessors for 0.57.0 can now remove them. +- **Connectivity checks:** If you need to distinguish between "no results" and + "tmux is unreachable", use the explicit {meth}`~libtmux.Server.is_alive` or + {meth}`~libtmux.Server.raise_if_dead` methods. + +#### Stricter `search_*` methods + +The newer {meth}`~libtmux.Server.search_sessions`, +{meth}`~libtmux.Server.search_windows`, and +{meth}`~libtmux.Server.search_panes` continue to raise on tmux errors. +Since these methods take a caller-supplied filter, a tmux failure is +considered a meaningful error signal that should not be swallowed. + ## libtmux 0.57.0 (2026-05-17) libtmux 0.57.0 broadens tmux support around attached clients, tmux-native diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 9cc40e034..30ec0d699 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -2298,19 +2298,19 @@ def sessions(self) -> QueryList[Session]: :meth:`.sessions.get() ` and :meth:`.sessions.filter() ` - Raises - ------ - :exc:`~libtmux.exc.LibTmuxException` - When tmux's ``list-sessions`` fails for a reason other than - a not-yet-started server, such as socket permission errors or - unsupported tmux flags. A server with no sessions, or a server - before its daemon has started, returns an empty - :class:`~libtmux._internal.query_list.QueryList`. + Returns an empty :class:`~libtmux._internal.query_list.QueryList` when + tmux's ``list-sessions`` fails for any reason — no running daemon, a + missing socket, a permission error, or a subprocess failure. To + distinguish "no sessions" from "tmux unreachable", call + :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. """ - sessions: list[Session] = [ - Session(server=self, **obj) - for obj in _fetch_or_empty(server=self, list_cmd="list-sessions") - ] + try: + sessions: list[Session] = [ + Session(server=self, **obj) + for obj in fetch_objs(server=self, list_cmd="list-sessions") + ] + except exc.LibTmuxException: + return QueryList([]) return QueryList(sessions) @property @@ -2359,6 +2359,12 @@ def clients(self) -> QueryList[Client]: returns the typed view; ``client.client_readonly``, ``client.client_termtype``, ``client.client_session`` etc. read tmux's ``client_*`` format tokens. + Returns an empty :class:`~libtmux._internal.query_list.QueryList` when + tmux's ``list-clients`` fails for any reason — no running daemon, a + missing socket, a permission error, or a subprocess failure. To + distinguish "no clients attached" from "tmux unreachable", call + :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + Returns ------- :class:`~libtmux._internal.query_list.QueryList` of :class:`Client` @@ -2370,10 +2376,13 @@ def clients(self) -> QueryList[Client]: ... ctl.client_name in names True """ - clients: list[Client] = [ - Client(server=self, **obj) - for obj in _fetch_or_empty(server=self, list_cmd="list-clients") - ] + try: + clients: list[Client] = [ + Client(server=self, **obj) + for obj in fetch_objs(server=self, list_cmd="list-clients") + ] + except exc.LibTmuxException: + return QueryList([]) return QueryList(clients) def search_sessions( diff --git a/tests/test_server.py b/tests/test_server.py index 6f2e8df6d..01091d322 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1322,15 +1322,16 @@ def test_server_search_panes_filter_by_id(server: Server) -> None: assert [p.pane_id for p in matches] == [target.pane_id] -def test_server_clients_propagates_errors( +def test_server_clients_returns_empty_on_tmux_error( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """``Server.clients`` re-raises tmux errors instead of swallowing them. + """``Server.clients`` returns an empty QueryList on tmux failure. - 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. + Lenient-by-default contract: ``list-clients`` failing for any reason + yields ``QueryList([])``, matching the historic shape of + :attr:`Server.sessions`. Callers needing a connectivity check should + use :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. """ sentinel = exc.LibTmuxException("simulated list-clients failure") @@ -1338,8 +1339,7 @@ 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 + assert list(server.clients) == [] def test_server_search_sessions_propagates_errors( @@ -1362,16 +1362,17 @@ def _boom(**_: object) -> list[dict[str, str]]: server.search_sessions(filter="#{m:keep_*,#{session_name}}") -def test_server_sessions_propagates_errors( +def test_server_sessions_returns_empty_on_tmux_error( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """``Server.sessions`` re-raises tmux errors instead of swallowing them. + """``Server.sessions`` returns an empty QueryList on tmux failure. - Closes the gap left when the clients/search_sessions accessors moved - off the legacy ``except Exception: pass`` shape but ``Server.sessions`` - stayed behind. A genuine list-sessions failure should surface the - same way for all three accessors. + Pins the lenient-by-default contract: a ``list-sessions`` failure — + daemon down, missing socket, permission error, subprocess crash — + yields ``QueryList([])`` rather than propagating. Callers that need + to distinguish "no sessions" from "tmux unreachable" should use + :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. """ sentinel = exc.LibTmuxException("simulated list-sessions failure") @@ -1379,8 +1380,7 @@ 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.sessions # noqa: B018 + assert list(server.sessions) == [] def test_server_sessions_missing_socket_returns_empty(tmp_path: pathlib.Path) -> None: @@ -1390,11 +1390,11 @@ def test_server_sessions_missing_socket_returns_empty(tmp_path: pathlib.Path) -> assert list(missing_server.sessions) == [] -def test_server_sessions_permission_error_propagates( +def test_server_sessions_permission_error_returns_empty( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Connection errors other than missing-daemon states still raise.""" + """Connection errors are absorbed into the empty-list contract too.""" sentinel = exc.LibTmuxException( "error connecting to /root/libtmux-review.sock (Permission denied)" ) @@ -1403,8 +1403,7 @@ def _boom(**_: object) -> list[dict[str, str]]: raise sentinel monkeypatch.setattr("libtmux.server.fetch_objs", _boom) - with pytest.raises(exc.LibTmuxException, match="Permission denied"): - server.sessions # noqa: B018 + assert list(server.sessions) == [] def test_if_shell_true(server: Server) -> None: