diff --git a/CHANGES b/CHANGES index ae62aaafd..7342560e0 100644 --- a/CHANGES +++ b/CHANGES @@ -9,11 +9,36 @@ To install via [pip](https://pip.pypa.io/en/stable/), use: $ pip install --user --upgrade --pre libtmux ``` -## libtmux 0.43.x (Yet to be released) +## libtmux 0.44.x (Yet to be released) + + - _Future release notes will be placed here_ - +## libtmux 0.43.0 (2025-02-15) + +### Features + +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 +- `on_init`: Callback that runs after server initialization +- Useful for creating multiple servers with unique names and tracking server instances +- Socket name factory is tried after socket_name, maintaining backward compatibility + +#### New test fixture: `TestServer` + +Add `TestServer` pytest fixture for creating temporary tmux servers (#565): + +- Creates servers with unique socket names that clean up after themselves +- Useful for testing interactions between multiple tmux servers +- Includes comprehensive test coverage and documentation +- Available in doctest namespace + +### Documentation + +- Fix links to the "Topics" section +- More docs for "Traversal" Topic (#567) ## libtmux 0.42.1 (2024-02-15) @@ -88,6 +113,7 @@ _Maintenance only, no bug fixes or new features_ ```sh ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` + - Tests: Stability fixes for legacy `test_select_pane` test (#552) ## libtmux 0.39.0 (2024-11-26) diff --git a/conftest.py b/conftest.py index fe1c58050..ada5aae3f 100644 --- a/conftest.py +++ b/conftest.py @@ -41,6 +41,7 @@ def add_doctest_fixtures( doctest_namespace["Window"] = Window doctest_namespace["Pane"] = Pane doctest_namespace["server"] = request.getfixturevalue("server") + doctest_namespace["Server"] = request.getfixturevalue("TestServer") session: Session = request.getfixturevalue("session") doctest_namespace["session"] = session doctest_namespace["window"] = session.active_window diff --git a/docs/index.md b/docs/index.md index 25da79752..390a2a46d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ hide-toc: true quickstart about -topics/traversal +topics/index api/index pytest-plugin/index ``` diff --git a/docs/pytest-plugin/index.md b/docs/pytest-plugin/index.md index bb4368655..7c3eed133 100644 --- a/docs/pytest-plugin/index.md +++ b/docs/pytest-plugin/index.md @@ -93,6 +93,34 @@ def test_something(session): The above will assure the libtmux session launches with `-x 800 -y 600`. +(temp_server)= + +### Creating temporary servers + +If you need multiple independent tmux servers in your tests, the {func}`TestServer fixture ` provides a factory that creates servers with unique socket names. Each server is automatically cleaned up when the test completes. + +```python +def test_something(TestServer): + Server = TestServer() # Get unique partial'd Server + server = Server() # Create server instance + + session = server.new_session() + assert server.is_alive() +``` + +You can also use it with custom configurations, similar to the {ref}`server fixture `: + +```python +def test_with_config(TestServer, tmp_path): + config_file = tmp_path / "tmux.conf" + config_file.write_text("set -g status off") + + Server = TestServer() + server = Server(config_file=str(config_file)) +``` + +This is particularly useful when testing interactions between multiple tmux servers or when you need to verify behavior across server restarts. + (set_home)= ### Setting a temporary home directory diff --git a/docs/topics/index.md b/docs/topics/index.md index 7fe9893b8..512a1290e 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -2,7 +2,9 @@ orphan: true --- -# Topic Guides +# Topics + +Explore libtmux’s core functionalities and underlying principles at a high level, while providing essential context and detailed explanations to help you understand its design and usage. ```{toctree} diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index d5e9e2090..5493ae7c4 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -1,6 +1,6 @@ (traversal)= -# Usage +# Traversal libtmux provides convenient access to move around the hierarchy of sessions, windows and panes in tmux. @@ -22,81 +22,159 @@ Terminal two, `python` or `ptpython` if you have it: $ python ``` -Import `libtmux`: +## Setup + +First, create a test session: + +```python +>>> session = server.new_session() # Create a test session using existing server +``` + +## Server Level + +View the server's representation: + +```python +>>> server # doctest: +ELLIPSIS +Server(socket_name=...) +``` + +Get all sessions in the server: ```python -import libtmux +>>> server.sessions # doctest: +ELLIPSIS +[Session($... ...)] ``` -Attach default tmux {class}`~libtmux.Server` to `t`: +Get all windows across all sessions: ```python ->>> import libtmux ->>> t = libtmux.Server(); ->>> t -Server(socket_path=/tmp/tmux-.../default) +>>> server.windows # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] ``` -Get first session {class}`~libtmux.Session` to `session`: +Get all panes across all windows: + +```python +>>> server.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] +``` + +## Session Level + +Get first session: ```python >>> session = server.sessions[0] ->>> session -Session($1 ...) +>>> session # doctest: +ELLIPSIS +Session($... ...) ``` -Get a list of sessions: +Get windows in a session: ```python ->>> server.sessions -[Session($1 ...), Session($0 ...)] +>>> session.windows # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] ``` -Iterate through sessions in a server: +Get active window and pane: ```python ->>> for sess in server.sessions: -... print(sess) -Session($1 ...) -Session($0 ...) +>>> session.active_window # doctest: +ELLIPSIS +Window(@... ..., Session($... ...)) + +>>> session.active_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) ``` -Grab a {class}`~libtmux.Window` from a session: +## Window Level + +Get a window and inspect its properties: ```python ->>> session.windows[0] -Window(@1 ...:..., Session($1 ...)) +>>> window = session.windows[0] +>>> window.window_index # doctest: +ELLIPSIS +'...' ``` -Grab the currently focused window from session: +Access the window's parent session: ```python ->>> session.active_window -Window(@1 ...:..., Session($1 ...)) +>>> window.session # doctest: +ELLIPSIS +Session($... ...) +>>> window.session.session_id == session.session_id +True ``` -Grab the currently focused {class}`Pane` from session: +Get panes in a window: ```python ->>> session.active_pane -Pane(%1 Window(@1 ...:..., Session($1 ...))) +>>> window.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] ``` -Assign the attached {class}`~libtmux.Pane` to `p`: +Get active pane: ```python ->>> p = session.active_pane +>>> window.active_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) ``` -Access the window/server of a pane: +## Pane Level + +Get a pane and traverse upwards: ```python ->>> p = session.active_pane ->>> p.window -Window(@1 ...:..., Session($1 ...)) +>>> pane = window.panes[0] +>>> pane.window.window_id == window.window_id +True +>>> pane.session.session_id == session.session_id +True +>>> pane.server is server +True +``` + +## Filtering and Finding Objects ->>> p.server -Server(socket_name=libtmux_test...) +Find windows by index: + +```python +>>> session.windows.filter(window_index=window.window_index) # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] +``` + +Get a specific pane by ID: + +```python +>>> window.panes.get(pane_id=pane.pane_id) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Checking Relationships + +Check if objects are related: + +```python +>>> window in session.windows +True +>>> pane in window.panes +True +>>> session in server.sessions +True +``` + +Check if a window is active: + +```python +>>> window.window_id == session.active_window.window_id +True +``` + +Check if a pane is active: + +```python +>>> pane.pane_id == window.active_pane.pane_id +True ``` [target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS diff --git a/pyproject.toml b/pyproject.toml index dfac19627..310bc52fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "libtmux" -version = "0.42.1" +version = "0.43.0" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." requires-python = ">=3.9,<4.0" authors = [ diff --git a/src/libtmux/__about__.py b/src/libtmux/__about__.py index c177eca00..c10b1dd29 100644 --- a/src/libtmux/__about__.py +++ b/src/libtmux/__about__.py @@ -4,7 +4,7 @@ __title__ = "libtmux" __package_name__ = "libtmux" -__version__ = "0.42.1" +__version__ = "0.43.0" __description__ = "Typed scripting library / ORM / API wrapper for tmux" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index aad746912..320d31ca2 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import functools import getpass import logging import os @@ -256,3 +257,58 @@ def session( assert TEST_SESSION_NAME != "tmuxp" return session + + +@pytest.fixture +def TestServer( + request: pytest.FixtureRequest, +) -> type[Server]: + """Create a temporary tmux server that cleans up after itself. + + This is similar to the server pytest fixture, but can be used outside of pytest. + The server will be killed when the test completes. + + Returns + ------- + type[Server] + A factory function that returns a Server with a unique socket_name + + Examples + -------- + >>> server = Server() # Create server instance + >>> server.new_session() + Session($... ...) + >>> server.is_alive() + True + >>> # Each call creates a new server with unique socket + >>> server2 = Server() + >>> server2.socket_name != server.socket_name + True + """ + created_sockets: list[str] = [] + + def on_init(server: Server) -> None: + """Track created servers for cleanup.""" + created_sockets.append(server.socket_name or "default") + + def socket_name_factory() -> str: + """Generate unique socket names.""" + return f"libtmux_test{next(namer)}" + + def fin() -> None: + """Kill all servers created with these sockets.""" + for socket_name in created_sockets: + server = Server(socket_name=socket_name) + if server.is_alive(): + server.kill() + + request.addfinalizer(fin) + + return t.cast( + "type[Server]", + functools.partial( + Server, + on_init=on_init, + socket_name_factory=socket_name_factory, + ), + ) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index d235eafed..9b37e4f02 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -59,6 +59,8 @@ class Server(EnvironmentMixin): socket_path : str, optional config_file : str, optional colors : str, optional + on_init : callable, optional + socket_name_factory : callable, optional Examples -------- @@ -110,6 +112,8 @@ def __init__( socket_path: str | pathlib.Path | None = None, config_file: str | None = None, colors: int | None = None, + on_init: t.Callable[[Server], None] | None = None, + socket_name_factory: t.Callable[[], str] | None = None, **kwargs: t.Any, ) -> None: EnvironmentMixin.__init__(self, "-g") @@ -120,6 +124,8 @@ def __init__( self.socket_path = socket_path elif socket_name is not None: self.socket_name = socket_name + elif socket_name_factory is not None: + self.socket_name = socket_name_factory() tmux_tmpdir = pathlib.Path(os.getenv("TMUX_TMPDIR", "/tmp")) socket_name = self.socket_name or "default" @@ -137,6 +143,9 @@ def __init__( if colors: self.colors = colors + if on_init is not None: + on_init(self) + def is_alive(self) -> bool: """Return True if tmux server alive. diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index a9cf77778..59040fe85 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -3,11 +3,16 @@ from __future__ import annotations import textwrap +import time import typing as t if t.TYPE_CHECKING: + import pathlib + import pytest + from libtmux.server import Server + def test_plugin( pytester: pytest.Pytester, @@ -71,3 +76,83 @@ def test_repo_git_remote_checkout( # Test result = pytester.runpytest(str(first_test_filename)) result.assert_outcomes(passed=1) + + +def test_test_server(TestServer: t.Callable[..., Server]) -> None: + """Test TestServer creates and cleans up server.""" + server = TestServer() + assert server.is_alive() is False # Server not started yet + + session = server.new_session() + assert server.is_alive() is True + assert len(server.sessions) == 1 + assert session.session_name is not None + + # Test socket name is unique + assert server.socket_name is not None + assert server.socket_name.startswith("libtmux_test") + + # Each call creates a new server with unique socket + server2 = TestServer() + assert server2.socket_name is not None + assert server2.socket_name.startswith("libtmux_test") + assert server2.socket_name != server.socket_name + + +def test_test_server_with_config( + TestServer: t.Callable[..., Server], + tmp_path: pathlib.Path, +) -> None: + """Test TestServer with config file.""" + config_file = tmp_path / "tmux.conf" + config_file.write_text("set -g status off", encoding="utf-8") + + server = TestServer(config_file=str(config_file)) + session = server.new_session() + + # Verify config was loaded + assert session.cmd("show-options", "-g", "status").stdout[0] == "status off" + + +def test_test_server_cleanup(TestServer: t.Callable[..., Server]) -> None: + """Test TestServer properly cleans up after itself.""" + server = TestServer() + socket_name = server.socket_name + assert socket_name is not None + + # Create multiple sessions + server.new_session(session_name="test1") + server.new_session(session_name="test2") + assert len(server.sessions) == 2 + + # Verify server is alive + assert server.is_alive() is True + + # Delete server and verify cleanup + server.kill() + time.sleep(0.1) # Give time for cleanup + + # Create new server to verify old one was cleaned up + new_server = TestServer() + assert new_server.is_alive() is False # Server not started yet + new_server.new_session() # This should work if old server was cleaned up + assert new_server.is_alive() is True + + +def test_test_server_multiple(TestServer: t.Callable[..., Server]) -> None: + """Test multiple TestServer instances can coexist.""" + server1 = TestServer() + server2 = TestServer() + + # Each server should have a unique socket + assert server1.socket_name != server2.socket_name + + # Create sessions in each server + server1.new_session(session_name="test1") + server2.new_session(session_name="test2") + + # Verify sessions are in correct servers + assert any(s.session_name == "test1" for s in server1.sessions) + assert any(s.session_name == "test2" for s in server2.sessions) + assert not any(s.session_name == "test1" for s in server2.sessions) + assert not any(s.session_name == "test2" for s in server1.sessions) diff --git a/tests/test_server.py b/tests/test_server.py index 9e5201aff..895fb872b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -228,3 +228,71 @@ def test_raise_if_dead_does_not_raise_if_alive(server: Server) -> None: """Verify new_session() does not raise if tmux server is alive.""" server.new_session() server.raise_if_dead() + + +def test_on_init(server: Server) -> None: + """Verify on_init callback is called during Server initialization.""" + called_with: list[Server] = [] + + def on_init(server: Server) -> None: + called_with.append(server) + + myserver = Server(socket_name="test_on_init", on_init=on_init) + try: + assert len(called_with) == 1 + assert called_with[0] is myserver + finally: + if myserver.is_alive(): + myserver.kill() + + +def test_socket_name_factory(server: Server) -> None: + """Verify socket_name_factory generates socket names.""" + socket_names: list[str] = [] + + def socket_name_factory() -> str: + name = f"test_socket_{len(socket_names)}" + socket_names.append(name) + return name + + myserver = Server(socket_name_factory=socket_name_factory) + try: + assert myserver.socket_name == "test_socket_0" + assert socket_names == ["test_socket_0"] + + # Creating another server should use factory again + myserver2 = Server(socket_name_factory=socket_name_factory) + try: + assert myserver2.socket_name == "test_socket_1" + assert socket_names == ["test_socket_0", "test_socket_1"] + finally: + if myserver2.is_alive(): + myserver2.kill() + finally: + if myserver.is_alive(): + myserver.kill() + if myserver2.is_alive(): + myserver2.kill() + + +def test_socket_name_precedence(server: Server) -> None: + """Verify socket_name takes precedence over socket_name_factory.""" + + def socket_name_factory() -> str: + return "from_factory" + + myserver = Server( + socket_name="explicit_name", + socket_name_factory=socket_name_factory, + ) + myserver2 = Server(socket_name_factory=socket_name_factory) + try: + assert myserver.socket_name == "explicit_name" + + # Without socket_name, factory is used + assert myserver2.socket_name == "from_factory" + finally: + if myserver.is_alive(): + myserver.kill() + if myserver2.is_alive(): + myserver2.kill() diff --git a/uv.lock b/uv.lock index 9d68789e9..d73f199f0 100644 --- a/uv.lock +++ b/uv.lock @@ -378,7 +378,7 @@ wheels = [ [[package]] name = "libtmux" -version = "0.42.1" +version = "0.43.0" source = { editable = "." } [package.dev-dependencies] pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy