diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0f771d99f..9e2db16c7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.13'] + python-version: ['3.14'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Filter changed file paths to outputs uses: dorny/paths-filter@v3.0.2 @@ -36,7 +36,7 @@ jobs: - name: Install uv if: env.PUBLISH == 'true' - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 44c1a4ce6..d8c5b6b56 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,19 +10,28 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.13'] - tmux-version: ['2.6', '2.7', '2.8', '3.0a', '3.1b', '3.2a', '3.3a', '3.4', '3.5', 'master'] + python-version: ['3.14'] + tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', 'master'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} + - name: Test runtime dependencies + run: | + uv run --no-dev -p python${{ matrix.python-version }} -- python -c ' + from libtmux import common, constants, exc, formats, neo, pane, server, session, window, __version__ + server = server.Server() + print("libtmux version:", __version__) + print("libtmux Server:", server) + ' + - name: Install dependencies run: uv sync --all-extras --dev @@ -82,16 +91,19 @@ jobs: runs-on: ubuntu-latest needs: build if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + permissions: + id-token: write # Required for OIDC trusted publishing + attestations: write # Required for generating attestations strategy: matrix: - python-version: ['3.13'] + python-version: ['3.14'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 with: enable-cache: true @@ -109,6 +121,5 @@ jobs: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} - skip_existing: true + attestations: true + skip-existing: true diff --git a/.gitignore b/.gitignore index d8c8a65a4..c15b86ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ target/ # Monkeytype monkeytype.sqlite3 + +# Claude code +**/CLAUDE.local.md +**/CLAUDE.*.md +**/.claude/settings.local.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..b40402760 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.8.0 diff --git a/.python-version b/.python-version index 4eba2a62e..6324d401a 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13.0 +3.14 diff --git a/.tool-versions b/.tool-versions index 5a3a79a5a..a537f5d1f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -uv 0.6.0 -python 3.13.2 3.12.9 3.11.11 3.10.16 3.9.21 3.8.20 3.7.17 +uv 0.9.16 +python 3.14 3.13.11 3.12.12 3.11.14 3.10.19 diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 000000000..e5c31a13e --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,136 @@ +# libtmux Python Project Rules + + +- uv - Python package management and virtual environments +- ruff - Fast Python linter and formatter +- py.test - Testing framework + - pytest-watcher - Continuous test runner +- mypy - Static type checking +- doctest - Testing code examples in documentation + + + +- Use a consistent coding style throughout the project +- Format code with ruff before committing +- Run linting and type checking before finalizing changes +- Verify tests pass after each significant change + + + +- Use reStructuredText format for all docstrings in src/**/*.py files +- Keep the main description on the first line after the opening `"""` +- Use NumPy docstyle for parameter and return value documentation +- Format docstrings as follows: + ```python + """Short description of the function or class. + + Detailed description using reStructuredText format. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + + Returns + ------- + type + Description of return value + """ + ``` + + + +- Use narrative descriptions for test sections rather than inline comments +- Format doctests as follows: + ```python + """ + Examples + -------- + Create an instance: + + >>> obj = ExampleClass() + + Verify a property: + + >>> obj.property + 'expected value' + """ + ``` +- Add blank lines between test sections for improved readability +- Keep doctests simple and focused on demonstrating usage +- Move complex examples to dedicated test files at tests/examples//test_.py +- Utilize pytest fixtures via doctest_namespace for complex scenarios + + + +- Run tests with `uv run py.test` before committing changes +- Use pytest-watcher for continuous testing: `uv run ptw . --now --doctest-modules` +- Fix any test failures before proceeding with additional changes + + + +- Make atomic commits with conventional commit messages +- Start with an initial commit of functional changes +- Follow with separate commits for formatting, linting, and type checking fixes + + + +- Use the following commit message format: + ``` + Component/File(commit-type[Subcomponent/method]): Concise description + + why: Explanation of necessity or impact. + what: + - Specific technical changes made + - Focused on a single topic + + refs: #issue-number, breaking changes, or relevant links + ``` + +- Common commit types: + - **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 + +- Prefix Python package changes with: + - `py(deps):` for standard packages + - `py(deps[dev]):` for development packages + - `py(deps[extra]):` for extras/sub-packages + +- 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 fixtures from conftest.py instead of monkeypatch and MagicMock when available +- For instance, if using libtmux, use provided fixtures: server, session, window, and pane +- Document in test docstrings why standard fixtures weren't used for exceptional cases +- Use tmp_path (pathlib.Path) fixture over Python's tempfile +- Use monkeypatch fixture over unittest.mock + + + +- Prefer namespace imports over importing specific symbols +- Import modules and access attributes through the namespace: + - Use `import enum` and access `enum.Enum` instead of `from enum import Enum` + - This applies to standard library modules like pathlib, os, and similar cases +- For typing, use `import typing as t` and access via the namespace: + - Access typing elements as `t.NamedTuple`, `t.TypedDict`, etc. + - Note primitive types like unions can be done via `|` pipes + - Primitive types like list and dict can be done via `list` and `dict` directly +- Benefits of namespace imports: + - Improves code readability by making the source of symbols clear + - Reduces potential naming conflicts + - Makes import statements more maintainable + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..40b0e0ef4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,320 @@ +# AGENTS.md + +This file provides guidance to AI agents (including Claude Code, Cursor, and other LLM-powered tools) when working with code in this repository. + +## CRITICAL REQUIREMENTS + +### Test Success +- ALL tests MUST pass for code to be considered complete and working +- Never describe code as "working as expected" if there are ANY failing tests +- Even if specific feature tests pass, failing tests elsewhere indicate broken functionality +- Changes that break existing tests must be fixed before considering implementation complete +- A successful implementation must pass linting, type checking, AND all existing tests + +## Project Overview + +libtmux is a typed Python library that provides an Object-Relational Mapping (ORM) wrapper for interacting programmatically with [tmux](https://github.com/tmux/tmux), a terminal multiplexer. + +Key features: +- Manage tmux servers, sessions, windows, and panes programmatically +- Typed Python API with full type hints +- Built on tmux's target and formats system +- Powers [tmuxp](https://github.com/tmux-python/tmuxp), a tmux workspace manager +- Provides pytest fixtures for testing with tmux + +## Development Environment + +This project uses: +- Python 3.10+ +- [uv](https://github.com/astral-sh/uv) for dependency management +- [ruff](https://github.com/astral-sh/ruff) for linting and formatting +- [mypy](https://github.com/python/mypy) for type checking +- [pytest](https://docs.pytest.org/) for testing + - [pytest-watcher](https://github.com/olzhasar/pytest-watcher) for continuous testing + +## Common Commands + +### Setting Up Environment + +```bash +# Install dependencies +uv pip install --editable . +uv pip sync + +# Install with development dependencies +uv pip install --editable . -G dev +``` + +### Running Tests + +```bash +# Run all tests +make test +# or directly with pytest +uv run pytest + +# Run a single test file +uv run pytest tests/test_pane.py + +# Run a specific test +uv run pytest tests/test_pane.py::test_send_keys + +# Run tests with test watcher +make start +# or +uv run ptw . + +# Run tests with doctests +uv run ptw . --now --doctest-modules +``` + +### Linting and Type Checking + +```bash +# Run ruff for linting +make ruff +# or directly +uv run ruff check . + +# Format code with ruff +make ruff_format +# or directly +uv run ruff format . + +# Run ruff linting with auto-fixes +uv run ruff check . --fix --show-fixes + +# Run mypy for type checking +make mypy +# or directly +uv run mypy src tests + +# Watch mode for linting (using entr) +make watch_ruff +make watch_mypy +``` + +### Development Workflow + +Follow this workflow for code changes: + +1. **Format First**: `uv run ruff format .` +2. **Run Tests**: `uv run pytest` +3. **Run Linting**: `uv run ruff check . --fix --show-fixes` +4. **Check Types**: `uv run mypy` +5. **Verify Tests Again**: `uv run pytest` + +### Documentation + +```bash +# Build documentation +make build_docs + +# Start documentation server with auto-reload +make start_docs + +# Update documentation CSS/JS +make design_docs +``` + +## Code Architecture + +libtmux follows an object-oriented design that mirrors tmux's hierarchy: + +``` +Server (tmux server instance) + └─ Session (tmux session) + └─ Window (tmux window) + └─ Pane (tmux pane) +``` + +### Core Modules + +1. **Server** (`src/libtmux/server.py`) + - Represents a tmux server instance + - Manages sessions + - Executes tmux commands via `tmux()` method + - Entry point for most libtmux interactions + +2. **Session** (`src/libtmux/session.py`) + - Represents a tmux session + - Manages windows within the session + - Provides session-level operations (attach, kill, rename, etc.) + +3. **Window** (`src/libtmux/window.py`) + - Represents a tmux window + - Manages panes within the window + - Provides window-level operations (split, rename, move, etc.) + +4. **Pane** (`src/libtmux/pane.py`) + - Represents a tmux pane (terminal instance) + - Provides pane-level operations (send-keys, capture, resize, etc.) + - Core unit for command execution and output capture + +5. **Common** (`src/libtmux/common.py`) + - Base classes and shared functionality + - `TmuxRelationalObject` and `TmuxMappingObject` base classes + - Format handling and command execution + +6. **Formats** (`src/libtmux/formats.py`) + - Tmux format string constants + - Used for querying tmux state + +7. **Neo** (`src/libtmux/neo.py`) + - Modern query interface and dataclass-based objects + - Alternative to traditional ORM-style objects + +8. **pytest Plugin** (`src/libtmux/pytest_plugin.py`) + - Provides fixtures for testing with tmux + - Creates temporary tmux sessions/windows/panes + +## Testing Strategy + +libtmux uses pytest for testing with custom fixtures. The pytest plugin (`pytest_plugin.py`) defines fixtures for creating temporary tmux objects for testing. These include: + +- `server`: A tmux server instance for testing +- `session`: A tmux session for testing +- `window`: A tmux window for testing +- `pane`: A tmux pane for testing + +These fixtures handle setup and teardown automatically, creating isolated test environments. + +### Testing Guidelines + +1. **Use existing fixtures over mocks** + - Use fixtures from conftest.py instead of `monkeypatch` and `MagicMock` when available + - For libtmux, use provided fixtures: `server`, `session`, `window`, and `pane` + - Document in test docstrings why standard fixtures weren't used for exceptional cases + +2. **Preferred pytest patterns** + - Use `tmp_path` (pathlib.Path) fixture over Python's `tempfile` + - Use `monkeypatch` fixture over `unittest.mock` + +3. **Running tests continuously** + - Use pytest-watcher during development: `uv run ptw .` + - For doctests: `uv run ptw . --now --doctest-modules` + +### Example Fixture Usage + +```python +def test_window_rename(window): + """Test renaming a window.""" + # window is already a Window instance with a live tmux window + window.rename_window('new_name') + assert window.window_name == 'new_name' +``` + +## Coding Standards + +Key highlights: + +### Imports + +- **Use namespace imports for standard library modules**: `import enum` instead of `from enum import Enum` + - **Exception**: `dataclasses` module may use `from dataclasses import dataclass, field` for cleaner decorator syntax + - This rule applies to Python standard library only; third-party packages may use `from X import Y` +- **For typing**, use `import typing as t` and access via namespace: `t.NamedTuple`, etc. +- **Use `from __future__ import annotations`** at the top of all Python files + +### Docstrings + +Follow NumPy docstring style for all functions and methods: + +```python +"""Short description of the function or class. + +Detailed description using reStructuredText format. + +Parameters +---------- +param1 : type + Description of param1 +param2 : type + Description of param2 + +Returns +------- +type + Description of return value +""" +``` + +### Doctest Guidelines + +1. **Use narrative descriptions** for test sections rather than inline comments +2. **Move complex examples** to dedicated test files at `tests/examples//test_.py` +3. **Keep doctests simple and focused** on demonstrating usage +4. **Add blank lines between test sections** for improved readability + +### Git Commit Standards + +Format commit messages as: +``` +Component/File(commit-type[Subcomponent/method]): Concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +- Focused on a single topic +``` + +Common commit types: +- **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 +- **py(deps)**: Dependencies +- **py(deps[dev])**: Dev Dependencies +- **ai(rules[LLM type])**: AI Rule Updates + +Example: +``` +Pane(feat[send_keys]): Add support for literal flag + +why: Enable sending literal characters without tmux interpretation +what: +- Add literal parameter to send_keys method +- Update send_keys to pass -l flag when literal=True +- Add tests for literal key sending +``` + +## Debugging Tips + +When stuck in debugging loops: + +1. **Pause and acknowledge the loop** +2. **Minimize to MVP**: Remove all debugging cruft and experimental code +3. **Document the issue** comprehensively for a fresh approach +4. **Format for portability** (using quadruple backticks) + +## tmux-Specific Considerations + +### tmux Command Execution + +- All tmux commands go through the `cmd()` method on Server/Session/Window/Pane objects +- Commands return a `CommandResult` object with `stdout` and `stderr` +- Use tmux format strings to query object state (see `formats.py`) + +### Format Strings + +libtmux uses tmux's format system extensively: +- Defined in `src/libtmux/formats.py` +- Used to query session_id, window_id, pane_id, etc. +- Format: `#{format_name}` (e.g., `#{session_id}`, `#{window_name}`) + +### Object Refresh + +- Objects can become stale if tmux state changes externally +- Use refresh methods (e.g., `session.refresh()`) to update object state +- Alternative: use `neo.py` query interface for fresh data + +## References + +- Documentation: https://libtmux.git-pull.com/ +- API Reference: https://libtmux.git-pull.com/api.html +- Architecture: https://libtmux.git-pull.com/about.html +- tmux man page: http://man.openbsd.org/OpenBSD-current/man1/tmux.1 +- tmuxp (workspace manager): https://tmuxp.git-pull.com/ diff --git a/CHANGES b/CHANGES index ae62aaafd..c68281c03 100644 --- a/CHANGES +++ b/CHANGES @@ -3,17 +3,536 @@ For instructions on installing the development version of libtmux, refer to [development releases](https://libtmux.git-pull.com/quickstart.html#developmental-releases). -To install via [pip](https://pip.pypa.io/en/stable/), use: +[pip](https://pip.pypa.io/en/stable/): ```console $ pip install --user --upgrade --pre libtmux ``` -## libtmux 0.43.x (Yet to be released) +[pipx](https://pypa.github.io/pipx/docs/): -- _Future release notes will be placed here_ +```console +$ pipx install --suffix=@next 'libtmux' --pip-args '\--pre' --force +// Usage: libtmux@next [command] +``` + +[uv](https://docs.astral.sh/uv/): + +```console +$ uv add libtmux --prerelease allow +``` + +[uvx](https://docs.astral.sh/uv/guides/tools/): + +```console +$ uvx --from 'libtmux' --prerelease allow python +``` + +## libtmux 0.53.x (Yet to be released) + + + +## libtmux 0.52.1 (2025-12-07) + +### CI + +#### Migrate to PyPI Trusted Publisher (#615) + +PyPI publishing now uses OIDC-based Trusted Publisher instead of API tokens. +This improves security and enables package attestations for supply chain verification. + +## libtmux 0.52.0 (2025-12-07) + +#### Pane.capture_pane() enhanced (#614) + +The {meth}`~libtmux.pane.Pane.capture_pane` method now supports 5 new parameters +that expose additional tmux `capture-pane` flags: + +| Parameter | tmux Flag | Description | +|-----------|-----------|-------------| +| `escape_sequences` | `-e` | Include ANSI escape sequences (colors, attributes) | +| `escape_non_printable` | `-C` | Escape non-printable chars as octal `\xxx` | +| `join_wrapped` | `-J` | Join wrapped lines back together | +| `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | +| `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | + +**Capturing colored output:** + +```python +# Capture with ANSI escape sequences preserved +pane.send_keys('printf "\\033[31mRED\\033[0m"', enter=True) +output = pane.capture_pane(escape_sequences=True) +# Output contains: '\x1b[31mRED\x1b[0m' +``` + +**Joining wrapped lines:** + +```python +# Long lines that wrap are joined back together +output = pane.capture_pane(join_wrapped=True) +``` + +**Version compatibility:** + +The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, +a warning is issued and the flag is ignored. All other parameters work with +libtmux's minimum supported version (tmux 3.2a). + +## libtmux 0.51.0 (2025-12-06) + +### Breaking changes + +#### APIs deprecated (#611) + +Legacy API methods (deprecated in 0.16-0.33) now raise {exc}`~libtmux.exc.DeprecatedError` (hard error) instead of emitting {class}`DeprecationWarning`. + +See {doc}`migration` for full context and examples. + +| Deprecated API | Replacement | Deprecated | Raises | Note | +|----------------|-------------|------------|--------|------| +| `kill_server()` | {meth}`~libtmux.Server.kill` | 0.30.0 | 0.51.0 | Server | +| `attach_session()`, `kill_session()` | {meth}`~libtmux.Session.attach`, {meth}`~libtmux.Session.kill` | 0.30.0 | 0.51.0 | Session | +| `select_window()`, `kill_window()`, `split_window()` | {meth}`~libtmux.Window.select`, {meth}`~libtmux.Window.kill`, {meth}`~libtmux.Window.split` | 0.30.0 / 0.33.0 | 0.51.0 | Window | +| `resize_pane()`, `select_pane()`, `split_window()` | {meth}`~libtmux.Pane.resize`, {meth}`~libtmux.Pane.select`, {meth}`~libtmux.Pane.split` | 0.28.0 / 0.30.0 / 0.33.0 | 0.51.0 | Pane | +| `attached_window`, `attached_pane` | {attr}`~libtmux.Session.active_window`, {attr}`~libtmux.Session.active_pane` / {attr}`~libtmux.Window.active_pane` | 0.31.0 | 0.51.0 | Session/Window | +| `list_*()`, `_list_*()`, `_update_*()`, `children`, `where()`, `find_where()`, `get_by_id()` | {attr}`~libtmux.Server.sessions` / {attr}`~libtmux.Session.windows` / {attr}`~libtmux.Window.panes` with {meth}`~libtmux.common.QueryList.filter` / {meth}`~libtmux.common.QueryList.get` | 0.16.0 / 0.17.0 | 0.51.0 | Query/filter helpers | +| Dict-style access (`obj["key"]`, `obj.get(...)`) | Attribute access (e.g., {attr}`~libtmux.window.Window.window_name`) | 0.17.0 | 0.51.0 | All tmux objects | + +The following deprecations from 0.50.0 continue to emit {class}`DeprecationWarning` (soft deprecation): + +| Deprecated API | Replacement | Deprecated | Note | +|----------------|-------------|------------|------| +| `set_window_option()`, `show_window_option()`, `show_window_options()` | {meth}`~libtmux.window.Window.set_option`, {meth}`~libtmux.window.Window.show_option`, {meth}`~libtmux.window.Window.show_options` | 0.50.0 | Window | +| `g` parameter on options/hooks methods | `global_` on {meth}`~libtmux.options.OptionsMixin.set_option`, {meth}`~libtmux.options.OptionsMixin.show_option`, {meth}`~libtmux.options.OptionsMixin.show_options` | 0.50.0 | Options & hooks | + +## libtmux 0.50.1 (2025-12-06) + +### Documentation (#612) + +- Normalize docs headings and Sphinx module directives to fix anchor and index generation issues. +- Tweak Sphinx type-hints configuration to avoid RST indentation conflicts and suppress forward-reference warnings. +- Refresh docstrings and cross-references (pane/window APIs, environment helpers, pytest plugin) for clearer return types and stable anchors. +- Fix incorrect return type annotations for `capture_pane()` and `display_message()` methods + (changed from `str | list[str]` to `list[str]` - the methods always return a list). + +## libtmux 0.50.0 (2025-11-30) + +### Overview + +libtmux 0.50 brings a major enhancement to option and hook management. The new +{class}`~options.OptionsMixin` and {class}`~hooks.HooksMixin` classes provide a +unified, typed API for managing tmux options and hooks across all object types. + +**Highlights:** + +- **Unified Options API**: New `show_option()`, `show_options()`, `set_option()`, + and `unset_option()` methods available on Server, Session, Window, and Pane. +- **Hook Management**: Full programmatic control over tmux hooks with support for + indexed hook arrays and bulk operations. +- **SparseArray**: New internal data structure for handling tmux's sparse indexed + arrays (e.g., `command-alias[0]`, `command-alias[99]`). +- **tmux 3.2+ baseline**: Removed support for tmux versions below 3.2a, enabling + cleaner code and full hook/option feature support. + +### What's New + +### Unified Options API (#516) + +All tmux objects now share a consistent options interface through +{class}`~options.OptionsMixin`: + +```python +import libtmux + +server = libtmux.Server() +session = server.sessions[0] +window = session.windows[0] +pane = window.panes[0] + +# Get all options as a structured dict +session.show_options() +# {'activity-action': 'other', 'base-index': 0, ...} + +# Get a single option value +session.show_option('base-index') +# 0 + +# Set an option +window.set_option('automatic-rename', True) + +# Unset an option (revert to default) +window.unset_option('automatic-rename') +``` + +**New methods on Server, Session, Window, and Pane:** + +| Method | Description | +|--------|-------------| +| `show_options()` | Get all options as a structured dict | +| `show_option(name)` | Get a single option value | +| `set_option(name, value)` | Set an option | +| `unset_option(name)` | Unset/remove an option | + +**New parameters for `set_option()`:** + +| Parameter | tmux flag | Description | +|-----------|-----------|-------------| +| `_format` | `-F` | Expand format strings in value | +| `unset` | `-u` | Unset the option | +| `global_` | `-g` | Set as global option | +| `unset_panes` | `-U` | Also unset in child panes | +| `prevent_overwrite` | `-o` | Don't overwrite if exists | +| `suppress_warnings` | `-q` | Suppress warnings | +| `append` | `-a` | Append to existing value | + +### Hook Management (#516) + +New {class}`~hooks.HooksMixin` provides programmatic control over tmux hooks: + +```python +session = server.sessions[0] + +# Set a hook +session.set_hook('session-renamed', 'display-message "Renamed!"') + +# Get hook value +session.show_hook('session-renamed') +# 'display-message "Renamed!"' + +# Get all hooks +session.show_hooks() +# {'session-renamed': 'display-message "Renamed!"'} + +# Remove a hook +session.unset_hook('session-renamed') +``` + +**Indexed hooks and bulk operations:** + +tmux hooks support multiple values via indices (e.g., `session-renamed[0]`, +`session-renamed[1]`). The bulk operations API makes this easy: + +```python +# Set multiple hooks at once +session.set_hooks('session-renamed', { + 0: 'display-message "Hook 0"', + 1: 'display-message "Hook 1"', + 5: 'run-shell "echo hook 5"', +}) +``` + +**Hook methods available on Server, Session, Window, and Pane:** + +| Method | Description | +|--------|-------------| +| `set_hook(hook, value)` | Set a hook | +| `show_hook(hook)` | Get hook value (returns SparseArray for indexed hooks) | +| `show_hooks()` | Get all hooks | +| `unset_hook(hook)` | Remove a hook | +| `run_hook(hook)` | Run a hook immediately | +| `set_hooks(hook, values)` | Set multiple indexed hooks at once | + +### SparseArray for Indexed Options (#516) + +tmux uses sparse indexed arrays for options like `command-alias[0]`, +`command-alias[99]`, `terminal-features[0]`. Python lists can't represent +gaps in indices, so libtmux introduces {class}`~_internal.sparse_array.SparseArray`: + +```python +>>> from libtmux._internal.sparse_array import SparseArray + +>>> arr: SparseArray[str] = SparseArray() +>>> arr.add(0, "first") +>>> arr.add(99, "ninety-ninth") # Gap in indices preserved! +>>> arr[0] +'first' +>>> arr[99] +'ninety-ninth' +>>> list(arr.keys()) +[0, 99] +>>> list(arr.iter_values()) # Values in index order +['first', 'ninety-ninth'] +``` + +### New Constants (#516) + +- {class}`~constants.OptionScope` enum: `Server`, `Session`, `Window`, `Pane` +- `OPTION_SCOPE_FLAG_MAP`: Maps scope to tmux flags (`-s`, `-w`, `-p`) +- `HOOK_SCOPE_FLAG_MAP`: Maps scope to hook flags + +### Breaking changes + +### Deprecated Window methods (#516) + +The following methods are deprecated and will be removed in a future release: + +| Deprecated | Replacement | +|------------|-------------| +| `Window.set_window_option()` | `Window.set_option()` | +| `Window.show_window_option()` | `Window.show_option()` | +| `Window.show_window_options()` | `Window.show_options()` | + +The old methods will emit a {class}`DeprecationWarning` when called: + +```python +window.set_window_option('automatic-rename', 'on') +# DeprecationWarning: Window.set_window_option() is deprecated + +# Use the new method instead: +window.set_option('automatic-rename', True) +``` + +### tmux Version Compatibility + +| Feature | Minimum tmux | +|---------|-------------| +| All options/hooks features | 3.2+ | +| Window/Pane hook scopes (`-w`, `-p`) | 3.2+ | +| `client-active`, `window-resized` hooks | 3.3+ | +| `pane-title-changed` hook | 3.5+ | + +## libtmux 0.49.0 (2025-11-29) + +### Breaking Changes + +### tmux 1.8 to 3.1c support removed (#608) + +Support for tmux versions below 3.2a has been removed. This completes the +deprecation announced in v0.48.0. + +- Minimum tmux version is now 3.2a (`TMUX_MIN_VERSION`) +- Removed `TMUX_SOFT_MIN_VERSION` constant and deprecation warning system +- Removed version guards throughout the codebase +- For users on older tmux, use libtmux v0.48.x + +## libtmux 0.48.0 (2025-11-28) + +### Breaking Changes + +### Minimum tmux version bumped to 3.2+ (606) + +tmux versions below 3.2a are now deprecated. libtmux 0.48.0 will be the last +version to support tmux below 3.2. This is to ensure support for hooks, options, +and newer tmux features. + +A `FutureWarning` will be emitted on first use. Support for these versions will be removed in a future +release. Set `LIBTMUX_SUPPRESS_VERSION_WARNING=1` to suppress the warning. - +### Internal + +- Added `TMUX_SOFT_MIN_VERSION` constant (3.2a) for deprecation threshold (#606) + +### What's new + +### tmux 3.6 support (#607) + +Added tmux 3.6 to test grid and `TMUX_MAX_VERSION` 3.4 -> 3.6. + +## libtmux 0.47.0 (2025-11-01) + +### Breaking changes + +- Drop support for Python 3.9; the new minimum is Python 3.10 (#602). See also: + - [Python 3.9 EOL timeline](https://devguide.python.org/versions/#:~:text=Release%20manager-,3.9,-PEP%20596) + - [PEP 596](https://peps.python.org/pep-0596/) + +### Development + +- Add Python 3.14 to test matrix (#601) + +## libtmux 0.46.2 (2025-05-26) + +### Development + +- Add `StrPath` type support for `start_directory` parameters (#596, #597, #598): + - `Server.new_session`: Accept PathLike objects for session start directory + - `Session.new_window`: Accept PathLike objects for window start directory + - `Pane.split` and `Pane.split_window`: Accept PathLike objects for pane start directory + - `Window.split` and `Window.split_window`: Accept PathLike objects for pane start directory + - Enables `pathlib.Path` objects alongside strings for all start directory parameters + - Includes comprehensive tests for all parameter types (None, empty string, string paths, PathLike objects) + + Thank you @Data5tream for the initial commit in #596! + +## libtmux 0.46.1 (2025-03-16) + +_Maintenance only, no bug fixes or new features_ + +A version branch has been created at v0.46.x, the next release of v0.47.0 may +be a few months in waiting (watchers / snapshots are in development in #587). + +### Documentation + +- Typo fix for `Pane.send_keys` (#593), thank you @subbyte! + +## libtmux 0.46.0 (2025-02-25) + +### Breaking + +### Imports removed from libtmux.test (#580) + +Root-level of imports from `libtmux.test` are no longer possible. + +```python +# Before 0.46.0 +from libtmux.test import namer +``` + +```python +# From 0.46.0 onward +from libtmux.test.named import namer +``` + +Same thing with constants: + +```python +# Before 0.46.0 +from libtmux.test import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +```python +# From 0.46.0 onward +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +### Development + +### Test helpers: Increased coverage (#580) + +Several improvements to the test helper modules: + +- Enhanced `EnvironmentVarGuard` in `libtmux.test.environment` to better handle variable cleanup +- Added comprehensive test suites for test constants and environment utilities +- Improved docstrings and examples in `libtmux.test.random` with test coverage annotations +- Fixed potential issues with environment variable handling during tests +- Added proper coverage markers to exclude type checking blocks from coverage reports + +## libtmux 0.45.0 (2025-02-23) + +### Breaking Changes + +### Test helpers: Refactor + +Test helper functionality has been split into focused modules (#578): + +- `libtmux.test` module split into: + - `libtmux.test.constants`: Test-related constants (`TEST_SESSION_PREFIX`, etc.) + - `libtmux.test.environment`: Environment variable mocking + - `libtmux.test.random`: Random string generation utilities + - `libtmux.test.temporary`: Temporary session/window management + +**Breaking**: Import paths have changed. Update imports: + +```python +# Old (0.44.x and earlier) +from libtmux.test import ( + TEST_SESSION_PREFIX, + get_test_session_name, + get_test_window_name, + namer, + temp_session, + temp_window, + EnvironmentVarGuard, +) +``` + +```python +# New (0.45.0+) +from libtmux.test.constants import TEST_SESSION_PREFIX +from libtmux.test.environment import EnvironmentVarGuard +from libtmux.test.random import get_test_session_name, get_test_window_name, namer +from libtmux.test.temporary import temp_session, temp_window +``` + +### Development + +- CI: Check for runtime dependencies (#574) + + Kudos @ppentchev for inspiration on the command + ([comment](https://github.com/tmux-python/libtmux/pull/572#issuecomment-2663642923)). + +## libtmux 0.44.2 (2025-02-17) + +### Bug fix + +- Fix `typing_extensions` issue by wrapping it in `TYPE_CHECKING`, continuation of #564, via #572. + +### Development + +- Improved test organization and coverage in `test_common.py` (#570): + - Consolidated version-related tests into parametrized fixtures using NamedTuples + - Added comprehensive test cases for various version formats (master, next, OpenBSD, dev, rc) + - Improved test readability with clear test IDs and logical grouping + - Consistent use of pytest parametrize convention across test suite +- Fix broken test for `test_window_rename` (#570) + +## libtmux 0.44.1 (2025-02-17) + +### Packaging + +- Types: Only import `typing_extensions` when necessary, via #563, @ppentchev! + +## libtmux 0.44.0 (2025-02-16) + +### New Features + +### Context Managers support (#566) + +Added context manager support for all major object types: + +- `Server`: Automatically kills the server when exiting the context +- `Session`: Automatically kills the session when exiting the context +- `Window`: Automatically kills the window when exiting the context +- `Pane`: Automatically kills the pane when exiting the context + +Example usage: + +```python +with Server() as server: + with server.new_session() as session: + with session.new_window() as window: + with window.split() as pane: + pane.send_keys('echo "Hello"') + # Do work with the pane + # Everything is cleaned up automatically when exiting contexts +``` + +This makes it easier to write clean, safe code that properly cleans up tmux resources. + +## libtmux 0.43.0 (2025-02-15) + +### New Features + +### Server Initialization Callbacks + +Server now accepts 2 new optional params, `socket_name_factory` and `on_init` callbacks (#565): + +- `socket_name_factory`: Callable that generates unique socket names for new servers +- `on_init`: Callback that runs after server initialization +- Useful for creating multiple servers with unique names and tracking server instances +- Socket name factory is tried after socket_name, maintaining backward compatibility + +### New test fixture: `TestServer` + +Add `TestServer` pytest fixture for creating temporary tmux servers (#565): + +- Creates servers with unique socket names that clean up after themselves +- Useful for testing interactions between multiple tmux servers +- Includes comprehensive test coverage and documentation +- Available in doctest namespace + +### Documentation + +- Fix links to the "Topics" section +- More docs for "Traversal" Topic (#567) ## libtmux 0.42.1 (2024-02-15) @@ -55,7 +574,7 @@ $ pip install --user --upgrade --pre libtmux ### Development -#### chore: Implement PEP 563 deferred annotation resolution (#555) +### chore: Implement PEP 563 deferred annotation resolution (#555) - Add `from __future__ import annotations` to defer annotation resolution and reduce unnecessary runtime computations during type checking. - Enable Ruff checks for PEP-compliant annotations: @@ -88,6 +607,7 @@ _Maintenance only, no bug fixes or new features_ ```sh ruff check --select ALL . --fix --unsafe-fixes --preview --show-fixes; ruff format . ``` + - Tests: Stability fixes for legacy `test_select_pane` test (#552) ## libtmux 0.39.0 (2024-11-26) @@ -110,13 +630,13 @@ _Maintenance only, no bug fixes or new features_ ### Breaking changes -#### Project and package management: poetry to uv (#547) +### Project and package management: poetry to uv (#547) [uv] is the new package and project manager for the project, replacing Poetry. [uv]: https://github.com/astral-sh/uv -#### Build system: poetry to hatchling (#547) +### Build system: poetry to hatchling (#547) [Build system] moved from [poetry] to [hatchling]. @@ -130,8 +650,6 @@ _Maintenance only, no bug fixes or new features_ via [ruff 0.4.2](https://github.com/astral-sh/ruff/blob/v0.4.2/CHANGELOG.md). -[uv]: https://github.com/astral-sh/uv - ### Documentation - Fix docstrings in `query_list` for `MultipleObjectsReturned` and @@ -220,7 +738,7 @@ _Maintenance only, no bug fixes or new features_ ### Breaking changes -#### Command target change (#535) +### Command target change (#535) Commands: All `cmd()` methods using custom or overridden targets must use the keyword argument `target`. This avoids entanglement with inner shell values that include `-t` for @@ -235,7 +753,7 @@ other purposes. These methods include: ### Breaking changes -#### Improved new sessions (#532) +### Improved new sessions (#532) - `Session.new_window()`: @@ -247,7 +765,7 @@ other purposes. These methods include: [PEP 3102]: https://www.python.org/dev/peps/pep-3102/ -#### Improved window splitting (#532) +### Improved window splitting (#532) - `Window.split_window()` to {meth}`Window.split()` @@ -262,7 +780,7 @@ other purposes. These methods include: - Learned `zoom` -#### Tweak: Pane position (#532) +### Tweak: Pane position (#532) It's now possible to retrieve the position of a pane in a window via a `bool` helper:: @@ -372,11 +890,11 @@ _Maintenance only, no bug fixes or new features_ ## libtmux 0.29.0 (2024-02-16) -#### Fixes +### Fixes - Use {exc}`DeprecationWarning` for APIs set to be deprecated (#526) -#### Testing +### Testing - pytest: Ignore {exc}`DeprecationWarning` by default (#526) @@ -384,11 +902,11 @@ _Maintenance only, no bug fixes or new features_ _Maintenance only, no bug fixes or new features_ -#### Testing +### Testing - CI: Bump actions to node 20+ versions -#### Documentation +### Documentation - Refine docs and add migration for v0.28.0 @@ -396,7 +914,7 @@ _Maintenance only, no bug fixes or new features_ ### Breaking changes -#### Detached / unselected by default (#523) +### Detached / unselected by default (#523) To ensure consistency and principle of least surprise, keep these set to not use `-a` unless explicitly specified. @@ -801,7 +1319,7 @@ _Maintenance only, no bug fixes or new features_ ### New features -#### Detect if server active (#448) +### Detect if server active (#448) - `Server.is_alive()` - `Server.raise_if_dead()` @@ -1144,10 +1662,8 @@ _Maintenance only, no bug fixes or new features_ - Python 3.7 and 3.8 returns in 0.12.0 - ~~Final python 3.7 and 3.8 release~~ - - ~~Fixes and security updates will go to - [`v0.11.x`](https://github.com/tmux-python/libtmux/tree/v0.11.x)~~ + *Note: This was not the final Python 3.7 and 3.8 release as originally stated. + Python 3.7 and 3.8 support was extended in 0.12.0.* - Internal: Use new separator to split `tmux(1)` formatting information (#289, #343) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/MIGRATION b/MIGRATION index 2c88341b9..123b0cdcf 100644 --- a/MIGRATION +++ b/MIGRATION @@ -19,12 +19,258 @@ well. [tracker]: https://github.com/tmux-python/libtmux/discussions ``` +## Complete Deprecation Reference + +This table provides a quick reference for all deprecated APIs. See version-specific +sections below for detailed migration examples and code samples. + +### Method Renamings + +| Class | Deprecated | Replacement | Since | Raises | +|-------|------------|-------------|-------|--------| +| Server | `kill_server()` | `kill()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Session | `attach_session()` | `attach()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Session | `kill_session()` | `kill()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Window | `select_window()` | `select()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Window | `kill_window()` | `kill()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Window | `split_window()` | `split()` | 0.33.0 (2024-03-17) | 0.51.0 | +| Window | `set_window_option()` | `set_option()` | 0.50.0 (2025-11-30) | _(warning)_ | +| Window | `show_window_option()` | `show_option()` | 0.50.0 (2025-11-30) | _(warning)_ | +| Window | `show_window_options()` | `show_options()` | 0.50.0 (2025-11-30) | _(warning)_ | +| Pane | `select_pane()` | `select()` | 0.30.0 (2024-02-16) | 0.51.0 | +| Pane | `resize_pane()` | `resize()` | 0.28.0 (2024-02-14) | 0.51.0 | +| Pane | `split_window()` | `split()` | 0.33.0 (2024-03-17) | 0.51.0 | + +### Property Renamings + +| Class | Deprecated | Replacement | Since | Raises | +|-------|------------|-------------|-------|--------| +| Session | `attached_window` | `active_window` | 0.31.0 (2024-02-17) | 0.51.0 | +| Session | `attached_pane` | `active_pane` | 0.31.0 (2024-02-17) | 0.51.0 | +| Window | `attached_pane` | `active_pane` | 0.31.0 (2024-02-17) | 0.51.0 | + +### Parameter Changes + +| Method(s) | Deprecated | Replacement | Since | Raises | +|-----------|------------|-------------|-------|--------| +| Options/hooks methods | `g` | `global_` | 0.50.0 (2025-11-30) | _(warning)_ | +| `split_window()` / `split()` | `percent` | `size` | 0.28.0 (2024-02-14) | 0.51.0 | +| `split_window()` / `split()` | `vertical`/`horizontal` | `direction` (PaneDirection) | 0.33.0 (2024-03-17) | 0.51.0 | +| `resize_pane()` | `-U`, `-D`, `-L`, `-R` | `adjustment_direction` | 0.28.0 (2024-02-14) | 0.51.0 | +| `Server.get_by_id()` | `id` | `session_id` | 0.16.0 (2022-12-10) | 0.51.0 | +| `Session.get_by_id()` | `id` | `window_id` | 0.16.0 (2022-12-10) | 0.51.0 | +| `Window.get_by_id()` | `id` | `pane_id` | 0.16.0 (2022-12-10) | 0.51.0 | + +### Query/Filter API Changes + +| Class | Deprecated | Replacement | Since | Raises | +|-------|------------|-------------|-------|--------| +| Server | `list_sessions()` / `_list_sessions()` | `sessions` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `where({...})` | `sessions.filter(**kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `find_where({...})` | `sessions.get(default=None, **kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `_list_panes()` / `_update_panes()` | `panes` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `_list_windows()` / `_update_windows()` | `windows` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Server | `get_by_id(id)` | `sessions.get(session_id=..., default=None)` | 0.16.0 (2022-12-10) | 0.51.0 | +| Session | `list_windows()` / `_list_windows()` | `windows` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Session | `where({...})` | `windows.filter(**kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Session | `find_where({...})` | `windows.get(default=None, **kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Session | `get_by_id(id)` | `windows.get(window_id=..., default=None)` | 0.16.0 (2022-12-10) | 0.51.0 | +| Window | `list_panes()` / `_list_panes()` | `panes` property | 0.17.0 (2022-12-26) | 0.51.0 | +| Window | `where({...})` | `panes.filter(**kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Window | `find_where({...})` | `panes.get(default=None, **kwargs)` | 0.17.0 (2022-12-26) | 0.51.0 | +| Window | `get_by_id(id)` | `panes.get(pane_id=..., default=None)` | 0.16.0 (2022-12-10) | 0.51.0 | +| All | `children` property | `sessions`/`windows`/`panes` | 0.17.0 (2022-12-26) | 0.51.0 | + +### Attribute Access Changes + +| Pattern | Deprecated | Replacement | Since | Raises | +|---------|------------|-------------|-------|--------| +| Dict key access | `obj['key']` | `obj.key` | 0.17.0 (2022-12-26) | 0.51.0 | +| Dict get | `obj.get('key')` | `obj.key` | 0.17.0 (2022-12-26) | 0.51.0 | +| Dict get w/ default | `obj.get('key', None)` | `getattr(obj, 'key', None)` | 0.17.0 (2022-12-26) | 0.51.0 | + +### Removed Items + +| Item | Removed In | Migration | +|------|------------|-----------| +| tmux < 3.2a support | 0.49.0 (2025-11-29) | Upgrade tmux or use libtmux 0.48.x | +| `console_to_str()` | 0.42.0 (2025-02-02) | Use `text=True` in subprocess | +| `str_from_console()` | 0.42.0 (2025-02-02) | Use `text=True` in subprocess | +| `common.which()` | 0.12.0 (2022-07-13) | Use `shutil.which()` | + +### Default Behavior Changes + +| Method | Old Default | New Default | Since | +|--------|-------------|-------------|-------| +| `Session.new_window()` | `attach=True` | `attach=False` | 0.28.0 (2024-02-14) | +| `Window.split_window()` | `attach=True` | `attach=False` | 0.28.0 (2024-02-14) | + +--- + ## Upcoming Release _Detailed migration steps for the next version will be posted here._ +## libtmux 0.50.0: Unified Options and Hooks API (#516) + +### New unified options API + +All tmux objects (Server, Session, Window, Pane) now share a consistent options +interface through {class}`~libtmux.options.OptionsMixin`: + +```python +# Get all options +session.show_options() + +# Get a single option +session.show_option('base-index') + +# Set an option +window.set_option('automatic-rename', True) + +# Unset an option +window.unset_option('automatic-rename') +``` + +### New hooks API + +All tmux objects now support hook management through +{class}`~libtmux.hooks.HooksMixin`: + +```python +# Set a hook +session.set_hook('session-renamed', 'display-message "Renamed!"') + +# Get hook value +session.show_hook('session-renamed') + +# Get all hooks +session.show_hooks() + +# Remove a hook +session.unset_hook('session-renamed') +``` + +### Deprecated Window methods + +The following `Window` methods are deprecated and will be removed in a future +release: + +| Deprecated | Replacement | +|------------|-------------| +| `Window.set_window_option()` | {meth}`Window.set_option() ` | +| `Window.show_window_option()` | {meth}`Window.show_option() ` | +| `Window.show_window_options()` | {meth}`Window.show_options() ` | + +**Before (deprecated):** + +```python +window.set_window_option('automatic-rename', 'on') +window.show_window_option('automatic-rename') +window.show_window_options() +``` + +**After (0.50.0+):** + +```python +window.set_option('automatic-rename', True) +window.show_option('automatic-rename') +window.show_options() +``` + +### Deprecated `g` parameter + +The `g` parameter for global options is deprecated in favor of `global_`: + +**Before (deprecated):** + +```python +session.show_option('status', g=True) +session.set_option('status', 'off', g=True) +``` + +**After (0.50.0+):** + +```python +session.show_option('status', global_=True) +session.set_option('status', 'off', global_=True) +``` + +Using the old `g` parameter will emit a {class}`DeprecationWarning`. + +## libtmux 0.46.0 (2025-02-25) + +### Imports removed from libtmux.test (#580) + +Root-level of imports from `libtmux.test` are no longer possible. + +```python +# Before 0.46.0 +from libtmux.test import namer +``` + +```python +# From 0.46.0 onward +from libtmux.test.named import namer +``` + +Same thing with constants: + +```python +# Before 0.46.0 +from libtmux.test import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +```python +# From 0.46.0 onward +from libtmux.test.constants import ( + RETRY_INTERVAL_SECONDS, + RETRY_TIMEOUT_SECONDS, + TEST_SESSION_PREFIX +) +``` + +## libtmux 0.45.0 (2025-02-23) + +### Test helpers: Module moves + +Test helper functionality has been split into focused modules (#578): + +- `libtmux.test` module split into: + - `libtmux.test.constants`: Test-related constants (`TEST_SESSION_PREFIX`, etc.) + - `libtmux.test.environment`: Environment variable mocking + - `libtmux.test.random`: Random string generation utilities + - `libtmux.test.temporary`: Temporary session/window management + +**Breaking**: Import paths have changed. Update imports: + +```python +# Old (0.44.x and earlier) +from libtmux.test import ( + TEST_SESSION_PREFIX, + get_test_session_name, + get_test_window_name, + namer, + temp_session, + temp_window, + EnvironmentVarGuard, +) +``` + +```python +# New (0.45.0+) +from libtmux.test.constants import TEST_SESSION_PREFIX +from libtmux.test.environment import EnvironmentVarGuard +from libtmux.test.random import get_test_session_name, get_test_window_name, namer +from libtmux.test.temporary import temp_session, temp_window +``` + ## 0.35.0: Commands require explicit targets (2024-03-17) ### Commands require explicit targets (#535) @@ -64,17 +310,15 @@ _Detailed migration steps for the next version will be posted here._ ### Renamings (#527) - `Session.attached_window` renamed to {meth}`Session.active_window` - - `Session.attached_window` deprecated + - `Session.attached_window` deprecated - `Session.attached_pane` renamed to {meth}`Session.active_pane` - - `Session.attached_pane` deprecated + - `Session.attached_pane` deprecated - `Window.attached_pane` renamed to {meth}`Window.active_pane` - - `Window.attached_pane` deprecated - - + - `Window.attached_pane` deprecated ## 0.28.0: Resizing and detached by default (2024-02-15) -#### Detach by default +### Detach by default - {meth}`Session.new_window()` + {meth}`Window.split_window()` no longer attaches by default (#523) @@ -83,11 +327,12 @@ _Detailed migration steps for the next version will be posted here._ For the old behavior in 0.28.0 and beyond, pass `attach=True` explicitly. -#### Resizing panes +### Resizing panes - `Pane.resize_pane()` renamed to {meth}`Pane.resize()` (via #523) This convention will be more consistent with {meth}`Window.resize()`. + - {meth}`Pane.resize_pane()`'s params changed (#523) - No longer accepts `-U`, `-D`, `-L`, `-R` directly, instead accepts @@ -129,9 +374,11 @@ _Detailed migration steps for the next version will be posted here._ - 0.16 and below: `window['id']` 0.17 and after: `window.id` + - 0.16 and below: `window.get('id')` 0.17 and after: `window.id` + - 0.16 and below: `window.get('id', None)` 0.17 and after: `getattr(window, 'id', None)` diff --git a/README.md b/README.md index 92f81ab7e..1d3dc7770 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,99 @@ -# libtmux +
+

⚙️ libtmux

+

Drive tmux from Python: typed, object-oriented control over servers, sessions, windows, and panes.

+

+ libtmux logo +

+

+ PyPI version + Docs status + Tests status + Coverage + License +

+
-`libtmux` is a [typed](https://docs.python.org/3/library/typing.html) Python library that provides a wrapper for interacting programmatically with tmux, a terminal multiplexer. You can use it to manage tmux servers, -sessions, windows, and panes. Additionally, `libtmux` powers [tmuxp], a tmux workspace manager. +## 🐍 What is libtmux? -[![Python Package](https://img.shields.io/pypi/v/libtmux.svg)](https://pypi.org/project/libtmux/) -[![Docs](https://github.com/tmux-python/libtmux/workflows/docs/badge.svg)](https://libtmux.git-pull.com/) -[![Build Status](https://github.com/tmux-python/libtmux/workflows/tests/badge.svg)](https://github.com/tmux-python/tmux-python/actions?query=workflow%3A%22tests%22) -[![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) +libtmux is a typed Python API over [tmux], the terminal multiplexer. Stop shelling out and parsing `tmux ls`. Instead, interact with real Python objects: `Server`, `Session`, `Window`, and `Pane`. The same API powers [tmuxp], so it stays battle-tested in real-world workflows. -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 -create an object mapping to traverse, inspect and interact with live -tmux sessions. +### ✨ Features -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). +- Typed, object-oriented control of tmux state +- Query and [traverse](https://libtmux.git-pull.com/topics/traversal.html) live sessions, windows, and panes +- Raw escape hatch via `.cmd(...)` on any object +- Works with multiple tmux sockets and servers +- [Context managers](https://libtmux.git-pull.com/topics/context_managers.html) for automatic cleanup +- [pytest plugin](https://libtmux.git-pull.com/pytest-plugin/index.html) for isolated tmux fixtures +- Proven in production via tmuxp and other tooling -# Install +## Requirements & support -```console -$ pip install --user libtmux +- tmux: >= 3.2a +- Python: >= 3.10 (CPython and PyPy) + +Maintenance-only backports (no new fixes): + +- Python 2.x: [`v0.8.x`](https://github.com/tmux-python/libtmux/tree/v0.8.x) +- tmux 1.8-3.1c: [`v0.48.x`](https://github.com/tmux-python/libtmux/tree/v0.48.x) + +## 📦 Installation + +Stable release: + +```bash +pip install libtmux ``` -# Open a tmux session +With pipx: -Session name `foo`, window name `bar` +```bash +pipx install libtmux +``` -```console -$ tmux new-session -s foo -n bar +With uv / uvx: + +```bash +uv add libtmux +uvx --from "libtmux" python ``` -# Pilot your tmux session via python +From the main branch (bleeding edge): -```console -$ python +```bash +pip install 'git+https://github.com/tmux-python/libtmux.git' +``` + +Tip: libtmux is pre-1.0. Pin a range in projects to avoid surprises: + +requirements.txt: + +```ini +libtmux==0.50.* +``` + +pyproject.toml: + +```toml +libtmux = "0.50.*" ``` -Use [ptpython], [ipython], etc. for a nice shell with autocompletions: +## 🚀 Quickstart + +### Open a tmux session + +First, start a tmux session to connect to: ```console -$ pip install --user ptpython +$ tmux new-session -s foo -n bar ``` +### Pilot your tmux session via Python + +Use [ptpython], [ipython], etc. for a nice REPL with autocompletions: + ```console +$ pip install --user ptpython $ ptpython ``` @@ -58,17 +106,16 @@ Connect to a live tmux session: Server(socket_path=/tmp/tmux-.../default) ``` -Tip: You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your -current tmux server / session / window pane. +**Tip:** You can also use [tmuxp]'s [`tmuxp shell`] to drop straight into your +current tmux server / session / window / pane. -[tmuxp]: https://tmuxp.git-pull.com/ -[`tmuxp shell`]: https://tmuxp.git-pull.com/cli/shell.html [ptpython]: https://github.com/prompt-toolkit/ptpython [ipython]: https://ipython.org/ +[`tmuxp shell`]: https://tmuxp.git-pull.com/cli/shell.html -Run any tmux command, respective of context: +### Run any tmux command -Honors tmux socket name and path: +Every object has a `.cmd()` escape hatch that honors socket name and path: ```python >>> server = Server(socket_name='libtmux_doctest') @@ -76,203 +123,201 @@ Honors tmux socket name and path: ``` -New session: +Create a new session: ```python >>> server.cmd('new-session', '-d', '-P', '-F#{session_id}').stdout[0] -'$2' -``` - -```python ->>> session.cmd('new-window', '-P').stdout[0] -'libtmux...:2.0' -``` - -From raw command output, to a rich `Window` object (in practice and as shown -later, you'd use `Session.new_window()`): - -```python ->>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) -Window(@2 2:..., Session($1 libtmux_...)) +'$...' ``` -Create a pane from a window: +### List and filter sessions -```python ->>> window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0] -'%2' -``` - -Raw output directly to a `Pane`: - -```python ->>> Pane.from_pane_id(pane_id=window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) -Pane(%... Window(@1 1:..., Session($1 libtmux_...))) -``` - -List sessions: +[**Learn more about Filtering**](https://libtmux.git-pull.com/topics/filtering.html) ```python >>> server.sessions -[Session($1 ...), Session($0 ...)] +[Session($... ...), ...] ``` -Filter sessions by attribute: +Filter by attribute: ```python >>> server.sessions.filter(history_limit='2000') -[Session($1 ...), Session($0 ...)] +[Session($... ...), ...] ``` Direct lookup: ```python ->>> server.sessions.get(session_id="$1") -Session($1 ...) +>>> server.sessions.get(session_id=session.session_id) +Session($... ...) ``` -Filter sessions: +### Control sessions and windows -```python ->>> server.sessions[0].rename_session('foo') -Session($1 foo) ->>> server.sessions.filter(session_name="foo") -[Session($1 foo)] ->>> server.sessions.get(session_name="foo") -Session($1 foo) -``` - -Control your session: +[**Learn more about Workspace Setup**](https://libtmux.git-pull.com/topics/workspace_setup.html) ```python ->>> session -Session($1 ...) - >>> session.rename_session('my-session') -Session($1 my-session) +Session($... my-session) ``` Create new window in the background (don't switch to it): ```python ->>> bg_window = session.new_window(attach=False, window_name="ha in the bg") +>>> bg_window = session.new_window(attach=False, window_name="bg-work") >>> bg_window -Window(@... 2:ha in the bg, Session($1 ...)) +Window(@... ...:bg-work, Session($... ...)) -# Session can search the window ->>> session.windows.filter(window_name__startswith="ha") -[Window(@... 2:ha in the bg, Session($1 ...))] +>>> session.windows.filter(window_name__startswith="bg") +[Window(@... ...:bg-work, Session($... ...))] -# Directly ->>> session.windows.get(window_name__startswith="ha") -Window(@... 2:ha in the bg, Session($1 ...)) +>>> session.windows.get(window_name__startswith="bg") +Window(@... ...:bg-work, Session($... ...)) -# Clean up >>> bg_window.kill() ``` -Close window: - -```python ->>> w = session.active_window ->>> w.kill() -``` - -Grab remaining tmux window: - -```python ->>> window = session.active_window ->>> window.split(attach=False) -Pane(%2 Window(@1 1:... Session($1 ...))) -``` - -Rename window: - -```python ->>> window.rename_window('libtmuxower') -Window(@1 1:libtmuxower, Session($1 ...)) -``` +### Split windows and send keys -Split window (create a new pane): +[**Learn more about Pane Interaction**](https://libtmux.git-pull.com/topics/pane_interaction.html) ```python ->>> pane = window.split() ->>> pane = window.split(attach=False) ->>> pane.select() -Pane(%3 Window(@1 1:..., Session($1 ...))) ->>> window = session.new_window(attach=False, window_name="test") ->>> window -Window(@2 2:test, Session($1 ...)) >>> pane = window.split(attach=False) >>> pane -Pane(%5 Window(@2 2:test, Session($1 ...))) +Pane(%... Window(@... ...:..., Session($... ...))) ``` -Type inside the pane (send key strokes): +Type inside the pane (send keystrokes): ```python ->>> pane.send_keys('echo hey send now') - +>>> pane.send_keys('echo hello') >>> pane.send_keys('echo hey', enter=False) >>> pane.enter() -Pane(%1 ...) +Pane(%... ...) ``` -Grab the output of pane: +### Capture pane output ```python ->>> pane.clear() # clear the pane -Pane(%1 ...) ->>> pane.send_keys("cowsay 'hello'", enter=True) ->>> print('\n'.join(pane.cmd('capture-pane', '-p').stdout)) # doctest: +SKIP -$ cowsay 'hello' - _______ -< hello > - ------- - \ ^__^ - \ (oo)\_______ - (__)\ )\/\ - ||----w | - || || -... +>>> pane.clear() +Pane(%... ...) +>>> pane.send_keys("echo 'hello world'", enter=True) +>>> pane.cmd('capture-pane', '-p').stdout # doctest: +SKIP +["$ echo 'hello world'", 'hello world', '$'] ``` -Traverse and navigate: +### Traverse the hierarchy + +[**Learn more about Traversal**](https://libtmux.git-pull.com/topics/traversal.html) + +Navigate from pane up to window to session: ```python >>> pane.window -Window(@1 1:..., Session($1 ...)) +Window(@... ...:..., Session($... ...)) >>> pane.window.session -Session($1 ...) +Session($... ...) ``` -# Python support +## Core concepts -Unsupported / no security releases or bug fixes: +| libtmux object | tmux concept | Notes | +|----------------|-----------------------------|--------------------------------| +| [`Server`](https://libtmux.git-pull.com/api/servers.html) | tmux server / socket | Entry point; owns sessions | +| [`Session`](https://libtmux.git-pull.com/api/sessions.html) | tmux session (`$0`, `$1`,...) | Owns windows | +| [`Window`](https://libtmux.git-pull.com/api/windows.html) | tmux window (`@1`, `@2`,...) | Owns panes | +| [`Pane`](https://libtmux.git-pull.com/api/panes.html) | tmux pane (`%1`, `%2`,...) | Where commands run | -- Python 2.x: The backports branch is - [`v0.8.x`](https://github.com/tmux-python/libtmux/tree/v0.8.x). +Also available: [`Options`](https://libtmux.git-pull.com/api/options.html) and [`Hooks`](https://libtmux.git-pull.com/api/hooks.html) abstractions for tmux configuration. -# Donations +Collections are live and queryable: -Your donations fund development of new features, testing and support. -Your money will go directly to maintenance and development of the -project. If you are an individual, feel free to give whatever feels -right for the value you get out of the project. +```python +server = libtmux.Server() +session = server.sessions.get(session_name="demo") +api_windows = session.windows.filter(window_name__startswith="api") +pane = session.active_window.active_pane +pane.send_keys("echo 'hello from libtmux'", enter=True) +``` + +## tmux vs libtmux vs tmuxp + +| Tool | Layer | Typical use case | +|---------|----------------------------|----------------------------------------------------| +| tmux | CLI / terminal multiplexer | Everyday terminal usage, manual control | +| libtmux | Python API over tmux | Programmatic control, automation, testing | +| tmuxp | App on top of libtmux | Declarative tmux workspaces from YAML / TOML | -See donation options at . +## Testing & fixtures -# Project details +[**Learn more about the pytest plugin**](https://libtmux.git-pull.com/pytest-plugin/index.html) + +Writing a tool that interacts with tmux? Use our fixtures to keep your tests clean and isolated. + +```python +def test_my_tmux_tool(session): + # session is a real tmux session in an isolated server + window = session.new_window(window_name="test") + pane = window.active_pane + pane.send_keys("echo 'hello from test'", enter=True) + + assert window.window_name == "test" + # Fixtures handle cleanup automatically +``` -- tmux support: 1.8+ -- python support: >= 3.9, pypy, pypy3 -- Source: -- Docs: -- API: -- Changelog: -- Issues: -- Test Coverage: -- pypi: -- Open Hub: -- Repology: -- License: [MIT](http://opensource.org/licenses/MIT). +- Fresh tmux server/session/window/pane fixtures per test +- Temporary HOME and tmux config fixtures keep indices stable +- `TestServer` helper spins up multiple isolated tmux servers + +## When you might not need libtmux + +- Layouts are static and live entirely in tmux config files +- You do not need to introspect or control running tmux from other tools +- Python is unavailable where tmux is running + +## Project links + +**Topics:** +[Traversal](https://libtmux.git-pull.com/topics/traversal.html) · +[Filtering](https://libtmux.git-pull.com/topics/filtering.html) · +[Pane Interaction](https://libtmux.git-pull.com/topics/pane_interaction.html) · +[Workspace Setup](https://libtmux.git-pull.com/topics/workspace_setup.html) · +[Automation Patterns](https://libtmux.git-pull.com/topics/automation_patterns.html) · +[Context Managers](https://libtmux.git-pull.com/topics/context_managers.html) · +[Options & Hooks](https://libtmux.git-pull.com/topics/options_and_hooks.html) + +**Reference:** +[Docs][docs] · +[API][api] · +[pytest plugin](https://libtmux.git-pull.com/pytest-plugin/index.html) · +[Architecture][architecture] · +[Changelog][history] · +[Migration][migration] + +**Project:** +[Issues][issues] · +[Coverage][coverage] · +[Releases][releases] · +[License][license] · +[Support][support] + +**[The Tao of tmux][tao]** — deep-dive book on tmux fundamentals + +## Contributing & support + +Contributions are welcome. Please open an issue or PR if you find a bug or want to improve the API or docs. If libtmux helps you ship, consider sponsoring development via [support]. + +[docs]: https://libtmux.git-pull.com +[api]: https://libtmux.git-pull.com/api.html +[architecture]: https://libtmux.git-pull.com/about.html +[history]: https://libtmux.git-pull.com/history.html +[migration]: https://libtmux.git-pull.com/migration.html +[issues]: https://github.com/tmux-python/libtmux/issues +[coverage]: https://codecov.io/gh/tmux-python/libtmux +[releases]: https://pypi.org/project/libtmux/ +[license]: https://github.com/tmux-python/libtmux/blob/master/LICENSE +[support]: https://tony.sh/support.html +[tao]: https://leanpub.com/the-tao-of-tmux +[tmuxp]: https://tmuxp.git-pull.com +[tmux]: https://github.com/tmux/tmux diff --git a/conftest.py b/conftest.py index fe1c58050..ada5aae3f 100644 --- a/conftest.py +++ b/conftest.py @@ -41,6 +41,7 @@ def add_doctest_fixtures( doctest_namespace["Window"] = Window doctest_namespace["Pane"] = Pane doctest_namespace["server"] = request.getfixturevalue("server") + doctest_namespace["Server"] = request.getfixturevalue("TestServer") session: Session = request.getfixturevalue("session") doctest_namespace["session"] = session doctest_namespace["window"] = session.active_window diff --git a/docs/_templates/book.html b/docs/_templates/book.html index 861e978dd..16d2febcf 100644 --- a/docs/_templates/book.html +++ b/docs/_templates/book.html @@ -4,4 +4,4 @@

The book!

The Tao of tmux is available on Leanpub and Kindle (Amazon).

Read and browse the book for free on the web.

-Amazon Kindle +Amazon Kindle diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 217e41800..7b46e0bce 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -1,7 +1,7 @@ ``` -## Table of Contents - -:hidden: - ```{toctree} :maxdepth: 2 +:hidden: quickstart about -topics/traversal +topics/index api/index pytest-plugin/index +test-helpers/index ``` ```{toctree} diff --git a/docs/internals/constants.md b/docs/internals/constants.md new file mode 100644 index 000000000..65059ce94 --- /dev/null +++ b/docs/internals/constants.md @@ -0,0 +1,15 @@ +# Internal Constants - `libtmux._internal.constants` + +:::{warning} +Be careful with these! These constants are private, internal as they're **not** covered by version policies. They can break or be removed between minor versions! + +If you need a data structure here made public or stabilized please [file an issue](https://github.com/tmux-python/libtmux/issues). +::: + +```{eval-rst} +.. automodule:: libtmux._internal.constants + :members: + :undoc-members: + :inherited-members: + :show-inheritance: +``` diff --git a/docs/internals/index.md b/docs/internals/index.md index 09d4a1d6f..0d19d3763 100644 --- a/docs/internals/index.md +++ b/docs/internals/index.md @@ -11,6 +11,8 @@ If you need an internal API stabilized please [file an issue](https://github.com ```{toctree} dataclasses query_list +constants +sparse_array ``` ## Environmental variables diff --git a/docs/internals/sparse_array.md b/docs/internals/sparse_array.md new file mode 100644 index 000000000..74ea7892d --- /dev/null +++ b/docs/internals/sparse_array.md @@ -0,0 +1,14 @@ +# Internal Sparse Array - `libtmux._internal.sparse_array` + +:::{warning} +Be careful with these! Internal APIs are **not** covered by version policies. They can break or be removed between minor versions! + +If you need an internal API stabilized please [file an issue](https://github.com/tmux-python/libtmux/issues). +::: + +```{eval-rst} +.. automodule:: libtmux._internal.sparse_array + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/pytest-plugin/index.md b/docs/pytest-plugin/index.md index bb4368655..82d55dd2f 100644 --- a/docs/pytest-plugin/index.md +++ b/docs/pytest-plugin/index.md @@ -12,10 +12,8 @@ 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 - +:no-index: ``` ## Usage @@ -33,7 +31,7 @@ The pytest plugin will be automatically detected via pytest, and the fixtures wi 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, assertions and +libtmux's tests `autouse` the {ref}`recommended-fixtures` above to ensure stable test execution, assertions and object lookups in the test grid. ## pytest-tmux @@ -41,14 +39,14 @@ object lookups in the test grid. `pytest-tmux` works through providing {ref}`pytest fixtures ` - so read up on those! -The plugin's fixtures guarantee a fresh, headless `tmux(1)` server, session, window, or pane is +The plugin's fixtures guarantee a fresh, headless {command}`tmux(1)` server, session, window, or pane is passed into your test. (recommended-fixtures)= ## Recommended fixtures -These are fixtures are automatically used when the plugin is enabled and `pytest` is run. +These fixtures are automatically used when the plugin is enabled and `pytest` is run. - Creating temporary, test directories for: - `/home/` ({func}`home_path`) @@ -59,6 +57,8 @@ These are fixtures are automatically used when the plugin is enabled and `pytest These are set to ensure panes and windows can be reliably referenced and asserted. +(setting_a_tmux_configuration)= + ## Setting a tmux configuration If you would like {func}`session fixture ` to automatically use a configuration, you have a few @@ -67,14 +67,14 @@ 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 fixtures `'s in your own doctest. doctest. +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 custom the `session` fixture. The -dictionary will directly pass into :meth:`Server.new_session` keyword arguments. +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 @@ -93,6 +93,34 @@ def test_something(session): The above will assure the libtmux session launches with `-x 800 -y 600`. +(temp_server)= + +### Creating temporary servers + +If you need multiple independent tmux servers in your tests, the {func}`TestServer fixture ` provides a factory that creates servers with unique socket names. Each server is automatically cleaned up when the test completes. + +```python +def test_something(TestServer): + Server = TestServer() # Get unique partial'd Server + server = Server() # Create server instance + + session = server.new_session() + assert server.is_alive() +``` + +You can also use it with custom configurations, similar to the {ref}`server fixture `: + +```python +def test_with_config(TestServer, tmp_path): + config_file = tmp_path / "tmux.conf" + config_file.write_text("set -g status off") + + Server = TestServer() + server = Server(config_file=str(config_file)) +``` + +This is particularly useful when testing interactions between multiple tmux servers or when you need to verify behavior across server restarts. + (set_home)= ### Setting a temporary home directory @@ -119,11 +147,3 @@ def set_home( :show-inheritance: :member-order: bysource ``` - -## Test utilities - -```{toctree} -:maxdepth: 1 - -test -``` diff --git a/docs/pytest-plugin/test.md b/docs/pytest-plugin/test.md deleted file mode 100644 index 8fbff818b..000000000 --- a/docs/pytest-plugin/test.md +++ /dev/null @@ -1,6 +0,0 @@ -# Test helpers - -```{eval-rst} -.. automodule:: libtmux.test - :members: -``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 1c17fa87c..39b11aa70 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,7 +12,7 @@ from inside a live tmux session. ## Requirements -- [tmux] +- [tmux] 3.2a or newer - [pip] - for this handbook's examples [tmux]: https://tmux.github.io/ @@ -41,6 +41,31 @@ the 4th beta release of `1.10.0` before general availability. $ pip install --user --upgrade --pre libtmux ``` +- [pipx]\: + + ```console + $ pipx install --suffix=@next 'libtmux' --pip-args '\--pre' --force + // Usage: libtmux@next [command] + ``` + +- [uv tool install][uv-tools]\: + + ```console + $ uv tool install --prerelease=allow libtmux + ``` + +- [uv]\: + + ```console + $ uv add libtmux --prerelease allow + ``` + +- [uvx]\: + + ```console + $ uvx --from 'libtmux' --prerelease allow python + ``` + via trunk (can break easily): - [pip]\: @@ -49,16 +74,31 @@ via trunk (can break easily): $ pip install --user -e git+https://github.com/tmux-python/libtmux.git#egg=libtmux ``` +- [pipx]\: + + ```console + $ pipx install --suffix=@master 'libtmux @ git+https://github.com/tmux-python/libtmux.git@master' --force + ``` + +- [uv]\: + + ```console + $ uv tool install libtmux --from git+https://github.com/tmux-python/libtmux.git + ``` + [pip]: https://pip.pypa.io/en/stable/ +[pipx]: https://pypa.github.io/pipx/docs/ +[uv]: https://docs.astral.sh/uv/ +[uv-tools]: https://docs.astral.sh/uv/concepts/tools/ +[uvx]: https://docs.astral.sh/uv/guides/tools/ +[ptpython]: https://github.com/prompt-toolkit/ptpython ## Start a tmux session Now, let's open a tmux session. ```console - $ tmux new-session -n bar -s foo - ``` This tutorial will be using the session and window name in the example. @@ -89,7 +129,7 @@ $ ptpython ``` ```{module} libtmux - +:no-index: ``` First, we can grab a {class}`Server`. @@ -177,7 +217,7 @@ Session($1 ...) However, this isn't guaranteed, libtmux works against current tmux information, the session's name could be changed, or another tmux session may be created, -so {meth}`Server.sessions` and {meth}`Server.windows` exists as a lookup. +so {meth}`Server.sessions` and {meth}`Server.windows` exist as a lookup. ## Get session by ID @@ -401,6 +441,38 @@ automatically sent, the leading space character prevents adding it to the user's shell history. Omitting `enter=false` means the default behavior (sending the command) is done, without needing to use `pane.enter()` after. +## Working with options + +libtmux provides a unified API for managing tmux options across Server, Session, +Window, and Pane objects. + +### Getting options + +```python +>>> server.show_option('buffer-limit') +50 + +>>> window.show_options() # doctest: +ELLIPSIS +{...} +``` + +### Setting options + +```python +>>> window.set_option('automatic-rename', False) # doctest: +ELLIPSIS +Window(@... ...) + +>>> window.show_option('automatic-rename') +False + +>>> window.unset_option('automatic-rename') # doctest: +ELLIPSIS +Window(@... ...) +``` + +:::{seealso} +See {ref}`options-and-hooks` for more details on options and hooks. +::: + ## Final notes These objects created use tmux's internal usage of ID's to make servers, @@ -419,4 +491,3 @@ and our [test suite] (see {ref}`development`.) [workspacebuilder.py]: https://github.com/tmux-python/libtmux/blob/master/libtmux/workspacebuilder.py [test suite]: https://github.com/tmux-python/libtmux/tree/master/tests -[ptpython]: https://github.com/prompt-toolkit/ptpython diff --git a/docs/redirects.txt b/docs/redirects.txt index 1f20db7c0..afff787ad 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -15,3 +15,4 @@ "reference/servers.md" "api/servers.md" "reference/sessions.md" "api/sessions.md" "reference/windows.md" "api/windows.md" +"pytest-plugin/test.md" "test-helpers/index.md" diff --git a/docs/test-helpers/constants.md b/docs/test-helpers/constants.md new file mode 100644 index 000000000..b7583a251 --- /dev/null +++ b/docs/test-helpers/constants.md @@ -0,0 +1,13 @@ +(test_helpers_constants)= + +# Constants + +Test-related constants used across libtmux test helpers. + +```{eval-rst} +.. automodule:: libtmux.test.constants + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/test-helpers/environment.md b/docs/test-helpers/environment.md new file mode 100644 index 000000000..58b4bb549 --- /dev/null +++ b/docs/test-helpers/environment.md @@ -0,0 +1,13 @@ +(test_helpers_environment)= + +# Environment + +Environment variable mocking utilities for tests. + +```{eval-rst} +.. automodule:: libtmux.test.environment + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/test-helpers/index.md b/docs/test-helpers/index.md new file mode 100644 index 000000000..dd99384bf --- /dev/null +++ b/docs/test-helpers/index.md @@ -0,0 +1,18 @@ +# Test helpers + +Test helpers for libtmux and downstream libraries. + +```{toctree} +:maxdepth: 2 + +constants +environment +random +retry +temporary +``` + +```{eval-rst} +.. automodule:: libtmux.test + :members: +``` diff --git a/docs/test-helpers/random.md b/docs/test-helpers/random.md new file mode 100644 index 000000000..e4248a7fc --- /dev/null +++ b/docs/test-helpers/random.md @@ -0,0 +1,13 @@ +(test_helpers_random)= + +# Random + +Random string generation utilities for test names. + +```{eval-rst} +.. automodule:: libtmux.test.random + :members: + :undoc-members: + :show-inheritance: + :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 new file mode 100644 index 000000000..ea3b8ddf9 --- /dev/null +++ b/docs/test-helpers/temporary.md @@ -0,0 +1,13 @@ +(test_helpers_temporary_objects)= + +# Temporary Objects + +Context managers for temporary tmux objects (sessions, windows). + +```{eval-rst} +.. automodule:: libtmux.test.temporary + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md new file mode 100644 index 000000000..c0d00eb79 --- /dev/null +++ b/docs/topics/automation_patterns.md @@ -0,0 +1,481 @@ +(automation-patterns)= + +# Automation Patterns + +libtmux is ideal for automating terminal workflows, orchestrating multiple processes, +and building agentic systems that interact with terminal applications. This guide covers +practical patterns for automation use cases. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +## Process Control + +### Starting long-running processes + +```python +>>> import time + +>>> proc_window = session.new_window(window_name='process', attach=False) +>>> proc_pane = proc_window.active_pane + +>>> # Start a background process +>>> proc_pane.send_keys('sleep 2 && echo "Process complete"') + +>>> # Process is running +>>> time.sleep(0.1) +>>> proc_window.window_name +'process' + +>>> # Clean up +>>> proc_window.kill() +``` + +### Checking process status + +```python +>>> import time + +>>> status_window = session.new_window(window_name='status-check', attach=False) +>>> status_pane = status_window.active_pane + +>>> def is_process_running(pane, marker='RUNNING'): +... """Check if a marker indicates process is still running.""" +... output = pane.capture_pane() +... return marker in '\\n'.join(output) + +>>> # Start and mark a process +>>> status_pane.send_keys('echo "RUNNING"; sleep 0.3; echo "DONE"') +>>> time.sleep(0.1) + +>>> # Check while running +>>> 'RUNNING' in '\\n'.join(status_pane.capture_pane()) +True + +>>> # Wait for completion +>>> time.sleep(0.5) +>>> 'DONE' in '\\n'.join(status_pane.capture_pane()) +True + +>>> # Clean up +>>> status_window.kill() +``` + +## Output Monitoring + +### Waiting for specific output + +```python +>>> import time + +>>> monitor_window = session.new_window(window_name='monitor', attach=False) +>>> monitor_pane = monitor_window.active_pane + +>>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): +... """Wait for specific text to appear in pane output.""" +... start = time.time() +... while time.time() - start < timeout: +... output = '\\n'.join(pane.capture_pane()) +... if text in output: +... return True +... time.sleep(poll_interval) +... return False + +>>> monitor_pane.send_keys('sleep 0.2; echo "READY"') +>>> wait_for_output(monitor_pane, 'READY', timeout=2.0) +True + +>>> # Clean up +>>> monitor_window.kill() +``` + +### Detecting errors in output + +```python +>>> import time + +>>> error_window = session.new_window(window_name='error-check', attach=False) +>>> error_pane = error_window.active_pane + +>>> def check_for_errors(pane, patterns=None): +... """Check pane output for error patterns.""" +... if patterns is None: +... patterns = ['Error:', 'error:', 'ERROR', 'FAILED', 'Exception'] +... output = '\\n'.join(pane.capture_pane()) +... for pattern in patterns: +... if pattern in output: +... return pattern +... return None + +>>> # Test with successful output +>>> error_pane.send_keys('echo "Success!"') +>>> time.sleep(0.1) +>>> check_for_errors(error_pane) is None +True + +>>> # Clean up +>>> error_window.kill() +``` + +### Capturing output between markers + +```python +>>> import time + +>>> capture_window = session.new_window(window_name='capture', attach=False) +>>> capture_pane = capture_window.active_pane + +>>> def capture_after_marker(pane, marker, timeout=5.0): +... """Capture output after a marker appears.""" +... start_time = time.time() +... while time.time() - start_time < timeout: +... lines = pane.capture_pane() +... output = '\\n'.join(lines) +... if marker in output: +... # Return all lines after the marker +... found = False +... result = [] +... for line in lines: +... if marker in line: +... found = True +... continue +... if found: +... result.append(line) +... return result +... time.sleep(0.1) +... return None + +>>> # Test marker capture +>>> capture_pane.send_keys('echo "MARKER"; echo "captured data"') +>>> time.sleep(0.3) +>>> result = capture_after_marker(capture_pane, 'MARKER', timeout=2.0) +>>> any('captured' in line for line in (result or [])) +True + +>>> # Clean up +>>> capture_window.kill() +``` + +## Multi-Pane Orchestration + +### Running parallel tasks + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> parallel_window = session.new_window(window_name='parallel', attach=False) +>>> parallel_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> pane1 = parallel_window.active_pane +>>> pane2 = pane1.split(direction=PaneDirection.Right) +>>> pane3 = pane1.split(direction=PaneDirection.Below) + +>>> # Start tasks in parallel +>>> tasks = [ +... (pane1, 'echo "Task 1"; sleep 0.2; echo "DONE1"'), +... (pane2, 'echo "Task 2"; sleep 0.1; echo "DONE2"'), +... (pane3, 'echo "Task 3"; sleep 0.3; echo "DONE3"'), +... ] + +>>> for pane, cmd in tasks: +... pane.send_keys(cmd) + +>>> # Wait for all tasks +>>> time.sleep(0.5) + +>>> # Verify all completed +>>> all('DONE' in '\\n'.join(p.capture_pane()) for p, _ in tasks) +True + +>>> # Clean up +>>> parallel_window.kill() +``` + +### Monitoring multiple panes for completion + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> multi_window = session.new_window(window_name='multi-monitor', attach=False) +>>> multi_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> panes = [multi_window.active_pane] +>>> panes.append(panes[0].split(direction=PaneDirection.Right)) +>>> panes.append(panes[0].split(direction=PaneDirection.Below)) + +>>> def wait_all_complete(panes, marker='COMPLETE', timeout=10.0): +... """Wait for all panes to show completion marker.""" +... start = time.time() +... remaining = set(range(len(panes))) +... while remaining and time.time() - start < timeout: +... for i in list(remaining): +... if marker in '\\n'.join(panes[i].capture_pane()): +... remaining.remove(i) +... time.sleep(0.1) +... return len(remaining) == 0 + +>>> # Start tasks with different durations +>>> for i, pane in enumerate(panes): +... pane.send_keys(f'sleep 0.{i+1}; echo "COMPLETE"') + +>>> # Wait for all +>>> wait_all_complete(panes, 'COMPLETE', timeout=2.0) +True + +>>> # Clean up +>>> multi_window.kill() +``` + +## Context Manager Patterns + +### Temporary session for isolated work + +```python +>>> # Create isolated session for a task +>>> with server.new_session(session_name='temp-work') as temp_session: +... window = temp_session.new_window(window_name='task') +... pane = window.active_pane +... pane.send_keys('echo "Isolated work"') +... # Session exists during work +... temp_session in server.sessions +True + +>>> # Session automatically killed after context +>>> temp_session not in server.sessions +True +``` + +### Temporary window for subtask + +```python +>>> import time + +>>> with session.new_window(window_name='subtask') as sub_window: +... pane = sub_window.active_pane +... pane.send_keys('echo "Subtask running"') +... time.sleep(0.1) +... 'Subtask' in '\\n'.join(pane.capture_pane()) +True + +>>> # Window cleaned up automatically +>>> sub_window not in session.windows +True +``` + +## Timeout Handling + +### Command with timeout + +```python +>>> import time + +>>> timeout_window = session.new_window(window_name='timeout-demo', attach=False) +>>> timeout_pane = timeout_window.active_pane + +>>> class CommandTimeout(Exception): +... """Raised when a command times out.""" +... pass + +>>> def run_with_timeout(pane, command, marker='__DONE__', timeout=5.0): +... """Run command and wait for completion with timeout.""" +... pane.send_keys(f'{command}; echo {marker}') +... start = time.time() +... while time.time() - start < timeout: +... output = '\\n'.join(pane.capture_pane()) +... if marker in output: +... return output +... time.sleep(0.1) +... raise CommandTimeout(f'Command timed out after {timeout}s') + +>>> # Test successful command +>>> result = run_with_timeout(timeout_pane, 'echo "fast"', timeout=2.0) +>>> 'fast' in result +True + +>>> # Clean up +>>> timeout_window.kill() +``` + +### Retry pattern + +```python +>>> import time + +>>> retry_window = session.new_window(window_name='retry-demo', attach=False) +>>> retry_pane = retry_window.active_pane + +>>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5): +... """Retry command until success marker appears.""" +... for attempt in range(max_retries): +... pane.send_keys(command) +... time.sleep(delay) +... output = '\\n'.join(pane.capture_pane()) +... if success_marker in output: +... return True, attempt + 1 +... return False, max_retries + +>>> # Test retry +>>> success, attempts = retry_until_success( +... retry_pane, 'echo "OK"', 'OK', max_retries=3, delay=0.2 +... ) +>>> success +True +>>> attempts +1 + +>>> # Clean up +>>> retry_window.kill() +``` + +## Agentic Workflow Patterns + +### Task queue processor + +```python +>>> import time + +>>> queue_window = session.new_window(window_name='queue', attach=False) +>>> queue_pane = queue_window.active_pane + +>>> def process_task_queue(pane, tasks, completion_marker='TASK_DONE'): +... """Process a queue of tasks sequentially.""" +... results = [] +... for i, task in enumerate(tasks): +... pane.send_keys(f'{task}; echo "{completion_marker}_{i}"') +... # Wait for this task to complete +... start = time.time() +... while time.time() - start < 5.0: +... output = '\\n'.join(pane.capture_pane()) +... if f'{completion_marker}_{i}' in output: +... results.append((i, True)) +... break +... time.sleep(0.1) +... else: +... results.append((i, False)) +... return results + +>>> tasks = ['echo "Step 1"', 'echo "Step 2"', 'echo "Step 3"'] +>>> results = process_task_queue(queue_pane, tasks) +>>> all(success for _, success in results) +True + +>>> # Clean up +>>> queue_window.kill() +``` + +### State machine runner + +```python +>>> import time + +>>> state_window = session.new_window(window_name='state-machine', attach=False) +>>> state_pane = state_window.active_pane + +>>> def run_state_machine(pane, states, timeout_per_state=2.0): +... """Run through a series of states with transitions.""" +... current_state = 0 +... history = [] +... +... while current_state < len(states): +... state_name, command, next_marker = states[current_state] +... pane.send_keys(command) +... +... start = time.time() +... while time.time() - start < timeout_per_state: +... output = '\\n'.join(pane.capture_pane()) +... if next_marker in output: +... history.append(state_name) +... current_state += 1 +... break +... time.sleep(0.1) +... else: +... return history, False # Timeout +... +... return history, True + +>>> states = [ +... ('init', 'echo "INIT_DONE"', 'INIT_DONE'), +... ('process', 'echo "PROCESS_DONE"', 'PROCESS_DONE'), +... ('cleanup', 'echo "CLEANUP_DONE"', 'CLEANUP_DONE'), +... ] + +>>> history, success = run_state_machine(state_pane, states) +>>> success +True +>>> len(history) +3 + +>>> # Clean up +>>> state_window.kill() +``` + +## Best Practices + +### 1. Always use markers for completion detection + +Instead of relying on timing, use explicit markers: + +```python +>>> bp_window = session.new_window(window_name='best-practice', attach=False) +>>> bp_pane = bp_window.active_pane + +>>> # Good: Use completion marker +>>> bp_pane.send_keys('long_command; echo "__DONE__"') + +>>> # Then poll for marker +>>> import time +>>> time.sleep(0.2) +>>> '__DONE__' in '\\n'.join(bp_pane.capture_pane()) +True + +>>> bp_window.kill() +``` + +### 2. Clean up resources + +Always clean up windows and sessions when done: + +```python +>>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False) +>>> cleanup_window # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Do work... + +>>> # Always clean up +>>> cleanup_window.kill() +>>> cleanup_window not in session.windows +True +``` + +### 3. Use context managers for automatic cleanup + +```python +>>> # Context managers ensure cleanup even on exceptions +>>> with session.new_window(window_name='safe-work') as safe_window: +... pane = safe_window.active_pane +... # Work happens here +... pass # Even if exception occurs, window is cleaned up +``` + +:::{seealso} +- {ref}`pane-interaction` for basic pane operations +- {ref}`workspace-setup` for creating workspace layouts +- {ref}`context-managers` for resource management patterns +- {class}`~libtmux.Pane` for all pane methods +::: diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md new file mode 100644 index 000000000..60b710ad9 --- /dev/null +++ b/docs/topics/context_managers.md @@ -0,0 +1,128 @@ +(context_managers)= + +# Context Managers + +libtmux provides context managers for all main tmux objects to ensure proper cleanup of resources. This is done through Python's `with` statement, which automatically handles cleanup when you're done with the tmux objects. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +Import `libtmux`: + +```python +import libtmux +``` + +## Server Context Manager + +Create a temporary server that will be killed when you're done: + +```python +>>> with Server() as server: +... session = server.new_session() +... print(server.is_alive()) +True +>>> print(server.is_alive()) # Server is killed after exiting context +False +``` + +## Session Context Manager + +Create a temporary session that will be killed when you're done: + +```python +>>> server = Server() +>>> with server.new_session() as session: +... print(session in server.sessions) +... window = session.new_window() +True +>>> print(session in server.sessions) # Session is killed after exiting context +False +``` + +## Window Context Manager + +Create a temporary window that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> with session.new_window() as window: +... print(window in session.windows) +... pane = window.split() +True +>>> print(window in session.windows) # Window is killed after exiting context +False +``` + +## Pane Context Manager + +Create a temporary pane that will be killed when you're done: + +```python +>>> server = Server() +>>> session = server.new_session() +>>> window = session.new_window() +>>> with window.split() as pane: +... print(pane in window.panes) +... pane.send_keys('echo "Hello"') +True +>>> print(pane in window.panes) # Pane is killed after exiting context +False +``` + +## Nested Context Managers + +Context managers can be nested to create a clean hierarchy of tmux objects that are automatically cleaned up: + +```python +>>> with Server() as server: +... with server.new_session() as session: +... with session.new_window() as window: +... with window.split() as pane: +... pane.send_keys('echo "Hello"') +... # Do work with the pane +... # Everything is cleaned up automatically when exiting contexts +``` + +This ensures that: + +1. The pane is killed when exiting its context +2. The window is killed when exiting its context +3. The session is killed when exiting its context +4. The server is killed when exiting its context + +The cleanup happens in reverse order (pane → window → session → server), ensuring proper resource management. + +## Benefits + +Using context managers provides several advantages: + +1. **Automatic Cleanup**: Resources are automatically cleaned up when you're done with them +2. **Clean Code**: No need to manually call `kill()` methods +3. **Exception Safety**: Resources are cleaned up even if an exception occurs +4. **Hierarchical Cleanup**: Nested contexts ensure proper cleanup order +5. **Resource Management**: Prevents resource leaks by ensuring tmux objects are properly destroyed + +## When to Use + +Context managers are particularly useful when: + +1. Creating temporary tmux objects for testing +2. Running short-lived tmux sessions +3. Managing multiple tmux servers +4. Ensuring cleanup in scripts that may raise exceptions +5. Creating isolated environments that need to be cleaned up afterward + +[target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md new file mode 100644 index 000000000..bb71f81b7 --- /dev/null +++ b/docs/topics/filtering.md @@ -0,0 +1,263 @@ +(querylist-filtering)= + +# QueryList Filtering + +libtmux uses `QueryList` to enable Django-style filtering on tmux objects. +Every collection (`server.sessions`, `session.windows`, `window.panes`) returns +a `QueryList`, letting you filter sessions, windows, and panes with a fluent, +chainable API. + +## Basic Filtering + +The `filter()` method accepts keyword arguments with optional lookup suffixes: + +```python +>>> server.sessions # doctest: +ELLIPSIS +[Session($... ...)] +``` + +### Exact Match + +The default lookup is `exact`: + +```python +>>> # These are equivalent +>>> server.sessions.filter(session_name=session.session_name) # doctest: +ELLIPSIS +[Session($... ...)] +>>> server.sessions.filter(session_name__exact=session.session_name) # doctest: +ELLIPSIS +[Session($... ...)] +``` + +### Contains and Startswith + +Use suffixes for partial matching: + +```python +>>> # Create windows for this example +>>> w1 = session.new_window(window_name="api-server") +>>> w2 = session.new_window(window_name="api-worker") +>>> w3 = session.new_window(window_name="web-frontend") + +>>> # Windows containing 'api' +>>> api_windows = session.windows.filter(window_name__contains='api') +>>> len(api_windows) >= 2 +True + +>>> # Windows starting with 'web' +>>> web_windows = session.windows.filter(window_name__startswith='web') +>>> len(web_windows) >= 1 +True + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Available Lookups + +| Lookup | Description | +|--------|-------------| +| `exact` | Exact match (default) | +| `iexact` | Case-insensitive exact match | +| `contains` | Substring match | +| `icontains` | Case-insensitive substring | +| `startswith` | Prefix match | +| `istartswith` | Case-insensitive prefix | +| `endswith` | Suffix match | +| `iendswith` | Case-insensitive suffix | +| `in` | Value in list | +| `nin` | Value not in list | +| `regex` | Regular expression match | +| `iregex` | Case-insensitive regex | + +## Getting a Single Item + +Use `get()` to retrieve exactly one matching item: + +```python +>>> window = session.windows.get(window_id=session.active_window.window_id) +>>> window # doctest: +ELLIPSIS +Window(@... ..., Session($... ...)) +``` + +If no match or multiple matches are found, `get()` raises an exception: + +- `ObjectDoesNotExist` - no matching object found +- `MultipleObjectsReturned` - more than one object matches + +You can provide a default value to avoid the exception: + +```python +>>> session.windows.get(window_name="nonexistent", default=None) is None +True +``` + +## Chaining Filters + +Filters can be chained for complex queries: + +```python +>>> # Create windows for this example +>>> w1 = session.new_window(window_name="feature-login") +>>> w2 = session.new_window(window_name="feature-signup") +>>> w3 = session.new_window(window_name="bugfix-typo") + +>>> # Multiple conditions in one filter (AND) +>>> session.windows.filter( +... window_name__startswith='feature', +... window_name__endswith='signup' +... ) # doctest: +ELLIPSIS +[Window(@... ...:feature-signup, Session($... ...))] + +>>> # Chained filters (also AND) +>>> session.windows.filter( +... window_name__contains='feature' +... ).filter( +... window_name__contains='login' +... ) # doctest: +ELLIPSIS +[Window(@... ...:feature-login, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Case-Insensitive Filtering + +Use `i` prefix variants for case-insensitive matching: + +```python +>>> # Create windows with mixed case +>>> w1 = session.new_window(window_name="MyApp-Server") +>>> w2 = session.new_window(window_name="myapp-worker") + +>>> # Case-insensitive contains +>>> myapp_windows = session.windows.filter(window_name__icontains='MYAPP') +>>> len(myapp_windows) >= 2 +True + +>>> # Case-insensitive startswith +>>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS +[Window(@... ...:MyApp-Server, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +``` + +## Regex Filtering + +For complex patterns, use regex lookups: + +```python +>>> # Create windows with version-like names +>>> w1 = session.new_window(window_name="app-v1.0") +>>> w2 = session.new_window(window_name="app-v2.0") +>>> w3 = session.new_window(window_name="app-beta") + +>>> # Match version pattern +>>> versioned = session.windows.filter(window_name__regex=r'v\d+\.\d+$') +>>> len(versioned) >= 2 +True + +>>> # Case-insensitive regex +>>> session.windows.filter(window_name__iregex=r'BETA') # doctest: +ELLIPSIS +[Window(@... ...:app-beta, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Filtering by List Membership + +Use `in` and `nin` (not in) for list-based filtering: + +```python +>>> # Create test windows +>>> w1 = session.new_window(window_name="dev") +>>> w2 = session.new_window(window_name="staging") +>>> w3 = session.new_window(window_name="prod") + +>>> # Filter windows in a list of names +>>> target_envs = ["dev", "prod"] +>>> session.windows.filter(window_name__in=target_envs) # doctest: +ELLIPSIS +[Window(@... ...:dev, Session($... ...)), Window(@... ...:prod, Session($... ...))] + +>>> # Filter windows NOT in a list +>>> non_prod = session.windows.filter(window_name__nin=["prod"]) +>>> any(w.window_name == "prod" for w in non_prod) +False + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## Filtering Across the Hierarchy + +Filter at any level of the tmux hierarchy: + +```python +>>> # All panes across all windows in the server +>>> server.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] + +>>> # Filter panes by their window's name +>>> pane = session.active_pane +>>> pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Real-World Examples + +### Find all editor windows + +```python +>>> # Create sample windows +>>> w1 = session.new_window(window_name="vim-main") +>>> w2 = session.new_window(window_name="nvim-config") +>>> w3 = session.new_window(window_name="shell") + +>>> # Find vim/nvim windows +>>> editors = session.windows.filter(window_name__iregex=r'n?vim') +>>> len(editors) >= 2 +True + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +### Find windows by naming convention + +```python +>>> # Create windows following a naming convention +>>> w1 = session.new_window(window_name="project:frontend") +>>> w2 = session.new_window(window_name="project:backend") +>>> w3 = session.new_window(window_name="logs") + +>>> # Find all project windows +>>> project_windows = session.windows.filter(window_name__startswith='project:') +>>> len(project_windows) >= 2 +True + +>>> # Get specific project window +>>> backend = session.windows.get(window_name='project:backend') +>>> backend.window_name +'project:backend' + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() +``` + +## API Reference + +See {class}`~libtmux._internal.query_list.QueryList` for the complete API. diff --git a/docs/topics/index.md b/docs/topics/index.md index 7fe9893b8..f22e7f81b 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -2,9 +2,17 @@ orphan: true --- -# Topic Guides +# Topics + +Explore libtmux's core functionalities and underlying principles at a high level, while providing essential context and detailed explanations to help you understand its design and usage. ```{toctree} traversal +filtering +pane_interaction +workspace_setup +automation_patterns +context_managers +options_and_hooks ``` diff --git a/docs/topics/options_and_hooks.md b/docs/topics/options_and_hooks.md new file mode 100644 index 000000000..b95eeeda9 --- /dev/null +++ b/docs/topics/options_and_hooks.md @@ -0,0 +1,162 @@ +(options-and-hooks)= + +# Options and Hooks + +libtmux provides a unified API for managing tmux options and hooks across all +object types (Server, Session, Window, Pane). + +## Options + +tmux options control the behavior and appearance of sessions, windows, and +panes. libtmux provides a consistent interface through +{class}`~libtmux.options.OptionsMixin`. + +### Getting options + +Use {meth}`~libtmux.options.OptionsMixin.show_options` to get all options: + +```python +>>> session.show_options() # doctest: +ELLIPSIS +{...} +``` + +Use {meth}`~libtmux.options.OptionsMixin.show_option` to get a single option: + +```python +>>> server.show_option('buffer-limit') +50 +``` + +### Setting options + +Use {meth}`~libtmux.options.OptionsMixin.set_option` to set an option: + +```python +>>> window.set_option('automatic-rename', False) # doctest: +ELLIPSIS +Window(@... ...) + +>>> window.show_option('automatic-rename') +False +``` + +### Unsetting options + +Use {meth}`~libtmux.options.OptionsMixin.unset_option` to revert an option to +its default: + +```python +>>> window.unset_option('automatic-rename') # doctest: +ELLIPSIS +Window(@... ...) +``` + +### Option scopes + +tmux options exist at different scopes. Use the `scope` parameter to specify: + +```python +>>> from libtmux.constants import OptionScope + +>>> # Get window-scoped options from a session +>>> session.show_options(scope=OptionScope.Window) # doctest: +ELLIPSIS +{...} +``` + +### Global options + +Use `global_=True` to work with global options: + +```python +>>> server.show_option('buffer-limit', global_=True) +50 +``` + +## Hooks + +tmux hooks allow you to run commands when specific events occur. libtmux +provides hook management through {class}`~libtmux.hooks.HooksMixin`. + +### Setting and getting hooks + +Use {meth}`~libtmux.hooks.HooksMixin.set_hook` to set a hook and +{meth}`~libtmux.hooks.HooksMixin.show_hook` to get its value: + +```python +>>> session.set_hook('session-renamed', 'display-message "Session renamed"') # doctest: +ELLIPSIS +Session(...) + +>>> session.show_hook('session-renamed') # doctest: +ELLIPSIS +{0: 'display-message "Session renamed"'} + +>>> session.show_hooks() # doctest: +ELLIPSIS +{...} +``` + +Note that hooks are stored as indexed arrays in tmux, so `show_hook()` returns a +{class}`~libtmux._internal.sparse_array.SparseArray` (dict-like) with index keys. + +### Removing hooks + +Use {meth}`~libtmux.hooks.HooksMixin.unset_hook` to remove a hook: + +```python +>>> session.unset_hook('session-renamed') # doctest: +ELLIPSIS +Session(...) +``` + +### Indexed hooks + +tmux hooks support multiple values via indices (e.g., `session-renamed[0]`, +`session-renamed[1]`). This allows multiple commands to run for the same event: + +```python +>>> session.set_hook('after-split-window[0]', 'display-message "Split 0"') # doctest: +ELLIPSIS +Session(...) + +>>> session.set_hook('after-split-window[1]', 'display-message "Split 1"') # doctest: +ELLIPSIS +Session(...) + +>>> hooks = session.show_hook('after-split-window') +>>> sorted(hooks.keys()) +[0, 1] +``` + +The return value is a {class}`~libtmux._internal.sparse_array.SparseArray`, +which preserves sparse indices (e.g., indices 0 and 5 with no 1-4). + +### Bulk hook operations + +Use {meth}`~libtmux.hooks.HooksMixin.set_hooks` to set multiple indexed hooks: + +```python +>>> session.set_hooks('window-linked', { +... 0: 'display-message "Window linked 0"', +... 1: 'display-message "Window linked 1"', +... }) # doctest: +ELLIPSIS +Session(...) + +>>> # Clean up +>>> session.unset_hook('after-split-window[0]') # doctest: +ELLIPSIS +Session(...) +>>> session.unset_hook('after-split-window[1]') # doctest: +ELLIPSIS +Session(...) +>>> session.unset_hook('window-linked[0]') # doctest: +ELLIPSIS +Session(...) +>>> session.unset_hook('window-linked[1]') # doctest: +ELLIPSIS +Session(...) +``` + +## tmux version compatibility + +| Feature | Minimum tmux | +|---------|-------------| +| All options/hooks features | 3.2+ | +| Window/Pane hook scopes (`-w`, `-p`) | 3.2+ | +| `client-active`, `window-resized` hooks | 3.3+ | +| `pane-title-changed` hook | 3.5+ | + +:::{seealso} +- {ref}`api` for the full API reference +- {class}`~libtmux.options.OptionsMixin` for options methods +- {class}`~libtmux.hooks.HooksMixin` for hooks methods +- {class}`~libtmux._internal.sparse_array.SparseArray` for sparse array handling +::: diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md new file mode 100644 index 000000000..5163023f2 --- /dev/null +++ b/docs/topics/pane_interaction.md @@ -0,0 +1,423 @@ +(pane-interaction)= + +# Pane Interaction + +libtmux provides powerful methods for interacting with tmux panes programmatically. +This is especially useful for automation, testing, and orchestrating terminal-based +workflows. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +## Sending Commands + +The {meth}`~libtmux.Pane.send_keys` method sends text to a pane, optionally pressing +Enter to execute it. + +### Basic command execution + +```python +>>> pane = window.split(shell='sh') + +>>> pane.send_keys('echo "Hello from libtmux"') + +>>> import time; time.sleep(0.1) # Allow command to execute + +>>> output = pane.capture_pane() +>>> 'Hello from libtmux' in '\\n'.join(output) +True +``` + +### Send without pressing Enter + +Use `enter=False` to type text without executing: + +```python +>>> pane.send_keys('echo "waiting"', enter=False) + +>>> # Text is typed but not executed +>>> output = pane.capture_pane() +>>> 'waiting' in '\\n'.join(output) +True +``` + +Press Enter separately with {meth}`~libtmux.Pane.enter`: + +```python +>>> import time + +>>> # First type something without pressing Enter +>>> pane.send_keys('echo "execute me"', enter=False) + +>>> pane.enter() # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> time.sleep(0.2) + +>>> output = pane.capture_pane() +>>> 'execute me' in '\\n'.join(output) +True +``` + +### Literal mode for special characters + +Use `literal=True` to send special characters without interpretation: + +```python +>>> import time + +>>> pane.send_keys('echo "Tab:\\tNewline:\\n"', literal=True) + +>>> time.sleep(0.1) +``` + +### Suppress shell history + +Use `suppress_history=True` to prepend a space (prevents command from being +saved in shell history): + +```python +>>> import time + +>>> pane.send_keys('echo "secret command"', suppress_history=True) + +>>> time.sleep(0.1) +``` + +## Capturing Output + +The {meth}`~libtmux.Pane.capture_pane` method captures text from a pane's buffer. + +### Basic capture + +```python +>>> import time + +>>> pane.send_keys('echo "Line 1"; echo "Line 2"; echo "Line 3"') + +>>> time.sleep(0.1) + +>>> output = pane.capture_pane() +>>> isinstance(output, list) +True +>>> any('Line 2' in line for line in output) +True +``` + +### Capture with line ranges + +Capture specific line ranges using `start` and `end` parameters: + +```python +>>> # Capture last 5 lines of visible pane +>>> recent = pane.capture_pane(start=-5, end='-') +>>> isinstance(recent, list) +True + +>>> # Capture from start of history to current +>>> full_history = pane.capture_pane(start='-', end='-') +>>> len(full_history) >= 0 +True +``` + +### Capture with ANSI escape sequences + +Capture colored output with escape sequences preserved using `escape_sequences=True`: + +```python +>>> import time + +>>> pane.send_keys('printf "\\033[31mRED\\033[0m \\033[32mGREEN\\033[0m"') +>>> time.sleep(0.1) + +>>> # Capture with ANSI codes stripped (default) +>>> output = pane.capture_pane() +>>> 'RED' in '\\n'.join(output) +True + +>>> # Capture with ANSI escape sequences preserved +>>> colored_output = pane.capture_pane(escape_sequences=True) +>>> isinstance(colored_output, list) +True +``` + +### Join wrapped lines + +Long lines that wrap in the terminal can be joined back together: + +```python +>>> import time + +>>> # Send a very long line that will wrap +>>> pane.send_keys('echo "' + 'x' * 200 + '"') +>>> time.sleep(0.1) + +>>> # Capture with wrapped lines joined +>>> output = pane.capture_pane(join_wrapped=True) +>>> isinstance(output, list) +True +``` + +### Preserve trailing spaces + +By default, trailing spaces are trimmed. Use `preserve_trailing=True` to keep them: + +```python +>>> import time + +>>> pane.send_keys('printf "text \\n"') # 3 trailing spaces +>>> time.sleep(0.1) + +>>> # Capture with trailing spaces preserved +>>> output = pane.capture_pane(preserve_trailing=True) +>>> isinstance(output, list) +True +``` + +### Capture flags summary + +| Parameter | tmux Flag | Description | +|-----------|-----------|-------------| +| `escape_sequences` | `-e` | Include ANSI escape sequences (colors, attributes) | +| `escape_non_printable` | `-C` | Escape non-printable chars as octal `\xxx` | +| `join_wrapped` | `-J` | Join wrapped lines back together | +| `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | +| `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | + +:::{note} +The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, +a warning is issued and the flag is ignored. +::: + +## Waiting for Output + +A common pattern in automation is waiting for a command to complete. + +### Polling for completion marker + +```python +>>> import time + +>>> pane.send_keys('sleep 0.2; echo "TASK_COMPLETE"') + +>>> # Poll for completion +>>> for _ in range(30): +... output = pane.capture_pane() +... if 'TASK_COMPLETE' in '\\n'.join(output): +... break +... time.sleep(0.1) + +>>> 'TASK_COMPLETE' in '\\n'.join(output) +True +``` + +### Helper function for waiting + +```python +>>> import time + +>>> def wait_for_text(pane, text, timeout=5.0): +... """Wait for text to appear in pane output.""" +... start = time.time() +... while time.time() - start < timeout: +... output = pane.capture_pane() +... if text in '\\n'.join(output): +... return True +... time.sleep(0.1) +... return False + +>>> pane.send_keys('echo "READY"') +>>> wait_for_text(pane, 'READY', timeout=2.0) +True +``` + +## Querying Pane State + +The {meth}`~libtmux.Pane.display_message` method queries tmux format variables. + +### Get pane dimensions + +```python +>>> width = pane.display_message('#{pane_width}', get_text=True) +>>> isinstance(width, list) and len(width) > 0 +True + +>>> height = pane.display_message('#{pane_height}', get_text=True) +>>> isinstance(height, list) and len(height) > 0 +True +``` + +### Get pane information + +```python +>>> # Current working directory +>>> cwd = pane.display_message('#{pane_current_path}', get_text=True) +>>> isinstance(cwd, list) +True + +>>> # Pane ID +>>> pane_id = pane.display_message('#{pane_id}', get_text=True) +>>> pane_id[0].startswith('%') +True +``` + +### Common format variables + +| Variable | Description | +|----------|-------------| +| `#{pane_width}` | Pane width in characters | +| `#{pane_height}` | Pane height in characters | +| `#{pane_current_path}` | Current working directory | +| `#{pane_pid}` | PID of the pane's shell | +| `#{pane_id}` | Unique pane ID (e.g., `%0`) | +| `#{pane_index}` | Pane index in window | + +## Resizing Panes + +The {meth}`~libtmux.Pane.resize` method adjusts pane dimensions. + +### Resize by specific dimensions + +```python +>>> # Make pane larger +>>> pane.resize(height=20, width=80) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +### Resize by adjustment + +```python +>>> from libtmux.constants import ResizeAdjustmentDirection + +>>> # Increase height by 5 rows +>>> pane.resize(adjustment_direction=ResizeAdjustmentDirection.Up, adjustment=5) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> # Decrease width by 10 columns +>>> pane.resize(adjustment_direction=ResizeAdjustmentDirection.Left, adjustment=10) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +### Zoom toggle + +```python +>>> # Zoom pane to fill window +>>> pane.resize(zoom=True) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> # Unzoom +>>> pane.resize(zoom=True) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Clearing the Pane + +The {meth}`~libtmux.Pane.clear` method clears the pane's screen: + +```python +>>> pane.clear() # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Killing Panes + +The {meth}`~libtmux.Pane.kill` method destroys a pane: + +```python +>>> # Create a temporary pane +>>> temp_pane = pane.split() +>>> temp_pane in window.panes +True + +>>> # Kill it +>>> temp_pane.kill() +>>> temp_pane not in window.panes +True +``` + +### Kill all except current + +```python +>>> # Setup: create multiple panes +>>> pane.window.resize(height=60, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> keep_pane = pane.split() +>>> extra1 = pane.split() +>>> extra2 = pane.split() + +>>> # Kill all except keep_pane +>>> keep_pane.kill(all_except=True) + +>>> keep_pane in window.panes +True +>>> extra1 not in window.panes +True +>>> extra2 not in window.panes +True + +>>> # Cleanup +>>> keep_pane.kill() +``` + +## Practical Recipes + +### Recipe: Run command and capture output + +```python +>>> import time + +>>> def run_and_capture(pane, command, marker='__DONE__', timeout=5.0): +... """Run a command and return its output.""" +... pane.send_keys(f'{command}; echo {marker}') +... start = time.time() +... while time.time() - start < timeout: +... output = pane.capture_pane() +... output_str = '\\n'.join(output) +... if marker in output_str: +... return output # Return all captured output +... time.sleep(0.1) +... raise TimeoutError(f'Command did not complete within {timeout}s') + +>>> result = run_and_capture(pane, 'echo "captured text"', timeout=2.0) +>>> 'captured text' in '\\n'.join(result) +True +``` + +### Recipe: Check for error patterns + +```python +>>> import time + +>>> def check_for_errors(pane, error_patterns=None): +... """Check pane output for error patterns.""" +... if error_patterns is None: +... error_patterns = ['error:', 'Error:', 'ERROR', 'failed', 'FAILED'] +... output = '\\n'.join(pane.capture_pane()) +... for pattern in error_patterns: +... if pattern in output: +... return True +... return False + +>>> pane.send_keys('echo "All good"') +>>> time.sleep(0.1) +>>> check_for_errors(pane) +False +``` + +:::{seealso} +- {ref}`api` for the full API reference +- {class}`~libtmux.Pane` for all pane methods +- {ref}`automation-patterns` for advanced orchestration patterns +::: diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index d5e9e2090..214bbc861 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -1,6 +1,6 @@ (traversal)= -# Usage +# Traversal libtmux provides convenient access to move around the hierarchy of sessions, windows and panes in tmux. @@ -22,81 +22,267 @@ Terminal two, `python` or `ptpython` if you have it: $ python ``` -Import `libtmux`: +## Setup + +First, create a test session: + +```python +>>> session = server.new_session() # Create a test session using existing server +``` + +## Server Level + +View the server's representation: + +```python +>>> server # doctest: +ELLIPSIS +Server(socket_name=...) +``` + +Get all sessions in the server: ```python -import libtmux +>>> server.sessions # doctest: +ELLIPSIS +[Session($... ...)] ``` -Attach default tmux {class}`~libtmux.Server` to `t`: +Get all windows across all sessions: ```python ->>> import libtmux ->>> t = libtmux.Server(); ->>> t -Server(socket_path=/tmp/tmux-.../default) +>>> server.windows # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] ``` -Get first session {class}`~libtmux.Session` to `session`: +Get all panes across all windows: + +```python +>>> server.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] +``` + +## Session Level + +Get first session: ```python >>> session = server.sessions[0] ->>> session -Session($1 ...) +>>> session # doctest: +ELLIPSIS +Session($... ...) +``` + +Get windows in a session: + +```python +>>> session.windows # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] +``` + +Get active window and pane: + +```python +>>> session.active_window # doctest: +ELLIPSIS +Window(@... ..., Session($... ...)) + +>>> session.active_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Window Level + +Get a window and inspect its properties: + +```python +>>> window = session.windows[0] +>>> window.window_index # doctest: +ELLIPSIS +'...' +``` + +Access the window's parent session: + +```python +>>> window.session # doctest: +ELLIPSIS +Session($... ...) +>>> window.session.session_id == session.session_id +True +``` + +Get panes in a window: + +```python +>>> window.panes # doctest: +ELLIPSIS +[Pane(%... Window(@... ..., Session($... ...)))] +``` + +Get active pane: + +```python +>>> window.active_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) +``` + +## Pane Level + +Get a pane and traverse upwards: + +```python +>>> pane = window.panes[0] +>>> pane.window.window_id == window.window_id +True +>>> pane.session.session_id == session.session_id +True +>>> pane.server is server +True +``` + +## Filtering and Finding Objects + +libtmux collections support Django-style filtering with `filter()` and `get()`. +For comprehensive coverage of all lookup operators, see {ref}`querylist-filtering`. + +### Basic Filtering + +Find windows by exact attribute match: + +```python +>>> session.windows.filter(window_index=window.window_index) # doctest: +ELLIPSIS +[Window(@... ..., Session($... ...))] +``` + +Get a specific pane by ID: + +```python +>>> window.panes.get(pane_id=pane.pane_id) # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) ``` -Get a list of sessions: +### Partial Matching + +Use lookup suffixes like `__contains`, `__startswith`, `__endswith`: ```python ->>> server.sessions -[Session($1 ...), Session($0 ...)] +>>> # Create windows to demonstrate filtering +>>> w1 = session.new_window(window_name="app-frontend") +>>> w2 = session.new_window(window_name="app-backend") +>>> w3 = session.new_window(window_name="logs") + +>>> # Find windows starting with 'app-' +>>> session.windows.filter(window_name__startswith='app-') # doctest: +ELLIPSIS +[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] + +>>> # Find windows containing 'end' +>>> session.windows.filter(window_name__contains='end') # doctest: +ELLIPSIS +[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() ``` -Iterate through sessions in a server: +### Case-Insensitive Matching + +Prefix any lookup with `i` for case-insensitive matching: ```python ->>> for sess in server.sessions: -... print(sess) -Session($1 ...) -Session($0 ...) +>>> # Create windows with mixed case +>>> w1 = session.new_window(window_name="MyApp") +>>> w2 = session.new_window(window_name="myapp-worker") + +>>> # Case-insensitive search +>>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS +[Window(@... ...:MyApp, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() ``` -Grab a {class}`~libtmux.Window` from a session: +### Regex Filtering + +For complex patterns, use `__regex` or `__iregex`: ```python ->>> session.windows[0] -Window(@1 ...:..., Session($1 ...)) +>>> # Create versioned windows +>>> w1 = session.new_window(window_name="release-v1.0") +>>> w2 = session.new_window(window_name="release-v2.0") +>>> w3 = session.new_window(window_name="dev") + +>>> # Match semantic version pattern +>>> session.windows.filter(window_name__regex=r'v\d+\.\d+') # doctest: +ELLIPSIS +[Window(@... ...:release-v1.0, Session($... ...)), Window(@... ...:release-v2.0, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() ``` -Grab the currently focused window from session: +### Chaining Filters + +Multiple conditions can be combined: ```python ->>> session.active_window -Window(@1 ...:..., Session($1 ...)) +>>> # Create windows for chaining example +>>> w1 = session.new_window(window_name="api-prod") +>>> w2 = session.new_window(window_name="api-staging") +>>> w3 = session.new_window(window_name="web-prod") + +>>> # Multiple conditions in one call (AND) +>>> session.windows.filter( +... window_name__startswith='api', +... window_name__endswith='prod' +... ) # doctest: +ELLIPSIS +[Window(@... ...:api-prod, Session($... ...))] + +>>> # Chained calls (also AND) +>>> session.windows.filter( +... window_name__contains='api' +... ).filter( +... window_name__contains='staging' +... ) # doctest: +ELLIPSIS +[Window(@... ...:api-staging, Session($... ...))] + +>>> # Clean up +>>> w1.kill() +>>> w2.kill() +>>> w3.kill() ``` -Grab the currently focused {class}`Pane` from session: +### Get with Default + +Avoid exceptions when an object might not exist: ```python ->>> session.active_pane -Pane(%1 Window(@1 ...:..., Session($1 ...))) +>>> # Returns None instead of raising ObjectDoesNotExist +>>> session.windows.get(window_name="nonexistent", default=None) is None +True ``` -Assign the attached {class}`~libtmux.Pane` to `p`: +## Checking Relationships + +Check if objects are related: ```python ->>> p = session.active_pane +>>> window in session.windows +True +>>> pane in window.panes +True +>>> session in server.sessions +True ``` -Access the window/server of a pane: +Check if a window is active: ```python ->>> p = session.active_pane ->>> p.window -Window(@1 ...:..., Session($1 ...)) +>>> window.window_id == session.active_window.window_id +True +``` ->>> p.server -Server(socket_name=libtmux_test...) +Check if a pane is active: + +```python +>>> pane.pane_id == window.active_pane.pane_id +True ``` [target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS diff --git a/docs/topics/workspace_setup.md b/docs/topics/workspace_setup.md new file mode 100644 index 000000000..673e26db9 --- /dev/null +++ b/docs/topics/workspace_setup.md @@ -0,0 +1,353 @@ +(workspace-setup)= + +# Workspace Setup + +libtmux makes it easy to create and configure multi-pane workspaces programmatically. +This is useful for setting up development environments, running parallel tasks, +and orchestrating terminal-based workflows. + +Open two terminals: + +Terminal one: start tmux in a separate terminal: + +```console +$ tmux +``` + +Terminal two, `python` or `ptpython` if you have it: + +```console +$ python +``` + +## Creating Windows + +The {meth}`~libtmux.Session.new_window` method creates new windows within a session. + +### Basic window creation + +```python +>>> new_window = session.new_window(window_name='workspace') +>>> new_window # doctest: +ELLIPSIS +Window(@... ...:workspace, Session($... ...)) + +>>> # Window is part of the session +>>> new_window in session.windows +True +``` + +### Create without attaching + +Use `attach=False` to create a window in the background: + +```python +>>> background_window = session.new_window( +... window_name='background-task', +... attach=False, +... ) +>>> background_window # doctest: +ELLIPSIS +Window(@... ...:background-task, Session($... ...)) + +>>> # Clean up +>>> background_window.kill() +``` + +### Create with specific shell + +```python +>>> shell_window = session.new_window( +... window_name='shell-test', +... attach=False, +... window_shell='sh -c "echo Hello; exec sh"', +... ) +>>> shell_window # doctest: +ELLIPSIS +Window(@... ...:shell-test, Session($... ...)) + +>>> # Clean up +>>> shell_window.kill() +``` + +## Splitting Panes + +The {meth}`~libtmux.Window.split` method divides windows into multiple panes. + +### Vertical split (top/bottom) + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> # Create a window with enough space +>>> v_split_window = session.new_window(window_name='v-split-demo', attach=False) +>>> v_split_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Default split is vertical (creates pane below) +>>> top_pane = v_split_window.active_pane +>>> bottom_pane = v_split_window.split() +>>> bottom_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> len(v_split_window.panes) +2 + +>>> # Clean up +>>> v_split_window.kill() +``` + +### Horizontal split (left/right) + +```python +>>> from libtmux.constants import PaneDirection + +>>> # Create a fresh window for this demo +>>> h_split_window = session.new_window(window_name='h-split', attach=False) +>>> h_split_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> left_pane = h_split_window.active_pane +>>> right_pane = left_pane.split(direction=PaneDirection.Right) +>>> right_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> len(h_split_window.panes) +2 + +>>> # Clean up +>>> h_split_window.kill() +``` + +### Split with specific size + +```python +>>> # Create a fresh window for size demo +>>> size_window = session.new_window(window_name='size-demo', attach=False) +>>> size_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> main_pane = size_window.active_pane +>>> # Create pane with specific percentage +>>> small_pane = main_pane.split(size='20%') +>>> small_pane # doctest: +ELLIPSIS +Pane(%... Window(@... ..., Session($... ...))) + +>>> # Clean up +>>> size_window.kill() +``` + +## Layout Management + +The {meth}`~libtmux.Window.select_layout` method arranges panes using built-in layouts. + +### Available layouts + +tmux provides five built-in layouts: + +| Layout | Description | +|--------|-------------| +| `even-horizontal` | Panes spread evenly left to right | +| `even-vertical` | Panes spread evenly top to bottom | +| `main-horizontal` | Large pane on top, others below | +| `main-vertical` | Large pane on left, others on right | +| `tiled` | Panes spread evenly in rows and columns | + +### Applying layouts + +```python +>>> # Create window with multiple panes +>>> layout_window = session.new_window(window_name='layout-demo', attach=False) +>>> layout_window.resize(height=60, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> pane1 = layout_window.active_pane +>>> pane2 = layout_window.split() +>>> pane3 = layout_window.split() +>>> pane4 = layout_window.split() + +>>> # Apply tiled layout +>>> layout_window.select_layout('tiled') # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Apply even-horizontal layout +>>> layout_window.select_layout('even-horizontal') # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Apply main-vertical layout +>>> layout_window.select_layout('main-vertical') # doctest: +ELLIPSIS +Window(@... ...) + +>>> # Clean up +>>> layout_window.kill() +``` + +## Renaming and Organizing + +### Rename windows + +```python +>>> rename_window = session.new_window(window_name='old-name', attach=False) +>>> rename_window.rename_window('new-name') # doctest: +ELLIPSIS +Window(@... ...:new-name, Session($... ...)) + +>>> rename_window.window_name +'new-name' + +>>> # Clean up +>>> rename_window.kill() +``` + +### Access window properties + +```python +>>> demo_window = session.new_window(window_name='props-demo', attach=False) + +>>> # Window index +>>> demo_window.window_index # doctest: +ELLIPSIS +'...' + +>>> # Window ID +>>> demo_window.window_id # doctest: +ELLIPSIS +'@...' + +>>> # Parent session +>>> demo_window.session # doctest: +ELLIPSIS +Session($... ...) + +>>> # Clean up +>>> demo_window.kill() +``` + +## Practical Recipes + +### Recipe: Create a development workspace + +```python +>>> import time +>>> from libtmux.constants import PaneDirection + +>>> def create_dev_workspace(session, name='dev'): +... """Create a typical development workspace layout.""" +... window = session.new_window(window_name=name, attach=False) +... window.resize(height=50, width=160) +... +... # Main editing pane (large, left side) +... main_pane = window.active_pane +... +... # Terminal pane (bottom) +... terminal_pane = main_pane.split(size='30%') +... +... # Logs pane (right side of terminal) +... log_pane = terminal_pane.split(direction=PaneDirection.Right) +... +... return { +... 'window': window, +... 'main': main_pane, +... 'terminal': terminal_pane, +... 'logs': log_pane, +... } + +>>> workspace = create_dev_workspace(session, 'my-project') +>>> len(workspace['window'].panes) +3 + +>>> # Clean up +>>> workspace['window'].kill() +``` + +### Recipe: Create a grid of panes + +```python +>>> from libtmux.constants import PaneDirection + +>>> def create_pane_grid(session, rows=2, cols=2, name='grid'): +... """Create an NxM grid of panes.""" +... window = session.new_window(window_name=name, attach=False) +... window.resize(height=50, width=160) +... +... panes = [] +... base_pane = window.active_pane +... panes.append(base_pane) +... +... # Create first row of panes +... current = base_pane +... for _ in range(cols - 1): +... new_pane = current.split(direction=PaneDirection.Right) +... panes.append(new_pane) +... current = new_pane +... +... # Create additional rows +... for _ in range(rows - 1): +... row_start = panes[-cols] +... current = row_start +... for col in range(cols): +... new_pane = panes[-cols + col].split(direction=PaneDirection.Below) +... panes.append(new_pane) +... +... # Apply tiled layout for even distribution +... window.select_layout('tiled') +... return window, panes + +>>> grid_window, grid_panes = create_pane_grid(session, rows=2, cols=2, name='test-grid') +>>> len(grid_panes) >= 4 +True + +>>> # Clean up +>>> grid_window.kill() +``` + +### Recipe: Run commands in multiple panes + +```python +>>> import time + +>>> def run_in_panes(panes, commands): +... """Run different commands in each pane.""" +... for pane, cmd in zip(panes, commands): +... pane.send_keys(cmd) + +>>> multi_window = session.new_window(window_name='multi-cmd', attach=False) +>>> multi_window.resize(height=40, width=120) # doctest: +ELLIPSIS +Window(@... ...) + +>>> pane_a = multi_window.active_pane +>>> pane_b = multi_window.split() +>>> pane_c = multi_window.split() + +>>> run_in_panes( +... [pane_a, pane_b, pane_c], +... ['echo "Task A"', 'echo "Task B"', 'echo "Task C"'], +... ) + +>>> # Give commands time to execute +>>> time.sleep(0.2) + +>>> # Verify all commands ran +>>> 'Task A' in '\\n'.join(pane_a.capture_pane()) +True + +>>> # Clean up +>>> multi_window.kill() +``` + +## Window Context Managers + +Windows can be used as context managers for automatic cleanup: + +```python +>>> with session.new_window(window_name='temp-window') as temp_win: +... pane = temp_win.active_pane +... pane.send_keys('echo "temporary workspace"') +... temp_win in session.windows +True + +>>> # Window is automatically killed after exiting context +>>> temp_win not in session.windows +True +``` + +:::{seealso} +- {ref}`pane-interaction` for working with pane content +- {ref}`automation-patterns` for advanced orchestration +- {class}`~libtmux.Window` for all window methods +- {class}`~libtmux.Session` for session management +::: diff --git a/pyproject.toml b/pyproject.toml index dfac19627..212bdde2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "libtmux" -version = "0.42.1" +version = "0.52.1" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." -requires-python = ">=3.9,<4.0" +requires-python = ">=3.10,<4.0" authors = [ {name = "Tony Narlock", email = "tony@git-pull.com"} ] @@ -16,11 +16,11 @@ classifiers = [ "Framework :: Pytest", "Intended Audience :: Developers", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", @@ -49,8 +49,8 @@ Documentation = "https://libtmux.git-pull.com" Repository = "https://github.com/tmux-python/libtmux" Changes = "https://github.com/tmux-python/libtmux/blob/master/CHANGES" -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ # Docs "sphinx", "furo", @@ -80,7 +80,6 @@ dev-dependencies = [ "mypy", ] -[dependency-groups] docs = [ "sphinx", "furo", @@ -122,7 +121,7 @@ build-backend = "hatchling.build" [tool.mypy] strict = true -python_version = "3.9" +python_version = "3.10" files = [ "src", "tests", @@ -134,6 +133,8 @@ parallel = true omit = [ "*/_compat.py", "docs/conf.py", + "tests/test_*.py", + "tests/*/test_*.py", ] [tool.coverage.report] @@ -148,11 +149,21 @@ exclude_lines = [ "if TYPE_CHECKING:", "if t.TYPE_CHECKING:", "@overload( |$)", + 'class .*\bProtocol\):', "from __future__ import annotations", + "import typing as t", + "^\\s*\\.\\.\\.$", + "^\\s*pass$", + "^\\s*assert ", + "^\\s*logger\\s*=", + "^import ", + "^from .* import", + ": TypeAlias = ", + "= t\\.TypeVar\\(", ] [tool.ruff] -target-version = "py39" +target-version = "py310" [tool.ruff.lint] select = [ @@ -195,6 +206,7 @@ required-imports = [ [tool.ruff.lint.flake8-builtins] builtins-allowed-modules = [ "dataclasses", + "random", "types", ] diff --git a/src/libtmux/__about__.py b/src/libtmux/__about__.py index c177eca00..044bc799c 100644 --- a/src/libtmux/__about__.py +++ b/src/libtmux/__about__.py @@ -4,7 +4,7 @@ __title__ = "libtmux" __package_name__ = "libtmux" -__version__ = "0.42.1" +__version__ = "0.52.1" __description__ = "Typed scripting library / ORM / API wrapper for tmux" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/libtmux/_internal/constants.py b/src/libtmux/_internal/constants.py new file mode 100644 index 000000000..d344c9cd9 --- /dev/null +++ b/src/libtmux/_internal/constants.py @@ -0,0 +1,599 @@ +"""Internal constants.""" + +from __future__ import annotations + +import io +import logging +import typing as t +from dataclasses import dataclass, field + +from libtmux._internal.dataclasses import SkipDefaultFieldsReprMixin +from libtmux._internal.sparse_array import SparseArray, is_sparse_array_list + +if t.TYPE_CHECKING: + from typing import TypeAlias + + +T = t.TypeVar("T") + +TerminalFeatures = dict[str, list[str]] +HookArray: TypeAlias = "dict[str, SparseArray[str]]" + +logger = logging.getLogger(__name__) + + +@dataclass(repr=False) +class ServerOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux server options.""" + + backspace: str | None = field(default=None) + buffer_limit: int | None = field(default=None) + command_alias: SparseArray[str] = field(default_factory=SparseArray) + default_terminal: str | None = field(default=None) + copy_command: str | None = field(default=None) + escape_time: int | None = field(default=None) + editor: str | None = field(default=None) + exit_empty: t.Literal["on", "off"] | None = field(default=None) + exit_unattached: t.Literal["on", "off"] | None = field(default=None) + extended_keys: t.Literal["on", "off", "always"] | None = field(default=None) + focus_events: t.Literal["on", "off"] | None = field(default=None) + history_file: str | None = field(default=None) + message_limit: int | None = field(default=None) + prompt_history_limit: int | None = field(default=None) + set_clipboard: t.Literal["on", "external", "off"] | None = field(default=None) + terminal_features: TerminalFeatures = field(default_factory=dict) + terminal_overrides: SparseArray[str] = field(default_factory=SparseArray) + user_keys: SparseArray[str] = field(default_factory=SparseArray) + # tmux 3.5+ options + default_client_command: str | None = field(default=None) + extended_keys_format: t.Literal["csi-u", "xterm"] | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class SessionOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux session options.""" + + activity_action: t.Literal["any", "none", "current", "other"] | None = field( + default=None, + ) + assume_paste_time: int | None = field(default=None) + base_index: int | None = field(default=None) + bell_action: t.Literal["any", "none", "current", "other"] | None = field( + default=None, + ) + default_command: str | None = field(default=None) + default_shell: str | None = field(default=None) + default_size: str | None = field(default=None) # Format "XxY" + destroy_unattached: t.Literal["on", "off"] | None = field(default=None) + detach_on_destroy: ( + t.Literal["off", "on", "no-detached", "previous", "next"] | None + ) = field(default=None) + display_panes_active_colour: str | None = field(default=None) + display_panes_colour: str | None = field(default=None) + display_panes_time: int | None = field(default=None) + display_time: int | None = field(default=None) + history_limit: int | None = field(default=None) + key_table: str | None = field(default=None) + lock_after_time: int | None = field(default=None) + lock_command: str | None = field(default=None) + menu_style: str | None = field(default=None) + menu_selected_style: str | None = field(default=None) + menu_border_style: str | None = field(default=None) + menu_border_lines: ( + t.Literal["single", "rounded", "double", "heavy", "simple", "padded", "none"] + | None + ) = field(default=None) + message_command_style: str | None = field(default=None) + message_line: int | None = field(default=None) + message_style: str | None = field(default=None) + mouse: t.Literal["on", "off"] | None = field(default=None) + prefix: str | None = field(default=None) + prefix2: str | None = field(default=None) + renumber_windows: t.Literal["on", "off"] | None = field(default=None) + repeat_time: int | None = field(default=None) + set_titles: t.Literal["on", "off"] | None = field(default=None) + set_titles_string: str | None = field(default=None) + silence_action: t.Literal["any", "none", "current", "other"] | None = field( + default=None, + ) + status: t.Literal["off", "on"] | int | None = field(default=None) + status_format: list[str] | None = field(default=None) + status_interval: int | None = field(default=None) + status_justify: t.Literal["left", "centre", "right", "absolute-centre"] | None = ( + field(default=None) + ) + status_keys: t.Literal["vi", "emacs"] | None = field(default=None) + status_left: str | None = field(default=None) + status_left_length: int | None = field(default=None) + status_left_style: str | None = field(default=None) + status_position: t.Literal["top", "bottom"] | None = field(default=None) + status_right: str | None = field(default=None) + status_right_length: int | None = field(default=None) + status_right_style: str | None = field(default=None) + status_style: str | None = field(default=None) + update_environment: SparseArray[str] = field(default_factory=SparseArray) + visual_activity: t.Literal["on", "off", "both"] | None = field(default=None) + visual_bell: t.Literal["on", "off", "both"] | None = field(default=None) + visual_silence: t.Literal["on", "off", "both"] | None = field(default=None) + word_separators: str | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class WindowOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux window options.""" + + aggressive_resize: t.Literal["on", "off"] | None = field(default=None) + automatic_rename: t.Literal["on", "off"] | None = field(default=None) + automatic_rename_format: str | None = field(default=None) + clock_mode_colour: str | None = field(default=None) + clock_mode_style: t.Literal["12", "24"] | None = field(default=None) + fill_character: str | None = field(default=None) + main_pane_height: int | str | None = field(default=None) + main_pane_width: int | str | None = field(default=None) + copy_mode_match_style: str | None = field(default=None) + copy_mode_mark_style: str | None = field(default=None) + copy_mode_current_match_style: str | None = field(default=None) + mode_keys: t.Literal["vi", "emacs"] | None = field(default=None) + mode_style: str | None = field(default=None) + monitor_activity: t.Literal["on", "off"] | None = field(default=None) + monitor_bell: t.Literal["on", "off"] | None = field(default=None) + monitor_silence: int | None = field(default=None) # Assuming seconds as int + other_pane_height: int | str | None = field(default=None) + other_pane_width: int | str | None = field(default=None) + pane_active_border_style: str | None = field(default=None) + pane_base_index: int | None = field(default=None) + pane_border_format: str | None = field(default=None) + pane_border_indicators: t.Literal["off", "colour", "arrows", "both"] | None = field( + default=None, + ) + pane_border_lines: ( + t.Literal["single", "double", "heavy", "simple", "number"] | None + ) = field(default=None) + pane_border_status: t.Literal["off", "top", "bottom"] | None = field( + default=None, + ) + pane_border_style: str | None = field(default=None) + popup_style: str | None = field(default=None) + popup_border_style: str | None = field(default=None) + popup_border_lines: ( + t.Literal["single", "rounded", "double", "heavy", "simple", "padded", "none"] + | None + ) = field(default=None) + window_status_activity_style: str | None = field(default=None) + window_status_bell_style: str | None = field(default=None) + window_status_current_format: str | None = field(default=None) + window_status_current_style: str | None = field(default=None) + window_status_format: str | None = field(default=None) + window_status_last_style: str | None = field(default=None) + window_status_separator: str | None = field(default=None) + window_status_style: str | None = field(default=None) + window_size: t.Literal["largest", "smallest", "manual", "latest"] | None = field( + default=None, + ) + wrap_search: t.Literal["on", "off"] | None = field(default=None) + # tmux 3.5+ options + tiled_layout_max_columns: int | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class PaneOptions( + SkipDefaultFieldsReprMixin, +): + """Container for tmux pane options.""" + + allow_passthrough: t.Literal["on", "off", "all"] | None = field(default=None) + allow_rename: t.Literal["on", "off"] | None = field(default=None) + alternate_screen: t.Literal["on", "off"] | None = field(default=None) + cursor_colour: str | None = field(default=None) + pane_colours: list[str] | None = field(default=None) + cursor_style: ( + t.Literal[ + "default", + "blinking-block", + "block", + "blinking-underline", + "underline", + "blinking-bar", + "bar", + ] + | None + ) = field(default=None) + remain_on_exit: t.Literal["on", "off", "failed"] | None = field(default=None) + remain_on_exit_format: str | None = field(default=None) + scroll_on_clear: t.Literal["on", "off"] | None = field(default=None) + synchronize_panes: t.Literal["on", "off"] | None = field(default=None) + window_active_style: str | None = field(default=None) + window_style: str | None = field(default=None) + # tmux 3.5+ options + pane_scrollbars: t.Literal["off", "modal", "on"] | None = field(default=None) + pane_scrollbars_style: str | None = field(default=None) + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + setattr(self, key_underscored, value) + + +@dataclass(repr=False) +class Options( + ServerOptions, + SessionOptions, + WindowOptions, + PaneOptions, + SkipDefaultFieldsReprMixin, +): + """Container for all tmux options (server, session, window, and pane).""" + + def __init__(self, **kwargs: object) -> None: + # Convert hyphenated keys to underscored attribute names and assign values + # Remove asaterisk from inherited options + for key, value in kwargs.items(): + key_underscored = key.replace("-", "_") + key_asterisk_removed = key_underscored.rstrip("*") + setattr(self, key_asterisk_removed, value) + + +@dataclass(repr=False) +class Hooks( + SkipDefaultFieldsReprMixin, +): + """tmux hooks data structure. + + Parses tmux hook output into typed :class:`SparseArray` fields, preserving + array indices for hooks that can have multiple commands at different indices. + + Examples + -------- + Parse raw tmux hook output: + + >>> from libtmux._internal.constants import Hooks + + >>> raw = [ + ... "session-renamed[0] set-option -g status-left-style bg=red", + ... "session-renamed[1] display-message 'session renamed'", + ... ] + >>> hooks = Hooks.from_stdout(raw) + + Access individual hook commands by index: + + >>> hooks.session_renamed[0] + 'set-option -g status-left-style bg=red' + >>> hooks.session_renamed[1] + "display-message 'session renamed'" + + Get all commands as a list (sorted by index): + + >>> hooks.session_renamed.as_list() + ['set-option -g status-left-style bg=red', "display-message 'session renamed'"] + + Sparse indices are preserved (gaps in index numbers): + + >>> raw_sparse = [ + ... "pane-focus-in[0] refresh-client", + ... "pane-focus-in[5] display-message 'focus'", + ... ] + >>> hooks_sparse = Hooks.from_stdout(raw_sparse) + >>> 0 in hooks_sparse.pane_focus_in + True + >>> 5 in hooks_sparse.pane_focus_in + True + >>> 3 in hooks_sparse.pane_focus_in + False + >>> sorted(hooks_sparse.pane_focus_in.keys()) + [0, 5] + + Iterate over values in index order: + + >>> for cmd in hooks_sparse.pane_focus_in.iter_values(): + ... print(cmd) + refresh-client + display-message 'focus' + + Multiple hook types in one parse: + + >>> raw_multi = [ + ... "after-new-window[0] select-pane -t 0", + ... "after-new-window[1] send-keys 'clear' Enter", + ... "window-renamed[0] refresh-client -S", + ... ] + >>> hooks_multi = Hooks.from_stdout(raw_multi) + >>> len(hooks_multi.after_new_window) + 2 + >>> len(hooks_multi.window_renamed) + 1 + """ + + # --- Tmux normal hooks --- + # Run when a window has activity. See monitor-activity. + alert_activity: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window has received a bell. See monitor-bell. + alert_bell: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window has been silent. See monitor-silence. + alert_silence: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client becomes the latest active client of its session. + client_active: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is attached. + client_attached: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is detached. + client_detached: SparseArray[str] = field(default_factory=SparseArray) + # Run when focus enters a client. + client_focus_in: SparseArray[str] = field(default_factory=SparseArray) + # Run when focus exits a client. + client_focus_out: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client is resized. + client_resized: SparseArray[str] = field(default_factory=SparseArray) + # Run when a client's attached session is changed. + client_session_changed: SparseArray[str] = field(default_factory=SparseArray) + # Run when the program running in a pane exits, but remain-on-exit is on so the pane + # has not closed. + pane_died: SparseArray[str] = field(default_factory=SparseArray) + # Run when the program running in a pane exits. + pane_exited: SparseArray[str] = field(default_factory=SparseArray) + # Run when the focus enters a pane, if the focus-events option is on. + pane_focus_in: SparseArray[str] = field(default_factory=SparseArray) + # Run when the focus exits a pane, if the focus-events option is on. + pane_focus_out: SparseArray[str] = field(default_factory=SparseArray) + # Run when the terminal clipboard is set using the xterm(1) escape sequence. + pane_set_clipboard: SparseArray[str] = field(default_factory=SparseArray) + # Run when a new session created. + session_created: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session closed. + session_closed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a session is renamed. + session_renamed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is linked into a session. + window_linked: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is renamed. + window_renamed: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is resized. This may be after the client-resized hook is run. + window_resized: SparseArray[str] = field(default_factory=SparseArray) + # Run when a window is unlinked from a session. + window_unlinked: SparseArray[str] = field(default_factory=SparseArray) + # Run when a pane title changes (tmux 3.5+) + pane_title_changed: SparseArray[str] = field(default_factory=SparseArray) + # Run when terminal reports a light theme (tmux 3.5+) + client_light_theme: SparseArray[str] = field(default_factory=SparseArray) + # Run when terminal reports a dark theme (tmux 3.5+) + client_dark_theme: SparseArray[str] = field(default_factory=SparseArray) + + # --- Tmux control mode hooks --- + # The client has detached. + client_detached_control: SparseArray[str] = field(default_factory=SparseArray) + # The client is now attached to the session with ID session-id, which is named name. + client_session_changed_control: SparseArray[str] = field( + default_factory=SparseArray, + ) + # An error has happened in a configuration file. + config_error: SparseArray[str] = field(default_factory=SparseArray) + # The pane has been continued after being paused (if the pause-after flag is set, + # see refresh-client -A). + continue_control: SparseArray[str] = field(default_factory=SparseArray) + # The tmux client is exiting immediately, either because it is not attached to any + # session or an error occurred. + exit_control: SparseArray[str] = field(default_factory=SparseArray) + # New form of %output sent when the pause-after flag is set. + extended_output: SparseArray[str] = field(default_factory=SparseArray) + # The layout of a window with ID window-id changed. + layout_change: SparseArray[str] = field(default_factory=SparseArray) + # A message sent with the display-message command. + message_control: SparseArray[str] = field(default_factory=SparseArray) + # A window pane produced output. + output: SparseArray[str] = field(default_factory=SparseArray) + # The pane with ID pane-id has changed mode. + pane_mode_changed: SparseArray[str] = field(default_factory=SparseArray) + # Paste buffer name has been changed. + paste_buffer_changed: SparseArray[str] = field(default_factory=SparseArray) + # Paste buffer name has been deleted. + paste_buffer_deleted: SparseArray[str] = field(default_factory=SparseArray) + # The pane has been paused (if the pause-after flag is set). + pause_control: SparseArray[str] = field(default_factory=SparseArray) + # The client is now attached to the session with ID session-id, which is named name. + session_changed_control: SparseArray[str] = field(default_factory=SparseArray) + # The current session was renamed to name. + session_renamed_control: SparseArray[str] = field(default_factory=SparseArray) + # The session with ID session-id changed its active window to the window with ID + # window-id. + session_window_changed: SparseArray[str] = field(default_factory=SparseArray) + # A session was created or destroyed. + sessions_changed: SparseArray[str] = field(default_factory=SparseArray) + # The value of the format associated with subscription name has changed to value. + subscription_changed: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id was created but is not linked to the current session. + unlinked_window_add: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id, which is not linked to the current session, was + # closed. + unlinked_window_close: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id, which is not linked to the current session, was + # renamed. + unlinked_window_renamed: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id was linked to the current session. + window_add: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id closed. + window_close: SparseArray[str] = field(default_factory=SparseArray) + # The layout of a window with ID window-id changed. The new layout is window-layout. + # The window's visible layout is window-visible-layout and the window flags are + # window-flags. + window_layout_changed: SparseArray[str] = field(default_factory=SparseArray) + # The active pane in the window with ID window-id changed to the pane with ID + # pane-id. + window_pane_changed: SparseArray[str] = field(default_factory=SparseArray) + # The window with ID window-id was renamed to name. + window_renamed_control: SparseArray[str] = field(default_factory=SparseArray) + + # --- After hooks - Run after specific tmux commands complete --- + # Runs after 'bind-key' completes + after_bind_key: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'capture-pane' completes + after_capture_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'copy-mode' completes + after_copy_mode: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'display-message' completes + after_display_message: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'display-panes' completes + after_display_panes: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'kill-pane' completes + after_kill_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-buffers' completes + after_list_buffers: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-clients' completes + after_list_clients: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-keys' completes + after_list_keys: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-panes' completes + after_list_panes: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-sessions' completes + after_list_sessions: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'list-windows' completes + after_list_windows: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'load-buffer' completes + after_load_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'lock-server' completes + after_lock_server: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'new-session' completes + after_new_session: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'new-window' completes + after_new_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'paste-buffer' completes + after_paste_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'pipe-pane' completes + after_pipe_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'queue' command is processed + after_queue: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'refresh-client' completes + after_refresh_client: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'rename-session' completes + after_rename_session: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'rename-window' completes + after_rename_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'resize-pane' completes + after_resize_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'resize-window' completes + after_resize_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'save-buffer' completes + after_save_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'select-layout' completes + after_select_layout: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'select-pane' completes + after_select_pane: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'select-window' completes + after_select_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'send-keys' completes + after_send_keys: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-buffer' completes + after_set_buffer: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-environment' completes + after_set_environment: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-hook' completes + after_set_hook: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'set-option' completes + after_set_option: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'show-environment' completes + after_show_environment: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'show-messages' completes + after_show_messages: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'show-options' completes + after_show_options: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'split-window' completes + after_split_window: SparseArray[str] = field(default_factory=SparseArray) + # Runs after 'unbind-key' completes + after_unbind_key: SparseArray[str] = field(default_factory=SparseArray) + # Runs when a command fails (tmux 3.5+) + command_error: SparseArray[str] = field(default_factory=SparseArray) + + @classmethod + def from_stdout(cls, value: list[str]) -> Hooks: + """Parse raw tmux hook output into a Hooks instance. + + The parsing pipeline: + + 1. ``parse_options_to_dict()`` - Parse "key value" lines into dict + 2. ``explode_arrays(force_array=True)`` - Extract array indices into SparseArray + 3. ``explode_complex()`` - Handle complex option types + 4. Rename keys: ``session-renamed`` → ``session_renamed`` + + Parameters + ---------- + value : list[str] + Raw tmux output lines from ``show-hooks`` command. + + Returns + ------- + Hooks + Parsed hooks with SparseArray fields for each hook type. + + Examples + -------- + Basic parsing: + + >>> from libtmux._internal.constants import Hooks + + >>> raw = ["session-renamed[0] display-message 'renamed'"] + >>> hooks = Hooks.from_stdout(raw) + >>> hooks.session_renamed[0] + "display-message 'renamed'" + + The pipeline preserves sparse indices: + + >>> raw = [ + ... "after-select-window[0] refresh-client", + ... "after-select-window[10] display-message 'selected'", + ... ] + >>> hooks = Hooks.from_stdout(raw) + >>> sorted(hooks.after_select_window.keys()) + [0, 10] + + Empty input returns empty SparseArrays: + + >>> hooks_empty = Hooks.from_stdout([]) + >>> len(hooks_empty.session_renamed) + 0 + >>> hooks_empty.session_renamed.as_list() + [] + """ + from libtmux.options import ( + explode_arrays, + explode_complex, + parse_options_to_dict, + ) + + output_exploded = explode_complex( + explode_arrays( + parse_options_to_dict( + io.StringIO("\n".join(value)), + ), + force_array=True, + ), + ) + + assert is_sparse_array_list(output_exploded) + + output_renamed: HookArray = { + k.lstrip("%").replace("-", "_"): v for k, v in output_exploded.items() + } + + return cls(**output_renamed) diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 332968dbd..01dd6afef 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -107,7 +107,7 @@ def keygetter( except Exception as e: traceback.print_stack() - logger.debug(f"The above error was {e}") + logger.debug("The above error was %s", e) return None return dct @@ -143,12 +143,12 @@ def parse_lookup( """ try: if isinstance(path, str) and isinstance(lookup, str) and path.endswith(lookup): - field_name = path.rsplit(lookup)[0] + field_name = path.split(lookup, maxsplit=1)[0] if field_name is not None: return keygetter(obj, field_name) except Exception as e: traceback.print_stack() - logger.debug(f"The above error was {e}") + logger.debug("The above error was %s", e) return None @@ -492,7 +492,7 @@ def __eq__( return False if len(self) == len(data): - for a, b in zip(self, data): + for a, b in zip(self, data, strict=False): if isinstance(a, Mapping): a_keys = a.keys() if a.keys == b.keys(): diff --git a/src/libtmux/_internal/sparse_array.py b/src/libtmux/_internal/sparse_array.py new file mode 100644 index 000000000..6e783a226 --- /dev/null +++ b/src/libtmux/_internal/sparse_array.py @@ -0,0 +1,192 @@ +"""Sparse array for libtmux options and hooks.""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from typing import TypeAlias, TypeGuard + + from libtmux.options import ExplodedComplexUntypedOptionsDict + + +T = t.TypeVar("T") +HookArray: TypeAlias = "dict[str, SparseArray[str]]" + + +def is_sparse_array_list( + items: ExplodedComplexUntypedOptionsDict, +) -> TypeGuard[HookArray]: + return all( + isinstance( + v, + SparseArray, + ) + for k, v in items.items() + ) + + +class SparseArray(dict[int, T], t.Generic[T]): + """Support non-sequential indexes while maintaining :class:`list`-like behavior. + + A normal :class:`list` would raise :exc:`IndexError`. + + There are no native sparse arrays in python that contain non-sequential indexes and + maintain list-like behavior. This is useful for handling libtmux options and hooks: + + ``command-alias[1] split-pane=split-window`` to + ``{'command-alias[1]': {'split-pane=split-window'}}`` + + :class:`list` would lose indice info, and :class:`dict` would lose list-like + behavior. + + Examples + -------- + Create a sparse array and add values at non-sequential indices: + + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(0, "first hook command") + >>> arr.add(5, "fifth hook command") + >>> arr.add(2, "second hook command") + + Access values by index (dict-style): + + >>> arr[0] + 'first hook command' + >>> arr[5] + 'fifth hook command' + + Check index existence: + + >>> 0 in arr + True + >>> 3 in arr + False + + Iterate values in sorted index order: + + >>> list(arr.iter_values()) + ['first hook command', 'second hook command', 'fifth hook command'] + + Convert to a list (values only, sorted by index): + + >>> arr.as_list() + ['first hook command', 'second hook command', 'fifth hook command'] + + Append adds at max index + 1: + + >>> arr.append("appended command") + >>> arr[6] + 'appended command' + + Access raw indices: + + >>> sorted(arr.keys()) + [0, 2, 5, 6] + """ + + def add(self, index: int, value: T) -> None: + """Add a value at a specific index. + + Parameters + ---------- + index : int + The index at which to store the value. + value : T + The value to store. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(0, "hook at index 0") + >>> arr.add(10, "hook at index 10") + >>> arr[0] + 'hook at index 0' + >>> arr[10] + 'hook at index 10' + >>> sorted(arr.keys()) + [0, 10] + """ + self[index] = value + + def append(self, value: T) -> None: + """Append a value at the next available index (max + 1). + + Parameters + ---------- + value : T + The value to append. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + Appending to an empty array starts at index 0: + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.append("first") + >>> arr[0] + 'first' + + Appending to a non-empty array adds at max index + 1: + + >>> arr.add(5, "at index 5") + >>> arr.append("appended") + >>> arr[6] + 'appended' + >>> arr.append("another") + >>> arr[7] + 'another' + """ + index = max(self.keys(), default=-1) + 1 + self[index] = value + + def iter_values(self) -> t.Iterator[T]: + """Iterate over values in sorted index order. + + Yields + ------ + T + Values in ascending index order. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(5, "fifth") + >>> arr.add(0, "first") + >>> arr.add(2, "second") + >>> for val in arr.iter_values(): + ... print(val) + first + second + fifth + """ + for index in sorted(self.keys()): + yield self[index] + + def as_list(self) -> list[T]: + """Return values as a list in sorted index order. + + Returns + ------- + list[T] + List of values sorted by their indices. + + Examples + -------- + >>> from libtmux._internal.sparse_array import SparseArray + + >>> arr: SparseArray[str] = SparseArray() + >>> arr.add(10, "tenth") + >>> arr.add(0, "zeroth") + >>> arr.add(5, "fifth") + >>> arr.as_list() + ['zeroth', 'fifth', 'tenth'] + """ + return [self[index] for index in sorted(self.keys())] diff --git a/src/libtmux/_internal/types.py b/src/libtmux/_internal/types.py new file mode 100644 index 000000000..bbb2f32e7 --- /dev/null +++ b/src/libtmux/_internal/types.py @@ -0,0 +1,18 @@ +"""Internal type annotations. + +Notes +----- +:class:`StrPath` is based on `typeshed's`_. + +.. _typeshed's: https://github.com/python/typeshed/blob/5ff32f3/stdlib/_typeshed/__init__.pyi#L176-L179 +""" # E501 + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from os import PathLike + from typing import TypeAlias + +StrPath: TypeAlias = "str | PathLike[str]" diff --git a/src/libtmux/_vendor/version.py b/src/libtmux/_vendor/version.py index 7342bb5d4..b49dab12b 100644 --- a/src/libtmux/_vendor/version.py +++ b/src/libtmux/_vendor/version.py @@ -21,20 +21,13 @@ __all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"] -InfiniteTypes = t.Union[InfinityType, NegativeInfinityType] -PrePostDevType = t.Union[InfiniteTypes, tuple[str, int]] -SubLocalType = t.Union[InfiniteTypes, int, str] -LocalType = t.Union[ - NegativeInfinityType, - tuple[ - t.Union[ - SubLocalType, - tuple[SubLocalType, str], - tuple[NegativeInfinityType, SubLocalType], - ], - ..., - ], -] +InfiniteTypes = InfinityType | NegativeInfinityType +PrePostDevType = InfiniteTypes | tuple[str, int] +SubLocalType = InfiniteTypes | int | str +LocalTuple = ( + SubLocalType | tuple[SubLocalType, str] | tuple[NegativeInfinityType, SubLocalType] +) +LocalType = NegativeInfinityType | tuple[LocalTuple, ...] CmpKey = tuple[ int, tuple[int, ...], diff --git a/src/libtmux/common.py b/src/libtmux/common.py index db0b4151f..60a3b49c7 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -24,10 +24,10 @@ #: Minimum version of tmux required to run libtmux -TMUX_MIN_VERSION = "1.8" +TMUX_MIN_VERSION = "3.2a" #: Most recent version of tmux supported -TMUX_MAX_VERSION = "3.4" +TMUX_MAX_VERSION = "3.6" SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] @@ -35,6 +35,20 @@ PaneDict = dict[str, t.Any] +class CmdProtocol(t.Protocol): + """Command protocol for tmux command.""" + + def __call__(self, cmd: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: + """Wrap tmux_cmd.""" + ... + + +class CmdMixin: + """Command mixin for tmux command.""" + + cmd: CmdProtocol + + class EnvironmentMixin: """Mixin for manager session and server level environment variables in tmux.""" @@ -51,9 +65,14 @@ def set_environment(self, name: str, value: str) -> None: Parameters ---------- name : str - the environment variable name. such as 'PATH'. - option : str - environment value. + The environment variable name, e.g. 'PATH'. + value : str + Environment value. + + Raises + ------ + ValueError + If tmux returns an error. """ args = ["set-environment"] if self._add_option: @@ -78,7 +97,12 @@ def unset_environment(self, name: str) -> None: Parameters ---------- name : str - the environment variable name. such as 'PATH'. + The environment variable name, e.g. 'PATH'. + + Raises + ------ + ValueError + If tmux returns an error. """ args = ["set-environment"] if self._add_option: @@ -102,7 +126,12 @@ def remove_environment(self, name: str) -> None: Parameters ---------- name : str - the environment variable name. such as 'PATH'. + The environment variable name, e.g. 'PATH'. + + Raises + ------ + ValueError + If tmux returns an error. """ args = ["set-environment"] if self._add_option: @@ -288,7 +317,7 @@ def get_version() -> LooseVersion: 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." + " does not meet the minimum tmux version requirement." ) raise exc.LibTmuxException( msg, @@ -312,7 +341,7 @@ def has_version(version: str) -> bool: Parameters ---------- version : str - version number, e.g. '1.8' + version number, e.g. '3.2a' Returns ------- @@ -328,7 +357,7 @@ def has_gt_version(min_version: str) -> bool: Parameters ---------- min_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -344,7 +373,7 @@ def has_gte_version(min_version: str) -> bool: Parameters ---------- min_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -360,7 +389,7 @@ def has_lte_version(max_version: str) -> bool: Parameters ---------- max_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -376,7 +405,7 @@ def has_lt_version(max_version: str) -> bool: Parameters ---------- max_version : str - tmux version, e.g. '1.8' + tmux version, e.g. '3.2a' Returns ------- @@ -387,7 +416,7 @@ def has_lt_version(max_version: str) -> bool: 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 version requirement. Version >= 3.2a. Parameters ---------- @@ -406,20 +435,22 @@ def has_minimum_version(raises: bool = True) -> bool: Notes ----- + .. versionchanged:: 0.49.0 + Minimum version bumped to 3.2a. For older tmux, use libtmux v0.48.x. + .. versionchanged:: 0.7.0 No longer returns version, returns True or 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 will now remove trailing letters per + `Issue 55 `_. """ if get_version() < LooseVersion(TMUX_MIN_VERSION): if raises: msg = ( f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater. This " - + f"system has {get_version()} installed. Upgrade your tmux to use " - + "libtmux." + f"system has {get_version()} installed. Upgrade your tmux to use " + "libtmux, or use libtmux v0.48.x for older tmux versions." ) raise exc.VersionTooLow(msg) return False @@ -450,42 +481,6 @@ 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 - - In tmux <2.4, unknown option was the only option. - - All errors raised will have the base error of :exc:`exc.OptionError`. So to - catch any option error, use ``except exc.OptionError``. - - Parameters - ---------- - error : str - Error response from subprocess call. - - Raises - ------ - :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, :exc:`exc.InvalidOption`, - :exc:`exc.AmbiguousOption` - """ - if "unknown option" in error: - raise exc.UnknownOption(error) - if "invalid option" in error: - raise exc.InvalidOption(error) - if "ambiguous option" in error: - raise exc.AmbiguousOption(error) - raise exc.OptionError(error) # Raise generic option error - - def get_libtmux_version() -> LooseVersion: """Return libtmux version is a PEP386 compliant format. diff --git a/src/libtmux/constants.py b/src/libtmux/constants.py index b4c23ee64..43ad3f519 100644 --- a/src/libtmux/constants.py +++ b/src/libtmux/constants.py @@ -51,3 +51,35 @@ class PaneDirection(enum.Enum): PaneDirection.Right: ["-h"], PaneDirection.Left: ["-h", "-b"], } + + +class _DefaultOptionScope: + # Sentinel value for default scope + ... + + +DEFAULT_OPTION_SCOPE: _DefaultOptionScope = _DefaultOptionScope() + + +class OptionScope(enum.Enum): + """Scope used with ``set-option`` and ``show-option(s)`` commands.""" + + Server = "SERVER" + Session = "SESSION" + Window = "WINDOW" + Pane = "PANE" + + +OPTION_SCOPE_FLAG_MAP: dict[OptionScope, str] = { + OptionScope.Server: "-s", + OptionScope.Session: "", + OptionScope.Window: "-w", + OptionScope.Pane: "-p", +} + +HOOK_SCOPE_FLAG_MAP: dict[OptionScope, str] = { + OptionScope.Server: "-g", + OptionScope.Session: "", + OptionScope.Window: "-w", + OptionScope.Pane: "-p", +} diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 7777403f3..6a47b247c 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -19,6 +19,35 @@ class LibTmuxException(Exception): """Base Exception for libtmux Errors.""" +class DeprecatedError(LibTmuxException): + """Raised when a deprecated function, method, or parameter is used. + + This exception provides clear guidance on what to use instead. + + Parameters + ---------- + deprecated : str + The name of the deprecated API (e.g., "Pane.resize_pane()") + replacement : str + The recommended replacement API to use instead + version : str + The version when the API was deprecated (e.g., "0.28.0") + """ + + def __init__( + self, + *, + deprecated: str, + replacement: str, + version: str, + ) -> None: + msg = ( + f"{deprecated} was deprecated in {version} and has been removed. " + f"Use {replacement} instead." + ) + super().__init__(msg) + + class TmuxSessionExists(LibTmuxException): """Session does not exist in the server.""" @@ -81,7 +110,7 @@ def __init__(self, *args: object) -> None: class InvalidOption(OptionError): - """Option invalid to tmux, introduced in tmux v2.4.""" + """Option invalid to tmux.""" class AmbiguousOption(OptionError): diff --git a/src/libtmux/hooks.py b/src/libtmux/hooks.py new file mode 100644 index 000000000..c0c28fd09 --- /dev/null +++ b/src/libtmux/hooks.py @@ -0,0 +1,525 @@ +"""Helpers for tmux hooks. + +tmux Hook Features +------------------ +Hooks are array options (e.g., ``session-renamed[0]``, ``session-renamed[1]``) +with sparse index support (can have gaps: ``[0]``, ``[5]``, ``[10]``). + +All features available in libtmux's minimum supported version (tmux 3.2+): + +- Session, window, and pane-level hooks +- Window hooks via ``-w`` flag, pane hooks via ``-p`` flag +- Hook scope separation (session vs window vs pane) + +**tmux 3.3+**: +- ``client-active`` hook +- ``window-resized`` hook + +**tmux 3.5+**: +- ``pane-title-changed`` hook +- ``client-light-theme`` / ``client-dark-theme`` hooks +- ``command-error`` hook + +Bulk Operations API +------------------- +This module provides bulk operations for managing multiple indexed hooks: + +- :meth:`~HooksMixin.set_hooks` - Set multiple hooks at once +""" + +from __future__ import annotations + +import logging +import re +import typing as t +import warnings + +from libtmux._internal.constants import ( + Hooks, +) +from libtmux._internal.sparse_array import SparseArray +from libtmux.common import CmdMixin, has_lt_version +from libtmux.constants import ( + DEFAULT_OPTION_SCOPE, + HOOK_SCOPE_FLAG_MAP, + OptionScope, + _DefaultOptionScope, +) +from libtmux.options import handle_option_error + +if t.TYPE_CHECKING: + from typing_extensions import Self + +HookDict = dict[str, t.Any] +HookValues = dict[int, str] | SparseArray[str] | list[str] + +logger = logging.getLogger(__name__) + + +class HooksMixin(CmdMixin): + """Mixin for manager scoped hooks in tmux. + + Requires tmux 3.1+. For older versions, use raw commands. + """ + + default_hook_scope: OptionScope | None + hooks: Hooks + + def __init__(self, default_hook_scope: OptionScope | None) -> None: + """When not a user (custom) hook, scope can be implied.""" + self.default_hook_scope = default_hook_scope + self.hooks = Hooks() + + def run_hook( + self, + hook: str, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Run a hook immediately. Useful for testing.""" + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: list[str] = ["-R"] + + if global_ is not None and global_: + flags.append("-g") + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd( + "set-hook", + *flags, + hook, + ) + + if isinstance(cmd.stderr, list) and len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return self + + def set_hook( + self, + hook: str, + value: int | str, + unset: bool | None = None, + run: bool | None = None, + append: bool | None = None, + g: bool | None = None, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Set hook for tmux target. + + Wraps ``$ tmux set-hook ``. + + Parameters + ---------- + hook : str + hook to set, e.g. 'aggressive-resize' + value : int | str + hook command. + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + if g: + warnings.warn( + "g argument is deprecated in favor of global_", + category=DeprecationWarning, + stacklevel=2, + ) + global_ = g + + flags: list[str] = [] + + if unset is not None and unset: + assert isinstance(unset, bool) + flags.append("-u") + + if run is not None and run: + assert isinstance(run, bool) + flags.append("-R") + + if append is not None and append: + assert isinstance(append, bool) + flags.append("-a") + + if global_ is not None and global_: + assert isinstance(global_, bool) + flags.append("-g") + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd( + "set-hook", + *flags, + hook, + value, + ) + + if isinstance(cmd.stderr, list) and len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return self + + def unset_hook( + self, + hook: str, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Unset hook for tmux target. + + Wraps ``$ tmux set-hook -u `` / ``$ tmux set-hook -U `` + + Parameters + ---------- + hook : str + hook to unset, e.g. 'after-show-environment' + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: list[str] = ["-u"] + + if global_ is not None and global_: + assert isinstance(global_, bool) + flags.append("-g") + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd( + "set-hook", + *flags, + hook, + ) + + if isinstance(cmd.stderr, list) and len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return self + + def show_hooks( + self, + global_: bool | None = False, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> HookDict: + """Return a dict of hooks for the target. + + Parameters + ---------- + global_ : bool, optional + Pass ``-g`` flag for global hooks, default False. + scope : OptionScope | _DefaultOptionScope | None, optional + Hook scope (Server/Session/Window/Pane), defaults to object's scope. + + Returns + ------- + HookDict + Dictionary mapping hook names to their values. + + Examples + -------- + >>> session.set_hook('session-renamed[0]', 'display-message "test"') + Session($...) + + >>> hooks = session.show_hooks() + >>> isinstance(hooks, dict) + True + + >>> 'session-renamed[0]' in hooks + True + + >>> session.unset_hook('session-renamed') + Session($...) + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: tuple[str, ...] = () + + if global_: + flags += ("-g",) + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + cmd = self.cmd("show-hooks", *flags) + output = cmd.stdout + hooks: HookDict = {} + for item in output: + # Split on first whitespace only to handle multi-word hook values + parts = item.split(None, 1) + if len(parts) == 2: + key, val = parts + elif len(parts) == 1: + key, val = parts[0], None + else: + logger.warning(f"Error extracting hook: {item}") + continue + + if isinstance(val, str) and val.isdigit(): + hooks[key] = int(val) + elif isinstance(val, str): + hooks[key] = val + + return hooks + + def _show_hook( + self, + hook: str, + global_: bool = False, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> list[str] | None: + """Return value for the hook. + + Parameters + ---------- + hook : str + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + """ + if scope is DEFAULT_OPTION_SCOPE: + scope = self.default_hook_scope + + flags: tuple[str | int, ...] = () + + if global_: + flags += ("-g",) + + if scope is not None and not isinstance(scope, _DefaultOptionScope): + assert scope in HOOK_SCOPE_FLAG_MAP + + flag = HOOK_SCOPE_FLAG_MAP[scope] + if flag in {"-p", "-w"} and has_lt_version("3.2"): + warnings.warn( + "Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.", + stacklevel=2, + ) + else: + flags += (flag,) + + flags += (hook,) + + cmd = self.cmd("show-hooks", *flags) + + if len(cmd.stderr): + handle_option_error(cmd.stderr[0]) + + return cmd.stdout + + def show_hook( + self, + hook: str, + global_: bool = False, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> str | int | SparseArray[str] | None: + """Return value for a hook. + + For array hooks (e.g., ``session-renamed``), returns a + :class:`~libtmux._internal.sparse_array.SparseArray` with hook values + at their original indices. Use ``.keys()`` for indices and ``.values()`` + for values. + + Parameters + ---------- + hook : str + Hook name to query + + Returns + ------- + str | int | SparseArray[str] | None + Hook value. For array hooks, returns SparseArray. + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, + :exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption` + + Examples + -------- + >>> session.set_hook('session-renamed[0]', 'display-message "test"') + Session($...) + + >>> hooks = session.show_hook('session-renamed') + >>> isinstance(hooks, SparseArray) + True + + >>> sorted(hooks.keys()) + [0] + + >>> session.unset_hook('session-renamed') + Session($...) + """ + hooks_output = self._show_hook( + hook=hook, + global_=global_, + scope=scope, + ) + if hooks_output is None: + return None + hooks = Hooks.from_stdout(hooks_output) + + # Check if this is an indexed query (e.g., "session-renamed[0]") + # For indexed queries, return the specific value like _show_option does + hook_attr = hook.lstrip("%").replace("-", "_") + index_match = re.search(r"\[(\d+)\]$", hook_attr) + if index_match: + # Strip the index for attribute lookup + base_hook_attr = re.sub(r"\[\d+\]$", "", hook_attr) + hook_val = getattr(hooks, base_hook_attr, None) + if isinstance(hook_val, SparseArray): + return hook_val.get(int(index_match.group(1))) + return hook_val + + return getattr(hooks, hook_attr, None) + + def set_hooks( + self, + hook: str, + values: HookValues, + *, + clear_existing: bool = False, + global_: bool | None = None, + scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, + ) -> Self: + """Set multiple indexed hooks at once. + + Parameters + ---------- + hook : str + Hook name, e.g. 'session-renamed' + values : HookValues + Values to set. Can be: + - dict[int, str]: {0: 'cmd1', 1: 'cmd2'} - explicit indices + - SparseArray[str]: preserves indices from another hook + - list[str]: ['cmd1', 'cmd2'] - sequential indices starting at 0 + clear_existing : bool + If True, unset all existing hook values first + global_ : bool | None + Use global hooks + scope : OptionScope | None + Scope for the hook + + Returns + ------- + Self + Returns self for method chaining. + + Examples + -------- + Set hooks with explicit indices: + + >>> session.set_hooks('session-renamed', { + ... 0: 'display-message "hook 0"', + ... 1: 'display-message "hook 1"', + ... }) + Session($...) + + >>> hooks = session.show_hook('session-renamed') + >>> sorted(hooks.keys()) + [0, 1] + + >>> session.unset_hook('session-renamed') + Session($...) + + Set hooks from a list (sequential indices): + + >>> session.set_hooks('after-new-window', [ + ... 'select-pane -t 0', + ... 'send-keys "clear" Enter', + ... ]) + Session($...) + + >>> hooks = session.show_hook('after-new-window') + >>> sorted(hooks.keys()) + [0, 1] + + Replace all existing hooks with ``clear_existing=True``: + + >>> session.set_hooks( + ... 'session-renamed', + ... {0: 'display-message "new"'}, + ... clear_existing=True, + ... ) + Session($...) + + >>> hooks = session.show_hook('session-renamed') + >>> sorted(hooks.keys()) + [0] + + >>> session.unset_hook('session-renamed') + Session($...) + + >>> session.unset_hook('after-new-window') + Session($...) + """ + if clear_existing: + self.unset_hook(hook, global_=global_, scope=scope) + + # Convert list to dict with sequential indices + if isinstance(values, list): + values = dict(enumerate(values)) + + for index, value in values.items(): + self.set_hook( + f"{hook}[{index}]", + value, + global_=global_, + scope=scope, + ) + + return self diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index ab5cd712b..932f969e1 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -13,7 +13,7 @@ if t.TYPE_CHECKING: ListCmd = t.Literal["list-sessions", "list-windows", "list-panes"] - ListExtraArgs = t.Optional[Iterable[str]] + ListExtraArgs = Iterable[str] | None from libtmux.server import Server @@ -24,16 +24,6 @@ OutputsRaw = list[OutputRaw] -""" -Quirks: - -QUIRK_TMUX_3_1_X_0001: - -- tmux 3.1 and 3.1a: -- server crash with list-panes w/ buffer_created, client_activity, client_created -""" - - @dataclasses.dataclass() class Obj: """Dataclass of generic tmux object.""" @@ -43,14 +33,11 @@ class Obj: active_window_index: str | None = None alternate_saved_x: str | None = None alternate_saved_y: str | None = None - # See QUIRK_TMUX_3_1_X_0001 buffer_name: str | None = None buffer_sample: str | None = None buffer_size: str | None = None - # See QUIRK_TMUX_3_1_X_0001 client_cell_height: str | None = None client_cell_width: str | None = None - # See QUIRK_TMUX_3_1_X_0001 client_discarded: str | None = None client_flags: str | None = None client_height: str | None = None @@ -174,7 +161,7 @@ def _refresh( obj_key: str, obj_id: str, list_cmd: ListCmd = "list-panes", - list_extra_args: ListExtraArgs | None = None, + list_extra_args: ListExtraArgs = None, ) -> None: assert isinstance(obj_id, str) obj = fetch_obj( @@ -193,7 +180,7 @@ def _refresh( def fetch_objs( server: Server, list_cmd: ListCmd, - list_extra_args: ListExtraArgs | None = None, + list_extra_args: ListExtraArgs = None, ) -> OutputsRaw: """Fetch a listing of raw data from a tmux command.""" formats = list(Obj.__dataclass_fields__.keys()) @@ -224,7 +211,7 @@ def fetch_objs( obj_output = proc.stdout obj_formatters = [ - dict(zip(formats, formatter.split(FORMAT_SEPARATOR))) + dict(zip(formats, formatter.split(FORMAT_SEPARATOR), strict=False)) for formatter in obj_output ] @@ -237,7 +224,7 @@ def fetch_obj( obj_key: str, obj_id: str, list_cmd: ListCmd = "list-panes", - list_extra_args: ListExtraArgs | None = None, + list_extra_args: ListExtraArgs = None, ) -> OutputRaw: """Fetch raw data from tmux command.""" obj_formatters_filtered = fetch_objs( diff --git a/src/libtmux/options.py b/src/libtmux/options.py new file mode 100644 index 000000000..681b16575 --- /dev/null +++ b/src/libtmux/options.py @@ -0,0 +1,1262 @@ +# ruff: NOQA: E501 +"""Helpers for tmux options. + +Option parsing function trade testability and clarity for performance. + +Tmux options +------------ + +Options in tmux consist of empty values, strings, integers, arrays, and complex shapes. + +Marshalling types from text: + +Integers: ``buffer-limit 50`` to ``{'buffer-limit': 50}`` +Booleans: ``exit-unattached on`` to ``{'exit-unattached': True}`` + +Exploding arrays: + +``command-alias[1] split-pane=split-window`` to +``{'command-alias[1]': {'split-pane=split-window'}}`` + +However, there is no equivalent to the above type of object in Python (a sparse array), +so a SparseArray is used. + +Exploding complex shapes: + +``"choose-session=choose-tree -s"`` to ``{'choose-session': 'choose-tree -s'}`` + +Finally, we need to convert hyphenated keys to underscored attribute names and assign +values, as python does not allow hyphens in attribute names. + +``command-alias`` is ``command_alias`` in python. + +Options object +-------------- +Dataclasses are used to provide typed access to tmux' option shape. + +Extra data gleaned from the options, such as user options (custom data) and an option +being inherited, + +User options +------------ +There are also custom user options, preceded with @, which exist are stored to +`Options.context.user_options` as a dictionary. + +> tmux set-option -w my-custom-variable my-value +invalid option: my-custom-option + +> tmux set-option -w @my-custom-option my-value +> tmux show-option -w +@my-custom-optione my-value + +Inherited options +----------------- + +``tmux show-options`` -A can include inherited options. The raw output of an inherited +option is detected by the key having a ``*``:: + + visual-activity* on + visual-bell* off + +A list of options that are inherited is kept at ``Options.context._inherited_options`` and +``Options.context.inherited_options``. + +They are mixed with the normal options, +to differentiate them, run ``show_options()`` without ``include_inherited=True``. +""" + +from __future__ import annotations + +import io +import logging +import re +import shlex +import typing as t +import warnings + +from libtmux._internal.sparse_array import SparseArray +from libtmux.common import CmdMixin +from libtmux.constants import ( + DEFAULT_OPTION_SCOPE, + OPTION_SCOPE_FLAG_MAP, + OptionScope, + _DefaultOptionScope, +) + +from . import exc + +if t.TYPE_CHECKING: + from typing import TypeAlias + + from typing_extensions import Self + + from libtmux._internal.constants import TerminalFeatures + from libtmux.common import tmux_cmd + + +TerminalOverride = dict[str, str | None] +TerminalOverrides = dict[str, TerminalOverride] +CommandAliases = dict[str, str] + +OptionDict: TypeAlias = dict[str, t.Any] +UntypedOptionsDict: TypeAlias = dict[str, str | None] +ExplodedUntypedOptionsDict: TypeAlias = dict[ + str, + str | int | list[str] | dict[str, list[str]], +] +ExplodedComplexUntypedOptionsDict: TypeAlias = dict[ + str, + str + | int + | list[str | int] + | dict[str, list[str | int]] + | SparseArray[str | int] + | None, +] + +logger = logging.getLogger(__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 + + In tmux <2.4, unknown option was the only option. + + All errors raised will have the base error of :exc:`exc.OptionError`. So to + catch any option error, use ``except exc.OptionError``. + + Parameters + ---------- + error : str + Error response from subprocess call. + + Raises + ------ + :exc:`exc.OptionError`, :exc:`exc.UnknownOption`, :exc:`exc.InvalidOption`, + :exc:`exc.AmbiguousOption` + + Examples + -------- + >>> result = server.cmd( + ... 'set-option', + ... 'unknown-option-name', + ... ) + + >>> bool(isinstance(result.stderr, list) and len(result.stderr)) + True + + >>> import pytest + >>> from libtmux import exc + + >>> with pytest.raises(exc.OptionError): + ... handle_option_error(result.stderr[0]) + """ + if "unknown option" in error: + raise exc.UnknownOption(error) + if "invalid option" in error: + raise exc.InvalidOption(error) + if "ambiguous option" in error: + raise exc.AmbiguousOption(error) + raise exc.OptionError(error) # Raise generic option error + + +_V = t.TypeVar("_V") +ConvertedValue: TypeAlias = str | int | bool | None +ConvertedValues: TypeAlias = ( + ConvertedValue + | list[ConvertedValue] + | dict[str, ConvertedValue] + | SparseArray[ConvertedValue] +) + + +def convert_value( + value: _V | None, +) -> ConvertedValue | _V | None: + """Convert raw option strings to python types. + + Examples + -------- + >>> convert_value("on") + True + >>> convert_value("off") + False + + >>> convert_value("1") + 1 + >>> convert_value("50") + 50 + + >>> convert_value("%50") + '%50' + """ + if not isinstance(value, str): + return value + + if value.isdigit(): + return int(value) + + if value == "on": + return True + + if value == "off": + return False + + return value + + +def convert_values( + value: _V | None, +) -> ConvertedValues | _V | None: + """Recursively convert values to python types via :func:`convert_value`. + + >>> convert_values(None) + + >>> convert_values("on") + True + >>> convert_values("off") + False + + >>> convert_values(["on"]) + [True] + >>> convert_values(["off"]) + [False] + + >>> convert_values({"window_index": "1"}) + {'window_index': 1} + + >>> convert_values({"visual-bell": "on"}) + {'visual-bell': True} + """ + if value is None: + return None + if isinstance(value, dict): + # Note: SparseArray inherits from dict, so this branch handles both + for k, v in value.items(): + value[k] = convert_value(v) + return value + if isinstance(value, list): + for idx, v in enumerate(value): + value[idx] = convert_value(v) + return value + return convert_value(value) + + +def parse_options_to_dict( + stdout: t.IO[str], +) -> UntypedOptionsDict: + r"""Process subprocess.stdout options or hook output to flat, naive, untyped dict. + + Does not explode arrays or deep values. + + Examples + -------- + >>> import io + + >>> raw_options = io.StringIO("status-keys vi") + >>> parse_options_to_dict(raw_options) == {"status-keys": "vi"} + True + + >>> int_options = io.StringIO("message-limit 50") + >>> parse_options_to_dict(int_options) == {"message-limit": "50"} + True + + >>> empty_option = io.StringIO("user-keys") + >>> parse_options_to_dict(empty_option) == {"user-keys": None} + True + + >>> array_option = io.StringIO("command-alias[0] split-pane=split-window") + >>> parse_options_to_dict(array_option) == { + ... "command-alias[0]": "split-pane=split-window"} + True + + >>> array_option = io.StringIO("command-alias[40] split-pane=split-window") + >>> parse_options_to_dict(array_option) == { + ... "command-alias[40]": "split-pane=split-window"} + True + + >>> many_options = io.StringIO(r'''status-keys + ... command-alias[0] split-pane=split-window + ... ''') + >>> parse_options_to_dict(many_options) == { + ... "command-alias[0]": "split-pane=split-window", + ... "status-keys": None,} + True + + >>> many_more_options = io.StringIO(r''' + ... terminal-features[0] xterm*:clipboard:ccolour:cstyle:focus + ... terminal-features[1] screen*:title + ... ''') + >>> parse_options_to_dict(many_more_options) == { + ... "terminal-features[0]": "xterm*:clipboard:ccolour:cstyle:focus", + ... "terminal-features[1]": "screen*:title",} + True + + >>> quoted_option = io.StringIO(r''' + ... command-alias[0] "choose-session=choose-tree -s" + ... ''') + >>> parse_options_to_dict(quoted_option) == { + ... "command-alias[0]": "choose-session=choose-tree -s", + ... } + True + """ + output: UntypedOptionsDict = {} + + val: ConvertedValue | None = None + + for item in stdout.readlines(): + if " " in item: + try: + key, val = shlex.split(item) + except ValueError: + key, val = item.split(" ", maxsplit=1) + else: + key, val = item, None + key = key.strip() + + if key: + if isinstance(val, str) and val.endswith("\n"): + val = val.rstrip("\n") + + output[key] = val + return output + + +def explode_arrays( + _dict: UntypedOptionsDict, + force_array: bool = False, +) -> ExplodedUntypedOptionsDict: + """Explode flat, naive options dict's option arrays. + + Examples + -------- + >>> import io + + >>> many_more_options = io.StringIO(r''' + ... terminal-features[0] xterm*:clipboard:ccolour:cstyle:focus + ... terminal-features[1] screen*:title + ... ''') + >>> many_more_flat_dict = parse_options_to_dict(many_more_options) + >>> many_more_flat_dict == { + ... "terminal-features[0]": "xterm*:clipboard:ccolour:cstyle:focus", + ... "terminal-features[1]": "screen*:title",} + True + >>> explode_arrays(many_more_flat_dict) == { + ... "terminal-features": {0: "xterm*:clipboard:ccolour:cstyle:focus", + ... 1: "screen*:title"}} + True + + tmux arrays allow non-sequential indexes, so we need to support that: + + >>> explode_arrays(parse_options_to_dict(io.StringIO(r''' + ... terminal-features[0] xterm*:clipboard:ccolour:cstyle:focus + ... terminal-features[5] screen*:title + ... '''))) == { + ... "terminal-features": {0: "xterm*:clipboard:ccolour:cstyle:focus", + ... 5: "screen*:title"}} + True + + Use ``force_array=True`` for hooks, which always use array format: + + >>> from libtmux._internal.sparse_array import SparseArray + + >>> hooks_output = io.StringIO(r''' + ... session-renamed[0] display-message 'renamed' + ... session-renamed[5] refresh-client + ... pane-focus-in[0] run-shell 'echo focus' + ... ''') + >>> hooks_exploded = explode_arrays( + ... parse_options_to_dict(hooks_output), + ... force_array=True, + ... ) + + Each hook becomes a SparseArray preserving indices: + + >>> isinstance(hooks_exploded["session-renamed"], SparseArray) + True + >>> hooks_exploded["session-renamed"][0] + "display-message 'renamed'" + >>> hooks_exploded["session-renamed"][5] + 'refresh-client' + >>> sorted(hooks_exploded["session-renamed"].keys()) + [0, 5] + """ + options: dict[str, t.Any] = {} + for key, val in _dict.items(): + Default: type[dict[t.Any, t.Any] | SparseArray[str | int | bool | None]] = ( + dict if isinstance(key, str) and key == "terminal-features" else SparseArray + ) + if "[" not in key: + if force_array: + options[key] = Default() + if val is not None: + options[key][0] = val + else: + options[key] = val + continue + + try: + matchgroup = re.match( + r"(?P