diff --git a/CHANGES b/CHANGES index 199549c08..122a2ccb8 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,32 @@ $ pip install --user --upgrade --pre libtmux +### New commands + +- {meth}`Pane.kill()` + +### Renamed commands + +- `Window.select_window()` renamed to {meth}`Window.select()` + - Deprecated `Window.select_window()` +- `Pane.select_pane()` renamed to {meth}`Pane.select()` + - Deprecated `Pane.pane_select()` +- `Session.attach_session()` renamed to {meth}`Session.attach()` + - Deprecated `Session.attach_session()` +- `Server.kill_server()` renamed to {meth}`Server.kill()` + - Deprecated `Server.kill_server()` +- `Session.kill_session()` renamed to {meth}`Session.kill()` + - Deprecated `Session.kill_session()` +- `Window.kill_window()` renamed to {meth}`Window.kill()` + - Deprecated `Window.kill_window()` + +### Improved commands + +- {meth}`Server.new_session()`: Support environment variables +- {meth}`Window.split_window()`: Support `size` via `-l` + + Supports columns/rows (`size=10`) and percentage (`size='10%'`) + ## libtmux 0.29.0 (2024-02-16) #### Bug fixes diff --git a/README.md b/README.md index b45031e5d..aec7e8ca5 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Close window: ```python >>> w = session.attached_window ->>> w.kill_window() +>>> w.kill() ``` Grab remaining tmux window: @@ -140,7 +140,7 @@ Split window (create a new pane): ```python >>> pane = window.split_window() >>> pane = window.split_window(attach=False) ->>> pane.select_pane() +>>> pane.select() Pane(%3 Window(@1 1:..., Session($1 ...))) >>> window = session.new_window(attach=False, window_name="test") >>> window diff --git a/docs/quickstart.md b/docs/quickstart.md index c61e5223e..a4926fdf8 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -260,7 +260,7 @@ Window(@2 2:check this out, Session($1 ...)) And kill: ```python ->>> window.kill_window() +>>> window.kill() ``` Use {meth}`Session.windows` and {meth}`Session.windows.filter()` to list and sort @@ -311,14 +311,14 @@ For one, arguments such as `attach=False` can be omittted. This gives you the {class}`Pane` along with moving the cursor to a new window. You can also use the `.select_*` available on the object, in this case the pane has -{meth}`Pane.select_pane()`. +{meth}`Pane.select()`. ```python >>> pane = window.split_window(attach=False) ``` ```python ->>> pane.select_pane() +>>> pane.select() Pane(%1 Window(@1 ...:..., Session($1 ...))) ``` diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index f757d7c2a..e19cfbb9b 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -353,6 +353,62 @@ def display_message( self.cmd("display-message", cmd) return None + def kill( + self, + all_except: t.Optional[bool] = None, + ) -> None: + """Kill :class:`Pane`. + + ``$ tmux kill-pane``. + + Examples + -------- + Kill a pane: + >>> pane_1 = pane.split_window() + + >>> pane_1 in window.panes + True + + >>> pane_1.kill() + + >>> pane_1 not in window.panes + True + + Kill all panes except the current one: + >>> pane.window.resize(height=100, width=100) + Window(@1 1...) + + >>> one_pane_to_rule_them_all = pane.split_window() + + >>> other_panes = pane.split_window( + ... ), pane.split_window() + + >>> all([p in window.panes for p in other_panes]) + True + + >>> one_pane_to_rule_them_all.kill(all_except=True) + + >>> all([p not in window.panes for p in other_panes]) + True + + >>> one_pane_to_rule_them_all in window.panes + True + """ + flags: t.Tuple[str, ...] = () + + if all_except: + flags += ("-a",) + + proc = self.cmd( + "kill-pane", + *flags, + ) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return None + """ Commands ("climber"-helpers) @@ -360,13 +416,53 @@ def display_message( additional scoped window info. """ + def select(self) -> "Pane": + """Select pane. + + Examples + -------- + >>> pane = window.attached_pane + >>> new_pane = window.split_window() + >>> pane.refresh() + >>> active_panes = [p for p in window.panes if p.pane_active == '1'] + + >>> pane in active_panes + True + >>> new_pane in active_panes + False + + >>> new_pane.pane_active == '1' + False + + >>> new_pane.select() + Pane(...) + + >>> new_pane.pane_active == '1' + True + """ + proc = self.cmd("select-pane") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + self.refresh() + + return self + def select_pane(self) -> "Pane": """Select pane. - To select a window object asynchrously. If a ``pane`` object exists - and is no longer longer the current window, ``w.select_pane()`` - will make ``p`` the current pane. + Notes + ----- + .. deprecated:: 0.30 + + Deprecated in favor of :meth:`.select()`. """ + warnings.warn( + "Pane.select_pane() is deprecated in favor of Pane.select()", + category=DeprecationWarning, + stacklevel=2, + ) assert isinstance(self.pane_id, str) pane = self.window.select_pane(self.pane_id) if pane is None: @@ -376,9 +472,12 @@ def select_pane(self) -> "Pane": def split_window( self, attach: bool = False, - vertical: bool = True, start_directory: t.Optional[str] = None, - percent: t.Optional[int] = None, + vertical: bool = True, + shell: t.Optional[str] = None, + size: t.Optional[t.Union[str, int]] = None, + percent: t.Optional[int] = None, # deprecated + environment: t.Optional[t.Dict[str, str]] = None, ) -> "Pane": # New Pane, not self """Split window at pane and return newly created :class:`Pane`. @@ -392,13 +491,22 @@ def split_window( specifies the working directory in which the new pane is created. percent: int, optional percentage to occupy with respect to current pane + + Notes + ----- + .. deprecated:: 0.28.0 + + ``percent=25`` deprecated in favor of ``size="25%"``. """ return self.window.split_window( target=self.pane_id, - start_directory=start_directory, attach=attach, + start_directory=start_directory, vertical=vertical, + shell=shell, + size=size, percent=percent, + environment=environment, ) """ diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 27fe20c08..9e195c352 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -270,8 +270,24 @@ def has_session(self, target_session: str, exact: bool = True) -> bool: return False - def kill_server(self) -> None: - """Kill tmux server.""" + def kill(self) -> None: + """Kill tmux server. + + >>> svr = Server(socket_name="testing") + >>> svr + Server(socket_name=testing) + + >>> svr.new_session() + Session(...) + + >>> svr.is_alive() + True + + >>> svr.kill() + + >>> svr.is_alive() + False + """ self.cmd("kill-server") def kill_session(self, target_session: t.Union[str, int]) -> "Server": @@ -350,6 +366,7 @@ def new_session( window_command: t.Optional[str] = None, x: t.Optional[t.Union[int, "DashLiteral"]] = None, y: t.Optional[t.Union[int, "DashLiteral"]] = None, + environment: t.Optional[t.Dict[str, str]] = None, *args: t.Any, **kwargs: t.Any, ) -> Session: @@ -468,6 +485,15 @@ def new_session( if window_command: tmux_args += (window_command,) + if environment: + if has_gte_version("3.2"): + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) + else: + logger.warning( + "Environment flag ignored, tmux 3.2 or newer required.", + ) + proc = self.cmd("new-session", *tmux_args) if proc.stderr: @@ -575,6 +601,23 @@ def __repr__(self) -> str: # # Legacy: Redundant stuff we want to remove # + def kill_server(self) -> None: + """Kill tmux server. + + Notes + ----- + .. deprecated:: 0.30 + + Deprecated in favor of :meth:`.kill()`. + + """ + warnings.warn( + "Server.kill_server() is deprecated in favor of Server.kill()", + category=DeprecationWarning, + stacklevel=2, + ) + self.cmd("kill-server") + def _list_panes(self) -> t.List[PaneDict]: """Return list of panes in :py:obj:`dict` form. diff --git a/src/libtmux/session.py b/src/libtmux/session.py index e65c389b1..e7e7c732c 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -370,22 +370,104 @@ def attached_window(self) -> "Window": if len(self._windows) == 0: raise exc.NoWindowsExist() - def attach_session(self) -> "Session": - """Return ``$ tmux attach-session`` aka alias: ``$ tmux attach``.""" - proc = self.cmd("attach-session", "-t%s" % self.session_id) + def attach( + self, + _exit: t.Optional[bool] = None, + _flags: t.Optional[t.List[str]] = None, + ) -> "Session": + """Return ``$ tmux attach-session`` aka alias: ``$ tmux attach``. + + Examples + -------- + >>> session = server.new_session() + + >>> session not in server.attached_sessions + True + """ + flags: t.Tuple[str, ...] = () + + if _exit is not None and _exit: + flags += ("-x",) + + if _flags is not None and isinstance(_flags, list): + flags += tuple(f'{",".join(_flags)}') + + proc = self.cmd( + "attach-session", + *flags, + ) if proc.stderr: raise exc.LibTmuxException(proc.stderr) + self.refresh() + return self - def kill_session(self) -> None: - """Destroy session.""" - proc = self.cmd("kill-session", "-t%s" % self.session_id) + def kill( + self, + all_except: t.Optional[bool] = None, + clear: t.Optional[bool] = None, + ) -> None: + """Kill :class:`Session`, closes linked windows and detach all clients. + + ``$ tmux kill-session``. + + Parameters + ---------- + all_except : bool, optional + Kill all sessions in server except this one. + clear : bool, optional + Clear alerts (bell, activity, or silence) in all windows. + + Examples + -------- + Kill a session: + >>> session_1 = server.new_session() + + >>> session_1 in server.sessions + True + + >>> session_1.kill() + + >>> session_1 not in server.sessions + True + + Kill all sessions except the current one: + >>> one_session_to_rule_them_all = server.new_session() + + >>> other_sessions = server.new_session( + ... ), server.new_session() + + >>> all([w in server.sessions for w in other_sessions]) + True + + >>> one_session_to_rule_them_all.kill(all_except=True) + + >>> all([w not in server.sessions for w in other_sessions]) + True + + >>> one_session_to_rule_them_all in server.sessions + True + """ + flags: t.Tuple[str, ...] = () + + if all_except: + flags += ("-a",) + + if clear: # Clear alerts (bell, activity, or silence) in all windows + flags += ("-C",) + + proc = self.cmd( + "kill-session", + *flags, + ) if proc.stderr: raise exc.LibTmuxException(proc.stderr) + return None + def switch_client(self) -> "Session": """Switch client to session. @@ -500,7 +582,7 @@ def new_window( window_args += (f"-e{k}={v}",) else: logger.warning( - "Cannot set up environment as tmux 3.0 or newer is required.", + "Environment flag ignored, requires tmux 3.0 or newer.", ) if window_shell: @@ -595,6 +677,46 @@ def name(self) -> t.Optional[str]: # # Legacy: Redundant stuff we want to remove # + def attach_session(self) -> "Session": + """Return ``$ tmux attach-session`` aka alias: ``$ tmux attach``. + + Notes + ----- + .. deprecated:: 0.30 + + Deprecated in favor of :meth:`.attach()`. + """ + warnings.warn( + "Session.attach_session() is deprecated in favor of Session.attach()", + category=DeprecationWarning, + stacklevel=2, + ) + proc = self.cmd("attach-session", "-t%s" % self.session_id) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self + + def kill_session(self) -> None: + """Destroy session. + + Notes + ----- + .. deprecated:: 0.30 + + Deprecated in favor of :meth:`.kill()`. + """ + warnings.warn( + "Session.kill_session() is deprecated in favor of Session.kill()", + category=DeprecationWarning, + stacklevel=2, + ) + proc = self.cmd("kill-session", "-t%s" % self.session_id) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def get(self, key: str, default: t.Optional[t.Any] = None) -> t.Any: """Return key-based lookup. Deprecated by attributes. diff --git a/src/libtmux/test.py b/src/libtmux/test.py index c298af300..71cbe4433 100644 --- a/src/libtmux/test.py +++ b/src/libtmux/test.py @@ -242,7 +242,7 @@ def temp_window( Return a context manager with a temporary window. The window will destroy itself upon closing with :meth:`window. - kill_window()`. + kill()`. If no ``window_name`` is entered, :func:`get_test_window_name` will make an unused window name. @@ -290,7 +290,7 @@ def temp_window( yield window finally: if len(session.windows.filter(window_id=window_id)) > 0: - window.kill_window() + window.kill() return diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 68daf9277..c37746553 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -21,7 +21,7 @@ from libtmux.pane import Pane from . import exc -from .common import PaneDict, WindowOptionDict, handle_option_error +from .common import PaneDict, WindowOptionDict, handle_option_error, has_lt_version from .formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -184,7 +184,8 @@ def split_window( attach: bool = False, vertical: bool = True, shell: t.Optional[str] = None, - percent: t.Optional[int] = None, + size: t.Optional[t.Union[str, int]] = None, + percent: t.Optional[int] = None, # deprecated environment: t.Optional[t.Dict[str, str]] = None, ) -> "Pane": """Split window and return the created :class:`Pane`. @@ -209,8 +210,11 @@ def split_window( NOTE: When this command exits the pane will close. This feature is useful for long-running processes where the closing of the window upon completion is desired. + size: int, optional + Cell/row or percentage to occupy with respect to current window. percent: int, optional - percentage to occupy with respect to current window + Deprecated in favor of size. Percentage to occupy with respect to current + window. environment: dict, optional Environmental variables for new pane. tmux 3.0+ only. Passthrough to ``-e``. @@ -228,6 +232,10 @@ def split_window( .. versionchanged:: 0.28.0 ``attach`` default changed from ``True`` to ``False``. + + .. deprecated:: 0.28.0 + + ``percent=25`` deprecated in favor of ``size="25%"``. """ tmux_formats = ["#{pane_id}" + FORMAT_SEPARATOR] @@ -248,8 +256,28 @@ def split_window( else: tmux_args += ("-h",) + if size is not None: + if has_lt_version("3.1"): + if isinstance(size, str) and size.endswith("%"): + tmux_args += (f'-p{str(size).rstrip("%")}',) + else: + warnings.warn( + 'Ignored size. Use percent in tmux < 3.1, e.g. "size=50%"', + stacklevel=2, + ) + else: + tmux_args += (f"-l{size}",) + if percent is not None: - tmux_args += ("-p %d" % percent,) + # Deprecated in 3.1 in favor of -l + warnings.warn( + f'Deprecated in favor of size="{str(percent).rstrip("%")}%" ' + + ' ("-l" flag) in tmux 3.1+.', + category=DeprecationWarning, + stacklevel=2, + ) + tmux_args += (f"-p{percent}",) + tmux_args += ("-P", "-F%s" % "".join(tmux_formats)) # output if start_directory is not None: @@ -266,7 +294,7 @@ def split_window( tmux_args += (f"-e{k}={v}",) else: logger.warning( - "Cannot set up environment as tmux 3.0 or newer is required.", + "Environment flag ignored, tmux 3.0 or newer required.", ) if shell: @@ -569,16 +597,59 @@ def rename_window(self, new_name: str) -> "Window": return self - def kill_window(self) -> None: - """Kill the current :class:`Window` object. ``$ tmux kill-window``.""" + def kill( + self, + all_except: t.Optional[bool] = None, + ) -> None: + """Kill :class:`Window`. + + ``$ tmux kill-window``. + + Examples + -------- + Kill a window: + >>> window_1 = session.new_window() + + >>> window_1 in session.windows + True + + >>> window_1.kill() + + >>> window_1 not in session.windows + True + + Kill all windows except the current one: + >>> one_window_to_rule_them_all = session.new_window() + + >>> other_windows = session.new_window( + ... ), session.new_window() + + >>> all([w in session.windows for w in other_windows]) + True + + >>> one_window_to_rule_them_all.kill(all_except=True) + + >>> all([w not in session.windows for w in other_windows]) + True + + >>> one_window_to_rule_them_all in session.windows + True + """ + flags: t.Tuple[str, ...] = () + + if all_except: + flags += ("-a",) + proc = self.cmd( "kill-window", - f"-t{self.session_id}:{self.window_index}", + *flags, ) if proc.stderr: raise exc.LibTmuxException(proc.stderr) + return None + def move_window( self, destination: str = "", @@ -615,15 +686,37 @@ def move_window( # # Climbers # - def select_window(self) -> "Window": + def select(self) -> "Window": """Select window. To select a window object asynchrously. If a ``window`` object exists - and is no longer longer the current window, ``w.select_window()`` + and is no longer the current window, ``w.select_window()`` will make ``w`` the current window. + + Examples + -------- + >>> window = session.attached_window + >>> new_window = session.new_window() + >>> session.refresh() + >>> active_windows = [w for w in session.windows if w.window_active == '1'] + + >>> new_window.window_active == '1' + False + + >>> new_window.select() + Window(...) + + >>> new_window.window_active == '1' + True """ - assert isinstance(self.window_index, str) - return self.session.select_window(self.window_index) + proc = self.cmd("select-window") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + self.refresh() + + return self # # Computed properties @@ -721,6 +814,45 @@ def width(self) -> t.Optional[str]: # # Legacy: Redundant stuff we want to remove # + def select_window(self) -> "Window": + """Select window. + + Notes + ----- + .. deprecated:: 0.30 + + Deprecated in favor of :meth:`.select()`. + """ + warnings.warn( + "Window.select_window() is deprecated in favor of Window.select()", + category=DeprecationWarning, + stacklevel=2, + ) + assert isinstance(self.window_index, str) + return self.session.select_window(self.window_index) + + def kill_window(self) -> None: + """Kill the current :class:`Window` object. ``$ tmux kill-window``. + + Notes + ----- + .. deprecated:: 0.30 + + Deprecated in favor of :meth:`.kill()`. + """ + warnings.warn( + "Window.kill_server() is deprecated in favor of Window.kill()", + category=DeprecationWarning, + stacklevel=2, + ) + proc = self.cmd( + "kill-window", + f"-t{self.session_id}:{self.window_index}", + ) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def get(self, key: str, default: t.Optional[t.Any] = None) -> t.Any: """Return key-based lookup. Deprecated by attributes. diff --git a/tests/legacy_api/test_session.py b/tests/legacy_api/test_session.py index 93626a38e..dab299032 100644 --- a/tests/legacy_api/test_session.py +++ b/tests/legacy_api/test_session.py @@ -313,5 +313,5 @@ def test_new_window_with_environment_logs_warning_for_old_tmux( ) assert any( - "Cannot set up environment" in record.msg for record in caplog.records + "Environment flag ignored" in record.msg for record in caplog.records ), "Warning missing" diff --git a/tests/legacy_api/test_window.py b/tests/legacy_api/test_window.py index 924049d0f..8d8063d79 100644 --- a/tests/legacy_api/test_window.py +++ b/tests/legacy_api/test_window.py @@ -383,5 +383,5 @@ def test_split_window_with_environment_logs_warning_for_old_tmux( ) assert any( - "Cannot set up environment" in record.msg for record in caplog.records + "Environment flag ignored" in record.msg for record in caplog.records ), "Warning missing" diff --git a/tests/test_server.py b/tests/test_server.py index 3a84bd590..a5d0583a9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -146,6 +146,21 @@ def test_new_session_width_height(server: Server) -> None: assert pane.display_message("#{window_height}", get_text=True)[0] == "32" +def test_new_session_environmental_variables( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """Server.new_session creates and returns valid session.""" + my_session = server.new_session("test_new_session", environment={"FOO": "HI"}) + + if has_gte_version("3.2"): + assert my_session.show_environment()["FOO"] == "HI" + else: + assert any( + "Environment flag ignored" in record.msg for record in caplog.records + ), "Warning missing" + + def test_no_server_sessions() -> None: """Verify ``Server.sessions`` returns empty list without tmux server.""" server = Server(socket_name="test_attached_session_no_server") diff --git a/tests/test_session.py b/tests/test_session.py index b091a5f5f..6be7ac10f 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -324,5 +324,5 @@ def test_new_window_with_environment_logs_warning_for_old_tmux( ) assert any( - "Cannot set up environment" in record.msg for record in caplog.records + "Environment flag ignored" in record.msg for record in caplog.records ), "Warning missing" diff --git a/tests/test_window.py b/tests/test_window.py index 4252f8469..4d1e71e78 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -8,7 +8,7 @@ from libtmux import exc from libtmux._internal.query_list import ObjectDoesNotExist -from libtmux.common import has_gte_version, has_lt_version +from libtmux.common import has_gte_version, has_lt_version, has_version from libtmux.constants import ResizeAdjustmentDirection from libtmux.pane import Pane from libtmux.server import Server @@ -171,6 +171,54 @@ def test_split_window_horizontal(session: Session) -> None: assert float(first_pane.pane_width) <= ((float(window.window_width) + 1) / 2) +def test_split_percentage(session: Session) -> None: + """Test deprecated percent param.""" + window = session.new_window(window_name="split window size") + window.resize(height=100, width=100) + window_height_before = ( + int(window.window_height) if isinstance(window.window_height, str) else 0 + ) + if has_version("3.4"): + pytest.skip( + "tmux 3.4 has a split-window bug." + + " See https://github.com/tmux/tmux/pull/3840." + ) + with pytest.warns(match="Deprecated in favor of size.*"): + pane = window.split_window(percent=10) + assert pane.pane_height == str(int(window_height_before * 0.1)) + + +def test_split_window_size(session: Session) -> None: + """Window.split_window() respects size.""" + window = session.new_window(window_name="split window size") + window.resize(height=100, width=100) + + if has_gte_version("3.1"): + pane = window.split_window(size=10) + assert pane.pane_height == "10" + + pane = window.split_window(vertical=False, size=10) + assert pane.pane_width == "10" + + pane = window.split_window(size="10%") + assert pane.pane_height == "8" + + pane = window.split_window(vertical=False, size="10%") + assert pane.pane_width == "8" + else: + window_height_before = ( + int(window.window_height) if isinstance(window.window_height, str) else 0 + ) + window_width_before = ( + int(window.window_width) if isinstance(window.window_width, str) else 0 + ) + pane = window.split_window(size="10%") + assert pane.pane_height == str(int(window_height_before * 0.1)) + + pane = window.split_window(vertical=False, size="10%") + assert pane.pane_width == str(int(window_width_before * 0.1)) + + @pytest.mark.parametrize( "window_name_before,window_name_after", [("test", "ha ha ha fjewlkjflwef"), ("test", "hello \\ wazzup 0")], @@ -396,7 +444,7 @@ def test_split_window_with_environment_logs_warning_for_old_tmux( ) assert any( - "Cannot set up environment" in record.msg for record in caplog.records + "Environment flag ignored" in record.msg for record in caplog.records ), "Warning missing"