diff --git a/.cursor/rules/dev-loop.mdc b/.cursor/rules/dev-loop.mdc index 1886aa702..b6ecb405f 100644 --- a/.cursor/rules/dev-loop.mdc +++ b/.cursor/rules/dev-loop.mdc @@ -16,7 +16,7 @@ uv run mypy Lint: ``` -uv run ruff check . --fix; uv run ruff format .; +uv run ruff check . --fix --show-fixes; uv run ruff format .; ``` Check tests: diff --git a/.cursor/rules/git-commits.mdc b/.cursor/rules/git-commits.mdc index 0a5fa1184..1090f5f95 100644 --- a/.cursor/rules/git-commits.mdc +++ b/.cursor/rules/git-commits.mdc @@ -2,81 +2,93 @@ description: git-commits: Git commit message standards and AI assistance globs: git-commits: Git commit message standards and AI assistance | *.git/* .gitignore .github/* CHANGELOG.md CHANGES.md --- -# Git Commit Standards +# Optimized Git Commit Standards -## Format +## Commit Message Format ``` -type(scope[component]): concise description +Component/File(commit-type[Subcomponent/method]): Concise description -why: explanation of necessity/impact -what: -- technical changes made -- keep focused on single topic +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic -refs: #issue-number, breaking changes, links +refs: #issue-number, breaking changes, or relevant links ``` -## Commit Types -- `feat`: New features/enhancements -- `fix`: Bug fixes -- `refactor`: Code restructuring -- `docs`: Documentation changes -- `chore`: Maintenance tasks (deps, tooling) -- `test`: Test-related changes -- `style`: Code style/formatting - -## Guidelines -- Subject line: max 50 chars -- Body lines: max 72 chars -- Use imperative mood ("Add" not "Added") -- Single topic per commit -- Blank line between subject and body -- Mark breaking changes with "BREAKING:" -- Use "See also:" for external links - -## AI Assistance in Cursor -- Stage changes with `git add` -- Use `@commit` to generate initial message -- Review and adjust the generated message -- Ensure it follows format above - -## Examples - -Good commit: +## Component Patterns +### General Code Changes +``` +Component/File(feat[method]): Add feature +Component/File(fix[method]): Fix bug +Component/File(refactor[method]): Code restructure ``` -feat(subprocess[run]): Switch to unicode-only text handling -why: Improve consistency and type safety in subprocess handling -what: -- BREAKING: Changed run() to use text=True by default -- Removed console_to_str() helper and encoding logic -- Simplified output handling -- Updated type hints for better safety +### Packages and Dependencies +| Language | Standard Packages | Dev Packages | Extras / Sub-packages | +|------------|------------------------------------|-------------------------------|-----------------------------------------------| +| General | `lang(deps):` | `lang(deps[dev]):` | | +| Python | `py(deps):` | `py(deps[dev]):` | `py(deps[extra]):` | +| JavaScript | `js(deps):` | `js(deps[dev]):` | `js(deps[subpackage]):`, `js(deps[dev{subpackage}]):` | -refs: #485 -See also: https://docs.python.org/3/library/subprocess.html +#### Examples +- `py(deps[dev]): Update pytest to v8.1` +- `js(deps[ui-components]): Upgrade Button component package` +- `js(deps[dev{linting}]): Add ESLint plugin` + +### Documentation Changes +Prefix with `docs:` +``` +docs(Component/File[Subcomponent/method]): Update API usage guide ``` -Bad commit: +### Test Changes +Prefix with `tests:` ``` -updated some stuff and fixed bugs +tests(Component/File[Subcomponent/method]): Add edge case tests ``` -Cursor Rules: Add development QA and git commit standards (#cursor-rules) +## Commit Types Summary +- **feat**: New features or enhancements +- **fix**: Bug fixes +- **refactor**: Code restructuring without functional change +- **docs**: Documentation updates +- **chore**: Maintenance (dependencies, tooling, config) +- **test**: Test-related updates +- **style**: Code style and formatting + +## General Guidelines +- Subject line: Maximum 50 characters +- Body lines: Maximum 72 characters +- Use imperative mood (e.g., "Add", "Fix", not "Added", "Fixed") +- Limit to one topic per commit +- Separate subject from body with a blank line +- Mark breaking changes clearly: `BREAKING:` +- Use `See also:` to provide external references + +## AI Assistance Workflow in Cursor +- Stage changes with `git add` +- Use `@commit` to generate initial commit message +- Review and refine generated message +- Ensure adherence to these standards + +## Good Commit Example +``` +Pane(feat[capture_pane]): Add screenshot capture support -- Add dev-loop.mdc: QA process for code edits - - Type checking with mypy - - Linting with ruff - - Test validation with pytest - - Ensures edits are validated before commits +why: Provide visual debugging capability +what: +- Implement capturePane method with image export +- Integrate with existing Pane component logic +- Document usage in Pane README -- Add git-commits.mdc: Commit message standards - - Structured format with why/what sections - - Defined commit types and guidelines - - Examples of good/bad commits - - AI assistance instructions +refs: #485 +See also: https://example.com/docs/pane-capture +``` -Note: These rules help maintain code quality and commit history -consistency across the project. +## Bad Commit Example +``` +fixed stuff and improved some functions +``` -See also: https://docs.cursor.com/context/rules-for-ai \ No newline at end of file +These guidelines ensure clear, consistent commit histories, facilitating easier code review and maintenance. \ No newline at end of file diff --git a/CHANGES b/CHANGES index 451ce501c..508cef92f 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,18 @@ $ pip install --user --upgrade --pre libtmux - _Future release notes will be placed here_ +### New features + +#### Waiting (#582) + +Added experimental `waiter.py` module for polling for terminal content in tmux panes: + +- Fluent API inspired by Playwright for better readability and chainable options +- Support for multiple pattern types (exact text, contains, regex, custom predicates) +- Composable waiting conditions with `wait_for_any_content` and `wait_for_all_content` +- Enhanced error handling with detailed timeouts and match information +- Robust shell prompt detection + ## libtmux 0.46.0 (2025-02-25) ### Breaking diff --git a/README.md b/README.md index 357e1c3a1..2fcc771f6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,15 @@ sessions, windows, and panes. Additionally, `libtmux` powers [tmuxp], a tmux wor [![Code Coverage](https://codecov.io/gh/tmux-python/libtmux/branch/master/graph/badge.svg)](https://codecov.io/gh/tmux-python/libtmux) [![License](https://img.shields.io/github/license/tmux-python/libtmux.svg)](https://github.com/tmux-python/libtmux/blob/master/LICENSE) +## Key Features + +- **Intuitive API**: Control tmux servers, sessions, windows, and panes with a clean, object-oriented interface +- **Complete Automation**: Create and manage complex tmux environments programmatically +- **Type Annotations**: Full typing support for modern Python development +- **Pytest Plugin**: Built-in testing tools for tmux automation +- **Context Managers**: Safe session and window management with Python's context protocol +- **Robust Architecture**: Built on tmux's native concepts of targets and formats + libtmux builds upon tmux's [target](http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS) and [formats](http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#FORMATS) to @@ -19,6 +28,17 @@ View the [documentation](https://libtmux.git-pull.com/), [API](https://libtmux.git-pull.com/api.html) information and [architectural details](https://libtmux.git-pull.com/about.html). +## Use Cases + +- **Development Environment Automation**: Set up consistent workspaces across projects +- **CI/CD Systems**: Create isolated environments for testing and deployment +- **System Monitoring**: Build interactive dashboards for server administration +- **Remote Pair Programming**: Facilitate collaborative development sessions +- **Data Science Workflows**: Manage complex data processing pipelines +- **Education and Demonstrations**: Create multi-window learning environments + +For more detailed examples, see our [use cases documentation](https://libtmux.git-pull.com/topics/use_cases.html). + # Install ```console @@ -246,6 +266,64 @@ Window(@1 1:..., Session($1 ...)) Session($1 ...) ``` +# Testing with pytest + +libtmux includes a pytest plugin that provides fixtures for testing tmux operations: + +```python +def test_session_creation(session): + """Test creating a new window in the session.""" + window = session.new_window(window_name="test_window") + assert window.window_name == "test_window" + + # Create a new pane + pane = window.split_window() + assert len(window.panes) == 2 + + # Send keys to the pane + pane.send_keys("echo 'Hello from test'") +``` + +See [pytest plugin documentation](https://libtmux.git-pull.com/pytest-plugin/index.html) for more details. + +# Advanced Usage + +libtmux supports a wide range of advanced use cases: + +## Context Managers + +Safely manage sessions and windows with Python's context protocol: + +```python +with Server().new_session(session_name="my_session") as session: + window = session.new_window(window_name="my_window") + # Work with the window... +# Session is properly cleaned up when context exits +``` + +## Advanced Scripting + +Create complex window layouts and integrate with external systems: + +```python +session = server.new_session(session_name="dashboard") +main = session.new_window(window_name="main") + +# Create a grid layout with 4 panes +top_left = main.attached_pane +top_right = top_left.split_window(vertical=True) +bottom_left = top_left.split_window(vertical=False) +bottom_right = top_right.split_window(vertical=False) + +# Configure each pane +top_left.send_keys("htop", enter=True) +top_right.send_keys("watch -n 1 df -h", enter=True) +bottom_left.send_keys("tail -f /var/log/syslog", enter=True) +bottom_right.send_keys("netstat -tunapl", enter=True) +``` + +See [advanced scripting documentation](https://libtmux.git-pull.com/topics/advanced_scripting.html) for more examples. + # Python support Unsupported / no security releases or bug fixes: diff --git a/conftest.py b/conftest.py index ada5aae3f..9306d9a4c 100644 --- a/conftest.py +++ b/conftest.py @@ -1,11 +1,14 @@ -"""Conftest.py (root-level). +"""Configure root-level pytest fixtures for libtmux. -We keep this in root pytest fixtures in pytest's doctest plugin to be available, as well -as avoiding conftest.py from being included in the wheel, in addition to pytest_plugin -for pytester only being available via the root directory. +We keep this file at the root to make these fixtures available to all +tests, while also preventing unwanted inclusion in the distributed +wheel. Additionally, `pytest_plugins` references ensure that the +`pytester` plugin is accessible for test generation and execution. -See "pytest_plugins in non-top-level conftest files" in -https://docs.pytest.org/en/stable/deprecations.html +See Also +-------- +pytest_plugins in non-top-level conftest files + https://docs.pytest.org/en/stable/deprecations.html """ from __future__ import annotations @@ -33,7 +36,13 @@ def add_doctest_fixtures( request: pytest.FixtureRequest, doctest_namespace: dict[str, t.Any], ) -> None: - """Configure doctest fixtures for pytest-doctest.""" + """Configure doctest fixtures for pytest-doctest. + + Automatically sets up tmux-related classes and default fixtures, + making them available in doctest namespaces if `tmux` is found + on the system. This ensures that doctest blocks referencing tmux + structures can execute smoothly in the test environment. + """ if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): request.getfixturevalue("set_home") doctest_namespace["Server"] = Server @@ -54,7 +63,7 @@ def set_home( monkeypatch: pytest.MonkeyPatch, user_path: pathlib.Path, ) -> None: - """Configure home directory for pytest tests.""" + """Set the HOME environment variable to the temporary user directory.""" monkeypatch.setenv("HOME", str(user_path)) @@ -62,7 +71,7 @@ def set_home( def setup_fn( clear_env: None, ) -> None: - """Function-level test configuration fixtures for pytest.""" + """Apply function-level test fixture configuration (e.g., environment cleanup).""" @pytest.fixture(autouse=True, scope="session") @@ -70,6 +79,10 @@ def setup_session( request: pytest.FixtureRequest, config_file: pathlib.Path, ) -> None: - """Session-level test configuration for pytest.""" + """Apply session-level test fixture configuration for libtmux testing. + + If zsh is in use, applies a suppressing `.zshrc` fix to avoid + default interactive messages that might disrupt tmux sessions. + """ if USING_ZSH: request.getfixturevalue("zshrc") diff --git a/docs/internals/index.md b/docs/internals/index.md index 09d4a1d6f..e153725a6 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -11,6 +11,7 @@ If you need an internal API stabilized please [file an issue](https://github.com ```{toctree} dataclasses query_list +waiter ``` ## Environmental variables diff --git a/docs/internals/waiter.md b/docs/internals/waiter.md new file mode 100644 index 000000000..016d8b185 --- /dev/null +++ b/docs/internals/waiter.md @@ -0,0 +1,135 @@ +(waiter)= + +# Waiters - `libtmux._internal.waiter` + +The waiter module provides utilities for waiting on specific content to appear in tmux panes, making it easier to write reliable tests that interact with terminal output. + +## Key Features + +- **Fluent API**: Playwright-inspired chainable API for expressive, readable test code +- **Multiple Match Types**: Wait for exact matches, substring matches, regex patterns, or custom predicate functions +- **Composable Waiting**: Wait for any of multiple conditions or all conditions to be met +- **Flexible Timeout Handling**: Configure timeout behavior and error handling to suit your needs +- **Shell Prompt Detection**: Easily wait for shell readiness with built-in prompt detection +- **Robust Error Handling**: Improved exception handling and result reporting +- **Clean Code**: Well-formatted, linted code with proper type annotations + +## Basic Concepts + +When writing tests that interact with tmux sessions and panes, it's often necessary to wait for specific content to appear before proceeding with the next step. The waiter module provides a set of functions to help with this. + +There are multiple ways to match content: +- **Exact match**: The content exactly matches the specified string +- **Contains**: The content contains the specified string +- **Regex**: The content matches the specified regular expression +- **Predicate**: A custom function that takes the pane content and returns a boolean + +## Quick Start Examples + +### Simple Waiting + +Wait for specific text to appear in a pane: + +```{literalinclude} ../../tests/examples/_internal/waiter/test_wait_for_text.py +:language: python +``` + +### Advanced Matching + +Use regex patterns or custom predicates for more complex matching: + +```{literalinclude} ../../tests/examples/_internal/waiter/test_wait_for_regex.py +:language: python +``` + +```{literalinclude} ../../tests/examples/_internal/waiter/test_custom_predicate.py +:language: python +``` + +### Timeout Handling + +Control how long to wait and what happens when a timeout occurs: + +```{literalinclude} ../../tests/examples/_internal/waiter/test_timeout_handling.py +:language: python +``` + +### Waiting for Shell Readiness + +A common use case is waiting for a shell prompt to appear, indicating the command has completed. The example below uses a regular expression to match common shell prompt characters (`$`, `%`, `>`, `#`): + +```{literalinclude} ../../tests/examples/_internal/waiter/test_wait_until_ready.py +:language: python +``` + +> Note: This test is skipped in CI environments due to timing issues but works well for local development. + +## Fluent API (Playwright-inspired) + +For a more expressive and chainable API, you can use the fluent interface provided by the `PaneContentWaiter` class: + +```{literalinclude} ../../tests/examples/_internal/waiter/test_fluent_basic.py +:language: python +``` + +```{literalinclude} ../../tests/examples/_internal/waiter/test_fluent_chaining.py +:language: python +``` + +## Multiple Conditions + +The waiter module also supports waiting for multiple conditions at once: + +```{literalinclude} ../../tests/examples/_internal/waiter/test_wait_for_any_content.py +:language: python +``` + +```{literalinclude} ../../tests/examples/_internal/waiter/test_wait_for_all_content.py +:language: python +``` + +```{literalinclude} ../../tests/examples/_internal/waiter/test_mixed_pattern_types.py +:language: python +``` + +## Implementation Notes + +### Error Handling + +The waiting functions are designed to be robust and handle timing and error conditions gracefully: + +- All wait functions properly calculate elapsed time for performance tracking +- Functions handle exceptions consistently and provide clear error messages +- Proper handling of return values ensures consistent behavior whether or not raises=True + +### Type Safety + +The waiter module is fully type-annotated to ensure compatibility with static type checkers: + +- All functions include proper type hints for parameters and return values +- The ContentMatchType enum ensures that only valid match types are used +- Combined with runtime checks, this prevents type-related errors during testing + +### Example Usage in Documentation + +All examples in this documentation are actual test files from the libtmux test suite. The examples are included using `literalinclude` directives, ensuring that the documentation remains synchronized with the actual code. + +## API Reference + +```{eval-rst} +.. automodule:: libtmux._internal.waiter + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` + +## Extended Retry Functionality + +```{eval-rst} +.. automodule:: libtmux.test.retry_extended + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/pytest-plugin/advanced-techniques.md b/docs/pytest-plugin/advanced-techniques.md new file mode 100644 index 000000000..23cab9e86 --- /dev/null +++ b/docs/pytest-plugin/advanced-techniques.md @@ -0,0 +1,114 @@ +--- +myst: + html_meta: + description: "Advanced techniques for testing with the libtmux pytest plugin" + keywords: "tmux, pytest, advanced testing, polling, temporary files" +--- + +(advanced-techniques)= + +# Advanced Techniques + +This page covers advanced testing techniques using the libtmux pytest plugin for more sophisticated testing scenarios. + +## Testing with Temporary Files + +### Creating Temporary Project Directories + +```{literalinclude} ../../tests/examples/pytest_plugin/test_temp_files.py +:language: python +:pyobject: temp_project_dir +``` + +### Working with Files in Temporary Directories + +```{literalinclude} ../../tests/examples/pytest_plugin/test_temp_files.py +:language: python +:pyobject: test_project_file_manipulation +``` + +## Command Polling + +### Implementing Robust Wait Functions + +```{literalinclude} ../../tests/examples/pytest_plugin/test_command_polling.py +:language: python +:pyobject: wait_for_output +``` + +### Testing with Command Polling + +```{literalinclude} ../../tests/examples/pytest_plugin/test_command_polling.py +:language: python +:pyobject: test_command_with_polling +``` + +### Error Handling with Polling + +```{literalinclude} ../../tests/examples/pytest_plugin/test_command_polling.py +:language: python +:pyobject: test_error_handling +``` + +## Setting Custom Home Directory + +### Temporary Home Directory Setup + +```{literalinclude} ../../tests/examples/pytest_plugin/test_home_directory.py +:language: python +:pyobject: set_home +``` + +## Testing with Complex Layouts + +Creating and testing more complex window layouts: + +```{literalinclude} ../../tests/examples/pytest_plugin/test_complex_layouts.py +:language: python +:pyobject: test_complex_layouts +``` + +For an even more advanced layout, you can create a tiled configuration: + +```{literalinclude} ../../tests/examples/pytest_plugin/test_complex_layouts.py +:language: python +:pyobject: test_tiled_layout +``` + +## Testing Across Server Restarts + +When you need to test functionality that persists across server restarts: + +```{literalinclude} ../../tests/examples/pytest_plugin/test_server_restart.py +:language: python +:pyobject: test_persist_across_restart +``` + +## Best Practices + +### Test Structure + +1. **Arrange** - Set up your tmux environment and test data +2. **Act** - Perform the actions you want to test +3. **Assert** - Verify the expected outcome +4. **Clean up** - Reset any state changes (usually handled by fixtures) + +### Tips for Reliable Tests + +1. **Use appropriate waits**: Terminal operations aren't instantaneous. Add sufficient wait times or use polling techniques. + +2. **Capture full pane contents**: Use `pane.capture_pane()` to get all output content for verification. + +3. **Isolate tests**: Don't rely on state from other tests. Each test should set up its own environment. + +4. **Use descriptive assertions**: When tests fail, the assertion message should clarify what went wrong. + +5. **Test error conditions**: Include tests for error handling to ensure your code behaves correctly in failure scenarios. + +6. **Keep tests fast**: Minimize wait times while keeping tests reliable. + +7. **Use parametrized tests**: For similar tests with different inputs, use pytest's parametrize feature. + +8. **Document test requirements**: If tests require specific tmux features, document this in comments. + +9. **Mind CI environments**: Tests should work consistently in both local and CI environments, which may have different tmux versions and capabilities. diff --git a/docs/pytest-plugin/fixtures.md b/docs/pytest-plugin/fixtures.md new file mode 100644 index 000000000..35cbf1603 --- /dev/null +++ b/docs/pytest-plugin/fixtures.md @@ -0,0 +1,208 @@ +--- +myst: + html_meta: + description: "Core fixtures provided by the libtmux pytest plugin for tmux testing" + keywords: "tmux, pytest, fixture, testing, server, session, window, pane" +--- + +(fixtures)= + +# Fixtures + +The libtmux pytest plugin provides several fixtures to help you test tmux-related functionality. These fixtures handle the setup and teardown of tmux resources automatically. + +## Core fixtures + +### Server fixture + +```{eval-rst} +.. autofunction:: libtmux.pytest_plugin.server +``` + +Example usage: + +```python +def test_server_functions(server): + """Test basic server functions.""" + assert server.is_alive() + + # Create a new session + session = server.new_session(session_name="test-session") + assert session.name == "test-session" +``` + +### Session fixture + +```{eval-rst} +.. autofunction:: libtmux.pytest_plugin.session +``` + +Example usage: + +```python +def test_session_functions(session): + """Test basic session functions.""" + assert session.is_alive() + + # Create a new window + window = session.new_window(window_name="test-window") + assert window.window_name == "test-window" +``` + +### Window fixture + +```{eval-rst} +.. autofunction:: libtmux.pytest_plugin.window +``` + +Example usage: + +```python +def test_window_functions(window): + """Test basic window functions.""" + # Get the active pane + pane = window.active_pane + assert pane is not None + + # Split the window + new_pane = window.split_window() + assert len(window.panes) == 2 +``` + +### Pane fixture + +```{eval-rst} +.. autofunction:: libtmux.pytest_plugin.pane +``` + +Example usage: + +```python +def test_pane_functions(pane): + """Test basic pane functions.""" + # Send a command to the pane + pane.send_keys("echo 'Hello from pane'", enter=True) + + # Give the command time to execute + import time + time.sleep(0.5) + + # Capture and verify the output + output = pane.capture_pane() + assert any("Hello from pane" in line for line in output) +``` + +## Helper fixtures + +### TestServer fixture + +```{eval-rst} +.. autofunction:: libtmux.pytest_plugin.TestServer +``` + +Example usage: + +```python +def test_multiple_servers(TestServer): + """Test creating multiple independent tmux servers.""" + # Create first server + server1 = TestServer() + session1 = server1.new_session(session_name="session1") + + # Create second server (completely independent) + server2 = TestServer() + session2 = server2.new_session(session_name="session2") + + # Verify both servers are running + assert server1.is_alive() + assert server2.is_alive() + + # Verify sessions exist on their respective servers only + assert session1.server is server1 + assert session2.server is server2 +``` + +For more advanced usage with custom configuration: + +```{literalinclude} ../../tests/examples/pytest_plugin/test_direct_testserver.py +:language: python +:pyobject: test_custom_server_config +``` + +You can also use TestServer directly as a context manager: + +```{literalinclude} ../../tests/examples/pytest_plugin/test_direct_testserver.py +:language: python +:pyobject: test_testserver_direct_usage +``` + +### Environment fixtures + +These fixtures help manage the testing environment: + +```{eval-rst} +.. autofunction:: libtmux.pytest_plugin.home_path +.. autofunction:: libtmux.pytest_plugin.user_path +.. autofunction:: libtmux.pytest_plugin.config_file +``` + +## Customizing fixtures + +(custom_session_params)= + +### Custom session parameters + +You can override `session_params` to customize the `session` fixture: + +```python +@pytest.fixture +def session_params(): + """Customize session parameters.""" + return { + "x": 800, + "y": 600, + "suppress_history": True + } +``` + +These parameters are passed directly to {meth}`Server.new_session`. + +(set_home)= + +### Setting a temporary home directory + +You can customize the home directory used for tests: + +```python +@pytest.fixture +def set_home(monkeypatch, tmp_path): + """Set a custom temporary home directory.""" + monkeypatch.setenv("HOME", str(tmp_path)) + tmux_config = tmp_path / ".tmux.conf" + tmux_config.write_text("set -g status off\nset -g history-limit 1000") + return tmp_path +``` + +## Using a custom tmux configuration + +If you need to test with a specific tmux configuration: + +```python +@pytest.fixture +def custom_config(tmp_path): + """Create a custom tmux configuration.""" + config_file = tmp_path / "tmux.conf" + config_file.write_text(""" + set -g status off + set -g base-index 1 + set -g history-limit 5000 + """) + return str(config_file) + +@pytest.fixture +def server_with_config(custom_config): + """Create a server with a custom configuration.""" + server = libtmux.Server(config_file=custom_config) + yield server + server.kill_server() +``` diff --git a/docs/pytest-plugin/getting-started.md b/docs/pytest-plugin/getting-started.md new file mode 100644 index 000000000..e6f7f302f --- /dev/null +++ b/docs/pytest-plugin/getting-started.md @@ -0,0 +1,86 @@ +--- +myst: + html_meta: + description: "Getting started with libtmux pytest plugin for tmux testing" + keywords: "tmux, pytest, plugin, getting started, installation" +--- + +(getting_started)= + +# Getting Started + +## Installation + +The libtmux pytest plugin is included when you install the libtmux package: + +```{code-block} console +$ pip install libtmux +``` + +No additional configuration is needed as pytest will automatically detect and register the plugin. + +## Prerequisites + +The plugin requires: + +- Python 3.7+ +- pytest 6.0+ +- tmux 2.8+ + +## Basic usage + +Here's a simple example of using the plugin in your tests: + +```python +def test_basic_tmux_functionality(session): + """Test basic tmux functionality using the session fixture.""" + # The session fixture provides a fresh tmux session + assert session.is_alive() + + # Create a new window in the session + window = session.new_window(window_name="test-window") + assert window.window_name == "test-window" + + # Send commands to the active pane + pane = window.active_pane + pane.send_keys("echo 'Hello from tmux!'", enter=True) + + # Give the command time to execute + import time + time.sleep(0.5) + + # Capture and verify the output + output = pane.capture_pane() + assert any("Hello from tmux!" in line for line in output) +``` + +## How it works + +The libtmux pytest plugin provides several fixtures that automatically manage the tmux environment for your tests: + +1. **Core fixtures** - `server`, `session`, `window`, and `pane` create isolated tmux instances +2. **Helper fixtures** - Utilities for temporary directories, configurations, and environment management +3. **Custom fixtures** - Fixtures you can override to customize the test environment + +Each test gets its own isolated tmux environment, and all resources are automatically cleaned up after the test completes. + +(recommended-fixtures)= + +## Recommended fixtures + +These fixtures are automatically used when the plugin is enabled and `pytest` is run: + +- Creating temporary test directories: + - `/home/` ({func}`home_path`) + - `/home/${user}` ({func}`user_path`) + +- Default `.tmux.conf` configuration with these settings ({func}`config_file`): + - `base-index -g 1` + +These settings ensure panes and windows can be reliably referenced and asserted across different test environments. + +## Next steps + +- Explore the available {ref}`fixtures` for more advanced testing scenarios +- See {ref}`usage-examples` for detailed code examples +- Learn about {ref}`advanced-techniques` for complex testing requirements diff --git a/docs/pytest-plugin/index.md b/docs/pytest-plugin/index.md index 8f8dca41d..ba4b121ff 100644 --- a/docs/pytest-plugin/index.md +++ b/docs/pytest-plugin/index.md @@ -1,149 +1,65 @@ +--- +myst: + html_meta: + description: "libtmux pytest plugin for testing tmux applications with pytest" + keywords: "tmux, pytest, plugin, testing, libtmux" +--- + (pytest_plugin)= # tmux `pytest` plugin -libtmux provides pytest fixtures for tmux. The plugin automatically manages setup and teardown of an -independent tmux server. - -```{seealso} Using the pytest plugin? - -Do you want more flexibility? Correctness? Power? Defaults changed? [Connect with us] on the tracker, we want to know -your case, we won't stabilize APIs until we're sure everything is by the book. - -[connect with us]: https://github.com/tmux-python/libtmux/discussions - -``` - -```{module} libtmux.pytest_plugin - -``` - -## Usage - -Install `libtmux` via the python package manager of your choosing, e.g. +```{toctree} +:hidden: +:maxdepth: 2 -```console -$ pip install libtmux +getting-started +fixtures +usage-examples +advanced-techniques ``` -The pytest plugin will be automatically detected via pytest, and the fixtures will be added. - -### Real world usage - -View libtmux's own [tests/](https://github.com/tmux-python/libtmux/tree/master/tests) as well as -tmuxp's [tests/](https://github.com/tmux-python/tmuxp/tree/master/tests). - -libtmux's tests `autouse` the {ref}`recommended-fixtures` above to ensure stable test execution, assertions and -object lookups in the test grid. - -## pytest-tmux - -`pytest-tmux` works through providing {ref}`pytest fixtures ` - so read up on -those! - -The plugin's fixtures guarantee a fresh, headless {command}`tmux(1)` server, session, window, or pane is -passed into your test. - -(recommended-fixtures)= +libtmux provides pytest fixtures for tmux, making it easy to test tmux-related functionality with complete isolation. The plugin automatically manages setup and teardown of independent tmux servers, sessions, windows, and panes. -## Recommended fixtures +```{admonition} Connect with us! +:class: seealso -These fixtures are automatically used when the plugin is enabled and `pytest` is run. - -- Creating temporary, test directories for: - - `/home/` ({func}`home_path`) - - `/home/${user}` ({func}`user_path`) -- Default `.tmux.conf` configuration with these settings ({func}`config_file`): - - - `base-index -g 1` - - These are set to ensure panes and windows can be reliably referenced and asserted. - -## Setting a tmux configuration - -If you would like {func}`session fixture ` to automatically use a configuration, you have a few -options: - -- Pass a `config_file` into {class}`~libtmux.Server` -- Set the `HOME` directory to a local or temporary pytest path with a configuration file - -You could also read the code and override {func}`server fixture ` in your own doctest. - -(custom_session_params)= - -### Custom session parameters - -You can override `session_params` to customize the `session` fixture. The -dictionary will directly pass into {meth}`Server.new_session` keyword arguments. - -```python -import pytest - -@pytest.fixture -def session_params(): - return { - 'x': 800, - 'y': 600 - } - - -def test_something(session): - assert session +Do you want more flexibility? Correctness? Power? Defaults changed? [Connect with us](https://github.com/tmux-python/libtmux/discussions) on the tracker. We want to know your use case and won't stabilize APIs until we're sure everything is by the book. ``` -The above will assure the libtmux session launches with `-x 800 -y 600`. +## Benefits at a glance -(temp_server)= +- **Isolated Testing Environment**: Each test gets a fresh tmux server that won't interfere with other tests +- **Automatic Cleanup**: Servers, sessions, and other resources are automatically cleaned up after tests +- **Simplified Setup**: Common fixtures for server, session, window, and pane management +- **Reliable Testing**: Consistent environment for reproducible test results +- **Custom Configuration**: Easily test with different tmux configurations and settings -### Creating temporary servers +## Quick installation -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. +Install `libtmux` via the Python package manager of your choice: -```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() +```{code-block} console +$ pip install libtmux ``` -You can also use it with custom configurations, similar to the {ref}`server fixture `: +The pytest plugin will be automatically detected by pytest, and the fixtures will be available in your test environment. -```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. +## See real-world examples -(set_home)= +View libtmux's own [tests/](https://github.com/tmux-python/libtmux/tree/master/tests) as well as tmuxp's [tests/](https://github.com/tmux-python/tmuxp/tree/master/tests) for real-world examples. -### Setting a temporary home directory +For detailed code examples and usage patterns, refer to the {ref}`usage-examples` page. -```python -import pathlib -import pytest +## Module reference -@pytest.fixture(autouse=True, scope="function") -def set_home( - monkeypatch: pytest.MonkeyPatch, - user_path: pathlib.Path, -): - monkeypatch.setenv("HOME", str(user_path)) +```{module} libtmux.pytest_plugin ``` -## Fixtures - ```{eval-rst} .. automodule:: libtmux.pytest_plugin :members: :inherited-members: :private-members: :show-inheritance: - :member-order: bysource -``` + :member-order: bysource \ No newline at end of file diff --git a/docs/pytest-plugin/usage-examples.md b/docs/pytest-plugin/usage-examples.md new file mode 100644 index 000000000..e4a489a27 --- /dev/null +++ b/docs/pytest-plugin/usage-examples.md @@ -0,0 +1,237 @@ +--- +myst: + html_meta: + description: "Usage examples for the libtmux pytest plugin" + keywords: "tmux, pytest, examples, testing, patterns" +--- + +(usage-examples)= + +# Usage Examples + +This page provides practical code examples for testing with the libtmux pytest plugin. All examples shown here are included as real test files in the `tests/examples/pytest_plugin` directory. + +## Basic Examples + +### Server Testing + +```{literalinclude} ../../tests/examples/pytest_plugin/test_basic_usage.py +:language: python +:pyobject: test_basic_server +``` + +### Session Testing + +```{literalinclude} ../../tests/examples/pytest_plugin/test_basic_usage.py +:language: python +:pyobject: test_basic_session +``` + +### Window Testing + +```{literalinclude} ../../tests/examples/pytest_plugin/test_basic_usage.py +:language: python +:pyobject: test_basic_window +``` + +### Pane Testing + +```{literalinclude} ../../tests/examples/pytest_plugin/test_basic_usage.py +:language: python +:pyobject: test_basic_pane +``` + +## Environment Configuration + +### Setting Environment Variables + +```{literalinclude} ../../tests/examples/pytest_plugin/test_custom_environment.py +:language: python +:pyobject: test_environment_variables +``` + +### Directory Navigation + +```{literalinclude} ../../tests/examples/pytest_plugin/test_custom_environment.py +:language: python +:pyobject: test_directory_navigation +``` + +### Custom Session Parameters + +```{literalinclude} ../../tests/examples/pytest_plugin/test_session_params.py +:language: python +:pyobject: session_params +``` + +```{literalinclude} ../../tests/examples/pytest_plugin/test_session_params.py +:language: python +:pyobject: test_custom_session_dimensions +``` + +## Testing with Multiple Servers + +### Basic Test Server Usage + +```{literalinclude} ../../tests/examples/pytest_plugin/test_multiple_servers.py +:language: python +:pyobject: test_basic_test_server +``` + +### Multiple Servers with Custom Configuration + +```{literalinclude} ../../tests/examples/pytest_plugin/test_multiple_servers.py +:language: python +:pyobject: test_with_config +``` + +### Multiple Independent Servers + +```{literalinclude} ../../tests/examples/pytest_plugin/test_multiple_servers.py +:language: python +:pyobject: test_multiple_independent_servers +``` + +## Testing with Multiple Panes + +### Multi-Pane Interaction + +```{literalinclude} ../../tests/examples/pytest_plugin/test_multi_pane.py +:language: python +:pyobject: test_multi_pane_interaction +``` + +### Pane Layout Testing + +```{literalinclude} ../../tests/examples/pytest_plugin/test_multi_pane.py +:language: python +:pyobject: test_pane_layout +``` + +## Window Management + +Window management is a common task when working with tmux. Here are some examples: + +### Window Renaming + +```{literalinclude} ../../tests/examples/pytest_plugin/test_window_management.py +:language: python +:pyobject: test_window_renaming +``` + +### Moving Windows + +```{literalinclude} ../../tests/examples/pytest_plugin/test_window_management.py +:language: python +:pyobject: test_window_moving +``` + +### Switching Between Windows + +```{literalinclude} ../../tests/examples/pytest_plugin/test_window_management.py +:language: python +:pyobject: test_window_switching +``` + +### Killing Windows + +```{literalinclude} ../../tests/examples/pytest_plugin/test_window_management.py +:language: python +:pyobject: test_window_killing +``` + +## Pane Operations + +Panes are the subdivisions within windows where commands are executed: + +### Advanced Pane Functions + +```{literalinclude} ../../tests/examples/pytest_plugin/test_pane_operations.py +:language: python +:pyobject: test_pane_functions +``` + +### Resizing Panes + +```{literalinclude} ../../tests/examples/pytest_plugin/test_pane_operations.py +:language: python +:pyobject: test_pane_resizing +``` + +### Capturing Pane Content + +```{literalinclude} ../../tests/examples/pytest_plugin/test_pane_operations.py +:language: python +:pyobject: test_pane_capturing +``` + +## Process Control + +Working with processes in tmux panes: + +### Process Detection + +```{literalinclude} ../../tests/examples/pytest_plugin/test_process_control.py +:language: python +:pyobject: test_process_detection +``` + +### Handling Command Output + +```{literalinclude} ../../tests/examples/pytest_plugin/test_process_control.py +:language: python +:pyobject: test_command_output_scrollback +``` + +### Background Processes + +```{literalinclude} ../../tests/examples/pytest_plugin/test_process_control.py +:language: python +:pyobject: test_running_background_process +``` + +## Custom Configuration Testing + +### Creating a Custom Configuration + +```{literalinclude} ../../tests/examples/pytest_plugin/test_custom_config.py +:language: python +:pyobject: custom_config +``` + +### Using a Custom Server Configuration + +```{literalinclude} ../../tests/examples/pytest_plugin/test_custom_config.py +:language: python +:pyobject: custom_server +``` + +### Testing with Custom Configuration + +```{literalinclude} ../../tests/examples/pytest_plugin/test_custom_config.py +:language: python +:pyobject: test_with_custom_config +``` + +## Parametrized Testing + +### Testing Multiple Window Names + +```{literalinclude} ../../tests/examples/pytest_plugin/test_parametrized.py +:language: python +:pyobject: test_multiple_windows +``` + +### Testing Various Commands + +```{literalinclude} ../../tests/examples/pytest_plugin/test_parametrized.py +:language: python +:pyobject: test_various_commands +``` + +### Testing Window Layouts + +```{literalinclude} ../../tests/examples/pytest_plugin/test_parametrized.py +:language: python +:pyobject: test_window_layouts +``` diff --git a/docs/test-helpers/constants.md b/docs/test-helpers/constants.md index facbfb871..b7583a251 100644 --- a/docs/test-helpers/constants.md +++ b/docs/test-helpers/constants.md @@ -1,3 +1,5 @@ +(test_helpers_constants)= + # Constants Test-related constants used across libtmux test helpers. @@ -7,4 +9,5 @@ Test-related constants used across libtmux test helpers. :members: :undoc-members: :show-inheritance: -``` \ No newline at end of file + :member-order: bysource +``` diff --git a/docs/test-helpers/environment.md b/docs/test-helpers/environment.md index e385193a6..58b4bb549 100644 --- a/docs/test-helpers/environment.md +++ b/docs/test-helpers/environment.md @@ -1,3 +1,5 @@ +(test_helpers_environment)= + # Environment Environment variable mocking utilities for tests. @@ -7,4 +9,5 @@ Environment variable mocking utilities for tests. :members: :undoc-members: :show-inheritance: -``` \ No newline at end of file + :member-order: bysource +``` diff --git a/docs/test-helpers/index.md b/docs/test-helpers/index.md index b27fa8d3e..dd99384bf 100644 --- a/docs/test-helpers/index.md +++ b/docs/test-helpers/index.md @@ -8,10 +8,11 @@ Test helpers for libtmux and downstream libraries. constants environment random +retry temporary ``` ```{eval-rst} .. automodule:: libtmux.test :members: -``` \ No newline at end of file +``` diff --git a/docs/test-helpers/random.md b/docs/test-helpers/random.md index 2222a6cee..e4248a7fc 100644 --- a/docs/test-helpers/random.md +++ b/docs/test-helpers/random.md @@ -1,3 +1,5 @@ +(test_helpers_random)= + # Random Random string generation utilities for test names. @@ -7,4 +9,5 @@ Random string generation utilities for test names. :members: :undoc-members: :show-inheritance: -``` \ No newline at end of file + :member-order: bysource +``` diff --git a/docs/test-helpers/retry.md b/docs/test-helpers/retry.md new file mode 100644 index 000000000..6ec72e3c4 --- /dev/null +++ b/docs/test-helpers/retry.md @@ -0,0 +1,15 @@ +(test_helpers_retry)= + +# Retry Utilities + +Retry helper functions for libtmux test utilities. These utilities help manage testing operations that may require multiple attempts before succeeding. + +## Basic Retry Functionality + +```{eval-rst} +.. automodule:: libtmux.test.retry + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/test-helpers/temporary.md b/docs/test-helpers/temporary.md index f1ee07b2f..ea3b8ddf9 100644 --- a/docs/test-helpers/temporary.md +++ b/docs/test-helpers/temporary.md @@ -1,3 +1,5 @@ +(test_helpers_temporary_objects)= + # Temporary Objects Context managers for temporary tmux objects (sessions, windows). @@ -7,4 +9,5 @@ Context managers for temporary tmux objects (sessions, windows). :members: :undoc-members: :show-inheritance: -``` \ No newline at end of file + :member-order: bysource +``` diff --git a/docs/topics/advanced_scripting.md b/docs/topics/advanced_scripting.md new file mode 100644 index 000000000..665245616 --- /dev/null +++ b/docs/topics/advanced_scripting.md @@ -0,0 +1,700 @@ +--- +orphan: true +--- + +# Advanced Scripting + +libtmux enables sophisticated scripting of tmux operations, allowing developers to create robust tools and workflows. + +## Complex Window and Pane Layouts + +Creating a grid layout with multiple panes: + +```python +import libtmux +from libtmux.constants import PaneDirection + +# Connect to the tmux server +server = libtmux.Server() + +# Create or get session with proper handling for existing sessions +def get_clean_session(name): + """Get a clean session, killing any existing windows if it already exists""" + try: + session = server.new_session(session_name=name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=name) + # Clean up existing windows + for window in session.windows: + window.kill() + # Create a new window + window = session.new_window(window_name="main") + else: + window = session.active_window + return session, window + +# Create a 2x2 grid layout +def create_grid_layout(session_name="complex-layout"): + session, window = get_clean_session(session_name) + + # Create a 2x2 grid of panes + right_pane = window.split(direction=PaneDirection.Right) + bottom_left = window.split(direction=PaneDirection.Below) + + # Select the right pane and split it + window.select_pane(right_pane.pane_id) + bottom_right = window.split(direction=PaneDirection.Below) + + # Send some test commands to the panes + bottom_left.send_keys("echo 'Hello from bottom left'", enter=True) + bottom_right.send_keys("echo 'Hello from bottom right'", enter=True) + + # Select layout + window.select_layout("main-vertical") + + return session, window, [bottom_left, bottom_right] + +# Example usage +session, window, panes = create_grid_layout() +print(f"Created session '{session.session_name}' with {len(window.panes)} panes") + +# Clean up when done (uncomment if you want to kill the session) +# server.kill_session(session.session_id) +``` + +## Advanced Layouts and Window Management + +Working with different window layouts and managing multiple windows: + +```python +import libtmux +from libtmux.constants import PaneDirection + +# Connect to the tmux server +server = libtmux.Server() + +# Create or get session with proper handling for existing sessions +def get_clean_session(name): + """Get a clean session, killing any existing windows if it already exists""" + try: + session = server.new_session(session_name=name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=name) + # Clean up existing windows + for window in session.windows: + window.kill() + # Create a new window + window = session.new_window(window_name="main") + else: + window = session.active_window + return session, window + +# Create a session with multiple windows and layouts +def create_multi_window_session(session_name="advanced-layouts"): + session, window = get_clean_session(session_name) + + # Create a multi-pane first window + right_pane = window.split(direction=PaneDirection.Right) + bottom_left = window.split(direction=PaneDirection.Below) + + # Select the right pane and split it + window.select_pane(right_pane.pane_id) + bottom_right = window.split(direction=PaneDirection.Below) + + # Try different layouts + window.select_layout("main-vertical") # Main pane on left, two on right + + # Create a second window for logs + log_window = session.new_window(window_name="logs") + + # Switch between windows + first_window = session.select_window(1) + + return session, first_window, log_window + +# Example usage +session, first_window, log_window = create_multi_window_session() +print(f"Created session '{session.session_name}' with multiple windows") +print(f"First window has {len(first_window.panes)} panes") +print(f"Second window name: {log_window.window_name}") + +# Clean up when done (uncomment if you want to kill the session) +# server.kill_session(session.session_id) +``` + +## Reactive Scripts + +Creating scripts that monitor outputs and react to changes: + +```python +import libtmux +import re +import time +from libtmux.constants import PaneDirection + +# Connect to the tmux server +server = libtmux.Server() + +# Create or get a clean session for reactive monitoring +def setup_reactive_monitoring(session_name="reactive-monitor"): + """Set up a session for reactive monitoring with multiple panes""" + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=session_name) + # Clean up any existing windows + for window in session.windows: + window.kill() + window = session.new_window(window_name="monitor") + else: + window = session.active_window + window.rename_window("monitor") + + # Create source and monitoring panes + source_pane = window.active_pane + monitor_pane = window.split(direction=PaneDirection.Right) + + return session, window, source_pane, monitor_pane + +def monitor_for_pattern(source_pane, monitor_pane, pattern="ERROR", interval=1.0, max_time=10): + """Monitor source pane content for a pattern and react in the monitor pane""" + start_time = time.time() + pattern_re = re.compile(pattern) + found = False + + # Generate some test output in source pane + source_pane.send_keys("echo 'Starting test process...'", enter=True) + source_pane.send_keys("for i in {1..5}; do echo \"Processing item $i\"; sleep 1; done", enter=True) + source_pane.send_keys("echo 'ERROR: Something went wrong!'", enter=True) + source_pane.send_keys("echo 'Finishing process'", enter=True) + + # Monitor for pattern + while time.time() - start_time < max_time: + content = source_pane.capture_pane() + + for line in content: + if pattern_re.search(line): + monitor_pane.send_keys(f"ALERT: Found pattern '{pattern}' in output!", enter=True) + monitor_pane.send_keys(f"Taking corrective action...", enter=True) + found = True + break + + if found: + break + + time.sleep(interval) + + return found + +# Example usage +if __name__ == "__main__": + session, window, source_pane, monitor_pane = setup_reactive_monitoring() + + print(f"Starting monitoring in session '{session.session_name}'") + result = monitor_for_pattern(source_pane, monitor_pane) + + if result: + print("Successfully detected and responded to the pattern") + else: + print("Pattern not found within the time limit") +``` + +## Deployment Monitoring + +A practical example of using libtmux for deployment monitoring: + +```python +import libtmux +from libtmux.constants import PaneDirection +import time + +# Connect to the tmux server +server = libtmux.Server() + +def setup_deployment_monitor(repo_name, branch="main", session_name="deployment"): + """Set up a deployment monitoring environment with multiple windows""" + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=session_name) + # Clean up existing windows + for window in session.windows: + window.kill() + + # Create a window for deployment logs + deploy_window = session.new_window(window_name="deployment") + + # Window for API responses + api_window = session.new_window(window_name="api-status") + + # Split the deployment window for different log types + build_pane = deploy_window.active_pane + test_pane = deploy_window.split(direction=PaneDirection.Right) + + # Rename the panes (command sent to each pane) + build_pane.send_keys("# BUILD LOGS", enter=True) + test_pane.send_keys("# TEST LOGS", enter=True) + + # Simulate deployment process + build_pane.send_keys(f"echo 'Starting deployment of {repo_name}:{branch}'", enter=True) + build_pane.send_keys("echo 'Building application...'", enter=True) + + # Wait a bit for "build" to complete + time.sleep(1) + + test_pane.send_keys("echo 'Running tests...'", enter=True) + for i in range(3): + test_pane.send_keys(f"echo 'Test suite {i+1} passed'", enter=True) + time.sleep(0.5) + + # Switch to API window and display results + session.select_window("api-status") + api_window.active_pane.send_keys("echo 'Deployment status: SUCCESS'", enter=True) + api_window.active_pane.send_keys(f"echo 'Deployment ID: sample-{repo_name}-{int(time.time())}'", enter=True) + + # Return to the deployment window + session.select_window("deployment") + + return session + +# Example usage +if __name__ == "__main__": + session = setup_deployment_monitor("my-app", "develop") + print(f"Created deployment monitoring environment in session '{session.session_name}'") +``` + +## Working with Command Output + +libtmux allows you to easily capture and process command output: + +```python +import libtmux +import time +import re + +# Connect to the tmux server +server = libtmux.Server() + +def run_and_capture_command(command, session_name="command-output"): + """Run a command in a tmux pane and capture its output""" + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=session_name) + # Clean up existing windows + for window in session.windows: + window.kill() + window = session.new_window(window_name="command") + else: + window = session.active_window + window.rename_window("command") + + # Get the active pane + pane = window.active_pane + + # Run the command + pane.send_keys(command, enter=True) + + # Wait for the command to complete (in a real app, you might check for a prompt) + time.sleep(1) + + # Capture the output + output = pane.capture_pane() + + # Process the output (removing the command line itself) + processed_output = [line for line in output if command not in line and line.strip()] + + return processed_output + +def extract_info_from_output(output, pattern): + """Extract information from command output using regex""" + results = [] + regex = re.compile(pattern) + + for line in output: + match = regex.search(line) + if match: + results.append(match.group(0)) + + return results + +# Example usage +if __name__ == "__main__": + # Run some commands and capture output + ls_output = run_and_capture_command("ls -la") + df_output = run_and_capture_command("df -h") + ps_output = run_and_capture_command("ps aux | grep python") + + # Extract information from the output + files = extract_info_from_output(ls_output, r'\.py$') # Find Python files + disk_usage = extract_info_from_output(df_output, r'\d+%') # Find disk usage percentages + + print("Python files found:", files) + print("Disk usage percentages:", disk_usage) +``` + +## State Management + +Saving and restoring session state: + +```python +import libtmux +import json +import os + +# Connect to the tmux server +server = libtmux.Server() + +def save_state(session, state_file="tmux_state.json"): + """Save the current state of a tmux session to a JSON file""" + state = { + "session_name": session.session_name, + "windows": [] + } + + # Save information about each window + for window in session.windows: + window_info = { + "window_name": window.window_name, + "window_index": window.window_index, + "panes": [] + } + + # Save information about each pane in the window + for pane in window.panes: + pane_info = { + "pane_index": pane.pane_index, + "current_path": pane.current_path + } + window_info["panes"].append(pane_info) + + state["windows"].append(window_info) + + # Write state to file (comment out for demonstration) + # with open(state_file, 'w') as f: + # json.dump(state, f, indent=2) + + return state + +def restore_session(state_data, server=None): + """Restore a tmux session from saved state""" + if server is None: + server = libtmux.Server() + + session_name = state_data["session_name"] + + # Create or get the session + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=session_name) + # Keep only the first window and remove others + first_window = None + for i, window in enumerate(session.windows): + if i == 0: + first_window = window + else: + window.kill() + session = server.sessions.get(session_name=session_name) + + # Create windows from saved state + for window_info in state_data["windows"]: + # Skip the first window if it already exists + if window_info["window_index"] == "1" and session.windows: + window = session.windows[0] + window.rename_window(window_info["window_name"]) + else: + # Create a new window + window = session.new_window(window_name=window_info["window_name"]) + + # Create additional panes if needed + existing_pane_count = len(window.panes) + needed_panes = len(window_info["panes"]) + + # Create additional panes if needed + for i in range(existing_pane_count, needed_panes): + if i > 0: # Skip the first pane which already exists + window.split(direction=libtmux.constants.PaneDirection.Right) + + return session + +# Example usage +if __name__ == "__main__": + # Create a sample session + try: + session = server.new_session(session_name="state-example") + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name="state-example") + + # Create a couple of windows + main_window = session.active_window + main_window.rename_window("main") + + # Create a logs window if it doesn't exist + if "logs" not in [w.window_name for w in session.windows]: + logs_window = session.new_window(window_name="logs") + + # Save the state + state = save_state(session) + print(f"Saved state for session {state['session_name']} with {len(state['windows'])} windows") + + # To restore: + # restored_session = restore_session(state) +``` + +## Integration with External APIs + +Integrating libtmux with external APIs for deployment monitoring: + +```python +import libtmux +from libtmux.constants import PaneDirection +import time + +# Connect to the tmux server +server = libtmux.Server() + +def create_deployment_dashboard(repo_name, branch="main"): + """Create a visual dashboard for deployment monitoring using tmux""" + session_name = f"deploy-{repo_name}" + + # Create or get session + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=session_name) + # Clean up existing windows + for window in session.windows: + window.kill() + + # Create dashboard window + dashboard = session.new_window(window_name="dashboard") + + # Split into 4 panes + # Top right: Build status + build_pane = dashboard.split(direction=PaneDirection.Right) + + # Bottom left: Logs + logs_pane = dashboard.active_pane + logs_pane = logs_pane.split(direction=PaneDirection.Below) + + # Bottom right: Tests + dashboard.select_pane(build_pane.pane_id) + tests_pane = build_pane.split(direction=PaneDirection.Below) + + # In a real application, you would make API calls to your CI/CD system: + # import requests + # + # response = requests.get( + # f"https://api.github.com/repos/{owner}/{repo_name}/actions/runs", + # headers={"Authorization": f"token {github_token}"} + # ) + # workflow_runs = response.json()["workflow_runs"] + # latest_run = workflow_runs[0] + + # Instead, we'll simulate for this example: + + # Display info in each pane + dashboard.select_pane(dashboard.panes[0].pane_id) + dashboard.panes[0].send_keys("echo '=== DEPLOYMENT OVERVIEW ==='", enter=True) + dashboard.panes[0].send_keys(f"echo 'Repository: {repo_name}'", enter=True) + dashboard.panes[0].send_keys(f"echo 'Branch: {branch}'", enter=True) + dashboard.panes[0].send_keys("echo 'Status: In Progress'", enter=True) + + # Build pane + build_pane.send_keys("echo '=== BUILD STATUS ==='", enter=True) + build_pane.send_keys("echo 'Build #123'", enter=True) + for i in range(3): + build_pane.send_keys(f"echo 'Building step {i+1}...'", enter=True) + time.sleep(0.5) + build_pane.send_keys("echo 'Build completed successfully'", enter=True) + + # Logs pane + logs_pane.send_keys("echo '=== DEPLOYMENT LOGS ==='", enter=True) + logs_pane.send_keys("echo 'Initializing deployment...'", enter=True) + logs_pane.send_keys("echo 'Updating dependencies...'", enter=True) + logs_pane.send_keys("echo 'Running database migrations...'", enter=True) + logs_pane.send_keys("echo 'Restarting services...'", enter=True) + + # Tests pane + tests_pane.send_keys("echo '=== TEST RESULTS ==='", enter=True) + tests_pane.send_keys("echo 'Running test suite...'", enter=True) + for i in range(3): + tests_pane.send_keys(f"echo 'Test suite {i+1}: PASSED'", enter=True) + time.sleep(0.5) + tests_pane.send_keys("echo 'All tests passed!'", enter=True) + + # Update status in overview pane + time.sleep(2) + dashboard.select_pane(dashboard.panes[0].pane_id) + dashboard.panes[0].send_keys("echo 'Status: DEPLOYED'", enter=True) + + return session + +# Example usage +if __name__ == "__main__": + session = create_deployment_dashboard("my-service", "production") + print(f"Created deployment dashboard in session: {session.session_name}") +``` + +## Layout Management + +Managing complex window layouts programmatically: + +```python +import libtmux +from libtmux.constants import PaneDirection +import time + +# Connect to the tmux server +server = libtmux.Server() + +def create_complex_layout(session_name="layout-demo"): + """Create a session with complex layout patterns""" + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + # Get the existing session + session = server.sessions.get(session_name=session_name) + # Clean up existing windows + for window in session.windows: + window.kill() + + # Create a window for our layout + layout_window = session.new_window(window_name="complex-layout") + + # Create a 2x2 grid of panes + right_pane = layout_window.split(direction=PaneDirection.Right) + bottom_left = layout_window.split(direction=PaneDirection.Below) + + # Select the right pane and split it + layout_window.select_pane(right_pane.pane_id) + bottom_right = right_pane.split(direction=PaneDirection.Below) + + # Try different built-in layouts + layouts = [ + "even-horizontal", + "even-vertical", + "main-horizontal", + "main-vertical", + "tiled" + ] + + # Demonstrate different layouts + for layout in layouts: + # Display the layout name + layout_window.select_pane(layout_window.panes[0].pane_id) + layout_window.panes[0].send_keys(f"echo 'Switching to {layout}'", enter=True) + + # Apply the layout + layout_window.select_layout(layout) + + # Send some test output to each pane to make it visible + for i, pane in enumerate(layout_window.panes): + pane.send_keys(f"echo 'Pane {i+1} in {layout} layout'", enter=True) + + # Pause to view the layout + time.sleep(1) + + # Create a second window for other layouts + custom_window = session.new_window(window_name="custom-layout") + + # Switch back to first window + first_window = session.select_window(1) + + return session, first_window, custom_window + +# Example usage +if __name__ == "__main__": + session, first_window, custom_window = create_complex_layout() + print(f"Created layout demonstration in session '{session.session_name}'") + print(f"Window 1 index: {first_window.window_index}") + print(f"Window 2 name: {custom_window.window_name}") +``` + +## Debugging Tips + +Here are some useful tips for debugging libtmux scripts: + +1. **Enable logging in your scripts** - This helps track what's happening during script execution: + ```python + import logging + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[logging.FileHandler('libtmux_debug.log'), logging.StreamHandler()] + ) + ``` + +2. **Handle tmux session existence gracefully** - Always check if a session exists before creating or trying to use it: + ```python + try: + session = server.new_session(session_name="debug-session") + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name="debug-session") + ``` + +3. **Check pane content effectively** - When checking for output in panes, be aware that the output may take time to appear: + ```python + def wait_for_output(pane, search_text, max_wait_time=10): + """Wait for text to appear in pane output.""" + import time + start_time = time.time() + while time.time() - start_time < max_wait_time: + output = pane.capture_pane() + if any(search_text in line for line in output): + return True + time.sleep(0.5) + return False + ``` + +4. **Handle window and pane selection errors** - When selecting windows or panes, handle potential errors: + ```python + try: + window = session.select_window(1) + except libtmux.exc.LibTmuxException as e: + print(f"Error selecting window: {e}") + # Create the window if it doesn't exist + window = session.new_window() + ``` + +5. **Use context managers for session cleanup** - Implement context managers to ensure sessions are properly cleaned up: + ```python + class TmuxSessionContext: + def __init__(self, session_name): + self.server = libtmux.Server() + self.session_name = session_name + self.session = None + + def __enter__(self): + try: + self.session = self.server.new_session(session_name=self.session_name) + except libtmux.exc.TmuxSessionExists: + self.session = self.server.sessions.get(session_name=self.session_name) + return self.session + + def __exit__(self, exc_type, exc_val, exc_tb): + # Clean up if requested + # self.server.kill_session(self.session_name) + pass + + # Usage + with TmuxSessionContext("debug-session") as session: + window = session.new_window(window_name="debug") + # Rest of your code... + ``` + +6. **Inspect tmux directly** - For complex issues, you can use direct tmux commands to debug: + ```python + # Execute a tmux command and capture output + def run_tmux_command(cmd): + import subprocess + result = subprocess.run(['tmux'] + cmd.split(), capture_output=True, text=True) + return result.stdout.strip() + + # Example: List sessions directly + sessions = run_tmux_command("list-sessions") + print(f"Current tmux sessions: {sessions}") + ``` + +By following these tips, you can make your libtmux scripts more robust and easier to debug. diff --git a/docs/topics/automation.md b/docs/topics/automation.md new file mode 100644 index 000000000..c6f5caf3d --- /dev/null +++ b/docs/topics/automation.md @@ -0,0 +1,234 @@ +# Continuous Integration + +## General example + +Set up a comprehensive continuous integration pipeline using libtmux: + +```python +import libtmux +from libtmux.constants import PaneDirection +import time +import os + +# Connect to the tmux server +server = libtmux.Server() + +def create_ci_pipeline(project_name, project_path, session_name=None): + """Create a CI pipeline environment for a project""" + # Generate session name if not provided + if session_name is None: + session_name = f"ci-{project_name}" + + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + # Get existing session + session = server.sessions.get(session_name=session_name) + # Clean up existing windows + for window in session.windows: + window.kill() + + # Create a window for each stage of the CI pipeline + build_window = session.new_window(window_name="build") + test_window = session.new_window(window_name="test") + lint_window = session.new_window(window_name="lint") + deploy_window = session.new_window(window_name="deploy") + + # Set up build window + build_pane = build_window.active_pane + build_pane.send_keys(f"cd {project_path}", enter=True) + build_pane.send_keys("echo 'Building project...'", enter=True) + build_pane.send_keys("npm install && npm run build", enter=True) + + # Set up test window with multiple test types + test_pane = test_window.active_pane + test_pane.send_keys(f"cd {project_path}", enter=True) + test_pane.send_keys("echo 'Running unit tests...'", enter=True) + test_pane.send_keys("npm run test:unit", enter=True) + + # Create additional test panes + integration_pane = test_window.split(direction=PaneDirection.Right) + integration_pane.send_keys(f"cd {project_path}", enter=True) + integration_pane.send_keys("echo 'Running integration tests...'", enter=True) + integration_pane.send_keys("npm run test:integration", enter=True) + + e2e_pane = test_pane.split(direction=PaneDirection.Below) + e2e_pane.send_keys(f"cd {project_path}", enter=True) + e2e_pane.send_keys("echo 'Running E2E tests...'", enter=True) + e2e_pane.send_keys("npm run test:e2e", enter=True) + + # Set up lint window + lint_pane = lint_window.active_pane + lint_pane.send_keys(f"cd {project_path}", enter=True) + lint_pane.send_keys("echo 'Running linters and code quality checks...'", enter=True) + lint_pane.send_keys("npm run lint", enter=True) + + # Add coverage pane to lint window + coverage_pane = lint_window.split(direction=PaneDirection.Below) + coverage_pane.send_keys(f"cd {project_path}", enter=True) + coverage_pane.send_keys("echo 'Generating code coverage report...'", enter=True) + coverage_pane.send_keys("npm run coverage", enter=True) + + # Set up deploy window + deploy_pane = deploy_window.active_pane + deploy_pane.send_keys(f"cd {project_path}", enter=True) + deploy_pane.send_keys("echo 'Preparing for deployment...'", enter=True) + deploy_status_pane = deploy_window.split(direction=PaneDirection.Right) + deploy_status_pane.send_keys(f"cd {project_path}", enter=True) + deploy_status_pane.send_keys("echo 'Deployment status will appear here'", enter=True) + + # Return to build window + session.select_window("build") + + return session + +# Example usage +if __name__ == "__main__": + project_path = os.path.expanduser("~/projects/web-app") + session = create_ci_pipeline("web-app", project_path) + print(f"CI pipeline created in session: {session.session_name}") + print(f"Pipeline stages: {[w.window_name for w in session.windows]}") +``` + +## Integration with Fabric for Remote Deployment + +```python +import libtmux +from libtmux.constants import PaneDirection +import fabric + +# Example function to set up a deployment control center using libtmux and fabric +def create_deployment_dashboard(environments=None): + """Create a deployment dashboard for multiple environments""" + if environments is None: + environments = ["dev", "staging", "production"] + + server = libtmux.Server() + + try: + session = server.new_session(session_name="deployment") + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name="deployment") + for window in session.windows: + window.kill() + + # Create a control window + control_window = session.new_window(window_name="control") + control_pane = control_window.active_pane + status_pane = control_window.split(direction=PaneDirection.Right) + + # Set up control commands + control_pane.send_keys("echo 'Deployment Control Center'", enter=True) + status_pane.send_keys("echo 'Deployment Status Dashboard'", enter=True) + + # Create a window for each environment + env_windows = {} + for env in environments: + env_window = session.new_window(window_name=env) + deploy_pane = env_window.active_pane + log_pane = env_window.split(direction=PaneDirection.Below) + + deploy_pane.send_keys(f"echo 'Ready to deploy to {env}'", enter=True) + log_pane.send_keys(f"echo 'Deployment logs for {env} will appear here'", enter=True) + + env_windows[env] = env_window + + # Return to control window + session.select_window("control") + + return session, env_windows + +# Example of using fabric with libtmux +def deploy_to_environment(session, environment, server_host): + """Deploy to a specific environment using fabric""" + # Select the environment window + env_window = session.select_window(environment) + deploy_pane = env_window.panes[0] + log_pane = env_window.panes[1] + + # Display the deployment command + deploy_pane.send_keys(f"echo 'Deploying to {server_host}...'", enter=True) + + # Example fabric command (would be executed by your code, not shown in pane) + # This is just for demonstration + deploy_pane.send_keys( + f"echo 'Running: fab deploy --host={server_host} --environment={environment}'", + enter=True + ) + + # In a real implementation, you'd use fabric to execute the deployment + # and then capture and display the output in the tmux panes +``` + +## 2. Integration with Pytest for Test Automation + +Libtmux works well with pytest to automate testing across multiple environments: + +```python +import libtmux +from libtmux.constants import PaneDirection +import os +import subprocess + +def create_test_environment(project_path): + """Create a test environment with different python versions""" + server = libtmux.Server() + + try: + session = server.new_session(session_name="test-matrix") + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name="test-matrix") + for window in session.windows: + window.kill() + + # Create a main test control window + control_window = session.new_window(window_name="control") + control_pane = control_window.active_pane + control_pane.send_keys(f"cd {project_path}", enter=True) + control_pane.send_keys("echo 'Test Control Center'", enter=True) + + # Create windows for different python versions + versions = ["3.8", "3.9", "3.10", "3.11"] + version_windows = {} + + for version in versions: + version_window = session.new_window(window_name=f"py{version}") + test_pane = version_window.active_pane + test_pane.send_keys(f"cd {project_path}", enter=True) + + # Create a virtual environment for this Python version + venv_dir = f"venv-{version}" + test_pane.send_keys(f"echo 'Setting up Python {version} environment'", enter=True) + test_pane.send_keys(f"python{version} -m venv {venv_dir} || echo 'Failed to create venv'", enter=True) + test_pane.send_keys(f"source {venv_dir}/bin/activate", enter=True) + test_pane.send_keys("pip install -e .[test]", enter=True) + + # Create a pane for test output + output_pane = version_window.split(direction=PaneDirection.Right) + output_pane.send_keys(f"cd {project_path}", enter=True) + output_pane.send_keys(f"source {venv_dir}/bin/activate", enter=True) + output_pane.send_keys(f"echo 'Test results for Python {version} will appear here'", enter=True) + + version_windows[version] = version_window + + # Return to control window + session.select_window("control") + + return session, version_windows + +def run_tests_on_version(session, version, test_path=None): + """Run tests on a specific python version""" + version_window = session.select_window(f"py{version}") + test_pane = version_window.panes[0] + output_pane = version_window.panes[1] + + # Run the tests + test_command = "pytest" + if test_path: + test_command += f" {test_path}" + + test_command += " -v" # Verbose output + + test_pane.send_keys(f"echo 'Running: {test_command}'", enter=True) + output_pane.send_keys(f"{test_command} | tee test_output.log", enter=True) +``` diff --git a/docs/topics/index.md b/docs/topics/index.md index 0653bb57b..7c76353d7 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -10,4 +10,7 @@ Explore libtmux’s core functionalities and underlying principles at a high lev context_managers traversal +automation +advanced_scripting +use_cases ``` diff --git a/docs/topics/use_cases.md b/docs/topics/use_cases.md new file mode 100644 index 000000000..501f8a615 --- /dev/null +++ b/docs/topics/use_cases.md @@ -0,0 +1,627 @@ +--- +orphan: true +--- + +# Use Cases + +libtmux provides a powerful abstraction layer for tmux, enabling a wide range of use cases beyond manual terminal management. This document explores practical applications and real-world scenarios where libtmux shines. + +## DevOps and Infrastructure + +### CI/CD Pipeline Integration + +libtmux can be integrated into Continuous Integration and Continuous Deployment pipelines to: + +- Create isolated environments for running tests +- Capture test output and logs +- Provide a persistent terminal interface for long-running processes +- Execute deployment steps in parallel + +```python +>>> import libtmux +>>> from libtmux.constants import PaneDirection +>>> +>>> def setup_test_environment(): +... server = libtmux.Server() +... try: +... session = server.new_session(session_name="ci-tests") +... except libtmux.exc.TmuxSessionExists: +... session = server.sessions.get(session_name="ci-tests") +... # Clean up existing windows +... for window in session.windows: +... window.kill() +... # Create a new window +... session.new_window(window_name="main") +... +... # Create windows for different test suites +... unit_tests = session.active_window +... unit_tests.rename_window("unit-tests") +... integration_tests = session.new_window(window_name="integration-tests") +... ui_tests = session.new_window(window_name="ui-tests") +... +... # The following would run tests in parallel (commented out for doctest) +... # unit_tests.send_keys("cd /path/to/project && pytest tests/unit", enter=True) +... # integration_tests.send_keys("cd /path/to/project && pytest tests/integration", enter=True) +... # ui_tests.send_keys("cd /path/to/project && pytest tests/ui", enter=True) +... +... return session +>>> +>>> # Example usage (not executed in doctest) +>>> # test_session = setup_test_environment() +>>> # print(f"Created test session with {len(test_session.windows)} windows") +``` + +### Server Management + +Automate administrative tasks across multiple servers: + +```python +>>> import libtmux +>>> from libtmux.constants import PaneDirection +>>> +>>> def monitor_server_cluster(servers): +... """Create a tmux dashboard to monitor multiple servers at once""" +... server = libtmux.Server() +... try: +... session = server.new_session(session_name="server-cluster") +... except libtmux.exc.TmuxSessionExists: +... session = server.sessions.get(session_name="server-cluster") +... # Clean up existing windows +... for window in session.windows: +... window.kill() +... # Create a new window +... session.new_window(window_name="main") +... +... # Create initial window +... first_window = session.active_window +... first_window.rename_window(servers[0]) +... +... # The following commands would connect to servers (commented out for doctest) +... # first_window.send_keys(f"ssh admin@{servers[0]}", enter=True) +... # first_window.send_keys("htop", enter=True) +... +... # Create windows for other servers +... for server_name in servers[1:]: +... window = session.new_window(window_name=server_name) +... # window.send_keys(f"ssh admin@{server_name}", enter=True) +... # window.send_keys("htop", enter=True) +... +... return session +>>> +>>> # Example usage (not executed in doctest) +>>> # servers = ["web-server-1", "db-server-1", "cache-server-1"] +>>> # cluster_session = monitor_server_cluster(servers) +``` + +## Development Workflows + +### Project-Specific Environments + +Create custom development environments for different projects: + +```python +>>> import libtmux +>>> from libtmux.constants import PaneDirection +>>> +>>> def python_dev_environment(project_path): +... server = libtmux.Server() +... try: +... session = server.new_session(session_name="python-dev") +... except libtmux.exc.TmuxSessionExists: +... session = server.sessions.get(session_name="python-dev") +... # Clean up existing windows +... for window in session.windows: +... window.kill() +... # Create a new window +... session.new_window(window_name="main") +... +... # Editor window +... editor = session.active_window +... editor.rename_window("editor") +... +... # The following commands are commented out for doctest +... # editor.send_keys(f"cd {project_path} && vim .", enter=True) +... +... # Terminal window with virtual environment +... terminal = session.new_window(window_name="terminal") +... # terminal.send_keys(f"cd {project_path}", enter=True) +... # terminal.send_keys("source venv/bin/activate", enter=True) +... +... # Test window +... test = session.new_window(window_name="tests") +... # test.send_keys(f"cd {project_path}", enter=True) +... # test.send_keys("source venv/bin/activate", enter=True) +... # test.send_keys("pytest -xvs", enter=True) +... +... # Documentation window +... docs = session.new_window(window_name="docs") +... # docs.send_keys(f"cd {project_path}/docs", enter=True) +... # docs.send_keys("make html", enter=True) +... +... return session +>>> +>>> # Example usage (not executed in doctest) +>>> # dev_session = python_dev_environment("/path/to/my-project") +>>> # print(f"Created dev environment with {len(dev_session.windows)} windows") +``` + +### Pair Programming + +Facilitate pair programming sessions: + +```python +>>> import libtmux +>>> from libtmux.constants import PaneDirection +>>> +>>> def pair_programming_session(project_path, partner_ip=None): +... server = libtmux.Server() +... try: +... session = server.new_session(session_name="pair-programming") +... except libtmux.exc.TmuxSessionExists: +... session = server.sessions.get(session_name="pair-programming") +... # Clean up existing windows +... for window in session.windows: +... window.kill() +... # Create a new window +... session.new_window(window_name="main") +... +... # Setup main editor window +... main = session.active_window +... main.rename_window("code") +... +... # The following commands are commented out for doctest +... # main.send_keys(f"cd {project_path}", enter=True) +... # main.send_keys("vim .", enter=True) +... +... # Setup terminal for running commands +... terminal = session.new_window(window_name="terminal") +... # terminal.send_keys(f"cd {project_path}", enter=True) +... +... # Setup tests window +... tests = session.new_window(window_name="tests") +... # tests.send_keys(f"cd {project_path}", enter=True) +... # tests.send_keys("npm test -- --watch", enter=True) +... +... # If remote pairing, setup SSH +... if partner_ip: +... # Allow SSH connections to this tmux session +... session.cmd("set-option", "allow-rename", "off") +... session.cmd("set-option", "mouse", "on") +... +... # Instructions for partner +... notes = session.new_window(window_name="notes") +... # notes.send_keys(f"echo 'To join this session, run: ssh user@{partner_ip} -t \"tmux attach -t pair-programming\"'", enter=True) +... +... return session +>>> +>>> # Example usage (not executed in doctest) +>>> # pp_session = pair_programming_session("/path/to/project", "192.168.1.100") +``` + +## Data Science and Analytics + +### Data Processing Workflows + +Manage complex data processing pipelines: + +```python +>>> import libtmux +>>> from libtmux.constants import PaneDirection +>>> +>>> def data_processing_pipeline(data_path): +... server = libtmux.Server() +... try: +... session = server.new_session(session_name="data-pipeline") +... except libtmux.exc.TmuxSessionExists: +... session = server.sessions.get(session_name="data-pipeline") +... # Clean up existing windows +... for window in session.windows: +... window.kill() +... # Create a new window +... session.new_window(window_name="main") +... +... # Data preparation +... prep = session.active_window +... prep.rename_window("preparation") +... +... # The following commands are commented out for doctest +... # prep.send_keys(f"cd {data_path}", enter=True) +... # prep.send_keys("python prepare_data.py", enter=True) +... +... # Model training +... train = session.new_window(window_name="training") +... # train.send_keys(f"cd {data_path}", enter=True) +... # train.send_keys("python train_model.py", enter=True) +... +... # Monitoring training with split panes +... monitor = session.new_window(window_name="monitor") +... # monitor.send_keys(f"cd {data_path}", enter=True) +... # monitor.send_keys("nvidia-smi", enter=True) +... +... # Create a second pane for monitoring system resources +... system_pane = monitor.split(direction=PaneDirection.Right) +... # system_pane.send_keys("htop", enter=True) +... +... # Create a third pane for logs +... log_pane = system_pane.split(direction=PaneDirection.Below) +... # log_pane.send_keys(f"tail -f {data_path}/logs/training.log", enter=True) +... +... return session +>>> +>>> # Example usage (not executed in doctest) +>>> # pipeline_session = data_processing_pipeline("/path/to/data") +``` + +## Education and Presentation + +### Live Coding Demonstrations + +Create environments for teaching and presenting code: + +```python +>>> import libtmux +>>> from libtmux.constants import PaneDirection +>>> +>>> def teaching_session(course_materials_path): +... server = libtmux.Server() +... try: +... session = server.new_session(session_name="teaching") +... except libtmux.exc.TmuxSessionExists: +... session = server.sessions.get(session_name="teaching") +... # Clean up existing windows +... for window in session.windows: +... window.kill() +... # Create a new window +... session.new_window(window_name="main") +... +... # Main presentation window +... main = session.active_window +... main.rename_window("slides") +... +... # The following commands are commented out for doctest +... # main.send_keys(f"cd {course_materials_path}/slides", enter=True) +... # main.send_keys("mdp presentation.md", enter=True) +... +... # Code examples window +... code = session.new_window(window_name="code") +... # code.send_keys(f"cd {course_materials_path}/examples", enter=True) +... # code.send_keys("vim -M main.py", enter=True) # Open in read-only mode for safety +... +... # Live coding window +... live = session.new_window(window_name="live-coding") +... # live.send_keys(f"cd {course_materials_path}/workspace", enter=True) +... +... # Exercise window for students to follow along +... exercise = session.new_window(window_name="exercise") +... # exercise.send_keys(f"cd {course_materials_path}/exercises", enter=True) +... # exercise.send_keys("vim exercise_01.py", enter=True) +... +... return session +>>> +>>> # Example usage (not executed in doctest) +>>> # teaching = teaching_session("/path/to/course") +``` + +## System Administration + +### Log Monitoring Dashboard + +Create a comprehensive log monitoring system: + +```python +>>> import libtmux +>>> from libtmux.constants import PaneDirection +>>> import os # Used in the example +>>> +>>> def log_monitoring_dashboard(log_paths): +... server = libtmux.Server() +... try: +... session = server.new_session(session_name="log-monitor") +... except libtmux.exc.TmuxSessionExists: +... session = server.sessions.get(session_name="log-monitor") +... # Clean up existing windows +... for window in session.windows: +... window.kill() +... # Create a new window +... session.new_window(window_name="main") +... +... # Create first window with first log +... window = session.active_window +... window.rename_window(os.path.basename(log_paths[0])) +... +... # The following commands are commented out for doctest +... # window.send_keys(f"tail -f {log_paths[0]}", enter=True) +... +... # Create windows for remaining logs +... for log_path in log_paths[1:]: +... log_name = os.path.basename(log_path) +... log_window = session.new_window(window_name=log_name) +... # log_window.send_keys(f"tail -f {log_path}", enter=True) +... +... # Create a summary window +... summary = session.new_window(window_name="summary") +... summary_pane = summary.active_pane +... +... # Split into multiple panes for different summaries +... error_pane = summary_pane.split(direction=PaneDirection.Right) +... warning_pane = summary_pane.split(direction=PaneDirection.Below) +... +... # Set up grep commands to highlight different log levels +... # summary_pane.send_keys(f"tail -f {' '.join(log_paths)} | grep -i 'ERROR' --color=always", enter=True) +... # error_pane.send_keys(f"tail -f {' '.join(log_paths)} | grep -i 'WARN' --color=always", enter=True) +... # warning_pane.send_keys(f"tail -f {' '.join(log_paths)} | grep -i 'INFO' --color=always", enter=True) +... +... return session +>>> +>>> # Example usage (not executed in doctest) +>>> # logs = ["/var/log/system.log", "/var/log/application.log", "/var/log/errors.log"] +>>> # log_session = log_monitoring_dashboard(logs) +``` + +## Additional Use Cases + +- **Automated testing environments**: Create isolated environments for running tests with visual feedback +- **Remote server management**: Control multiple remote machines through a single interface +- **Long-running process management**: Start, monitor, and control processes that need to run for extended periods +- **Distributed system management**: Coordinate actions across multiple systems +- **Interactive documentation**: Create interactive tutorials that guide users through complex procedures +- **Recovery systems**: Build automated recovery procedures for system failures + +## DevOps Workflows + +Create an infrastructure management dashboard with multiple environments: + +```python +import libtmux +from libtmux.constants import PaneDirection +import time + +# Connect to the tmux server +server = libtmux.Server() + +def create_infra_dashboard(session_name="infra-management"): + """Create an infrastructure management dashboard""" + try: + session = server.new_session(session_name=session_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=session_name) + # Clean up existing windows + for window in session.windows: + window.kill() + + # Create a window for each environment + staging_window = session.new_window(window_name="staging") + production_window = session.new_window(window_name="production") + monitoring_window = session.new_window(window_name="monitoring") + + # Setup staging window with multiple panes + staging_pane = staging_window.active_pane + staging_pane.send_keys("echo 'Staging Environment Control'", enter=True) + + # Split the staging window for different components + db_pane = staging_window.split(direction=PaneDirection.Right) + db_pane.send_keys("echo 'Database Management'", enter=True) + + app_pane = staging_pane.split(direction=PaneDirection.Below) + app_pane.send_keys("echo 'Application Deployment'", enter=True) + + # Setup production window + prod_pane = production_window.active_pane + prod_pane.send_keys("echo 'Production Environment Control'", enter=True) + + deploy_pane = production_window.split(direction=PaneDirection.Right) + deploy_pane.send_keys("echo 'Deployment Pipeline'", enter=True) + + # Setup monitoring with multiple panes + monitor_pane = monitoring_window.active_pane + monitor_pane.send_keys("echo 'System Monitoring'", enter=True) + + log_pane = monitoring_window.split(direction=PaneDirection.Right) + log_pane.send_keys("echo 'Log Monitoring'", enter=True) + + alert_pane = monitor_pane.split(direction=PaneDirection.Below) + alert_pane.send_keys("echo 'Alerts Dashboard'", enter=True) + + return session, staging_window, production_window, monitoring_window + +# Example usage +if __name__ == "__main__": + session, staging, production, monitoring = create_infra_dashboard() + print(f"Created infrastructure dashboard in session: {session.session_name}") + print(f"Windows: {[w.window_name for w in session.windows]}") +``` + +## Development Workflows + +Set up a comprehensive development environment for any project: + +```python +import libtmux +from libtmux.constants import PaneDirection +import os + +# Connect to the tmux server +server = libtmux.Server() + +def setup_dev_environment(project_name, project_dir=None): + """ + Set up a development environment with windows for: + - Code editing + - Testing + - Version control + - Running the application + """ + if project_dir is None: + project_dir = os.path.expanduser(f"~/projects/{project_name}") + + try: + session = server.new_session(session_name=project_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=project_name) + # Clean up existing windows + for window in session.windows: + window.kill() + + # Set up windows for different development tasks + code_window = session.new_window(window_name="code") + test_window = session.new_window(window_name="tests") + git_window = session.new_window(window_name="git") + run_window = session.new_window(window_name="run") + + # Set up the code editing window with split panes + editor_pane = code_window.active_pane + + # Navigate to project directory in all panes + editor_pane.send_keys(f"cd {project_dir}", enter=True) + editor_pane.send_keys("echo 'Code Editor'", enter=True) + editor_pane.send_keys("vim .", enter=True) + + # File browser on the side + file_pane = code_window.split(direction=PaneDirection.Right, size=25) + file_pane.send_keys(f"cd {project_dir}", enter=True) + file_pane.send_keys("echo 'File Browser'", enter=True) + file_pane.send_keys("ls -la", enter=True) + + # Terminal below + terminal_pane = editor_pane.split(direction=PaneDirection.Below, size=10) + terminal_pane.send_keys(f"cd {project_dir}", enter=True) + terminal_pane.send_keys("echo 'Terminal'", enter=True) + + # Setup test window + test_pane = test_window.active_pane + test_pane.send_keys(f"cd {project_dir}", enter=True) + test_pane.send_keys("echo 'Running tests...'", enter=True) + + # Add test output pane + test_output = test_window.split(direction=PaneDirection.Right) + test_output.send_keys(f"cd {project_dir}", enter=True) + test_output.send_keys("echo 'Test output will appear here'", enter=True) + + # Setup git window + git_pane = git_window.active_pane + git_pane.send_keys(f"cd {project_dir}", enter=True) + git_pane.send_keys("echo 'Git status'", enter=True) + git_pane.send_keys("git status", enter=True) + + # Add git log pane + git_log = git_window.split(direction=PaneDirection.Right) + git_log.send_keys(f"cd {project_dir}", enter=True) + git_log.send_keys("echo 'Git log'", enter=True) + git_log.send_keys("git log --oneline --graph --all -n 10", enter=True) + + # Setup run window + run_pane = run_window.active_pane + run_pane.send_keys(f"cd {project_dir}", enter=True) + run_pane.send_keys("echo 'Starting application...'", enter=True) + + # Add app logs pane + logs_pane = run_window.split(direction=PaneDirection.Below) + logs_pane.send_keys(f"cd {project_dir}", enter=True) + logs_pane.send_keys("echo 'Application logs will appear here'", enter=True) + + # Return to code window + session.select_window("code") + + return session, code_window, test_window, git_window, run_window + +# Example usage +if __name__ == "__main__": + project_name = "webapp-project" + project_dir = os.path.expanduser(f"~/projects/{project_name}") + + # Ensure project directory exists + os.makedirs(project_dir, exist_ok=True) + + session, code_window, test_window, git_window, run_window = setup_dev_environment(project_name, project_dir) + print(f"Development environment set up in session: {session.session_name}") + print(f"Windows created: {[w.window_name for w in session.windows]}") +``` + +## Data Science Workflows + +Create a comprehensive data science environment: + +```python +import libtmux +from libtmux.constants import PaneDirection +import os + +# Connect to the tmux server +server = libtmux.Server() + +def setup_data_science_env(project_name="data-science", data_dir=None): + """Create a comprehensive data science environment""" + if data_dir is None: + data_dir = os.path.expanduser(f"~/data/{project_name}") + + try: + session = server.new_session(session_name=project_name) + except libtmux.exc.TmuxSessionExists: + session = server.sessions.get(session_name=project_name) + # Clean up existing windows + for window in session.windows: + window.kill() + + # Create windows for different data science tasks + jupyter_window = session.new_window(window_name="jupyter") + data_window = session.new_window(window_name="data") + model_window = session.new_window(window_name="model") + viz_window = session.new_window(window_name="visualization") + + # Setup Jupyter notebook window + jupyter_pane = jupyter_window.active_pane + jupyter_pane.send_keys(f"cd {data_dir}", enter=True) + jupyter_pane.send_keys("echo 'Starting Jupyter Notebook'", enter=True) + jupyter_pane.send_keys("jupyter notebook", enter=True) + + # Setup data processing window + data_pane = data_window.active_pane + data_pane.send_keys(f"cd {data_dir}", enter=True) + data_pane.send_keys("echo 'Data Processing'", enter=True) + + # Split for data exploration + explore_pane = data_window.split(direction=PaneDirection.Right) + explore_pane.send_keys(f"cd {data_dir}", enter=True) + explore_pane.send_keys("echo 'Data Exploration'", enter=True) + explore_pane.send_keys("python -c 'import pandas as pd; print(\"Pandas version:\", pd.__version__)'", enter=True) + + # Setup modeling window + model_pane = model_window.active_pane + model_pane.send_keys(f"cd {data_dir}", enter=True) + model_pane.send_keys("echo 'Model Training'", enter=True) + + # Split for model evaluation + eval_pane = model_window.split(direction=PaneDirection.Right) + eval_pane.send_keys(f"cd {data_dir}", enter=True) + eval_pane.send_keys("echo 'Model Evaluation'", enter=True) + + # Split for hyperparameter tuning + tune_pane = model_pane.split(direction=PaneDirection.Below) + tune_pane.send_keys(f"cd {data_dir}", enter=True) + tune_pane.send_keys("echo 'Hyperparameter Tuning'", enter=True) + + # Setup visualization window + viz_pane = viz_window.active_pane + viz_pane.send_keys(f"cd {data_dir}", enter=True) + viz_pane.send_keys("echo 'Data Visualization'", enter=True) + + # Split for interactive dashboards + dash_pane = viz_window.split(direction=PaneDirection.Below) + dash_pane.send_keys(f"cd {data_dir}", enter=True) + dash_pane.send_keys("echo 'Interactive Dashboards'", enter=True) + + # Return to Jupyter window + session.select_window("jupyter") + + return session + +# Example usage +if __name__ == "__main__": + # Ensure data directory exists + data_dir = os.path.expanduser("~/data/analysis-project") + os.makedirs(data_dir, exist_ok=True) + + session = setup_data_science_env("analysis-project", data_dir) + print(f"Data science environment set up in session: {session.session_name}") + print(f"Windows: {[w.window_name for w in session.windows]}") +``` diff --git a/pyproject.toml b/pyproject.toml index 1115cd419..ecad6dd28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,6 +128,16 @@ files = [ "tests", ] +[[tool.mypy.overrides]] +module = "tests.examples.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[[tool.mypy.overrides]] +module = "tests.examples.pytest_plugin.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + [tool.coverage.run] branch = true parallel = true diff --git a/src/libtmux/_internal/retry_extended.py b/src/libtmux/_internal/retry_extended.py new file mode 100644 index 000000000..6d76ef998 --- /dev/null +++ b/src/libtmux/_internal/retry_extended.py @@ -0,0 +1,65 @@ +"""Extended retry functionality for libtmux.""" + +from __future__ import annotations + +import logging +import time +import typing as t + +from libtmux.exc import WaitTimeout +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, +) + +logger = logging.getLogger(__name__) + +if t.TYPE_CHECKING: + from collections.abc import Callable + + +def retry_until_extended( + fun: Callable[[], bool], + seconds: float = RETRY_TIMEOUT_SECONDS, + *, + interval: float = RETRY_INTERVAL_SECONDS, + raises: bool | None = True, +) -> tuple[bool, Exception | None]: + """ + Retry a function until a condition meets or the specified time passes. + + Extended version that returns both success state and exception. + + Parameters + ---------- + fun : callable + A function that will be called repeatedly until it returns ``True`` or + the specified time passes. + seconds : float + Seconds to retry. Defaults to ``8``, which is configurable via + ``RETRY_TIMEOUT_SECONDS`` environment variables. + interval : float + Time in seconds to wait between calls. Defaults to ``0.05`` and is + configurable via ``RETRY_INTERVAL_SECONDS`` environment variable. + raises : bool + Whether or not to raise an exception on timeout. Defaults to ``True``. + + Returns + ------- + tuple[bool, Exception | None] + Tuple containing (success, exception). If successful, the exception will + be None. + """ + ini = time.time() + exception = None + + while not fun(): + end = time.time() + if end - ini >= seconds: + timeout_msg = f"Timed out after {seconds} seconds" + exception = WaitTimeout(timeout_msg) + if raises: + raise exception + return False, exception + time.sleep(interval) + return True, None diff --git a/src/libtmux/_internal/waiter.py b/src/libtmux/_internal/waiter.py new file mode 100644 index 000000000..eb687917f --- /dev/null +++ b/src/libtmux/_internal/waiter.py @@ -0,0 +1,1806 @@ +"""Terminal content waiting utility for libtmux tests. + +This module provides functions to wait for specific content to appear in tmux panes, +making it easier to write reliable tests that interact with terminal output. +""" + +from __future__ import annotations + +import logging +import re +import time +import typing as t +from dataclasses import dataclass +from enum import Enum, auto + +from libtmux._internal.retry_extended import retry_until_extended +from libtmux.exc import WaitTimeout +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, +) +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from collections.abc import Callable + + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + +logger = logging.getLogger(__name__) + + +class ContentMatchType(Enum): + """Type of content matching to use when waiting for pane content. + + Examples + -------- + >>> # Using content match types with their intended patterns + >>> ContentMatchType.EXACT + + >>> ContentMatchType.CONTAINS + + >>> ContentMatchType.REGEX + + >>> ContentMatchType.PREDICATE + + + >>> # These match types are used to specify how to match content in wait functions + >>> def demo_match_types(): + ... # For exact matching (entire content must exactly match) + ... exact_type = ContentMatchType.EXACT + ... # For substring matching (content contains the specified string) + ... contains_type = ContentMatchType.CONTAINS + ... # For regex pattern matching + ... regex_type = ContentMatchType.REGEX + ... # For custom predicate functions + ... predicate_type = ContentMatchType.PREDICATE + ... return [exact_type, contains_type, regex_type, predicate_type] + >>> match_types = demo_match_types() + >>> len(match_types) + 4 + """ + + EXACT = auto() # Full exact match of content + CONTAINS = auto() # Content contains the specified string + REGEX = auto() # Content matches the specified regex pattern + PREDICATE = auto() # Custom predicate function returns True + + +@dataclass +class WaitResult: + """Result from a wait operation. + + Attributes + ---------- + success : bool + Whether the wait operation succeeded + content : list[str] | None + The content of the pane at the time of the match + matched_content : str | list[str] | None + The content that matched the pattern + match_line : int | None + The line number of the match (0-indexed) + elapsed_time : float | None + Time taken for the wait operation + error : str | None + Error message if the wait operation failed + matched_pattern_index : int | None + Index of the pattern that matched (only for wait_for_any_content) + + Examples + -------- + >>> # Create a successful wait result + >>> result = WaitResult( + ... success=True, + ... content=["line 1", "hello world", "line 3"], + ... matched_content="hello world", + ... match_line=1, + ... elapsed_time=0.5, + ... ) + >>> result.success + True + >>> result.matched_content + 'hello world' + >>> result.match_line + 1 + + >>> # Create a failed wait result with an error message + >>> error_result = WaitResult( + ... success=False, + ... error="Timed out waiting for 'pattern' after 5.0 seconds", + ... ) + >>> error_result.success + False + >>> error_result.error + "Timed out waiting for 'pattern' after 5.0 seconds" + >>> error_result.content is None + True + + >>> # Wait result with matched_pattern_index (from wait_for_any_content) + >>> multi_pattern = WaitResult( + ... success=True, + ... content=["command output", "success: operation completed", "more output"], + ... matched_content="success: operation completed", + ... match_line=1, + ... matched_pattern_index=2, + ... ) + >>> multi_pattern.matched_pattern_index + 2 + """ + + success: bool + content: list[str] | None = None + matched_content: str | list[str] | None = None + match_line: int | None = None + elapsed_time: float | None = None + error: str | None = None + matched_pattern_index: int | None = None + + +# Error messages as constants +ERR_PREDICATE_TYPE = "content_pattern must be callable when match_type is PREDICATE" +ERR_EXACT_TYPE = "content_pattern must be a string when match_type is EXACT" +ERR_CONTAINS_TYPE = "content_pattern must be a string when match_type is CONTAINS" +ERR_REGEX_TYPE = ( + "content_pattern must be a string or regex pattern when match_type is REGEX" +) + + +class PaneContentWaiter: + r"""Fluent interface for waiting on pane content. + + This class provides a more fluent API for waiting on pane content, + allowing method chaining for better readability. + + Examples + -------- + >>> # Basic usage - assuming pane is a fixture from conftest.py + >>> waiter = PaneContentWaiter(pane) + >>> isinstance(waiter, PaneContentWaiter) + True + + >>> # Method chaining to configure options + >>> waiter = ( + ... PaneContentWaiter(pane) + ... .with_timeout(10.0) + ... .with_interval(0.5) + ... .without_raising() + ... ) + >>> waiter.timeout + 10.0 + >>> waiter.interval + 0.5 + >>> waiter.raises + False + + >>> # Configure line range for capture + >>> waiter = PaneContentWaiter(pane).with_line_range(0, 10) + >>> waiter.start_line + 0 + >>> waiter.end_line + 10 + + >>> # Create a checker for demonstration + >>> import re + >>> def is_ready(content): + ... return any("ready" in line.lower() for line in content) + + >>> # Methods available for different match types + >>> hasattr(waiter, 'wait_for_text') + True + >>> hasattr(waiter, 'wait_for_exact_text') + True + >>> hasattr(waiter, 'wait_for_regex') + True + >>> hasattr(waiter, 'wait_for_predicate') + True + >>> hasattr(waiter, 'wait_until_ready') + True + + A functional example: send text to the pane and wait for it: + + >>> # First, send "hello world" to the pane + >>> pane.send_keys("echo 'hello world'", enter=True) + >>> + >>> # Then wait for it to appear in the pane content + >>> result = PaneContentWaiter(pane).wait_for_text("hello world") + >>> result.success + True + >>> "hello world" in result.matched_content + True + >>> + + With options: + + >>> result = ( + ... PaneContentWaiter(pane) + ... .with_timeout(5.0) + ... .wait_for_text("hello world") + ... ) + + Wait for text with a longer timeout: + + >>> pane.send_keys("echo 'Operation completed'", enter=True) + >>> try: + ... result = ( + ... expect(pane) + ... .with_timeout(1.0) # Reduce timeout for faster doctest execution + ... .wait_for_text("Operation completed") + ... ) + ... print(f"Result success: {result.success}") + ... except Exception as e: + ... print(f"Caught exception: {type(e).__name__}: {e}") + Result success: True + + Wait for regex pattern: + + >>> pane.send_keys("echo 'Process 0 completed.'", enter=True) + >>> try: + ... result = ( + ... PaneContentWaiter(pane) + ... .with_timeout(1.0) # Reduce timeout for faster doctest execution + ... .wait_for_regex(r"Process \d+ completed") + ... ) + ... # Print debug info about the result for doctest + ... print(f"Result success: {result.success}") + ... except Exception as e: + ... print(f"Caught exception: {type(e).__name__}: {e}") + Result success: True + + Custom predicate: + + >>> pane.send_keys("echo 'We are ready!'", enter=True) + >>> def is_ready(content): + ... return any("ready" in line.lower() for line in content) + >>> result = PaneContentWaiter(pane).wait_for_predicate(is_ready) + + Timeout: + + >>> try: + ... result = ( + ... PaneContentWaiter(pane) + ... .with_timeout(0.01) + ... .wait_for_exact_text("hello world") + ... ) + ... except WaitTimeout: + ... print('No exact match') + No exact match + """ + + def __init__(self, pane: Pane) -> None: + """Initialize with a tmux pane. + + Parameters + ---------- + pane : Pane + The tmux pane to check + """ + self.pane = pane + self.timeout: float = RETRY_TIMEOUT_SECONDS + self.interval: float = RETRY_INTERVAL_SECONDS + self.raises: bool = True + self.start_line: t.Literal["-"] | int | None = None + self.end_line: t.Literal["-"] | int | None = None + + def with_timeout(self, timeout: float) -> PaneContentWaiter: + """Set the timeout for waiting. + + Parameters + ---------- + timeout : float + Maximum time to wait in seconds + + Returns + ------- + PaneContentWaiter + Self for method chaining + """ + self.timeout = timeout + return self + + def with_interval(self, interval: float) -> PaneContentWaiter: + """Set the interval between checks. + + Parameters + ---------- + interval : float + Time between checks in seconds + + Returns + ------- + PaneContentWaiter + Self for method chaining + """ + self.interval = interval + return self + + def without_raising(self) -> PaneContentWaiter: + """Disable raising exceptions on timeout. + + Returns + ------- + PaneContentWaiter + Self for method chaining + """ + self.raises = False + return self + + def with_line_range( + self, + start: t.Literal["-"] | int | None, + end: t.Literal["-"] | int | None, + ) -> PaneContentWaiter: + """Specify lines to capture from the pane. + + Parameters + ---------- + start : int | "-" | None + Starting line for capture_pane (passed to pane.capture_pane) + end : int | "-" | None + End line for capture_pane (passed to pane.capture_pane) + + Returns + ------- + PaneContentWaiter + Self for method chaining + """ + self.start_line = start + self.end_line = end + return self + + def wait_for_text(self, text: str) -> WaitResult: + """Wait for text to appear in the pane (contains match). + + Parameters + ---------- + text : str + Text to wait for (contains match) + + Returns + ------- + WaitResult + Result of the wait operation + """ + return wait_for_pane_content( + pane=self.pane, + content_pattern=text, + match_type=ContentMatchType.CONTAINS, + timeout=self.timeout, + interval=self.interval, + start=self.start_line, + end=self.end_line, + raises=self.raises, + ) + + def wait_for_exact_text(self, text: str) -> WaitResult: + """Wait for exact text to appear in the pane. + + Parameters + ---------- + text : str + Text to wait for (exact match) + + Returns + ------- + WaitResult + Result of the wait operation + """ + return wait_for_pane_content( + pane=self.pane, + content_pattern=text, + match_type=ContentMatchType.EXACT, + timeout=self.timeout, + interval=self.interval, + start=self.start_line, + end=self.end_line, + raises=self.raises, + ) + + def wait_for_regex(self, pattern: str | re.Pattern[str]) -> WaitResult: + """Wait for text matching a regex pattern. + + Parameters + ---------- + pattern : str | re.Pattern + Regex pattern to match + + Returns + ------- + WaitResult + Result of the wait operation + """ + return wait_for_pane_content( + pane=self.pane, + content_pattern=pattern, + match_type=ContentMatchType.REGEX, + timeout=self.timeout, + interval=self.interval, + start=self.start_line, + end=self.end_line, + raises=self.raises, + ) + + def wait_for_predicate(self, predicate: Callable[[list[str]], bool]) -> WaitResult: + """Wait for a custom predicate function to return True. + + Parameters + ---------- + predicate : callable + Function that takes pane content lines and returns boolean + + Returns + ------- + WaitResult + Result of the wait operation + """ + return wait_for_pane_content( + pane=self.pane, + content_pattern=predicate, + match_type=ContentMatchType.PREDICATE, + timeout=self.timeout, + interval=self.interval, + start=self.start_line, + end=self.end_line, + raises=self.raises, + ) + + def wait_until_ready( + self, + shell_prompt: str | re.Pattern[str] | None = None, + ) -> WaitResult: + """Wait until the pane is ready with a shell prompt. + + Parameters + ---------- + shell_prompt : str | re.Pattern | None + The shell prompt pattern to look for, or None to auto-detect + + Returns + ------- + WaitResult + Result of the wait operation + """ + return wait_until_pane_ready( + pane=self.pane, + shell_prompt=shell_prompt, + timeout=self.timeout, + interval=self.interval, + raises=self.raises, + ) + + +def expect(pane: Pane) -> PaneContentWaiter: + r"""Fluent interface for waiting on pane content. + + This function provides a more fluent API for waiting on pane content, + allowing method chaining for better readability. + + Examples + -------- + Basic usage with pane fixture: + + >>> waiter = expect(pane) + >>> isinstance(waiter, PaneContentWaiter) + True + + Method chaining to configure the waiter: + + >>> configured_waiter = expect(pane).with_timeout(15.0).without_raising() + >>> configured_waiter.timeout + 15.0 + >>> configured_waiter.raises + False + + Equivalent to :class:`PaneContentWaiter` but with a more expressive name: + + >>> expect(pane) is not PaneContentWaiter(pane) # Different instances + True + >>> type(expect(pane)) == type(PaneContentWaiter(pane)) # Same class + True + + A functional example showing actual usage: + + >>> # Send a command to the pane + >>> pane.send_keys("echo 'testing expect'", enter=True) + >>> + >>> # Wait for the output using the expect function + >>> result = expect(pane).wait_for_text("testing expect") + >>> result.success + True + >>> + + Wait for text with a longer timeout: + + >>> pane.send_keys("echo 'Operation completed'", enter=True) + >>> try: + ... result = ( + ... expect(pane) + ... .with_timeout(1.0) # Reduce timeout for faster doctest execution + ... .without_raising() # Don't raise exceptions + ... .wait_for_text("Operation completed") + ... ) + ... print(f"Result success: {result.success}") + ... except Exception as e: + ... print(f"Caught exception: {type(e).__name__}: {e}") + Result success: True + + Wait for a regex match without raising exceptions on timeout: + >>> pane.send_keys("echo 'Process 19 completed'", enter=True) + >>> try: + ... result = ( + ... expect(pane) + ... .with_timeout(1.0) # Reduce timeout for faster doctest execution + ... .without_raising() # Don't raise exceptions + ... .wait_for_regex(r"Process \d+ completed") + ... ) + ... print(f"Result success: {result.success}") + ... except Exception as e: + ... print(f"Caught exception: {type(e).__name__}: {e}") + Result success: True + """ + return PaneContentWaiter(pane) + + +def wait_for_pane_content( + pane: Pane, + content_pattern: str | re.Pattern[str] | Callable[[list[str]], bool], + match_type: ContentMatchType = ContentMatchType.CONTAINS, + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + raises: bool = True, +) -> WaitResult: + r"""Wait for specific content to appear in a pane. + + Parameters + ---------- + pane : Pane + The tmux pane to wait for content in + content_pattern : str | re.Pattern | callable + Content to wait for. This can be: + - A string to match exactly or check if contained (based on match_type) + - A compiled regex pattern to match against + - A predicate function that takes the pane content lines and returns a boolean + match_type : ContentMatchType + How to match the content_pattern against pane content + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + start : int | "-" | None + Starting line for capture_pane (passed to pane.capture_pane) + end : int | "-" | None + End line for capture_pane (passed to pane.capture_pane) + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + WaitResult + Result object with success status and matched content information + + Raises + ------ + WaitTimeout + If raises=True and the timeout is reached before content is found + + Examples + -------- + Wait with contains match (default), for testing purposes with a small timeout + and no raises: + + >>> result = wait_for_pane_content( + ... pane=pane, + ... content_pattern=r"$", # Look for shell prompt + ... timeout=0.5, + ... raises=False + ... ) + >>> isinstance(result, WaitResult) + True + + Using exact match: + + >>> result_exact = wait_for_pane_content( + ... pane=pane, + ... content_pattern="exact text to match", + ... match_type=ContentMatchType.EXACT, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result_exact, WaitResult) + True + + Using regex pattern: + + >>> import re + >>> pattern = re.compile(r"\$|%|>") # Common shell prompts + >>> result_regex = wait_for_pane_content( + ... pane=pane, + ... content_pattern=pattern, + ... match_type=ContentMatchType.REGEX, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result_regex, WaitResult) + True + + Using predicate function: + + >>> def has_at_least_1_line(content): + ... return len(content) >= 1 + >>> result_pred = wait_for_pane_content( + ... pane=pane, + ... content_pattern=has_at_least_1_line, + ... match_type=ContentMatchType.PREDICATE, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result_pred, WaitResult) + True + + Wait for a `$` written on the screen (unsubmitted): + + >>> pane.send_keys("$") + >>> result = wait_for_pane_content(pane, "$", ContentMatchType.CONTAINS) + + Wait for exact text (unsubmitted, and fails): + + >>> try: + ... pane.send_keys("echo 'Success'") + ... result = wait_for_pane_content( + ... pane, + ... "Success", + ... ContentMatchType.EXACT, + ... timeout=0.01 + ... ) + ... except WaitTimeout: + ... print("No exact match.") + No exact match. + + Use regex pattern matching: + + >>> import re + >>> pane.send_keys("echo 'Error: There was a problem.'") + >>> result = wait_for_pane_content( + ... pane, + ... re.compile(r"Error: .*"), + ... ContentMatchType.REGEX + ... ) + + Use custom predicate function: + + >>> def has_at_least_3_lines(content): + ... return len(content) >= 3 + + >>> for _ in range(5): + ... pane.send_keys("echo 'A line'", enter=True) + >>> result = wait_for_pane_content( + ... pane, + ... has_at_least_3_lines, + ... ContentMatchType.PREDICATE + ... ) + """ + result = WaitResult(success=False) + + def check_content() -> bool: + """Check if the content pattern is in the pane.""" + content = pane.capture_pane(start=start, end=end) + if isinstance(content, str): + content = [content] + + result.content = content + + # Handle predicate match type + if match_type == ContentMatchType.PREDICATE: + if not callable(content_pattern): + raise TypeError(ERR_PREDICATE_TYPE) + # For predicate, we pass the list of content lines + matched = content_pattern(content) + if matched: + result.matched_content = "\n".join(content) + return True + return False + + # Handle exact match type + if match_type == ContentMatchType.EXACT: + if not isinstance(content_pattern, str): + raise TypeError(ERR_EXACT_TYPE) + matched = "\n".join(content) == content_pattern + if matched: + result.matched_content = content_pattern + return True + return False + + # Handle contains match type + if match_type == ContentMatchType.CONTAINS: + if not isinstance(content_pattern, str): + raise TypeError(ERR_CONTAINS_TYPE) + content_str = "\n".join(content) + if content_pattern in content_str: + result.matched_content = content_pattern + # Find which line contains the match + for i, line in enumerate(content): + if content_pattern in line: + result.match_line = i + break + return True + return False + + # Handle regex match type + if match_type == ContentMatchType.REGEX: + if isinstance(content_pattern, (str, re.Pattern)): + pattern = ( + content_pattern + if isinstance(content_pattern, re.Pattern) + else re.compile(content_pattern) + ) + content_str = "\n".join(content) + match = pattern.search(content_str) + if match: + result.matched_content = match.group(0) + # Try to find which line contains the match + for i, line in enumerate(content): + if pattern.search(line): + result.match_line = i + break + return True + return False + raise TypeError(ERR_REGEX_TYPE) + return None + + try: + success, exception = retry_until_extended( + check_content, + timeout, + interval=interval, + raises=raises, + ) + if exception: + if raises: + raise + result.error = str(exception) + return result + result.success = success + except WaitTimeout as e: + if raises: + raise + result.error = str(e) + return result + + +def wait_until_pane_ready( + pane: Pane, + shell_prompt: str | re.Pattern[str] | Callable[[list[str]], bool] | None = None, + match_type: ContentMatchType = ContentMatchType.CONTAINS, + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + raises: bool = True, +) -> WaitResult: + r"""Wait until pane is ready with shell prompt. + + This is a convenience function for the common case of waiting for a shell prompt. + + Parameters + ---------- + pane : Pane + The tmux pane to check + shell_prompt : str | re.Pattern | callable + The shell prompt pattern to look for, or None to auto-detect + match_type : ContentMatchType + How to match the shell_prompt + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + WaitResult + Result of the wait operation + + Examples + -------- + Basic usage - auto-detecting shell prompt: + + >>> result = wait_until_pane_ready( + ... pane=pane, + ... timeout=0.5, + ... raises=False + ... ) + >>> isinstance(result, WaitResult) + True + + Wait with specific prompt pattern: + + >>> result_prompt = wait_until_pane_ready( + ... pane=pane, + ... shell_prompt=r"$", + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result_prompt, WaitResult) + True + + Using regex pattern: + + >>> import re + >>> pattern = re.compile(r"[$%#>]") + >>> result_regex = wait_until_pane_ready( + ... pane=pane, + ... shell_prompt=pattern, + ... match_type=ContentMatchType.REGEX, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result_regex, WaitResult) + True + + Using custom predicate function: + + >>> def has_prompt(content): + ... return any(line.endswith("$") for line in content) + >>> result_predicate = wait_until_pane_ready( + ... pane=pane, + ... shell_prompt=has_prompt, + ... match_type=ContentMatchType.PREDICATE, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result_predicate, WaitResult) + True + """ + if shell_prompt is None: + # Default to checking for common shell prompts + def check_for_prompt(lines: list[str]) -> bool: + content = "\n".join(lines) + return "$" in content or "%" in content or "#" in content + + shell_prompt = check_for_prompt + match_type = ContentMatchType.PREDICATE + + return wait_for_pane_content( + pane=pane, + content_pattern=shell_prompt, + match_type=match_type, + timeout=timeout, + interval=interval, + raises=raises, + ) + + +def wait_for_server_condition( + server: Server, + condition: Callable[[Server], bool], + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + raises: bool = True, +) -> bool: + """Wait for a condition on the server to be true. + + Parameters + ---------- + server : Server + The tmux server to check + condition : callable + A function that takes the server and returns a boolean + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + bool + True if the condition was met, False if timed out (and raises=False) + + Examples + -------- + Basic usage with a simple condition: + + >>> def has_sessions(server): + ... return len(server.sessions) > 0 + + Assuming server has at least one session: + + >>> result = wait_for_server_condition( + ... server, + ... has_sessions, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + Using a lambda for a simple condition: + + >>> result = wait_for_server_condition( + ... server, + ... lambda s: len(s.sessions) >= 1, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + Condition that checks for a specific session: + + >>> def has_specific_session(server): + ... return any(s.name == "specific_name" for s in server.sessions) + + This will likely timeout since we haven't created that session: + + >>> result = wait_for_server_condition( + ... server, + ... has_specific_session, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + """ + + def check_condition() -> bool: + return condition(server) + + return retry_until(check_condition, timeout, interval=interval, raises=raises) + + +def wait_for_session_condition( + session: Session, + condition: Callable[[Session], bool], + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + raises: bool = True, +) -> bool: + """Wait for a condition on the session to be true. + + Parameters + ---------- + session : Session + The tmux session to check + condition : callable + A function that takes the session and returns a boolean + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + bool + True if the condition was met, False if timed out (and raises=False) + + Examples + -------- + Basic usage with a simple condition: + + >>> def has_windows(session): + ... return len(session.windows) > 0 + + Assuming session has at least one window: + + >>> result = wait_for_session_condition( + ... session, + ... has_windows, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + Using a lambda for a simple condition: + + >>> result = wait_for_session_condition( + ... session, + ... lambda s: len(s.windows) >= 1, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + Condition that checks for a specific window: + + >>> def has_specific_window(session): + ... return any(w.name == "specific_window" for w in session.windows) + + This will likely timeout since we haven't created that window: + + >>> result = wait_for_session_condition( + ... session, + ... has_specific_window, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + """ + + def check_condition() -> bool: + return condition(session) + + return retry_until(check_condition, timeout, interval=interval, raises=raises) + + +def wait_for_window_condition( + window: Window, + condition: Callable[[Window], bool], + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + raises: bool = True, +) -> bool: + """Wait for a condition on the window to be true. + + Parameters + ---------- + window : Window + The tmux window to check + condition : callable + A function that takes the window and returns a boolean + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + bool + True if the condition was met, False if timed out (and raises=False) + + Examples + -------- + Basic usage with a simple condition: + + >>> def has_panes(window): + ... return len(window.panes) > 0 + + Assuming window has at least one pane: + + >>> result = wait_for_window_condition( + ... window, + ... has_panes, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + Using a lambda for a simple condition: + + >>> result = wait_for_window_condition( + ... window, + ... lambda w: len(w.panes) >= 1, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + Condition that checks window layout: + + >>> def is_tiled_layout(window): + ... return window.window_layout == "tiled" + + Check for a specific layout: + + >>> result = wait_for_window_condition( + ... window, + ... is_tiled_layout, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + """ + + def check_condition() -> bool: + return condition(window) + + return retry_until(check_condition, timeout, interval=interval, raises=raises) + + +def wait_for_window_panes( + window: Window, + expected_count: int, + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + raises: bool = True, +) -> bool: + """Wait until window has a specific number of panes. + + Parameters + ---------- + window : Window + The tmux window to check + expected_count : int + The number of panes to wait for + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + bool + True if the condition was met, False if timed out (and raises=False) + + Examples + -------- + Basic usage - wait for a window to have exactly 1 pane: + + >>> result = wait_for_window_panes( + ... window, + ... expected_count=1, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + Wait for a window to have 2 panes (will likely timeout in this example): + + >>> result = wait_for_window_panes( + ... window, + ... expected_count=2, + ... timeout=0.1, + ... raises=False + ... ) + >>> isinstance(result, bool) + True + + In a real test, you might split the window first: + + >>> # window.split_window() # Create a new pane + >>> # Then wait for the pane count to update: + >>> # result = wait_for_window_panes(window, 2) + """ + + def check_pane_count() -> bool: + # Force refresh window panes list + panes = window.panes + return len(panes) == expected_count + + return retry_until(check_pane_count, timeout, interval=interval, raises=raises) + + +def wait_for_any_content( + pane: Pane, + content_patterns: list[str | re.Pattern[str] | Callable[[list[str]], bool]], + match_types: list[ContentMatchType] | ContentMatchType, + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + raises: bool = True, +) -> WaitResult: + """Wait for any of the specified content patterns to appear in a pane. + + This is useful for handling alternative expected outputs. + + Parameters + ---------- + pane : Pane + The tmux pane to check + content_patterns : list[str | re.Pattern | callable] + List of content patterns to wait for, any of which can match + match_types : list[ContentMatchType] | ContentMatchType + How to match each content_pattern against pane content + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + start : int | "-" | None + Starting line for capture_pane (passed to pane.capture_pane) + end : int | "-" | None + End line for capture_pane (passed to pane.capture_pane) + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + WaitResult + Result object with success status and matched pattern information + + Raises + ------ + WaitTimeout + If raises=True and the timeout is reached before any pattern is found + TypeError + If a match type is incompatible with the specified pattern + ValueError + If match_types list has a different length than content_patterns + + Examples + -------- + Wait for any of the specified patterns: + + >>> pane.send_keys("echo 'pattern2'", enter=True) + >>> result = wait_for_any_content( + ... pane, + ... ["pattern1", "pattern2"], + ... ContentMatchType.CONTAINS + ... ) + + Wait for any of the specified regex patterns: + + >>> import re + >>> pane.send_keys("echo 'Error: this did not do the trick'", enter=True) + >>> pane.send_keys("echo 'Success: But subsequently this worked'", enter=True) + >>> result = wait_for_any_content( + ... pane, + ... [re.compile(r"Error: .*"), re.compile(r"Success: .*")], + ... ContentMatchType.REGEX + ... ) + + Wait for any of the specified predicate functions: + + >>> def has_at_least_3_lines(content): + ... return len(content) >= 3 + >>> + >>> def has_at_least_5_lines(content): + ... return len(content) >= 5 + >>> + >>> for _ in range(5): + ... pane.send_keys("echo 'A line'", enter=True) + >>> result = wait_for_any_content( + ... pane, + ... [has_at_least_3_lines, has_at_least_5_lines], + ... ContentMatchType.PREDICATE + ... ) + """ + if not content_patterns: + msg = "At least one content pattern must be provided" + raise ValueError(msg) + + # If match_types is a single value, convert to a list of the same value + if not isinstance(match_types, list): + match_types = [match_types] * len(content_patterns) + elif len(match_types) != len(content_patterns): + msg = ( + f"match_types list ({len(match_types)}) " + f"doesn't match patterns ({len(content_patterns)})" + ) + raise ValueError(msg) + + result = WaitResult(success=False) + start_time = time.time() + + def check_any_content() -> bool: + """Try to match any of the specified patterns.""" + content = pane.capture_pane(start=start, end=end) + if isinstance(content, str): + content = [content] + + result.content = content + + for i, (pattern, match_type) in enumerate( + zip(content_patterns, match_types), + ): + # Handle predicate match + if match_type == ContentMatchType.PREDICATE: + if not callable(pattern): + msg = f"Pattern at index {i}: {ERR_PREDICATE_TYPE}" + raise TypeError(msg) + # For predicate, we pass the list of content lines + if pattern(content): + result.matched_content = "\n".join(content) + result.matched_pattern_index = i + return True + continue # Try next pattern + + # Handle exact match + if match_type == ContentMatchType.EXACT: + if not isinstance(pattern, str): + msg = f"Pattern at index {i}: {ERR_EXACT_TYPE}" + raise TypeError(msg) + if "\n".join(content) == pattern: + result.matched_content = pattern + result.matched_pattern_index = i + return True + continue # Try next pattern + + # Handle contains match + if match_type == ContentMatchType.CONTAINS: + if not isinstance(pattern, str): + msg = f"Pattern at index {i}: {ERR_CONTAINS_TYPE}" + raise TypeError(msg) + content_str = "\n".join(content) + if pattern in content_str: + result.matched_content = pattern + result.matched_pattern_index = i + # Find which line contains the match + for i, line in enumerate(content): + if pattern in line: + result.match_line = i + break + return True + continue # Try next pattern + + # Handle regex match + if match_type == ContentMatchType.REGEX: + if isinstance(pattern, (str, re.Pattern)): + regex = ( + pattern + if isinstance(pattern, re.Pattern) + else re.compile(pattern) + ) + content_str = "\n".join(content) + match = regex.search(content_str) + if match: + result.matched_content = match.group(0) + result.matched_pattern_index = i + # Try to find which line contains the match + for i, line in enumerate(content): + if regex.search(line): + result.match_line = i + break + return True + continue # Try next pattern + msg = f"Pattern at index {i}: {ERR_REGEX_TYPE}" + raise TypeError(msg) + + # None of the patterns matched + return False + + try: + success, exception = retry_until_extended( + check_any_content, + timeout, + interval=interval, + raises=raises, + ) + if exception: + if raises: + raise + result.error = str(exception) + return result + result.success = success + result.elapsed_time = time.time() - start_time + except WaitTimeout as e: + if raises: + raise + result.error = str(e) + result.elapsed_time = time.time() - start_time + return result + + +def wait_for_all_content( + pane: Pane, + content_patterns: list[str | re.Pattern[str] | Callable[[list[str]], bool]], + match_types: list[ContentMatchType] | ContentMatchType, + timeout: float = RETRY_TIMEOUT_SECONDS, + interval: float = RETRY_INTERVAL_SECONDS, + start: t.Literal["-"] | int | None = None, + end: t.Literal["-"] | int | None = None, + raises: bool = True, +) -> WaitResult: + """Wait for all patterns to appear in a pane. + + This function waits until all specified patterns are found in a pane. + It supports mixed match types, allowing different patterns to be matched + in different ways. + + Parameters + ---------- + pane : Pane + The tmux pane to check + content_patterns : list[str | re.Pattern | callable] + List of patterns to wait for + match_types : list[ContentMatchType] | ContentMatchType + How to match each pattern. Either a single match type for all patterns, + or a list of match types, one for each pattern. + timeout : float + Maximum time to wait in seconds + interval : float + Time between checks in seconds + start : int | "-" | None + Starting line for capture_pane (passed to pane.capture_pane) + end : int | "-" | None + End line for capture_pane (passed to pane.capture_pane) + raises : bool + Whether to raise an exception on timeout + + Returns + ------- + WaitResult + Result object with status and match information + + Raises + ------ + WaitTimeout + If raises=True and the timeout is reached before all patterns are found + TypeError + If match types and patterns are incompatible + ValueError + If match_types list has a different length than content_patterns + + Examples + -------- + Wait for all of the specified patterns: + + >>> # Send some text to the pane that will match both patterns + >>> pane.send_keys("echo 'pattern1 pattern2'", enter=True) + >>> + >>> result = wait_for_all_content( + ... pane, + ... ["pattern1", "pattern2"], + ... ContentMatchType.CONTAINS, + ... timeout=0.5, + ... raises=False + ... ) + >>> isinstance(result, WaitResult) + True + >>> result.success + True + + Using regex patterns: + + >>> import re + >>> # Send content that matches both regex patterns + >>> pane.send_keys("echo 'Error: something went wrong'", enter=True) + >>> pane.send_keys("echo 'Success: but we fixed it'", enter=True) + >>> + >>> result = wait_for_all_content( + ... pane, + ... [re.compile(r"Error: .*"), re.compile(r"Success: .*")], + ... ContentMatchType.REGEX, + ... timeout=0.5, + ... raises=False + ... ) + >>> isinstance(result, WaitResult) + True + + Using predicate functions: + + >>> def has_at_least_3_lines(content): + ... return len(content) >= 3 + >>> + >>> def has_at_least_5_lines(content): + ... return len(content) >= 5 + >>> + >>> # Send enough lines to satisfy both predicates + >>> for _ in range(5): + ... pane.send_keys("echo 'Adding a line'", enter=True) + >>> + >>> result = wait_for_all_content( + ... pane, + ... [has_at_least_3_lines, has_at_least_5_lines], + ... ContentMatchType.PREDICATE, + ... timeout=0.5, + ... raises=False + ... ) + >>> isinstance(result, WaitResult) + True + """ + if not content_patterns: + msg = "At least one content pattern must be provided" + raise ValueError(msg) + + # Convert single match_type to list of same type + if not isinstance(match_types, list): + match_types = [match_types] * len(content_patterns) + elif len(match_types) != len(content_patterns): + msg = ( + f"match_types list ({len(match_types)}) " + f"doesn't match patterns ({len(content_patterns)})" + ) + raise ValueError(msg) + + result = WaitResult(success=False) + matched_patterns: list[str] = [] + start_time = time.time() + + def check_all_content() -> bool: + content = pane.capture_pane(start=start, end=end) + if isinstance(content, str): + content = [content] + + result.content = content + matched_patterns.clear() + + for i, (pattern, match_type) in enumerate( + zip(content_patterns, match_types), + ): + # Handle predicate match + if match_type == ContentMatchType.PREDICATE: + if not callable(pattern): + msg = f"Pattern at index {i}: {ERR_PREDICATE_TYPE}" + raise TypeError(msg) + # For predicate, we pass the list of content lines + if not pattern(content): + return False + matched_patterns.append(f"predicate_function_{i}") + continue # Pattern matched, check next + + # Handle exact match + if match_type == ContentMatchType.EXACT: + if not isinstance(pattern, str): + msg = f"Pattern at index {i}: {ERR_EXACT_TYPE}" + raise TypeError(msg) + if "\n".join(content) != pattern: + return False + matched_patterns.append(pattern) + continue # Pattern matched, check next + + # Handle contains match + if match_type == ContentMatchType.CONTAINS: + if not isinstance(pattern, str): + msg = f"Pattern at index {i}: {ERR_CONTAINS_TYPE}" + raise TypeError(msg) + content_str = "\n".join(content) + if pattern not in content_str: + return False + matched_patterns.append(pattern) + continue # Pattern matched, check next + + # Handle regex match + if match_type == ContentMatchType.REGEX: + if isinstance(pattern, (str, re.Pattern)): + regex = ( + pattern + if isinstance(pattern, re.Pattern) + else re.compile(pattern) + ) + content_str = "\n".join(content) + match = regex.search(content_str) + if not match: + return False + matched_patterns.append( + pattern if isinstance(pattern, str) else pattern.pattern, + ) + continue # Pattern matched, check next + msg = f"Pattern at index {i}: {ERR_REGEX_TYPE}" + raise TypeError(msg) + + # All patterns matched + result.matched_content = matched_patterns + return True + + try: + success, exception = retry_until_extended( + check_all_content, + timeout, + interval=interval, + raises=raises, + ) + if exception: + if raises: + raise + result.error = str(exception) + return result + result.success = success + result.elapsed_time = time.time() - start_time + except WaitTimeout as e: + if raises: + raise + result.error = str(e) + result.elapsed_time = time.time() - start_time + return result + + +def _contains_match( + content: list[str], + pattern: str, +) -> tuple[bool, str | None, int | None]: + r"""Check if content contains the pattern. + + Parameters + ---------- + content : list[str] + Lines of content to check + pattern : str + String to check for in content + + Returns + ------- + tuple[bool, str | None, int | None] + (matched, matched_content, match_line) + + Examples + -------- + Pattern found in content: + + >>> content = ["line 1", "hello world", "line 3"] + >>> matched, matched_text, line_num = _contains_match(content, "hello") + >>> matched + True + >>> matched_text + 'hello' + >>> line_num + 1 + + Pattern not found: + + >>> matched, matched_text, line_num = _contains_match(content, "not found") + >>> matched + False + >>> matched_text is None + True + >>> line_num is None + True + + Pattern spans multiple lines (in the combined content): + + >>> multi_line = ["first part", "second part"] + >>> content_str = "\n".join(multi_line) # "first part\nsecond part" + >>> # A pattern that spans the line boundary can be matched + >>> "part\nsec" in content_str + True + >>> matched, _, _ = _contains_match(multi_line, "part\nsec") + >>> matched + True + """ + content_str = "\n".join(content) + if pattern in content_str: + # Find which line contains the match + return next( + ((True, pattern, i) for i, line in enumerate(content) if pattern in line), + (True, pattern, None), + ) + + return False, None, None + + +def _regex_match( + content: list[str], + pattern: str | re.Pattern[str], +) -> tuple[bool, str | None, int | None]: + r"""Check if content matches the regex pattern. + + Parameters + ---------- + content : list[str] + Lines of content to check + pattern : str | re.Pattern + Regular expression pattern to match against content + + Returns + ------- + tuple[bool, str | None, int | None] + (matched, matched_content, match_line) + + Examples + -------- + Using string pattern: + + >>> content = ["line 1", "hello world 123", "line 3"] + >>> matched, matched_text, line_num = _regex_match(content, r"world \d+") + >>> matched + True + >>> matched_text + 'world 123' + >>> line_num + 1 + + Using compiled pattern: + + >>> import re + >>> pattern = re.compile(r"line \d") + >>> matched, matched_text, line_num = _regex_match(content, pattern) + >>> matched + True + >>> matched_text + 'line 1' + >>> line_num + 0 + + Pattern not found: + + >>> matched, matched_text, line_num = _regex_match(content, r"not found") + >>> matched + False + >>> matched_text is None + True + >>> line_num is None + True + + Matching groups in pattern: + + >>> content = ["user: john", "email: john@example.com"] + >>> pattern = re.compile(r"email: ([\w.@]+)") + >>> matched, matched_text, line_num = _regex_match(content, pattern) + >>> matched + True + >>> matched_text + 'email: john@example.com' + >>> line_num + 1 + """ + content_str = "\n".join(content) + regex = pattern if isinstance(pattern, re.Pattern) else re.compile(pattern) + + if match := regex.search(content_str): + matched_text = match.group(0) + # Try to find which line contains the match + return next( + ( + (True, matched_text, i) + for i, line in enumerate(content) + if regex.search(line) + ), + (True, matched_text, None), + ) + + return False, None, None + + +def _match_regex_across_lines( + content: list[str], + pattern: re.Pattern[str], +) -> tuple[bool, str | None, int | None]: + r"""Try to match a regex across multiple lines. + + Args: + content: List of content lines + pattern: Regex pattern to match + + Returns + ------- + (matched, matched_content, match_line) + + Examples + -------- + Pattern that spans multiple lines: + + >>> import re + >>> content = ["start of", "multi-line", "content"] + >>> pattern = re.compile(r"of\nmulti", re.DOTALL) + >>> matched, matched_text, line_num = _match_regex_across_lines(content, pattern) + >>> matched + True + >>> matched_text + 'of\nmulti' + >>> line_num + 0 + + Pattern that spans multiple lines but isn't found: + + >>> pattern = re.compile(r"not\nfound", re.DOTALL) + >>> matched, matched_text, line_num = _match_regex_across_lines(content, pattern) + >>> matched + False + >>> matched_text is None + True + >>> line_num is None + True + + Complex multi-line pattern with groups: + + >>> content = ["user: john", "email: john@example.com", "status: active"] + >>> pattern = re.compile(r"email: ([\w.@]+)\nstatus: (\w+)", re.DOTALL) + >>> matched, matched_text, line_num = _match_regex_across_lines(content, pattern) + >>> matched + True + >>> matched_text + 'email: john@example.com\nstatus: active' + >>> line_num + 1 + """ + content_str = "\n".join(content) + regex = pattern if isinstance(pattern, re.Pattern) else re.compile(pattern) + + if match := regex.search(content_str): + matched_text = match.group(0) + + # Find the starting position of the match in the joined string + start_pos = match.start() + + # Count newlines before the match to determine the starting line + newlines_before_match = content_str[:start_pos].count("\n") + return True, matched_text, newlines_before_match + + return False, None, None diff --git a/src/libtmux/common.py b/src/libtmux/common.py index db0b4151f..c0815cad0 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -1,8 +1,11 @@ -"""Helper methods and mixins for libtmux. +"""Provide helper methods and mixins for libtmux. + +This module includes helper functions for version checking, environment variable +management, tmux command execution, and other miscellaneous utilities used by +libtmux. It preserves and respects existing doctests without removal. libtmux.common ~~~~~~~~~~~~~~ - """ from __future__ import annotations @@ -20,8 +23,8 @@ if t.TYPE_CHECKING: from collections.abc import Callable -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) #: Minimum version of tmux required to run libtmux TMUX_MIN_VERSION = "1.8" @@ -36,7 +39,7 @@ class EnvironmentMixin: - """Mixin for manager session and server level environment variables in tmux.""" + """Manage session- and server-level environment variables within tmux.""" _add_option = None @@ -46,39 +49,32 @@ def __init__(self, add_option: str | None = None) -> None: self._add_option = add_option def set_environment(self, name: str, value: str) -> None: - """Set environment ``$ tmux set-environment ``. + """Set an environment variable via ``tmux set-environment ``. Parameters ---------- name : str - the environment variable name. such as 'PATH'. - option : str - environment value. + Name of the environment variable (e.g. 'PATH'). + value : str + Value of the environment variable. """ args = ["set-environment"] if self._add_option: args += [self._add_option] - args += [name, value] cmd = self.cmd(*args) - if cmd.stderr: - ( - cmd.stderr[0] - if isinstance(cmd.stderr, list) and len(cmd.stderr) == 1 - else cmd.stderr - ) msg = f"tmux set-environment stderr: {cmd.stderr}" raise ValueError(msg) def unset_environment(self, name: str) -> None: - """Unset environment variable ``$ tmux set-environment -u ``. + """Unset an environment variable via ``tmux set-environment -u ``. Parameters ---------- name : str - the environment variable name. such as 'PATH'. + Name of the environment variable (e.g. 'PATH'). """ args = ["set-environment"] if self._add_option: @@ -86,23 +82,17 @@ def unset_environment(self, name: str) -> None: args += ["-u", name] cmd = self.cmd(*args) - if cmd.stderr: - ( - cmd.stderr[0] - if isinstance(cmd.stderr, list) and len(cmd.stderr) == 1 - else cmd.stderr - ) msg = f"tmux set-environment stderr: {cmd.stderr}" raise ValueError(msg) def remove_environment(self, name: str) -> None: - """Remove environment variable ``$ tmux set-environment -r ``. + """Remove an environment variable via ``tmux set-environment -r ``. Parameters ---------- name : str - the environment variable name. such as 'PATH'. + Name of the environment variable (e.g. 'PATH'). """ args = ["set-environment"] if self._add_option: @@ -110,34 +100,25 @@ def remove_environment(self, name: str) -> None: args += ["-r", name] cmd = self.cmd(*args) - if cmd.stderr: - ( - cmd.stderr[0] - if isinstance(cmd.stderr, list) and len(cmd.stderr) == 1 - else cmd.stderr - ) msg = f"tmux set-environment stderr: {cmd.stderr}" raise ValueError(msg) def show_environment(self) -> dict[str, bool | str]: - """Show environment ``$ tmux show-environment -t [session]``. - - Return dict of environment variables for the session. - - .. versionchanged:: 0.13 - - Removed per-item lookups. Use :meth:`libtmux.common.EnvironmentMixin.getenv`. + """Show environment variables via ``tmux show-environment``. Returns ------- dict - environmental variables in dict, if no name, or str if name - entered. + Dictionary of environment variables for the session. + + .. versionchanged:: 0.13 + Removed per-item lookups. Use :meth:`.getenv` to get a single env var. """ tmux_args = ["show-environment"] if self._add_option: tmux_args += [self._add_option] + cmd = self.cmd(*tmux_args) output = cmd.stdout opts = [tuple(item.split("=", 1)) for item in output] @@ -153,28 +134,26 @@ def show_environment(self) -> dict[str, bool | str]: return opts_dict def getenv(self, name: str) -> str | bool | None: - """Show environment variable ``$ tmux show-environment -t [session] ``. - - Return the value of a specific variable if the name is specified. - - .. versionadded:: 0.13 + """Show value of an environment variable via ``tmux show-environment ``. Parameters ---------- name : str - the environment variable name. such as 'PATH'. + The environment variable name (e.g. 'PATH'). Returns ------- - str - Value of environment variable - """ - tmux_args: tuple[str | int, ...] = () + str or bool or None + The environment variable value, True if set without an '=' value, or + None if not set. - tmux_args += ("show-environment",) + .. versionadded:: 0.13 + """ + tmux_args: list[str | int] = ["show-environment"] if self._add_option: - tmux_args += (self._add_option,) - tmux_args += (name,) + tmux_args += [self._add_option] + tmux_args.append(name) + cmd = self.cmd(*tmux_args) output = cmd.stdout opts = [tuple(item.split("=", 1)) for item in output] @@ -191,7 +170,7 @@ def getenv(self, name: str) -> str | bool | None: class tmux_cmd: - """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. + """Execute a tmux command via :py:mod:`subprocess`. Examples -------- @@ -203,7 +182,6 @@ class tmux_cmd: ... 'Command: %s returned error: %s' % (proc.cmd, proc.stderr) ... ) ... - >>> print(f'tmux command returned {" ".join(proc.stdout)}') tmux command returned 2 @@ -216,7 +194,7 @@ class tmux_cmd: Notes ----- .. versionchanged:: 0.8 - Renamed from ``tmux`` to ``tmux_cmd``. + Renamed from ``tmux`` to ``tmux_cmd``. """ def __init__(self, *args: t.Any) -> None: @@ -229,7 +207,6 @@ def __init__(self, *args: t.Any) -> None: cmd = [str(c) for c in cmd] self.cmd = cmd - try: self.process = subprocess.Popen( cmd, @@ -248,38 +225,36 @@ def __init__(self, *args: t.Any) -> None: stdout_split = stdout.split("\n") # remove trailing newlines from stdout + # remove trailing empty lines while stdout_split and stdout_split[-1] == "": stdout_split.pop() stderr_split = stderr.split("\n") self.stderr = list(filter(None, stderr_split)) # filter empty values + # fix for 'has-session' command output edge cases if "has-session" in cmd and len(self.stderr) and not stdout_split: self.stdout = [self.stderr[0]] else: self.stdout = stdout_split logger.debug( - "self.stdout for {cmd}: {stdout}".format( - cmd=" ".join(cmd), - stdout=self.stdout, - ), + "self.stdout for %s: %s", + " ".join(cmd), + self.stdout, ) def get_version() -> LooseVersion: - """Return tmux version. - - If tmux is built from git master, the version returned will be the latest - version appended with -master, e.g. ``2.4-master``. + """Return the installed tmux version. - If using OpenBSD's base system tmux, the version will have ``-openbsd`` - appended to the latest version, e.g. ``2.4-openbsd``. + If tmux is built from git master, appends '-master', e.g. '2.4-master'. + If using OpenBSD's base system tmux, appends '-openbsd', e.g. '2.4-openbsd'. Returns ------- - :class:`distutils.version.LooseVersion` - tmux version according to :func:`shtuil.which`'s tmux + LooseVersion + Detected tmux version. """ proc = tmux_cmd("-V") if proc.stderr: @@ -287,132 +262,128 @@ def get_version() -> LooseVersion: if sys.platform.startswith("openbsd"): # openbsd has no tmux -V return LooseVersion(f"{TMUX_MAX_VERSION}-openbsd") msg = ( - f"libtmux supports tmux {TMUX_MIN_VERSION} and greater. This system" - " is running tmux 1.3 or earlier." - ) - raise exc.LibTmuxException( - msg, + f"libtmux supports tmux {TMUX_MIN_VERSION} and greater. " + "This system is running tmux 1.3 or earlier." ) + raise exc.LibTmuxException(msg) raise exc.VersionTooLow(proc.stderr) version = proc.stdout[0].split("tmux ")[1] - # Allow latest tmux HEAD + # allow HEAD to be recognized if version == "master": return LooseVersion(f"{TMUX_MAX_VERSION}-master") version = re.sub(r"[a-z-]", "", version) - return LooseVersion(version) def has_version(version: str) -> bool: - """Return True if tmux version installed. + """Return True if the installed tmux version matches exactly. Parameters ---------- version : str - version number, e.g. '1.8' + e.g. '1.8' Returns ------- bool - True if version matches + True if installed tmux matches the version exactly. """ return get_version() == LooseVersion(version) def has_gt_version(min_version: str) -> bool: - """Return True if tmux version greater than minimum. + """Return True if the installed tmux version is greater than min_version. Parameters ---------- min_version : str - tmux version, e.g. '1.8' + e.g. '1.8' Returns ------- bool - True if version above min_version + True if version above min_version. """ return get_version() > LooseVersion(min_version) def has_gte_version(min_version: str) -> bool: - """Return True if tmux version greater or equal to minimum. + """Return True if the installed tmux version is >= min_version. Parameters ---------- min_version : str - tmux version, e.g. '1.8' + e.g. '1.8' Returns ------- bool - True if version above or equal to min_version + True if version is above or equal to min_version. """ return get_version() >= LooseVersion(min_version) def has_lte_version(max_version: str) -> bool: - """Return True if tmux version less or equal to minimum. + """Return True if the installed tmux version is <= max_version. Parameters ---------- max_version : str - tmux version, e.g. '1.8' + e.g. '1.8' Returns ------- bool - True if version below or equal to max_version + True if version is below or equal to max_version. """ return get_version() <= LooseVersion(max_version) def has_lt_version(max_version: str) -> bool: - """Return True if tmux version less than minimum. + """Return True if the installed tmux version is < max_version. Parameters ---------- max_version : str - tmux version, e.g. '1.8' + e.g. '1.8' Returns ------- bool - True if version below max_version + True if version is below max_version. """ return get_version() < LooseVersion(max_version) def has_minimum_version(raises: bool = True) -> bool: - """Return True if tmux meets version requirement. Version >1.8 or above. + """Return True if tmux meets the required minimum version. + + The minimum version is defined by ``TMUX_MIN_VERSION``, default '1.8'. Parameters ---------- - raises : bool - raise exception if below minimum version requirement + raises : bool, optional + If True (default), raise an exception if below the min version. Returns ------- bool - True if tmux meets minimum required version. + True if tmux meets the minimum required version, otherwise False. Raises ------ - libtmux.exc.VersionTooLow - tmux version below minimum required for libtmux + exc.VersionTooLow + If `raises=True` and tmux is below the minimum required version. Notes ----- .. versionchanged:: 0.7.0 - No longer returns version, returns True or False - + No longer returns version, returns True/False. .. versionchanged:: 0.1.7 - Versions will now remove trailing letters per `Issue 55`_. - - .. _Issue 55: https://github.com/tmux-python/tmuxp/issues/55. + Versions remove trailing letters per Issue #55. """ if get_version() < LooseVersion(TMUX_MIN_VERSION): if raises: @@ -427,20 +398,17 @@ def has_minimum_version(raises: bool = True) -> bool: def session_check_name(session_name: str | None) -> None: - """Raise exception session name invalid, modeled after tmux function. - - tmux(1) session names may not be empty, or include periods or colons. - These delimiters are reserved for noting session, window and pane. + """Raise if session name is invalid, as tmux forbids periods/colons. Parameters ---------- session_name : str - Name of session. + The session name to validate. Raises ------ - :exc:`exc.BadSessionName` - Invalid session name. + exc.BadSessionName + If the session name is empty, contains colons, or contains periods. """ if session_name is None or len(session_name) == 0: raise exc.BadSessionName(reason="empty", session_name=session_name) @@ -450,32 +418,26 @@ def session_check_name(session_name: str | None) -> None: raise exc.BadSessionName(reason="contains colons", session_name=session_name) -def handle_option_error(error: str) -> type[exc.OptionError]: - """Raise exception if error in option command found. - - In tmux 3.0, show-option and show-window-option return invalid option instead of - unknown option. See https://github.com/tmux/tmux/blob/3.0/cmd-show-options.c. - - In tmux >2.4, there are 3 different types of option errors: - - - unknown option - - invalid option - - ambiguous option +def handle_option_error(error: str) -> t.NoReturn: + """Raise appropriate exception if an option error is encountered. - In tmux <2.4, unknown option was the only option. + In tmux 3.0, 'show-option' or 'show-window-option' return 'invalid option' + instead of 'unknown option'. In tmux >=2.4, there are three types of + option errors: unknown, invalid, ambiguous. - All errors raised will have the base error of :exc:`exc.OptionError`. So to - catch any option error, use ``except exc.OptionError``. + For older tmux (<2.4), 'unknown option' was the only possibility. Parameters ---------- error : str - Error response from subprocess call. + Error string from tmux. Raises ------ - :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, :exc:`exc.InvalidOption`, - :exc:`exc.AmbiguousOption` + exc.UnknownOption + exc.InvalidOption + exc.AmbiguousOption + exc.OptionError """ if "unknown option" in error: raise exc.UnknownOption(error) @@ -483,16 +445,16 @@ def handle_option_error(error: str) -> type[exc.OptionError]: raise exc.InvalidOption(error) if "ambiguous option" in error: raise exc.AmbiguousOption(error) - raise exc.OptionError(error) # Raise generic option error + raise exc.OptionError(error) def get_libtmux_version() -> LooseVersion: - """Return libtmux version is a PEP386 compliant format. + """Return the PEP386-compliant libtmux version. Returns ------- - distutils.version.LooseVersion - libtmux version + LooseVersion + The libtmux version. """ from libtmux.__about__ import __version__ diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 7777403f3..1d5822eec 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -1,8 +1,17 @@ -"""libtmux exceptions. +"""Provide exceptions used by libtmux. libtmux.exc ~~~~~~~~~~~ +This module implements exceptions used throughout libtmux for error +handling in sessions, windows, panes, and general usage. It preserves +existing exception definitions for backward compatibility and does not +remove any doctests. + +Notes +----- +Exceptions in this module inherit from :exc:`LibTmuxException` or +specialized base classes to form a hierarchy of tmux-related errors. """ from __future__ import annotations @@ -16,19 +25,19 @@ class LibTmuxException(Exception): - """Base Exception for libtmux Errors.""" + """Base exception for all libtmux errors.""" class TmuxSessionExists(LibTmuxException): - """Session does not exist in the server.""" + """Raised if a tmux session with the requested name already exists.""" class TmuxCommandNotFound(LibTmuxException): - """Application binary for tmux not found.""" + """Raised when the tmux binary cannot be found on the system.""" class TmuxObjectDoesNotExist(ObjectDoesNotExist): - """The query returned multiple objects when only one was expected.""" + """Raised when a tmux object cannot be found in the server output.""" def __init__( self, @@ -39,19 +48,20 @@ def __init__( *args: object, ) -> None: if all(arg is not None for arg in [obj_key, obj_id, list_cmd, list_extra_args]): - return super().__init__( + super().__init__( f"Could not find {obj_key}={obj_id} for {list_cmd} " f"{list_extra_args if list_extra_args is not None else ''}", ) - return super().__init__("Could not find object") + else: + super().__init__("Could not find object") class VersionTooLow(LibTmuxException): - """Raised if tmux below the minimum version to use libtmux.""" + """Raised if the installed tmux version is below the minimum required.""" class BadSessionName(LibTmuxException): - """Disallowed session name for tmux (empty, contains periods or colons).""" + """Raised if a tmux session name is disallowed (e.g., empty, has colons/periods).""" def __init__( self, @@ -62,83 +72,84 @@ def __init__( msg = f"Bad session name: {reason}" if session_name is not None: msg += f" (session name: {session_name})" - return super().__init__(msg) + super().__init__(msg) class OptionError(LibTmuxException): - """Root error for any error involving invalid, ambiguous or bad options.""" + """Base exception for errors involving invalid, ambiguous, or unknown options.""" class UnknownOption(OptionError): - """Option unknown to tmux show-option(s) or show-window-option(s).""" + """Raised if tmux reports an unknown option.""" class UnknownColorOption(UnknownOption): - """Unknown color option.""" + """Raised if a server color option is unknown (must be 88 or 256).""" def __init__(self, *args: object) -> None: - return super().__init__("Server.colors must equal 88 or 256") + super().__init__("Server.colors must equal 88 or 256") class InvalidOption(OptionError): - """Option invalid to tmux, introduced in tmux v2.4.""" + """Raised if tmux reports an invalid option (tmux >= 2.4).""" class AmbiguousOption(OptionError): - """Option that could potentially match more than one.""" + """Raised if tmux reports an option that could match more than one.""" class WaitTimeout(LibTmuxException): - """Function timed out without meeting condition.""" + """Raised when a function times out waiting for a condition.""" class VariableUnpackingError(LibTmuxException): - """Error unpacking variable.""" + """Raised when an environment variable cannot be unpacked as expected.""" def __init__(self, variable: t.Any | None = None, *args: object) -> None: - return super().__init__(f"Unexpected variable: {variable!s}") + super().__init__(f"Unexpected variable: {variable!s}") class PaneError(LibTmuxException): - """Any type of pane related error.""" + """Base exception for pane-related errors.""" class PaneNotFound(PaneError): - """Pane not found.""" + """Raised if a specified pane cannot be found.""" def __init__(self, pane_id: str | None = None, *args: object) -> None: if pane_id is not None: - return super().__init__(f"Pane not found: {pane_id}") - return super().__init__("Pane not found") + super().__init__(f"Pane not found: {pane_id}") + else: + super().__init__("Pane not found") class WindowError(LibTmuxException): - """Any type of window related error.""" + """Base exception for window-related errors.""" class MultipleActiveWindows(WindowError): - """Multiple active windows.""" + """Raised if multiple active windows are detected (where only one is expected).""" def __init__(self, count: int, *args: object) -> None: - return super().__init__(f"Multiple active windows: {count} found") + super().__init__(f"Multiple active windows: {count} found") class NoActiveWindow(WindowError): - """No active window found.""" + """Raised if no active window exists when one is expected.""" def __init__(self, *args: object) -> None: - return super().__init__("No active windows found") + super().__init__("No active windows found") class NoWindowsExist(WindowError): - """No windows exist for object.""" + """Raised if a session or server has no windows.""" def __init__(self, *args: object) -> None: - return super().__init__("No windows exist for object") + super().__init__("No windows exist for object") class AdjustmentDirectionRequiresAdjustment(LibTmuxException, ValueError): - """If *adjustment_direction* is set, *adjustment* must be set.""" + """Raised if an adjustment direction is set, but no adjustment value is provided.""" def __init__(self) -> None: super().__init__("adjustment_direction requires adjustment") @@ -148,18 +159,18 @@ class WindowAdjustmentDirectionRequiresAdjustment( WindowError, AdjustmentDirectionRequiresAdjustment, ): - """ValueError for :meth:`libtmux.Window.resize_window`.""" + """Raised if window resizing requires an adjustment value, but none is provided.""" class PaneAdjustmentDirectionRequiresAdjustment( WindowError, AdjustmentDirectionRequiresAdjustment, ): - """ValueError for :meth:`libtmux.Pane.resize_pane`.""" + """Raised if pane resizing requires an adjustment value, but none is provided.""" class RequiresDigitOrPercentage(LibTmuxException, ValueError): - """Requires digit (int or str digit) or a percentage.""" + """Raised if a sizing argument must be a digit or a percentage.""" def __init__(self) -> None: super().__init__("Requires digit (int or str digit) or a percentage.") diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index ab5cd712b..e7c914dc1 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1,4 +1,21 @@ -"""Tools for hydrating tmux data into python dataclass objects.""" +"""Provide tools for hydrating tmux data into Python dataclass objects. + +This module defines mechanisms for fetching and converting tmux command outputs +into Python dataclasses (via the :class:`Obj` base class). This facilitates +more structured and Pythonic interaction with tmux objects such as sessions, +windows, and panes. + +Implementation Notes +-------------------- +- :func:`fetch_objs` retrieves lists of raw field data from tmux. +- :func:`fetch_obj` retrieves a single tmux object by its key and ID. +- :class:`Obj` is a base dataclass that holds common tmux fields. + +See Also +-------- +:func:`fetch_objs` +:func:`fetch_obj` +""" from __future__ import annotations @@ -12,14 +29,13 @@ from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: + from libtmux.server import Server + ListCmd = t.Literal["list-sessions", "list-windows", "list-panes"] ListExtraArgs = t.Optional[Iterable[str]] - from libtmux.server import Server - logger = logging.getLogger(__name__) - OutputRaw = dict[str, t.Any] OutputsRaw = list[OutputRaw] @@ -36,7 +52,26 @@ @dataclasses.dataclass() class Obj: - """Dataclass of generic tmux object.""" + """Represent a generic tmux dataclass object with standard fields. + + Objects extending this base class derive many fields from tmux commands + via the :func:`fetch_objs` and :func:`fetch_obj` functions. + + Parameters + ---------- + server + The :class:`Server` instance owning this tmux object. + + Attributes + ---------- + pane_id, window_id, session_id, etc. + Various tmux-specific fields automatically populated when refreshed. + + Examples + -------- + Subclasses of :class:`Obj` typically represent concrete tmux entities + (e.g., sessions, windows, and panes). + """ server: Server @@ -91,7 +126,7 @@ class Obj: mouse_standard_flag: str | None = None next_session_id: str | None = None origin_flag: str | None = None - pane_active: str | None = None # Not detected by script + pane_active: str | None = None pane_at_bottom: str | None = None pane_at_left: str | None = None pane_at_right: str | None = None @@ -146,7 +181,7 @@ class Obj: uid: str | None = None user: str | None = None version: str | None = None - window_active: str | None = None # Not detected by script + window_active: str | None = None window_active_clients: str | None = None window_active_sessions: str | None = None window_activity: str | None = None @@ -176,6 +211,24 @@ def _refresh( list_cmd: ListCmd = "list-panes", list_extra_args: ListExtraArgs | None = None, ) -> None: + """Refresh fields for this object by re-fetching from tmux. + + Parameters + ---------- + obj_key + The field name to match (e.g. 'pane_id'). + obj_id + The object identifier (e.g. '%1'). + list_cmd + The tmux command to use (e.g. 'list-panes'). + list_extra_args + Additional arguments to pass to the tmux command. + + Raises + ------ + exc.TmuxObjectDoesNotExist + If the requested object does not exist in tmux's output. + """ assert isinstance(obj_id, str) obj = fetch_obj( obj_key=obj_key, @@ -185,9 +238,8 @@ def _refresh( server=self.server, ) assert obj is not None - if obj is not None: - for k, v in obj.items(): - setattr(self, k, v) + for k, v in obj.items(): + setattr(self, k, v) def fetch_objs( @@ -195,40 +247,54 @@ def fetch_objs( list_cmd: ListCmd, list_extra_args: ListExtraArgs | None = None, ) -> OutputsRaw: - """Fetch a listing of raw data from a tmux command.""" + """Fetch a list of raw data from a tmux command. + + Parameters + ---------- + server + The :class:`Server` against which to run the command. + list_cmd + The tmux command to run (e.g. 'list-sessions', 'list-windows', 'list-panes'). + list_extra_args + Any extra arguments (e.g. ['-a']). + + Returns + ------- + list of dict + A list of dictionaries of field-name to field-value mappings. + + Raises + ------ + exc.LibTmuxException + If tmux reports an error in stderr. + """ formats = list(Obj.__dataclass_fields__.keys()) cmd_args: list[str | int] = [] - if server.socket_name: cmd_args.insert(0, f"-L{server.socket_name}") if server.socket_path: cmd_args.insert(0, f"-S{server.socket_path}") - tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] - tmux_cmds = [ - *cmd_args, - list_cmd, - ] + tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] + tmux_cmds = [*cmd_args, list_cmd] if list_extra_args is not None and isinstance(list_extra_args, Iterable): tmux_cmds.extend(list(list_extra_args)) tmux_cmds.append("-F{}".format("".join(tmux_formats))) - - proc = tmux_cmd(*tmux_cmds) # output + proc = tmux_cmd(*tmux_cmds) if proc.stderr: raise exc.LibTmuxException(proc.stderr) obj_output = proc.stdout - obj_formatters = [ dict(zip(formats, formatter.split(FORMAT_SEPARATOR))) for formatter in obj_output ] - # Filter empty values + # Filter out empty values return [{k: v for k, v in formatter.items() if v} for formatter in obj_formatters] @@ -239,7 +305,31 @@ def fetch_obj( list_cmd: ListCmd = "list-panes", list_extra_args: ListExtraArgs | None = None, ) -> OutputRaw: - """Fetch raw data from tmux command.""" + """Fetch a single tmux object by key and ID. + + Parameters + ---------- + server + The :class:`Server` instance to query. + obj_key + The field name to look for (e.g., 'pane_id'). + obj_id + The specific ID to match (e.g., '%0'). + list_cmd + The tmux command to run ('list-panes', 'list-windows', etc.). + list_extra_args + Extra arguments to pass (e.g., ['-a']). + + Returns + ------- + dict + A dictionary of field-name to field-value mappings for the object. + + Raises + ------ + exc.TmuxObjectDoesNotExist + If no matching object is found in tmux's output. + """ obj_formatters_filtered = fetch_objs( server=server, list_cmd=list_cmd, @@ -250,6 +340,7 @@ def fetch_obj( for _obj in obj_formatters_filtered: if _obj.get(obj_key) == obj_id: obj = _obj + break if obj is None: raise exc.TmuxObjectDoesNotExist( @@ -259,6 +350,4 @@ def fetch_obj( list_extra_args=list_extra_args, ) - assert obj is not None - return obj diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index a60bb36f6..6858c4889 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1,8 +1,12 @@ -"""Pythonization of the :ref:`tmux(1)` pane. +"""Provide a Pythonic representation of the :ref:`tmux(1)` pane. + +The :class:`Pane` class models a single tmux pane, allowing commands to be +sent directly to it, as well as traversal to related :class:`Window` and +:class:`Session` objects. It offers convenience methods for splitting, resizing, +and interacting with the pane's contents. libtmux.pane ~~~~~~~~~~~~ - """ from __future__ import annotations @@ -50,7 +54,9 @@ class Pane(Obj): Attributes ---------- - window : :class:`Window` + server : Server + pane_id : str + For example '%1'. Examples -------- @@ -145,12 +151,9 @@ def from_pane_id(cls, server: Server, pane_id: str) -> Pane: ) return cls(server=server, **pane) - # - # Relations - # @property def window(self) -> Window: - """Parent window of pane.""" + """Return the parent :class:`Window` of this pane.""" assert isinstance(self.window_id, str) from libtmux.window import Window @@ -158,23 +161,35 @@ def window(self) -> Window: @property def session(self) -> Session: - """Parent session of pane.""" + """Return the parent :class:`Session` of this pane.""" return self.window.session - """ - Commands (pane-scoped) - """ - + # + # Commands (pane-scoped) + # def cmd( self, cmd: str, *args: t.Any, target: str | int | None = None, ) -> tmux_cmd: - """Execute tmux subcommand within pane context. + """Execute a tmux command in the context of this pane. - Automatically binds target by adding ``-t`` for object's pane ID to the - command. Pass ``target`` to keyword arguments to override. + Automatically sets ``-t `` unless overridden by `target`. + + Parameters + ---------- + cmd + The tmux subcommand to run (e.g., 'split-window'). + *args + Additional arguments for the tmux command. + target, optional + Custom target. Default is the current pane's ID. + + Returns + ------- + tmux_cmd + Result of the tmux command execution. Examples -------- @@ -183,75 +198,62 @@ def cmd( From raw output to an enriched `Pane` object: - >>> Pane.from_pane_id(pane_id=pane.cmd( - ... 'split-window', '-P', '-F#{pane_id}').stdout[0], server=pane.server) + >>> Pane.from_pane_id( + ... pane_id=pane.cmd('split-window', '-P', '-F#{pane_id}').stdout[0], + ... server=pane.server + ... ) Pane(%... Window(@... ...:..., Session($1 libtmux_...))) - - Parameters - ---------- - target : str, optional - Optional custom target override. By default, the target is the pane ID. - - Returns - ------- - :meth:`server.cmd` """ if target is None: target = self.pane_id - return self.server.cmd(cmd, *args, target=target) - """ - Commands (tmux-like) - """ - + # + # Commands (tmux-like) + # def resize( self, /, - # Adjustments adjustment_direction: ResizeAdjustmentDirection | None = None, adjustment: int | None = None, - # Manual height: str | int | None = None, width: str | int | None = None, - # Zoom zoom: bool | None = None, - # Mouse mouse: bool | None = None, - # Optional flags trim_below: bool | None = None, ) -> Pane: - """Resize tmux pane. + """Resize this tmux pane. Parameters ---------- adjustment_direction : ResizeAdjustmentDirection, optional - direction to adjust, ``Up``, ``Down``, ``Left``, ``Right``. - adjustment : ResizeAdjustmentDirection, optional - - height : int, optional - ``resize-pane -y`` dimensions - width : int, optional - ``resize-pane -x`` dimensions - - zoom : bool - expand pane - - mouse : bool - resize via mouse - - trim_below : bool - trim below cursor + Direction to adjust, ``Up``, ``Down``, ``Left``, ``Right``. + adjustment : int, optional + Number of cells to move in the specified direction. + height : int or str, optional + ``resize-pane -y`` dimension, e.g. 20 or "50%". + width : int or str, optional + ``resize-pane -x`` dimension, e.g. 80 or "25%". + zoom : bool, optional + If True, expand (zoom) the pane to occupy the entire window. + mouse : bool, optional + If True, resize via mouse (``-M``). + trim_below : bool, optional + If True, trim below cursor (``-T``). Raises ------ - :exc:`exc.LibTmuxException`, - :exc:`exc.PaneAdjustmentDirectionRequiresAdjustment`, + :exc:`exc.LibTmuxException` + If tmux reports an error. + :exc:`exc.PaneAdjustmentDirectionRequiresAdjustment` + If `adjustment_direction` is given but no `adjustment`. :exc:`exc.RequiresDigitOrPercentage` + If a provided dimension is neither a digit nor ends with "%". Returns ------- :class:`Pane` + This pane. Notes ----- @@ -271,13 +273,13 @@ def resize( f"{RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP[adjustment_direction]}", str(adjustment), ) + # Manual resizing elif height or width: - # Manual resizing if height: if isinstance(height, str): if height.endswith("%") and not has_gte_version("3.1"): raise exc.VersionTooLow - if not height.isdigit() and not height.endswith("%"): + if not (height.isdigit() or height.endswith("%")): raise exc.RequiresDigitOrPercentage tmux_args += (f"-y{height}",) @@ -285,13 +287,13 @@ def resize( if isinstance(width, str): if width.endswith("%") and not has_gte_version("3.1"): raise exc.VersionTooLow - if not width.isdigit() and not width.endswith("%"): + if not (width.isdigit() or width.endswith("%")): raise exc.RequiresDigitOrPercentage - tmux_args += (f"-x{width}",) + # Zoom / Unzoom elif zoom: - # Zoom / Unzoom tmux_args += ("-Z",) + # Mouse-based resize elif mouse: tmux_args += ("-M",) @@ -299,7 +301,6 @@ def resize( tmux_args += ("-T",) proc = self.cmd("resize-pane", *tmux_args) - if proc.stderr: raise exc.LibTmuxException(proc.stderr) @@ -311,29 +312,28 @@ def capture_pane( start: t.Literal["-"] | int | None = None, end: t.Literal["-"] | int | None = None, ) -> str | list[str]: - """Capture text from pane. + """Capture text from this pane (``tmux capture-pane -p``). - ``$ tmux capture-pane`` to pane. - ``$ tmux capture-pane -S -10`` to pane. - ``$ tmux capture-pane`-E 3` to pane. - ``$ tmux capture-pane`-S - -E -` to pane. + ``$ tmux capture-pane -S -10`` etc. Parameters ---------- - start: [str,int] - Specify the starting line number. - Zero is the first line of the visible pane. - Positive numbers are lines in the visible pane. - Negative numbers are lines in the history. - `-` is the start of the history. - Default: None - end: [str,int] - Specify the ending line number. - Zero is the first line of the visible pane. - Positive numbers are lines in the visible pane. - Negative numbers are lines in the history. - `-` is the end of the visible pane - Default: None + start : int, '-', optional + Starting line number. + end : int, '-', optional + Ending line number. + + Returns + ------- + str or list[str] + The captured pane text as a list of lines (by default). + + Examples + -------- + Basic usage: + + >>> pane.capture_pane() + [...] """ cmd = ["capture-pane", "-p"] if start is not None: @@ -349,25 +349,22 @@ def send_keys( suppress_history: bool | None = False, literal: bool | None = False, ) -> None: - r"""``$ tmux send-keys`` to the pane. + r"""Send keys (as keyboard input) to this pane. - A leading space character is added to cmd to avoid polluting the - user's history. + A leading space character can be added to `cmd` to avoid polluting + the user's shell history. Parameters ---------- cmd : str - Text or input into pane + Text or input to send. enter : bool, optional - Send enter after sending the input, default True. + If True, send Enter after the input (default). suppress_history : bool, optional - Prepend a space to command to suppress shell history, default False. - - .. versionchanged:: 0.14 - - Default changed from True to False. + If True, prepend a space to the command, preventing it from + appearing in shell history. Default is False. literal : bool, optional - Send keys literally, default True. + If True, send keys literally (``-l``). Default is False. Examples -------- @@ -410,21 +407,24 @@ def display_message( cmd: str, get_text: bool = False, ) -> str | list[str] | None: - """Display message to pane. + """Display or retrieve a message in this pane. - Displays a message in target-client status line. + Uses ``$ tmux display-message``. Parameters ---------- cmd : str - Special parameters to request from pane. + The message or format string to display. get_text : bool, optional - Returns only text without displaying a message in - target-client status line. + If True, return the text instead of displaying it. + + Returns + ------- + str, list[str], or None + The displayed text if `get_text` is True, else None. """ if get_text: return self.cmd("display-message", "-p", cmd).stdout - self.cmd("display-message", cmd) return None @@ -432,9 +432,17 @@ def kill( self, all_except: bool | None = None, ) -> None: - """Kill :class:`Pane`. + """Kill this :class:`Pane` (``tmux kill-pane``). + + Parameters + ---------- + all_except : bool, optional + If True, kill all panes except this one. - ``$ tmux kill-pane``. + Raises + ------ + exc.LibTmuxException + If tmux reports an error. Examples -------- @@ -472,27 +480,28 @@ def kill( True """ flags: tuple[str, ...] = () - if all_except: flags += ("-a",) - proc = self.cmd( - "kill-pane", - *flags, - ) - + proc = self.cmd("kill-pane", *flags) if proc.stderr: raise exc.LibTmuxException(proc.stderr) - """ - Commands ("climber"-helpers) + # + # "Climber"-helpers + # + def select(self) -> Pane: + """Select this pane (make it the active pane in its window). - These are commands that climb to the parent scope's methods with - additional scoped window info. - """ + Returns + ------- + Pane + This :class:`Pane`. - def select(self) -> Pane: - """Select pane. + Raises + ------ + exc.LibTmuxException + If tmux reports an error. Examples -------- @@ -516,22 +525,16 @@ def select(self) -> Pane: 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. + """Select this pane (deprecated). - Notes - ----- .. deprecated:: 0.30 - - Deprecated in favor of :meth:`.select()`. + Use :meth:`.select()`. """ warnings.warn( "Pane.select_pane() is deprecated in favor of Pane.select()", @@ -557,34 +560,33 @@ def split( size: str | int | None = None, environment: dict[str, str] | None = None, ) -> Pane: - """Split window and return :class:`Pane`, by default beneath current pane. + """Split this pane, returning a new :class:`Pane`. + + By default, splits beneath the current pane. Specify a direction to + split horizontally or vertically, a size, and optionally run a shell + command in the new pane. Parameters ---------- - target : optional - Optional, custom *target-pane*, used by :meth:`Window.split`. - attach : bool, optional - make new window the current window after creating it, default - True. + target : int or str, optional + Custom *target-pane*. Defaults to this pane's ID. start_directory : str, optional - specifies the working directory in which the new window is created. + Working directory for the new pane. + attach : bool, optional + If True, select the new pane immediately (default is False). direction : PaneDirection, optional - split in direction. If none is specified, assume down. - full_window_split: bool, optional - split across full window width or height, rather than active pane. - zoom: bool, optional - expand pane + Direction to split, e.g. :attr:`PaneDirection.Right`. + full_window_split : bool, optional + If True, split across the entire window height/width. + zoom : bool, optional + If True, zoom the new pane (``-Z``). shell : str, optional - execute a command on splitting the window. The pane will close - when the command exits. - - 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. - environment: dict, optional - Environmental variables for new pane. tmux 3.0+ only. Passthrough to ``-e``. + Command to run immediately in the new pane. The pane closes when + the command exits. + size : int or str, optional + Size for the new pane (cells or percentage). + environment : dict, optional + Environment variables for the new pane (tmux 3.0+). Examples -------- @@ -623,7 +625,8 @@ def split( >>> pane = session.new_window().active_pane - >>> top_pane = pane.split(direction=PaneDirection.Above, full_window_split=True) + >>> top_pane = pane.split(direction=PaneDirection.Above, + ... full_window_split=True) >>> (top_pane.at_left, top_pane.at_right, ... top_pane.at_top, top_pane.at_bottom) @@ -631,16 +634,15 @@ def split( True, False) >>> bottom_pane = pane.split( - ... direction=PaneDirection.Below, - ... full_window_split=True) + ... direction=PaneDirection.Below, + ... full_window_split=True) >>> (bottom_pane.at_left, bottom_pane.at_right, ... bottom_pane.at_top, bottom_pane.at_bottom) (True, True, False, True) """ - tmux_formats = ["#{pane_id}" + FORMAT_SEPARATOR] - + tmux_formats = [f"#{'{'}pane_id{'}'}{FORMAT_SEPARATOR}"] tmux_args: tuple[str, ...] = () if direction: @@ -654,7 +656,7 @@ def split( tmux_args += (f"-p{str(size).rstrip('%')}",) else: warnings.warn( - 'Ignored size. Use percent in tmux < 3.1, e.g. "size=50%"', + 'Ignored size. Use percent in tmux < 3.1, e.g. "50%"', stacklevel=2, ) else: @@ -662,14 +664,12 @@ def split( if full_window_split: tmux_args += ("-f",) - if zoom: tmux_args += ("-Z",) - tmux_args += ("-P", "-F{}".format("".join(tmux_formats))) # output + tmux_args += ("-P", "-F{}".format("".join(tmux_formats))) if start_directory is not None: - # 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}",) @@ -689,12 +689,9 @@ def split( tmux_args += (shell,) pane_cmd = self.cmd("split-window", *tmux_args, target=target) - - # tmux < 1.7. This is added in 1.7. if pane_cmd.stderr: if "pane too small" in pane_cmd.stderr: raise exc.LibTmuxException(pane_cmd.stderr) - raise exc.LibTmuxException( pane_cmd.stderr, self.__dict__, @@ -702,52 +699,34 @@ def split( ) pane_output = pane_cmd.stdout[0] - pane_formatters = dict(zip(["pane_id"], pane_output.split(FORMAT_SEPARATOR))) - return self.from_pane_id(server=self.server, pane_id=pane_formatters["pane_id"]) - """ - Commands (helpers) - """ - + # + # Commands (helpers) + # def set_width(self, width: int) -> Pane: - """Set pane width. - - Parameters - ---------- - width : int - pane width, in cells - """ + """Set pane width in cells.""" self.resize_pane(width=width) return self def set_height(self, height: int) -> Pane: - """Set pane height. - - Parameters - ---------- - height : int - height of pain, in cells - """ + """Set pane height in cells.""" self.resize_pane(height=height) return self def enter(self) -> Pane: - """Send carriage return to pane. - - ``$ tmux send-keys`` send Enter to the pane. - """ + """Send an Enter keypress to this pane.""" self.cmd("send-keys", "Enter") return self def clear(self) -> Pane: - """Clear pane.""" + """Clear the pane by sending 'reset' command.""" self.send_keys("reset") return self def reset(self) -> Pane: - """Reset and clear pane history.""" + """Reset the pane and clear its history.""" self.cmd("send-keys", r"-R \; clear-history") return self @@ -755,13 +734,13 @@ def reset(self) -> Pane: # Dunder # def __eq__(self, other: object) -> bool: - """Equal operator for :class:`Pane` object.""" + """Compare two panes by their ``pane_id``.""" if isinstance(other, Pane): return self.pane_id == other.pane_id return False def __repr__(self) -> str: - """Representation of :class:`Pane` object.""" + """Return a string representation of this :class:`Pane`.""" return f"{self.__class__.__name__}({self.pane_id} {self.window})" # @@ -876,27 +855,31 @@ def split_window( size: str | int | None = None, percent: int | None = None, # deprecated environment: dict[str, str] | None = None, - ) -> Pane: # New Pane, not self - """Split window at pane and return newly created :class:`Pane`. + ) -> Pane: + """Split this pane and return the newly created :class:`Pane` (deprecated). + + .. deprecated:: 0.33 + Use :meth:`.split`. Parameters ---------- + target, optional + Target for the new pane. attach : bool, optional - Attach / select pane after creation. + If True, select the new pane immediately. start_directory : str, optional - specifies the working directory in which the new pane is created. + Working directory for the new pane. vertical : bool, optional - split vertically - percent: int, optional - percentage to occupy with respect to current pane - environment: dict, optional - Environmental variables for new pane. tmux 3.0+ only. Passthrough to ``-e``. - - Notes - ----- - .. deprecated:: 0.33 - - Deprecated in favor of :meth:`.split`. + If True (default), split vertically (below). + shell : str, optional + Command to run in the new pane. Pane closes when command exits. + size : str or int, optional + Size for the new pane (cells or percentage). + percent : int, optional + If provided, is converted to a string with a trailing '%' for + older tmux. E.g. '25%'. + environment : dict[str, str], optional + Environment variables for the new pane (tmux 3.0+). """ warnings.warn( "Pane.split_window() is deprecated in favor of Pane.split()", @@ -916,13 +899,10 @@ def split_window( ) def get(self, key: str, default: t.Any | None = None) -> t.Any: - """Return key-based lookup. Deprecated by attributes. + """Return a key-based lookup (deprecated). .. deprecated:: 0.16 - - Deprecated by attribute lookup, e.g. ``pane['window_name']`` is now - accessed via ``pane.window_name``. - + Deprecated by attribute lookup, e.g. ``pane.window_name``. """ warnings.warn( "Pane.get() is deprecated", @@ -932,13 +912,10 @@ def get(self, key: str, default: t.Any | None = None) -> t.Any: return getattr(self, key, default) def __getitem__(self, key: str) -> t.Any: - """Return item lookup by key. Deprecated in favor of attributes. + """Return an item by key (deprecated). .. deprecated:: 0.16 - - Deprecated in favor of attributes. e.g. ``pane['window_name']`` is now - accessed via ``pane.window_name``. - + Deprecated in favor of attributes. e.g. ``pane.window_name``. """ warnings.warn( f"Item lookups, e.g. pane['{key}'] is deprecated", @@ -949,24 +926,18 @@ def __getitem__(self, key: str) -> t.Any: def resize_pane( self, - # Adjustments adjustment_direction: ResizeAdjustmentDirection | None = None, adjustment: int | None = None, - # Manual height: str | int | None = None, width: str | int | None = None, - # Zoom zoom: bool | None = None, - # Mouse mouse: bool | None = None, - # Optional flags trim_below: bool | None = None, ) -> Pane: - """Resize pane, deprecated by :meth:`Pane.resize`. + """Resize this pane (deprecated). .. deprecated:: 0.28 - - Deprecated by :meth:`Pane.resize`. + Use :meth:`.resize`. """ warnings.warn( "Deprecated: Use Pane.resize() instead of Pane.resize_pane()", diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index 92da4676d..4c660fb6d 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -1,4 +1,16 @@ -"""libtmux pytest plugin.""" +"""Provide a pytest plugin that supplies libtmux testing fixtures. + +This plugin integrates with pytest to offer session, window, and environment +fixtures tailored for tmux-based tests. It ensures stable test environments by +creating and tearing down temporary sessions and windows for each test as +needed. + +Notes +----- +The existing doctests embedded within each fixture are preserved to maintain +clarity and verify core behaviors. + +""" from __future__ import annotations @@ -68,7 +80,8 @@ def config_file(user_path: pathlib.Path) -> pathlib.Path: - ``base-index -g 1`` - These guarantee pane and windows targets can be reliably referenced and asserted. + These guarantee pane and windows targets can be reliably referenced + and asserted. Note: You will need to set the home directory, see :ref:`set_home`. """ @@ -86,7 +99,8 @@ def config_file(user_path: pathlib.Path) -> pathlib.Path: def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: """Clear out any unnecessary environment variables that could interrupt tests. - tmux show-environment tests were being interrupted due to a lot of crazy env vars. + tmux show-environment tests were being interrupted due to a lot of + crazy env vars. """ for k in os.environ: if not any( diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 1eaf82f66..ef443efc2 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1,12 +1,32 @@ -"""Wrapper for :term:`tmux(1)` server. +"""Wrap the :term:`tmux(1)` server. + +This module manages the top-level tmux server, allowing for the creation, +control, and inspection of sessions, windows, and panes across a single server +instance. It provides the :class:`Server` class, which acts as a gateway +to the tmux server process. libtmux.server ~~~~~~~~~~~~~~ +Examples +-------- +>>> server.is_alive() # Check if tmux server is running +True +>>> # Clean up any existing test session first +>>> if server.has_session("test_session"): +... server.kill_session("test_session") +>>> new_session = server.new_session(session_name="test_session") +>>> new_session.name +'test_session' +>>> server.has_session("test_session") +True +>>> server.kill_session("test_session") # Clean up +Server(socket_name=libtmux_test...) """ from __future__ import annotations +import contextlib import logging import os import pathlib @@ -47,24 +67,31 @@ class Server(EnvironmentMixin): - """:term:`tmux(1)` :term:`Server` [server_manual]_. + """Represent a :term:`tmux(1)` server [server_manual]_. - - :attr:`Server.sessions` [:class:`Session`, ...] + This class provides the ability to create, manage, and destroy tmux + sessions and their associated windows and panes. It is the top-level + interface to the tmux server process, allowing you to query and control + all sessions within it. - - :attr:`Session.windows` [:class:`Window`, ...] + - :attr:`Server.sessions` => list of :class:`Session` - - :attr:`Window.panes` [:class:`Pane`, ...] + - :attr:`Session.windows` => list of :class:`Window` - - :class:`Pane` + - :attr:`Window.panes` => list of :class:`Pane` - When instantiated stores information on live, running tmux server. + When instantiated, it stores information about a live, running tmux server. Parameters ---------- socket_name : str, optional - socket_path : str, optional + Equivalent to tmux's ``-L `` option. + socket_path : str or pathlib.Path, optional + Equivalent to tmux's ``-S `` option. config_file : str, optional - colors : str, optional + Equivalent to tmux's ``-f `` option. + colors : int, optional + Can be 88 or 256 to specify supported colors (via ``-2`` or ``-8``). on_init : callable, optional socket_name_factory : callable, optional @@ -102,22 +129,22 @@ class Server(EnvironmentMixin): into it. Windows may be linked to multiple sessions and are made up of one or more panes, each of which contains a pseudo terminal." - https://man.openbsd.org/tmux.1#CLIENTS_AND_SESSIONS. + https://man.openbsd.org/tmux.1#CLIENTS_AND_SESSIONS Accessed April 1st, 2018. """ socket_name = None - """Passthrough to ``[-L socket-name]``""" + """Passthrough to ``[-L socket-name]``.""" socket_path = None - """Passthrough to ``[-S socket-path]``""" + """Passthrough to ``[-S socket-path]``.""" config_file = None - """Passthrough to ``[-f file]``""" + """Passthrough to ``[-f file]``.""" colors = None - """``256`` or ``88``""" + """May be ``-2`` or ``-8`` depending on color support (256 or 88).""" child_id_attribute = "session_id" - """Unique child ID used by :class:`~libtmux.common.TmuxRelationalObject`""" + """Unique child ID used by :class:`~libtmux.common.TmuxRelationalObject`.""" formatter_prefix = "server_" - """Namespace used for :class:`~libtmux.common.TmuxMappingObject`""" + """Namespace used for :class:`~libtmux.common.TmuxMappingObject`.""" def __init__( self, @@ -129,6 +156,27 @@ def __init__( socket_name_factory: t.Callable[[], str] | None = None, **kwargs: t.Any, ) -> None: + """Initialize the Server object, optionally specifying socket and config. + + If both ``socket_path`` and ``socket_name`` are provided, ``socket_path`` + takes precedence. + + Parameters + ---------- + socket_name : str, optional + Socket name for tmux server (-L flag). + socket_path : str or pathlib.Path, optional + Socket path for tmux server (-S flag). + config_file : str, optional + Path to a tmux config file (-f flag). + colors : int, optional + If 256, pass ``-2`` to tmux; if 88, pass ``-8``. + + Other Parameters + ---------------- + **kwargs + Additional keyword arguments are ignored. + """ EnvironmentMixin.__init__(self, "-g") self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] @@ -142,6 +190,8 @@ def __init__( tmux_tmpdir = pathlib.Path(os.getenv("TMUX_TMPDIR", "/tmp")) socket_name = self.socket_name or "default" + + # If no path is given and socket_name is not the default, build a path if ( tmux_tmpdir is not None and self.socket_path is None @@ -190,8 +240,10 @@ def __exit__( self.kill() def is_alive(self) -> bool: - """Return True if tmux server alive. + """Return True if the tmux server is alive and responding. + Examples + -------- >>> tmux = Server(socket_name="no_exist") >>> assert not tmux.is_alive() """ @@ -202,7 +254,7 @@ def is_alive(self) -> bool: return res.returncode == 0 def raise_if_dead(self) -> None: - """Raise if server not connected. + """Raise an error if the tmux server is not reachable. >>> tmux = Server(socket_name="no_exist") >>> try: @@ -210,6 +262,13 @@ def raise_if_dead(self) -> None: ... except Exception as e: ... print(type(e)) + + Raises + ------ + exc.TmuxCommandNotFound + If the tmux binary is not found in PATH. + subprocess.CalledProcessError + If the tmux server is not responding properly. """ tmux_bin = shutil.which("tmux") if tmux_bin is None: @@ -225,16 +284,26 @@ def raise_if_dead(self) -> None: subprocess.check_call([tmux_bin, *cmd_args]) - # - # Command - # def cmd( self, cmd: str, *args: t.Any, target: str | int | None = None, ) -> tmux_cmd: - """Execute tmux command respective of socket name and file, return output. + """Execute a tmux command with this server's configured socket and file. + + The returned object contains information about the tmux command + execution, including stdout, stderr, and exit code. + + Parameters + ---------- + cmd + The tmux subcommand to execute (e.g., 'list-sessions'). + *args + Additional arguments for the subcommand. + target, optional + Optional target for the command (usually specifies a session, + window, or pane). Examples -------- @@ -246,43 +315,36 @@ def cmd( >>> server.cmd('new-session', '-d', '-P', '-F#{session_id}').stdout[0] '$2' - >>> session.cmd('new-window', '-P').stdout[0] - 'libtmux...:2.0' + You can then convert raw tmux output to rich objects: - Output of `tmux -L ... new-window -P -F#{window_id}` to a `Window` object: - - >>> Window.from_window_id(window_id=session.cmd( - ... 'new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) - Window(@4 3:..., Session($1 libtmux_...)) + >>> from libtmux.window import Window + >>> Window.from_window_id( + ... window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], + ... server=window.server + ... ) + Window(@3 2:..., Session($1 libtmux_...)) Create a pane from a window: - >>> window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0] - '%5' - - Output of `tmux -L ... split-window -P -F#{pane_id}` to a `Pane` object: + '%4' + Output of ``tmux -L ... split-window -P -F#{pane_id}`` to a :class:`Pane`: >>> Pane.from_pane_id(pane_id=window.cmd( ... 'split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) Pane(%... Window(@... ...:..., Session($1 libtmux_...))) - Parameters - ---------- - target : str, optional - Optional custom target. - Returns ------- - :class:`common.tmux_cmd` + tmux_cmd + Object that wraps stdout, stderr, and return code from the tmux call. Notes ----- .. versionchanged:: 0.8 - - Renamed from ``.tmux`` to ``.cmd``. + Renamed from ``.tmux`` to ``.cmd``. """ svr_args: list[str | int] = [cmd] - cmd_args: list[str | int] = [] + if self.socket_name: svr_args.insert(0, f"-L{self.socket_name}") if self.socket_path: @@ -298,21 +360,18 @@ def cmd( raise exc.UnknownColorOption cmd_args = ["-t", str(target), *args] if target is not None else [*args] - return tmux_cmd(*svr_args, *cmd_args) @property def attached_sessions(self) -> list[Session]: - """Return active :class:`Session`s. + """Return a list of currently attached sessions. + + Attached sessions are those where ``session_attached`` is not '1'. Examples -------- >>> server.attached_sessions [] - - Returns - ------- - list of :class:`Session` """ return self.sessions.filter(session_attached__noeq="1") @@ -322,19 +381,28 @@ def has_session(self, target_session: str, exact: bool = True) -> bool: Parameters ---------- target_session : str - session name - exact : bool - match the session name exactly. tmux uses fnmatch by default. - Internally prepends ``=`` to the session in ``$ tmux has-session``. - tmux 2.1 and up only. + Target session name to check + exact : bool, optional + If True, match the name exactly. Otherwise, match as a pattern. - Raises - ------ - :exc:`exc.BadSessionName` - - Returns - ------- - bool + Examples + -------- + >>> # Clean up any existing test session + >>> if server.has_session("test_session"): + ... server.kill_session("test_session") + >>> server.new_session(session_name="test_session") + Session($... test_session) + >>> server.has_session("test_session") + True + >>> server.has_session("nonexistent") + False + >>> server.has_session("test_session", exact=True) # Exact match + True + >>> # Pattern matching (using tmux's pattern matching) + >>> server.has_session("test_sess*", exact=False) # Pattern match + True + >>> server.kill_session("test_session") # Clean up + Server(socket_name=libtmux_test...) """ session_check_name(target_session) @@ -342,87 +410,119 @@ def has_session(self, target_session: str, exact: bool = True) -> bool: target_session = f"={target_session}" proc = self.cmd("has-session", target=target_session) - - return bool(not proc.returncode) + return proc.returncode == 0 def kill(self) -> None: - """Kill tmux server. + """Kill the entire tmux server. - >>> svr = Server(socket_name="testing") - >>> svr - Server(socket_name=testing) + This closes all sessions, windows, and panes associated with it. - >>> svr.new_session() + Examples + -------- + >>> # Create a new server for testing kill() + >>> test_server = Server(socket_name="testing") + >>> test_server.new_session() Session(...) - - >>> svr.is_alive() + >>> test_server.is_alive() True - - >>> svr.kill() - - >>> svr.is_alive() + >>> test_server.kill() + >>> test_server.is_alive() False """ self.cmd("kill-server") def kill_session(self, target_session: str | int) -> Server: - """Kill tmux session. + """Kill a session by name. Parameters ---------- - target_session : str, optional - target_session: str. note this accepts ``fnmatch(3)``. 'asdf' will - kill 'asdfasd'. + target_session : str or int + Name of the session or session ID to kill - Returns - ------- - :class:`Server` - - Raises - ------ - :exc:`exc.BadSessionName` + Examples + -------- + >>> # Clean up any existing session first + >>> if server.has_session("temp"): + ... server.kill_session("temp") + >>> session = server.new_session(session_name="temp") + >>> server.has_session("temp") + True + >>> server.kill_session("temp") + Server(socket_name=libtmux_test...) + >>> server.has_session("temp") + False """ proc = self.cmd("kill-session", target=target_session) - if proc.stderr: raise exc.LibTmuxException(proc.stderr) - return self def switch_client(self, target_session: str) -> None: - """Switch tmux client. + """Switch a client to a different session. Parameters ---------- - target_session : str - name of the session. fnmatch(3) works. + target_session + The name or pattern of the target session. + + Examples + -------- + >>> # Create two test sessions + >>> for name in ["session1", "session2"]: + ... if server.has_session(name): + ... server.kill_session(name) + >>> session1 = server.new_session(session_name="session1") + >>> session2 = server.new_session(session_name="session2") + >>> # Note: switch_client() requires an interactive terminal + >>> # so we can't demonstrate it in doctests + >>> # Clean up + >>> server.kill_session("session1") + Server(socket_name=libtmux_test...) + >>> server.kill_session("session2") + Server(socket_name=libtmux_test...) Raises ------ - :exc:`exc.BadSessionName` + exc.BadSessionName + If the session name is invalid. + exc.LibTmuxException + If tmux reports an error (stderr output). """ session_check_name(target_session) - proc = self.cmd("switch-client", target=target_session) - if proc.stderr: raise exc.LibTmuxException(proc.stderr) def attach_session(self, target_session: str | None = None) -> None: - """Attach tmux session. + """Attach to a specific session, making it the active client. Parameters ---------- - target_session : str - name of the session. fnmatch(3) works. + target_session : str, optional + The name or pattern of the target session. If None, attaches to + the most recently used session. + + Examples + -------- + >>> # Create a test session + >>> if server.has_session("test_attach"): + ... server.kill_session("test_attach") + >>> session = server.new_session(session_name="test_attach") + >>> # Note: attach_session() requires an interactive terminal + >>> # so we can't demonstrate it in doctests + >>> # Clean up + >>> server.kill_session("test_attach") + Server(socket_name=libtmux_test...) Raises ------ - :exc:`exc.BadSessionName` + exc.BadSessionName + If the session name is invalid. + exc.LibTmuxException + If tmux reports an error (stderr output). """ session_check_name(target_session) proc = self.cmd("attach-session", target=target_session) - if proc.stderr: raise exc.LibTmuxException(proc.stderr) @@ -440,75 +540,62 @@ def new_session( *args: t.Any, **kwargs: t.Any, ) -> Session: - """Create new session, returns new :class:`Session`. - - Uses ``-P`` flag to print session info, ``-F`` for return formatting - returns new Session object. - - ``$ tmux new-session -d`` will create the session in the background - ``$ tmux new-session -Ad`` will move to the session name if it already - exists. todo: make an option to handle this. + """Create a new session. Parameters ---------- session_name : str, optional - :: - - $ tmux new-session -s - attach : bool, optional - create session in the foreground. ``attach=False`` is equivalent - to:: - - $ tmux new-session -d - - Other Parameters - ---------------- + Name of the session kill_session : bool, optional - Kill current session if ``$ tmux has-session``. - Useful for testing workspaces. + Kill session if it exists + attach : bool, optional + Attach to session after creating it start_directory : str, optional - specifies the working directory in which the - new session is created. + Working directory for the session window_name : str, optional - :: - - $ tmux new-session -n + Name of the initial window window_command : str, optional - execute a command on starting the session. The window will close - when the command exits. NOTE: When this command exits the window - will close. This feature is useful for long-running processes - where the closing of the window upon completion is desired. - x : [int, str], optional - Force the specified width instead of the tmux default for a - detached session - y : [int, str], optional - Force the specified height instead of the tmux default for a - detached session - - Returns - ------- - :class:`Session` - - Raises - ------ - :exc:`exc.BadSessionName` + Command to run in the initial window + x : int or "-", optional + Width of new window + y : int or "-", optional + Height of new window + environment : dict, optional + Dictionary of environment variables to set Examples -------- - Sessions can be created without a session name (0.14.2+): - - >>> server.new_session() - Session($2 2) - - Creating them in succession will enumerate IDs (via tmux): - - >>> server.new_session() - Session($3 3) - - With a `session_name`: - - >>> server.new_session(session_name='my session') - Session($4 my session) + >>> # Clean up any existing sessions first + >>> for name in ["basic", "custom", "env_test"]: + ... if server.has_session(name): + ... server.kill_session(name) + >>> # Create a basic session + >>> session1 = server.new_session(session_name="basic") + >>> session1.name + 'basic' + + >>> # Create session with custom window name + >>> session2 = server.new_session( + ... session_name="custom", + ... window_name="editor" + ... ) + >>> session2.windows[0].name + 'editor' + + >>> # Create session with environment variables + >>> session3 = server.new_session( + ... session_name="env_test", + ... environment={"TEST_VAR": "test_value"} + ... ) + >>> session3.name + 'env_test' + + >>> # Clean up + >>> for name in ["basic", "custom", "env_test"]: + ... server.kill_session(name) + Server(socket_name=libtmux_test...) + Server(socket_name=libtmux_test...) + Server(socket_name=libtmux_test...) """ if session_name is not None: session_check_name(session_name) @@ -518,101 +605,127 @@ def new_session( self.cmd("kill-session", target=session_name) logger.info(f"session {session_name} exists. killed it.") else: - msg = f"Session named {session_name} exists" - raise exc.TmuxSessionExists( - msg, - ) + msg = f"Session named {session_name} exists." + raise exc.TmuxSessionExists(msg) logger.debug(f"creating session {session_name}") - env = os.environ.get("TMUX") - if env: del os.environ["TMUX"] - tmux_args: tuple[str | int, ...] = ( - "-P", - "-F#{session_id}", # output - ) - + tmux_args: list[str | int] = ["-P", "-F#{session_id}"] if session_name is not None: - tmux_args += (f"-s{session_name}",) - + tmux_args.append(f"-s{session_name}") if not attach: - tmux_args += ("-d",) - + tmux_args.append("-d") if start_directory: - tmux_args += ("-c", start_directory) - + tmux_args += ["-c", start_directory] if window_name: - tmux_args += ("-n", window_name) - + tmux_args += ["-n", window_name] if x is not None: - tmux_args += ("-x", x) - + tmux_args += ["-x", x] if y is not None: - tmux_args += ("-y", y) - + tmux_args += ["-y", y] if environment: if has_gte_version("3.2"): for k, v in environment.items(): - tmux_args += (f"-e{k}={v}",) + tmux_args.append(f"-e{k}={v}") else: logger.warning( "Environment flag ignored, tmux 3.2 or newer required.", ) - if window_command: - tmux_args += (window_command,) + tmux_args.append(window_command) proc = self.cmd("new-session", *tmux_args) - if proc.stderr: raise exc.LibTmuxException(proc.stderr) session_stdout = proc.stdout[0] - if env: os.environ["TMUX"] = env session_formatters = dict( zip(["session_id"], session_stdout.split(formats.FORMAT_SEPARATOR)), ) - return Session.from_session_id( server=self, session_id=session_formatters["session_id"], ) - # - # Relations - # @property def sessions(self) -> QueryList[Session]: - """Sessions contained in server. + """Return list of sessions. - Can be accessed via - :meth:`.sessions.get() ` and - :meth:`.sessions.filter() ` + Examples + -------- + >>> # Clean up any existing test sessions first + >>> for name in ["test1", "test2"]: + ... if server.has_session(name): + ... server.kill_session(name) + >>> # Create some test sessions + >>> session1 = server.new_session(session_name="test1") + >>> session2 = server.new_session(session_name="test2") + >>> len(server.sessions) >= 2 # May have other sessions + True + >>> sorted([s.name for s in server.sessions if s.name in ["test1", "test2"]]) + ['test1', 'test2'] + >>> # Clean up + >>> server.kill_session("test1") + Server(socket_name=libtmux_test...) + >>> server.kill_session("test2") + Server(socket_name=libtmux_test...) """ sessions: list[Session] = [] - - try: - for obj in fetch_objs( - list_cmd="list-sessions", - server=self, - ): - sessions.append(Session(server=self, **obj)) # noqa: PERF401 - except Exception: - pass - + with contextlib.suppress(Exception): + sessions.extend( + Session(server=self, **obj) + for obj in fetch_objs( + list_cmd="list-sessions", + server=self, + ) + ) return QueryList(sessions) @property def windows(self) -> QueryList[Window]: - """Windows contained in server's sessions. + """Return a :class:`QueryList` of all :class:`Window` objects in this server. - Can be accessed via + This includes windows in all sessions. + + Examples + -------- + >>> # Clean up any existing test sessions + >>> for name in ["test_windows1", "test_windows2"]: + ... if server.has_session(name): + ... server.kill_session(name) + >>> # Create sessions with windows + >>> session1 = server.new_session(session_name="test_windows1") + >>> session2 = server.new_session(session_name="test_windows2") + >>> # Create additional windows + >>> _ = session1.new_window(window_name="win1") # Create window + >>> _ = session2.new_window(window_name="win2") # Create window + >>> # Each session should have 2 windows (default + new) + >>> len([w for w in server.windows if w.session.name == "test_windows1"]) + 2 + >>> len([w for w in server.windows if w.session.name == "test_windows2"]) + 2 + >>> # Verify window names + >>> wins1 = [w for w in server.windows if w.session.name == "test_windows1"] + >>> wins2 = [w for w in server.windows if w.session.name == "test_windows2"] + >>> # Default window name can vary (bash, zsh), but win1 should be there + >>> "win1" in [w.name for w in wins1] + True + >>> # Default window name can vary, but win2 should be there + >>> "win2" in [w.name for w in wins2] + True + >>> # Clean up + >>> server.kill_session("test_windows1") + Server(socket_name=libtmux_test...) + >>> server.kill_session("test_windows2") + Server(socket_name=libtmux_test...) + + Access advanced filtering and retrieval with: :meth:`.windows.get() ` and :meth:`.windows.filter() ` """ @@ -624,14 +737,33 @@ def windows(self) -> QueryList[Window]: server=self, ) ] - return QueryList(windows) @property def panes(self) -> QueryList[Pane]: - """Panes contained in tmux server (across all windows in all sessions). + """Return a :class:`QueryList` of all :class:`Pane` objects in this server. + + This includes panes from all windows in all sessions. - Can be accessed via + Examples + -------- + >>> # Clean up any existing test session + >>> if server.has_session("test_panes"): + ... server.kill_session("test_panes") + >>> # Create a session and split some panes + >>> session = server.new_session(session_name="test_panes") + >>> window = session.attached_window + >>> # Split into two panes + >>> window.split_window() + Pane(%... Window(@... 1:..., Session($... test_panes))) + >>> # Each window starts with 1 pane, split creates another + >>> len([p for p in server.panes if p.window.session.name == "test_panes"]) + 2 + >>> # Clean up + >>> server.kill_session("test_panes") + Server(socket_name=libtmux_test...) + + Access advanced filtering and retrieval with: :meth:`.panes.get() ` and :meth:`.panes.filter() ` """ @@ -643,14 +775,10 @@ def panes(self) -> QueryList[Pane]: server=self, ) ] - return QueryList(panes) - # - # Dunder - # def __eq__(self, other: object) -> bool: - """Equal operator for :class:`Server` object.""" + """Compare two servers by their socket name/path.""" if isinstance(other, Server): return ( self.socket_name == other.socket_name @@ -659,7 +787,7 @@ def __eq__(self, other: object) -> bool: return False def __repr__(self) -> str: - """Representation of :class:`Server` object.""" + """Return a string representation of this :class:`Server`.""" if self.socket_name is not None: return ( f"{self.__class__.__name__}" @@ -671,18 +799,13 @@ def __repr__(self) -> str: f"{self.__class__.__name__}(socket_path=/tmp/tmux-{os.geteuid()}/default)" ) - # - # Legacy: Redundant stuff we want to remove - # + # Deprecated / Legacy Methods + def kill_server(self) -> None: - """Kill tmux server. + """Kill the tmux server (deprecated). - Notes - ----- .. deprecated:: 0.30 - - Deprecated in favor of :meth:`.kill()`. - + Use :meth:`.kill()`. """ warnings.warn( "Server.kill_server() is deprecated in favor of Server.kill()", @@ -692,17 +815,10 @@ def kill_server(self) -> None: self.cmd("kill-server") def _list_panes(self) -> list[PaneDict]: - """Return list of panes in :py:obj:`dict` form. - - Retrieved from ``$ tmux(1) list-panes`` stdout. - - The :py:obj:`list` is derived from ``stdout`` in - :class:`util.tmux_cmd` which wraps :py:class:`subprocess.Popen`. + """Return a list of all panes in dict form (deprecated). .. deprecated:: 0.16 - - Deprecated in favor of :attr:`.panes`. - + Use :attr:`.panes`. """ warnings.warn( "Server._list_panes() is deprecated", @@ -712,15 +828,10 @@ def _list_panes(self) -> list[PaneDict]: return [p.__dict__ for p in self.panes] def _update_panes(self) -> Server: - """Update internal pane data and return ``self`` for chainability. + """Update internal pane data (deprecated). .. deprecated:: 0.16 - - Deprecated in favor of :attr:`.panes` and returning ``self``. - - Returns - ------- - :class:`Server` + Use :attr:`.panes` instead. """ warnings.warn( "Server._update_panes() is deprecated", @@ -731,12 +842,10 @@ def _update_panes(self) -> Server: return self def get_by_id(self, session_id: str) -> Session | None: - """Return session by id. Deprecated in favor of :meth:`.sessions.get()`. + """Return a session by its ID (deprecated). .. deprecated:: 0.16 - - Deprecated by :meth:`.sessions.get()`. - + Use :meth:`.sessions.get()`. """ warnings.warn( "Server.get_by_id() is deprecated", @@ -746,12 +855,10 @@ def get_by_id(self, session_id: str) -> Session | None: return self.sessions.get(session_id=session_id, default=None) def where(self, kwargs: dict[str, t.Any]) -> list[Session]: - """Filter through sessions, return list of :class:`Session`. + """Filter sessions (deprecated). .. deprecated:: 0.16 - - Deprecated by :meth:`.session.filter()`. - + Use :meth:`.sessions.filter()`. """ warnings.warn( "Server.find_where() is deprecated", @@ -764,12 +871,10 @@ def where(self, kwargs: dict[str, t.Any]) -> list[Session]: return [] def find_where(self, kwargs: dict[str, t.Any]) -> Session | None: - """Filter through sessions, return first :class:`Session`. + """Return the first matching session (deprecated). .. deprecated:: 0.16 - - Slated to be removed in favor of :meth:`.sessions.get()`. - + Use :meth:`.sessions.get()`. """ warnings.warn( "Server.find_where() is deprecated", @@ -779,17 +884,10 @@ def find_where(self, kwargs: dict[str, t.Any]) -> Session | None: return self.sessions.get(default=None, **kwargs) def _list_windows(self) -> list[WindowDict]: - """Return list of windows in :py:obj:`dict` form. - - Retrieved from ``$ tmux(1) list-windows`` stdout. - - The :py:obj:`list` is derived from ``stdout`` in - :class:`common.tmux_cmd` which wraps :py:class:`subprocess.Popen`. + """Return a list of all windows in dict form (deprecated). .. deprecated:: 0.16 - - Slated to be removed in favor of :attr:`.windows`. - + Use :attr:`.windows`. """ warnings.warn( "Server._list_windows() is deprecated", @@ -799,12 +897,10 @@ def _list_windows(self) -> list[WindowDict]: return [w.__dict__ for w in self.windows] def _update_windows(self) -> Server: - """Update internal window data and return ``self`` for chainability. + """Update internal window data (deprecated). .. deprecated:: 0.16 - - Deprecated in favor of :attr:`.windows` and returning ``self``. - + Use :attr:`.windows`. """ warnings.warn( "Server._update_windows() is deprecated", @@ -816,12 +912,10 @@ def _update_windows(self) -> Server: @property def _sessions(self) -> list[SessionDict]: - """Property / alias to return :meth:`~._list_sessions`. + """Return session objects in dict form (deprecated). .. deprecated:: 0.16 - - Slated to be removed in favor of :attr:`.sessions`. - + Use :attr:`.sessions`. """ warnings.warn( "Server._sessions is deprecated", @@ -831,11 +925,10 @@ def _sessions(self) -> list[SessionDict]: return self._list_sessions() def _list_sessions(self) -> list[SessionDict]: - """Return list of session object dictionaries. + """Return a list of all sessions in dict form (deprecated). .. deprecated:: 0.16 - - Slated to be removed in favor of :attr:`.sessions`. + Use :attr:`.sessions`. """ warnings.warn( "Server._list_sessions() is deprecated", @@ -845,15 +938,10 @@ def _list_sessions(self) -> list[SessionDict]: return [s.__dict__ for s in self.sessions] def list_sessions(self) -> list[Session]: - """Return list of :class:`Session` from the ``tmux(1)`` session. + """Return a list of all :class:`Session` objects (deprecated). .. deprecated:: 0.16 - - Slated to be removed in favor of :attr:`.sessions`. - - Returns - ------- - list of :class:`Session` + Use :attr:`.sessions`. """ warnings.warn( "Server.list_sessions is deprecated", @@ -864,12 +952,10 @@ def list_sessions(self) -> list[Session]: @property def children(self) -> QueryList[Session]: - """Was used by TmuxRelationalObject (but that's longer used in this class). + """Return child sessions (deprecated). .. deprecated:: 0.16 - - Slated to be removed in favor of :attr:`.sessions`. - + Use :attr:`.sessions`. """ warnings.warn( "Server.children is deprecated", diff --git a/src/libtmux/session.py b/src/libtmux/session.py index d1433e014..2e3e8c861 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -1,8 +1,12 @@ -"""Pythonization of the :term:`tmux(1)` session. +"""Provide a Pythonic representation of the :term:`tmux(1)` session. + +This module implements the :class:`Session` class, representing a tmux session +capable of containing multiple windows and panes. It includes methods for +attaching, killing, renaming, or modifying the session, as well as property +accessors for session attributes. libtmux.session ~~~~~~~~~~~~~~~ - """ from __future__ import annotations @@ -49,13 +53,14 @@ @dataclasses.dataclass() class Session(Obj, EnvironmentMixin): - """:term:`tmux(1)` :term:`Session` [session_manual]_. + """Represent a :term:`tmux(1)` session [session_manual]_. Holds :class:`Window` objects. Parameters ---------- - server : :class:`Server` + server + The :class:`Server` instance that owns this session. Examples -------- @@ -85,8 +90,8 @@ class Session(Obj, EnvironmentMixin): and displays it on screen..." "A session is a single collection of pseudo terminals under the - management of tmux. Each session has one or more windows linked to - it." + management of tmux. Each session has one or more windows linked + to it." https://man.openbsd.org/tmux.1#DESCRIPTION. Accessed April 1st, 2018. """ @@ -134,7 +139,7 @@ def refresh(self) -> None: @classmethod def from_session_id(cls, server: Server, session_id: str) -> Session: - """Create Session from existing session_id.""" + """Create a :class:`Session` from an existing session_id.""" session = fetch_obj( obj_key="session_id", obj_id=session_id, @@ -143,12 +148,9 @@ def from_session_id(cls, server: Server, session_id: str) -> Session: ) return cls(server=server, **session) - # - # Relations - # @property def windows(self) -> QueryList[Window]: - """Windows contained by session. + """Return a :class:`QueryList` of :class:`Window` objects in this session. Can be accessed via :meth:`.windows.get() ` and @@ -163,12 +165,11 @@ def windows(self) -> QueryList[Window]: ) if obj.get("session_id") == self.session_id ] - return QueryList(windows) @property def panes(self) -> QueryList[Pane]: - """Panes contained by session's windows. + """Return a :class:`QueryList` of :class:`Pane` for all windows of this session. Can be accessed via :meth:`.panes.get() ` and @@ -183,111 +184,97 @@ def panes(self) -> QueryList[Pane]: ) if obj.get("session_id") == self.session_id ] - return QueryList(panes) - # - # Command - # def cmd( self, cmd: str, *args: t.Any, target: str | int | None = None, ) -> tmux_cmd: - """Execute tmux subcommand within session context. + """Execute a tmux subcommand within the context of this session. + + Automatically binds ``-t `` to the command unless + overridden by the `target` parameter. - Automatically binds target by adding ``-t`` for object's session ID to the - command. Pass ``target`` to keyword arguments to override. + Parameters + ---------- + cmd + The tmux subcommand to execute. + *args + Additional arguments for the tmux command. + target, optional + Custom target override. By default, the target is this session's ID. Examples -------- >>> session.cmd('new-window', '-P').stdout[0] 'libtmux...:....0' - From raw output to an enriched `Window` object: + From raw output to a `Window` object: - >>> Window.from_window_id(window_id=session.cmd( - ... 'new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) + >>> Window.from_window_id( + ... window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], + ... server=session.server + ... ) Window(@... ...:..., Session($1 libtmux_...)) - Parameters - ---------- - target : str, optional - Optional custom target override. By default, the target is the session ID. - Returns ------- - :meth:`server.cmd` + tmux_cmd + The result of the tmux command execution. Notes ----- .. versionchanged:: 0.34 - - Passing target by ``-t`` is ignored. Use ``target`` keyword argument instead. + Passing target by ``-t`` is ignored. Use the ``target`` parameter instead. .. versionchanged:: 0.8 - - Renamed from ``.tmux`` to ``.cmd``. + Renamed from ``.tmux`` to ``.cmd``. """ if target is None: target = self.session_id return self.server.cmd(cmd, *args, target=target) - """ - Commands (tmux-like) - """ - def set_option( self, option: str, value: str | int, global_: bool = False, ) -> Session: - """Set option ``$ tmux set-option