diff --git a/CHANGES b/CHANGES index aa4328281..6bd349ee6 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,20 @@ $ pip install --user --upgrade --pre libtmux - _Future release notes will be placed here_ +## libtmux 0.46.2 (Unreleased) + +### Development + +- Add `StrPath` type support for `start_directory` parameters (#596, #597, #598): + - `Server.new_session`: Accept PathLike objects for session start directory + - `Session.new_window`: Accept PathLike objects for window start directory + - `Pane.split` and `Pane.split_window`: Accept PathLike objects for pane start directory + - `Window.split` and `Window.split_window`: Accept PathLike objects for pane start directory + - Enables `pathlib.Path` objects alongside strings for all start directory parameters + - Includes comprehensive tests for all parameter types (None, empty string, string paths, PathLike objects) + + Thank you @Data5tream for the initial commit in #596! + ## libtmux 0.46.1 (2025-03-16) _Maintenance only, no bug fixes or new features_ diff --git a/src/libtmux/_internal/types.py b/src/libtmux/_internal/types.py index 7e9953dfe..d23bfa474 100644 --- a/src/libtmux/_internal/types.py +++ b/src/libtmux/_internal/types.py @@ -2,7 +2,7 @@ Notes ----- -:class:`StrPath` and :class:`StrOrBytesPath` is based on `typeshed's`_. +:class:`StrPath` is based on `typeshed's`_. .. _typeshed's: https://github.com/python/typeshed/blob/5ff32f3/stdlib/_typeshed/__init__.pyi#L176-L179 """ # E501 diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index f16cbe9f7..9578bda0e 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -13,6 +13,8 @@ import typing as t import warnings +from libtmux import exc +from libtmux._internal.types import StrPath from libtmux.common import has_gte_version, has_lt_version, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, @@ -23,8 +25,6 @@ from libtmux.formats import FORMAT_SEPARATOR from libtmux.neo import Obj, fetch_obj -from . import exc - if t.TYPE_CHECKING: import sys import types @@ -548,7 +548,7 @@ def split( self, /, target: int | str | None = None, - start_directory: str | None = None, + start_directory: StrPath | None = None, attach: bool = False, direction: PaneDirection | None = None, full_window_split: bool | None = None, @@ -566,7 +566,7 @@ def split( attach : bool, optional make new window the current window after creating it, default True. - start_directory : str, optional + start_directory : str or PathLike, optional specifies the working directory in which the new window is created. direction : PaneDirection, optional split in direction. If none is specified, assume down. @@ -668,7 +668,7 @@ def split( tmux_args += ("-P", "-F{}".format("".join(tmux_formats))) # output - if start_directory is not None: + if start_directory: # as of 2014-02-08 tmux 1.9-dev doesn't expand ~ in new-window -c. start_path = pathlib.Path(start_directory).expanduser() tmux_args += (f"-c{start_path}",) @@ -870,7 +870,7 @@ def split_window( self, target: int | str | None = None, attach: bool = False, - start_directory: str | None = None, + start_directory: StrPath | None = None, vertical: bool = True, shell: str | None = None, size: str | int | None = None, @@ -883,7 +883,7 @@ def split_window( ---------- attach : bool, optional Attach / select pane after creation. - start_directory : str, optional + start_directory : str or PathLike, optional specifies the working directory in which the new pane is created. vertical : bool, optional split vertically diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 1eaf82f66..611e9aaeb 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -15,14 +15,15 @@ import typing as t import warnings +from libtmux import exc, formats from libtmux._internal.query_list import QueryList +from libtmux._internal.types import StrPath from libtmux.common import tmux_cmd from libtmux.neo import fetch_objs from libtmux.pane import Pane from libtmux.session import Session from libtmux.window import Window -from . import exc, formats from .common import ( EnvironmentMixin, PaneDict, @@ -431,7 +432,7 @@ def new_session( session_name: str | None = None, kill_session: bool = False, attach: bool = False, - start_directory: str | None = None, + start_directory: StrPath | None = None, window_name: str | None = None, window_command: str | None = None, x: int | DashLiteral | None = None, @@ -466,7 +467,7 @@ def new_session( kill_session : bool, optional Kill current session if ``$ tmux has-session``. Useful for testing workspaces. - start_directory : str, optional + start_directory : str or PathLike, optional specifies the working directory in which the new session is created. window_name : str, optional @@ -542,7 +543,9 @@ def new_session( tmux_args += ("-d",) if start_directory: - tmux_args += ("-c", start_directory) + # as of 2014-02-08 tmux 1.9-dev doesn't expand ~ in new-session -c. + start_directory = pathlib.Path(start_directory).expanduser() + tmux_args += ("-c", str(start_directory)) if window_name: tmux_args += ("-n", window_name) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 1016d423c..634f14798 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -679,7 +679,7 @@ def new_window( window_args += ("-P",) # Catch empty string and default (`None`) - if start_directory and isinstance(start_directory, str): + if start_directory: # as of 2014-02-08 tmux 1.9-dev doesn't expand ~ in new-window -c. start_directory = pathlib.Path(start_directory).expanduser() window_args += (f"-c{start_directory}",) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 121c7ea03..1462ce4d8 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -14,6 +14,7 @@ import warnings from libtmux._internal.query_list import QueryList +from libtmux._internal.types import StrPath from libtmux.common import has_gte_version, tmux_cmd from libtmux.constants import ( RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, @@ -258,7 +259,7 @@ def split( self, /, target: int | str | None = None, - start_directory: str | None = None, + start_directory: StrPath | None = None, attach: bool = False, direction: PaneDirection | None = None, full_window_split: bool | None = None, @@ -274,7 +275,7 @@ def split( attach : bool, optional make new window the current window after creating it, default True. - start_directory : str, optional + start_directory : str or PathLike, optional specifies the working directory in which the new window is created. direction : PaneDirection, optional split in direction. If none is specified, assume down. @@ -864,7 +865,7 @@ def width(self) -> str | None: def split_window( self, target: int | str | None = None, - start_directory: str | None = None, + start_directory: StrPath | None = None, attach: bool = False, vertical: bool = True, shell: str | None = None, diff --git a/tests/test_pane.py b/tests/test_pane.py index 746467851..2cb9183ce 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -3,11 +3,13 @@ from __future__ import annotations import logging +import pathlib import shutil import typing as t import pytest +from libtmux._internal.types import StrPath from libtmux.common import has_gte_version, has_lt_version, has_lte_version from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.test.retry import retry_until @@ -327,11 +329,117 @@ def test_split_pane_size(session: Session) -> None: def test_pane_context_manager(session: Session) -> None: """Test Pane context manager functionality.""" window = session.new_window() + initial_pane_count = len(window.panes) + with window.split() as pane: - pane.send_keys('echo "Hello"') + assert len(window.panes) == initial_pane_count + 1 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 + window.refresh() + assert len(window.panes) == initial_pane_count + + +class StartDirectoryTestFixture(t.NamedTuple): + """Test fixture for start_directory parameter testing.""" + + test_id: str + start_directory: StrPath | None + description: str + + +START_DIRECTORY_TEST_FIXTURES: list[StartDirectoryTestFixture] = [ + StartDirectoryTestFixture( + test_id="none_value", + start_directory=None, + description="None should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="empty_string", + start_directory="", + description="Empty string should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="user_path", + start_directory="{user_path}", + description="User path should add -c flag", + ), + StartDirectoryTestFixture( + test_id="relative_path", + start_directory="./relative/path", + description="Relative path should add -c flag", + ), +] + + +@pytest.mark.parametrize( + list(StartDirectoryTestFixture._fields), + START_DIRECTORY_TEST_FIXTURES, + ids=[test.test_id for test in START_DIRECTORY_TEST_FIXTURES], +) +def test_split_start_directory( + test_id: str, + start_directory: StrPath | None, + description: str, + session: Session, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + user_path: pathlib.Path, +) -> None: + """Test Pane.split start_directory parameter handling.""" + monkeypatch.chdir(tmp_path) + + window = session.new_window(window_name=f"test_split_{test_id}") + pane = window.active_pane + assert pane is not None + + # Format path placeholders with actual fixture values + actual_start_directory = start_directory + expected_path = None + + if start_directory and str(start_directory) not in ["", "None"]: + if "{user_path}" in str(start_directory): + # Replace placeholder with actual user_path + actual_start_directory = str(start_directory).format(user_path=user_path) + expected_path = str(user_path) + elif str(start_directory).startswith("./"): + # For relative paths, use tmp_path as base + temp_dir = tmp_path / "relative" / "path" + temp_dir.mkdir(parents=True, exist_ok=True) + actual_start_directory = str(temp_dir) + expected_path = str(temp_dir.resolve()) + + # Should not raise an error + new_pane = pane.split(start_directory=actual_start_directory) + + assert new_pane in window.panes + assert len(window.panes) == 2 + + # Verify working directory if we have an expected path + if expected_path: + new_pane.refresh() + assert new_pane.pane_current_path is not None + actual_path = str(pathlib.Path(new_pane.pane_current_path).resolve()) + assert actual_path == expected_path + + +def test_split_start_directory_pathlib( + session: Session, user_path: pathlib.Path +) -> None: + """Test Pane.split accepts pathlib.Path for start_directory.""" + window = session.new_window(window_name="test_split_pathlib") + pane = window.active_pane + assert pane is not None + + # Pass pathlib.Path directly to test pathlib.Path acceptance + new_pane = pane.split(start_directory=user_path) + + assert new_pane in window.panes + assert len(window.panes) == 2 + + # Verify working directory + new_pane.refresh() + assert new_pane.pane_current_path is not None + actual_path = str(pathlib.Path(new_pane.pane_current_path).resolve()) + expected_path = str(user_path.resolve()) + assert actual_path == expected_path diff --git a/tests/test_server.py b/tests/test_server.py index 32060ff24..634ebdc97 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4,12 +4,14 @@ import logging import os +import pathlib import subprocess import time import typing as t import pytest +from libtmux._internal.types import StrPath from libtmux.common import has_gte_version, has_version from libtmux.server import Server @@ -308,3 +310,110 @@ def test_server_context_manager(TestServer: type[Server]) -> None: # Server should be killed after exiting context assert not server.is_alive() + + +class StartDirectoryTestFixture(t.NamedTuple): + """Test fixture for start_directory parameter testing.""" + + test_id: str + start_directory: StrPath | None + description: str + + +START_DIRECTORY_TEST_FIXTURES: list[StartDirectoryTestFixture] = [ + StartDirectoryTestFixture( + test_id="none_value", + start_directory=None, + description="None should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="empty_string", + start_directory="", + description="Empty string should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="user_path", + start_directory="{user_path}", + description="User path should add -c flag", + ), + StartDirectoryTestFixture( + test_id="relative_path", + start_directory="./relative/path", + description="Relative path should add -c flag", + ), +] + + +@pytest.mark.parametrize( + list(StartDirectoryTestFixture._fields), + START_DIRECTORY_TEST_FIXTURES, + ids=[test.test_id for test in START_DIRECTORY_TEST_FIXTURES], +) +def test_new_session_start_directory( + test_id: str, + start_directory: StrPath | None, + description: str, + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + user_path: pathlib.Path, +) -> None: + """Test Server.new_session start_directory parameter handling.""" + monkeypatch.chdir(tmp_path) + + # Format path placeholders with actual fixture values + actual_start_directory = start_directory + expected_path = None + + if start_directory and str(start_directory) not in ["", "None"]: + if "{user_path}" in str(start_directory): + # Replace placeholder with actual user_path + actual_start_directory = str(start_directory).format(user_path=user_path) + expected_path = str(user_path) + elif str(start_directory).startswith("./"): + # For relative paths, use tmp_path as base + temp_dir = tmp_path / "relative" / "path" + temp_dir.mkdir(parents=True, exist_ok=True) + actual_start_directory = str(temp_dir) + expected_path = str(temp_dir.resolve()) + + # Should not raise an error + session = server.new_session( + session_name=f"test_session_{test_id}", + start_directory=actual_start_directory, + ) + + assert session.session_name == f"test_session_{test_id}" + assert server.has_session(f"test_session_{test_id}") + + # Verify working directory if we have an expected path + if expected_path: + active_pane = session.active_window.active_pane + assert active_pane is not None + active_pane.refresh() + assert active_pane.pane_current_path is not None + actual_path = str(pathlib.Path(active_pane.pane_current_path).resolve()) + assert actual_path == expected_path + + +def test_new_session_start_directory_pathlib( + server: Server, user_path: pathlib.Path +) -> None: + """Test Server.new_session accepts pathlib.Path for start_directory.""" + # Pass pathlib.Path directly to test pathlib.Path acceptance + session = server.new_session( + session_name="test_pathlib_start_dir", + start_directory=user_path, + ) + + assert session.session_name == "test_pathlib_start_dir" + assert server.has_session("test_pathlib_start_dir") + + # Verify working directory + active_pane = session.active_window.active_pane + assert active_pane is not None + active_pane.refresh() + assert active_pane.pane_current_path is not None + actual_path = str(pathlib.Path(active_pane.pane_current_path).resolve()) + expected_path = str(user_path.resolve()) + assert actual_path == expected_path diff --git a/tests/test_session.py b/tests/test_session.py index 88d5f79e6..432fbfdee 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +import pathlib import shutil import typing as t import pytest from libtmux import exc +from libtmux._internal.types import StrPath from libtmux.common import has_gte_version, has_lt_version from libtmux.constants import WindowDirection from libtmux.pane import Pane @@ -424,9 +426,117 @@ 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 len(session.windows) >= 2 # Initial window + new window 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 + session_name = session.session_name + assert session_name is not None + assert not server.has_session(session_name) + + +class StartDirectoryTestFixture(t.NamedTuple): + """Test fixture for start_directory parameter testing.""" + + test_id: str + start_directory: StrPath | None + description: str + + +START_DIRECTORY_TEST_FIXTURES: list[StartDirectoryTestFixture] = [ + StartDirectoryTestFixture( + test_id="none_value", + start_directory=None, + description="None should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="empty_string", + start_directory="", + description="Empty string should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="user_path", + start_directory="{user_path}", + description="User path should add -c flag", + ), + StartDirectoryTestFixture( + test_id="relative_path", + start_directory="./relative/path", + description="Relative path should add -c flag", + ), +] + + +@pytest.mark.parametrize( + list(StartDirectoryTestFixture._fields), + START_DIRECTORY_TEST_FIXTURES, + ids=[test.test_id for test in START_DIRECTORY_TEST_FIXTURES], +) +def test_new_window_start_directory( + test_id: str, + start_directory: StrPath | None, + description: str, + session: Session, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + user_path: pathlib.Path, +) -> None: + """Test Session.new_window start_directory parameter handling.""" + monkeypatch.chdir(tmp_path) + + # Format path placeholders with actual fixture values + actual_start_directory = start_directory + expected_path = None + + if start_directory and str(start_directory) not in ["", "None"]: + if "{user_path}" in str(start_directory): + # Replace placeholder with actual user_path + actual_start_directory = str(start_directory).format(user_path=user_path) + expected_path = str(user_path) + elif str(start_directory).startswith("./"): + # For relative paths, use tmp_path as base + temp_dir = tmp_path / "relative" / "path" + temp_dir.mkdir(parents=True, exist_ok=True) + actual_start_directory = str(temp_dir) + expected_path = str(temp_dir.resolve()) + + # Should not raise an error + window = session.new_window( + window_name=f"test_window_{test_id}", + start_directory=actual_start_directory, + ) + + assert window.window_name == f"test_window_{test_id}" + assert window in session.windows + + # Verify working directory if we have an expected path + if expected_path: + active_pane = window.active_pane + assert active_pane is not None + active_pane.refresh() + assert active_pane.pane_current_path is not None + actual_path = str(pathlib.Path(active_pane.pane_current_path).resolve()) + assert actual_path == expected_path + + +def test_new_window_start_directory_pathlib( + session: Session, user_path: pathlib.Path +) -> None: + """Test Session.new_window accepts pathlib.Path for start_directory.""" + # Pass pathlib.Path directly to test pathlib.Path acceptance + window = session.new_window( + window_name="test_pathlib_start_dir", + start_directory=user_path, + ) + + assert window.window_name == "test_pathlib_start_dir" + assert window in session.windows + + # Verify working directory + active_pane = window.active_pane + assert active_pane is not None + active_pane.refresh() + assert active_pane.pane_current_path is not None + actual_path = str(pathlib.Path(active_pane.pane_current_path).resolve()) + expected_path = str(user_path.resolve()) + assert actual_path == expected_path diff --git a/tests/test_window.py b/tests/test_window.py index a61345711..ace5eb83c 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import pathlib import shutil import time import typing as t @@ -11,6 +12,7 @@ from libtmux import exc from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux._internal.types import StrPath from libtmux.common import has_gte_version, has_lt_version, has_lte_version from libtmux.constants import ( PaneDirection, @@ -653,3 +655,104 @@ def test_window_context_manager(session: Session) -> None: # Window should be killed after exiting context assert window not in session.windows + + +class StartDirectoryTestFixture(t.NamedTuple): + """Test fixture for start_directory parameter testing.""" + + test_id: str + start_directory: StrPath | None + description: str + + +START_DIRECTORY_TEST_FIXTURES: list[StartDirectoryTestFixture] = [ + StartDirectoryTestFixture( + test_id="none_value", + start_directory=None, + description="None should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="empty_string", + start_directory="", + description="Empty string should not add -c flag", + ), + StartDirectoryTestFixture( + test_id="user_path", + start_directory="{user_path}", + description="User path should add -c flag", + ), + StartDirectoryTestFixture( + test_id="relative_path", + start_directory="./relative/path", + description="Relative path should add -c flag", + ), +] + + +@pytest.mark.parametrize( + list(StartDirectoryTestFixture._fields), + START_DIRECTORY_TEST_FIXTURES, + ids=[test.test_id for test in START_DIRECTORY_TEST_FIXTURES], +) +def test_split_start_directory( + test_id: str, + start_directory: StrPath | None, + description: str, + session: Session, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + user_path: pathlib.Path, +) -> None: + """Test Window.split start_directory parameter handling.""" + monkeypatch.chdir(tmp_path) + + window = session.new_window(window_name=f"test_window_split_{test_id}") + + # Format path placeholders with actual fixture values + actual_start_directory = start_directory + expected_path = None + + if start_directory and str(start_directory) not in ["", "None"]: + if "{user_path}" in str(start_directory): + # Replace placeholder with actual user_path + actual_start_directory = str(start_directory).format(user_path=user_path) + expected_path = str(user_path) + elif str(start_directory).startswith("./"): + # For relative paths, use tmp_path as base + temp_dir = tmp_path / "relative" / "path" + temp_dir.mkdir(parents=True, exist_ok=True) + actual_start_directory = str(temp_dir) + expected_path = str(temp_dir.resolve()) + + # Should not raise an error + new_pane = window.split(start_directory=actual_start_directory) + + assert new_pane in window.panes + assert len(window.panes) == 2 + + # Verify working directory if we have an expected path + if expected_path: + new_pane.refresh() + assert new_pane.pane_current_path is not None + actual_path = str(pathlib.Path(new_pane.pane_current_path).resolve()) + assert actual_path == expected_path + + +def test_split_start_directory_pathlib( + session: Session, user_path: pathlib.Path +) -> None: + """Test Window.split accepts pathlib.Path for start_directory.""" + window = session.new_window(window_name="test_window_split_pathlib") + + # Pass pathlib.Path directly to test pathlib.Path acceptance + new_pane = window.split(start_directory=user_path) + + assert new_pane in window.panes + assert len(window.panes) == 2 + + # Verify working directory + new_pane.refresh() + assert new_pane.pane_current_path is not None + actual_path = str(pathlib.Path(new_pane.pane_current_path).resolve()) + expected_path = str(user_path.resolve()) + assert actual_path == expected_path