From 58b4ffe24ccfbd8e3fb5d8f26d62aeaf019f6b81 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 08:58:01 -0600 Subject: [PATCH 1/4] Server, Session, Window, Pane: Add ContextManagers --- src/libtmux/pane.py | 44 ++++++++++++++++++++++++++++++++++++++++++ src/libtmux/server.py | 41 +++++++++++++++++++++++++++++++++++++++ src/libtmux/session.py | 41 +++++++++++++++++++++++++++++++++++++++ src/libtmux/window.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 76208c61f..895d83f89 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -13,6 +13,8 @@ import typing as t import warnings +from typing_extensions import Self + from libtmux.common import has_gte_version, has_lt_version, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, @@ -26,6 +28,8 @@ from . import exc if t.TYPE_CHECKING: + import types + from .server import Server from .session import Session from .window import Window @@ -59,6 +63,13 @@ class Pane(Obj): >>> pane.session Session($1 ...) + The pane can be used as a context manager to ensure proper cleanup: + + >>> with window.split() as pane: + ... pane.send_keys('echo "Hello"') + ... # Do work with the pane + ... # Pane will be killed automatically when exiting the context + Notes ----- .. versionchanged:: 0.8 @@ -77,6 +88,39 @@ class Pane(Obj): server: Server + def __enter__(self) -> Self: + """Enter the context, returning self. + + Returns + ------- + :class:`Pane` + The pane instance + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Exit the context, killing the pane if it exists. + + Parameters + ---------- + exc_type : type[BaseException] | None + The type of the exception that was raised + exc_value : BaseException | None + The instance of the exception that was raised + exc_tb : types.TracebackType | None + The traceback of the exception that was raised + """ + if ( + self.pane_id is not None + and len(self.window.panes.filter(pane_id=self.pane_id)) > 0 + ): + self.kill() + def refresh(self) -> None: """Refresh pane attributes from tmux.""" assert isinstance(self.pane_id, str) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 9b37e4f02..511329470 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -15,6 +15,8 @@ import typing as t import warnings +from typing_extensions import Self + from libtmux._internal.query_list import QueryList from libtmux.common import tmux_cmd from libtmux.neo import fetch_objs @@ -33,6 +35,8 @@ ) if t.TYPE_CHECKING: + import types + from typing_extensions import TypeAlias DashLiteral: TypeAlias = t.Literal["-"] @@ -79,6 +83,13 @@ class Server(EnvironmentMixin): >>> server.sessions[0].active_pane Pane(%1 Window(@1 1:..., Session($1 ...))) + The server can be used as a context manager to ensure proper cleanup: + + >>> with Server() as server: + ... session = server.new_session() + ... # Do work with the session + ... # Server will be killed automatically when exiting the context + References ---------- .. [server_manual] CLIENTS AND SESSIONS. openbsd manpage for TMUX(1) @@ -146,6 +157,36 @@ def __init__( if on_init is not None: on_init(self) + def __enter__(self) -> Self: + """Enter the context, returning self. + + Returns + ------- + :class:`Server` + The server instance + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Exit the context, killing the server if it exists. + + Parameters + ---------- + exc_type : type[BaseException] | None + The type of the exception that was raised + exc_value : BaseException | None + The instance of the exception that was raised + exc_tb : types.TracebackType | None + The traceback of the exception that was raised + """ + if self.is_alive(): + self.kill() + def is_alive(self) -> bool: """Return True if tmux server alive. diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 1b1fc260d..fd5bb36da 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -13,6 +13,8 @@ import typing as t import warnings +from typing_extensions import Self + from libtmux._internal.query_list import QueryList from libtmux.constants import WINDOW_DIRECTION_FLAG_MAP, WindowDirection from libtmux.formats import FORMAT_SEPARATOR @@ -31,6 +33,8 @@ ) if t.TYPE_CHECKING: + import types + from libtmux.common import tmux_cmd from .server import Server @@ -63,6 +67,13 @@ class Session(Obj, EnvironmentMixin): >>> session.active_pane Pane(%1 Window(@1 ...:..., Session($1 ...))) + The session can be used as a context manager to ensure proper cleanup: + + >>> with server.new_session() as session: + ... window = session.new_window() + ... # Do work with the window + ... # Session will be killed automatically when exiting the context + References ---------- .. [session_manual] tmux session. openbsd manpage for TMUX(1). @@ -78,6 +89,36 @@ class Session(Obj, EnvironmentMixin): server: Server + def __enter__(self) -> Self: + """Enter the context, returning self. + + Returns + ------- + :class:`Session` + The session instance + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Exit the context, killing the session if it exists. + + Parameters + ---------- + exc_type : type[BaseException] | None + The type of the exception that was raised + exc_value : BaseException | None + The instance of the exception that was raised + exc_tb : types.TracebackType | None + The traceback of the exception that was raised + """ + if self.session_name is not None and self.server.has_session(self.session_name): + self.kill() + def refresh(self) -> None: """Refresh session attributes from tmux.""" assert isinstance(self.session_id, str) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index a5dc93528..bf7495650 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -13,6 +13,8 @@ import typing as t import warnings +from typing_extensions import Self + from libtmux._internal.query_list import QueryList from libtmux.common import has_gte_version, tmux_cmd from libtmux.constants import ( @@ -28,6 +30,8 @@ from .common import PaneDict, WindowOptionDict, handle_option_error if t.TYPE_CHECKING: + import types + from .server import Server from .session import Session @@ -73,6 +77,13 @@ class Window(Obj): >>> window in session.windows True + The window can be used as a context manager to ensure proper cleanup: + + >>> with session.new_window() as window: + ... pane = window.split() + ... # Do work with the pane + ... # Window will be killed automatically when exiting the context + References ---------- .. [window_manual] tmux window. openbsd manpage for TMUX(1). @@ -85,6 +96,39 @@ class Window(Obj): server: Server + def __enter__(self) -> Self: + """Enter the context, returning self. + + Returns + ------- + :class:`Window` + The window instance + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Exit the context, killing the window if it exists. + + Parameters + ---------- + exc_type : type[BaseException] | None + The type of the exception that was raised + exc_value : BaseException | None + The instance of the exception that was raised + exc_tb : types.TracebackType | None + The traceback of the exception that was raised + """ + if ( + self.window_id is not None + and len(self.session.windows.filter(window_id=self.window_id)) > 0 + ): + self.kill() + def refresh(self) -> None: """Refresh window attributes from tmux.""" assert isinstance(self.window_id, str) From e2e830770a14e071ad15ba0996154e42497a10e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 08:59:03 -0600 Subject: [PATCH 2/4] tests: Add tests for context manager --- tests/test_pane.py | 13 +++++++++++++ tests/test_server.py | 12 ++++++++++++ tests/test_session.py | 12 ++++++++++++ tests/test_window.py | 12 ++++++++++++ 4 files changed, 49 insertions(+) diff --git a/tests/test_pane.py b/tests/test_pane.py index 49ed2d05b..21d0cbb87 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -322,3 +322,16 @@ def test_split_pane_size(session: Session) -> None: new_pane = new_pane.split(direction=PaneDirection.Right, size="10%") assert new_pane.pane_width == str(int(window_width_before * 0.1)) + + +def test_pane_context_manager(session: Session) -> None: + """Test Pane context manager functionality.""" + window = session.new_window() + with window.split() as pane: + pane.send_keys('echo "Hello"') + assert pane in window.panes + assert len(window.panes) == 2 # Initial pane + new pane + + # Pane should be killed after exiting context + assert pane not in window.panes + assert len(window.panes) == 1 # Only initial pane remains diff --git a/tests/test_server.py b/tests/test_server.py index 895fb872b..32060ff24 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -296,3 +296,15 @@ def socket_name_factory() -> str: myserver.kill() if myserver2.is_alive(): myserver2.kill() + + +def test_server_context_manager(TestServer: type[Server]) -> None: + """Test Server context manager functionality.""" + with TestServer() as server: + session = server.new_session() + assert server.is_alive() + assert len(server.sessions) == 1 + assert session in server.sessions + + # Server should be killed after exiting context + assert not server.is_alive() diff --git a/tests/test_session.py b/tests/test_session.py index bd398a06c..25c90f701 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -381,3 +381,15 @@ def test_session_new_window_with_direction_logs_warning_for_old_tmux( assert any("Direction flag ignored" in record.msg for record in caplog.records), ( "Warning missing" ) + + +def test_session_context_manager(server: Server) -> None: + """Test Session context manager functionality.""" + with server.new_session() as session: + window = session.new_window() + assert session in server.sessions + assert window in session.windows + assert len(session.windows) == 2 # Initial window + new window + + # Session should be killed after exiting context + assert session not in server.sessions diff --git a/tests/test_window.py b/tests/test_window.py index 767f500ea..b08ad2d14 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -603,3 +603,15 @@ def test_new_window_with_direction_logs_warning_for_old_tmux( assert any("Direction flag ignored" in record.msg for record in caplog.records), ( "Warning missing" ) + + +def test_window_context_manager(session: Session) -> None: + """Test Window context manager functionality.""" + with session.new_window() as window: + pane = window.split() + assert window in session.windows + assert pane in window.panes + assert len(window.panes) == 2 # Initial pane + new pane + + # Window should be killed after exiting context + assert window not in session.windows From c741065e65e317d62103fb10ada88d26accf0fd9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 09:07:30 -0600 Subject: [PATCH 3/4] docs(CHANGES) Note context managers --- CHANGES | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGES b/CHANGES index 7342560e0..ff936f6ac 100644 --- a/CHANGES +++ b/CHANGES @@ -19,6 +19,31 @@ $ pip install --user --upgrade --pre libtmux ### Features +#### Context Managers for tmux Objects + +Added context manager support for all main tmux objects: + +- `Server`: Automatically kills the server when exiting the context +- `Session`: Automatically kills the session when exiting the context +- `Window`: Automatically kills the window when exiting the context +- `Pane`: Automatically kills the pane when exiting the context + +Example usage: + +```python +with Server() as server: + with server.new_session() as session: + with session.new_window() as window: + with window.split() as pane: + pane.send_keys('echo "Hello"') + # Do work with the pane + # Everything is cleaned up automatically when exiting contexts +``` + +This makes it easier to write clean, safe code that properly cleans up tmux resources. + +#### Server Initialization Callbacks + Server now accepts 2 new optional params, `socket_name_factory` and `on_init` callbacks (#565): - `socket_name_factory`: Callable that generates unique socket names for new servers From 92660e4b568811df624bf1dd17be9e7df6b5aa17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 15 Feb 2025 10:13:03 -0600 Subject: [PATCH 4/4] docs(topics) Add `context_managers` docs page --- docs/topics/context_managers.md | 128 ++++++++++++++++++++++++++++++++ docs/topics/index.md | 1 + 2 files changed, 129 insertions(+) create mode 100644 docs/topics/context_managers.md diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md new file mode 100644 index 000000000..60b710ad9 --- /dev/null +++ b/docs/topics/context_managers.md @@ -0,0 +1,128 @@ +(context_managers)= + +# Context Managers + +libtmux provides context managers for all main tmux objects to ensure proper cleanup of resources. This is done through Python's `with` statement, which automatically handles cleanup when you're done with the tmux objects. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +Import `libtmux`: + +```python +import libtmux +``` + +## Server Context Manager + +Create a temporary server that will be killed when you're done: + +```python +>>> with Server() as server: +... session = server.new_session() +... print(server.is_alive()) +True +>>> print(server.is_alive()) # Server is killed after exiting context +False +``` + +## Session Context Manager + +Create a temporary session that will be killed when you're done: + +```python +>>> server = Server() +>>> with server.new_session() as session: +... print(session in server.sessions) +... window = session.new_window() +True +>>> print(session in server.sessions) # Session is killed after exiting context +False +``` + +## Window Context Manager + +Create a temporary window that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> with session.new_window() as window: +... print(window in session.windows) +... pane = window.split() +True +>>> print(window in session.windows) # Window is killed after exiting context +False +``` + +## Pane Context Manager + +Create a temporary pane that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> window = session.new_window() +>>> with window.split() as pane: +... print(pane in window.panes) +... pane.send_keys('echo "Hello"') +True +>>> print(pane in window.panes) # Pane is killed after exiting context +False +``` + +## Nested Context Managers + +Context managers can be nested to create a clean hierarchy of tmux objects that are automatically cleaned up: + +```python +>>> with Server() as server: +... with server.new_session() as session: +... with session.new_window() as window: +... with window.split() as pane: +... pane.send_keys('echo "Hello"') +... # Do work with the pane +... # Everything is cleaned up automatically when exiting contexts +``` + +This ensures that: + +1. The pane is killed when exiting its context +2. The window is killed when exiting its context +3. The session is killed when exiting its context +4. The server is killed when exiting its context + +The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management. + +## Benefits + +Using context managers provides several advantages: + +1. **Automatic Cleanup**: Resources are automatically cleaned up when you're done with them +2. **Clean Code**: No need to manually call `kill()` methods +3. **Exception Safety**: Resources are cleaned up even if an exception occurs +4. **Hierarchical Cleanup**: Nested contexts ensure proper cleanup order +5. **Resource Management**: Prevents resource leaks by ensuring tmux objects are properly destroyed + +## When to Use + +Context managers are particularly useful when: + +1. Creating temporary tmux objects for testing +2. Running short-lived tmux sessions +3. Managing multiple tmux servers +4. Ensuring cleanup in scripts that may raise exceptions +5. Creating isolated environments that need to be cleaned up afterward + +[target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS diff --git a/docs/topics/index.md b/docs/topics/index.md index 512a1290e..0653bb57b 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -8,5 +8,6 @@ Explore libtmux’s core functionalities and underlying principles at a high lev ```{toctree} +context_managers traversal ```