diff --git a/CHANGES b/CHANGES index 6a60334b7..56139c68a 100644 --- a/CHANGES +++ b/CHANGES @@ -28,12 +28,157 @@ $ uv add libtmux --prerelease allow $ uvx --from 'libtmux' --prerelease allow python ``` -## libtmux 0.50.x (Yet to be released) +## libtmux 0.51.x (Yet to be released) _Future release notes will be placed here_ +## 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 + ## libtmux 0.49.0 (2025-11-29) ### Breaking Changes @@ -48,6 +193,35 @@ deprecation announced in v0.48.0. - Removed version guards throughout the codebase - For users on older tmux, use libtmux v0.48.x +#### 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.48.0 (2025-11-28) ### Breaking Changes diff --git a/MIGRATION b/MIGRATION index 6d62cf917..77fd07dc8 100644 --- a/MIGRATION +++ b/MIGRATION @@ -25,6 +25,93 @@ _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) diff --git a/README.md b/README.md index efd324b67..8a8e3bbe7 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/libtmux/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.49.* +``` + +pyproject.toml: + +```toml +libtmux = "0.49.*" ``` -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,205 +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($... ...) ``` -# Backports +## 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). -- tmux 1.8 to 3.1c: The backports branch is - [`v0.48.x`](https://github.com/tmux-python/libtmux/tree/v0.48.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: >= 3.2a -- python support: >= 3.10, 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/docs/api/hooks.md b/docs/api/hooks.md new file mode 100644 index 000000000..7c4d1cf8f --- /dev/null +++ b/docs/api/hooks.md @@ -0,0 +1,6 @@ +# Hooks + +```{eval-rst} +.. automodule:: libtmux.hooks + :members: +``` diff --git a/docs/api/index.md b/docs/api/index.md index 99d614fee..49c720a5c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -11,6 +11,8 @@ servers sessions windows panes +options +hooks constants common exceptions diff --git a/docs/api/options.md b/docs/api/options.md new file mode 100644 index 000000000..5a5b9af3d --- /dev/null +++ b/docs/api/options.md @@ -0,0 +1,6 @@ +# Options + +```{eval-rst} +.. automodule:: libtmux.options + :members: +``` diff --git a/docs/conf.py b/docs/conf.py index 2297a3898..db39b88fc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -92,7 +92,7 @@ "source_repository": f"{about['__github__']}/", "source_branch": "master", "source_directory": "docs/", - "announcement": "Friendly reminder: 📌 Pin the package, libtmux is pre-1.0 and APIs will be changing throughout 2025.", + "announcement": "Friendly reminder: 📌 Pin the package, libtmux is pre-1.0 and APIs will be changing throughout 2026.", } html_sidebars = { "**": [ diff --git a/docs/index.md b/docs/index.md index 76c4796b6..341532ee9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,19 +1,14 @@ ---- -hide-toc: true ---- - (index)= -```{include} ../README.md +# libtmux +```{include} ../README.md +:start-after: ``` -## Table of Contents - -:hidden: - ```{toctree} :maxdepth: 2 +:hidden: quickstart about 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/quickstart.md b/docs/quickstart.md index 3d2790133..2350bdf97 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -441,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, 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/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 0653bb57b..f22e7f81b 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -4,10 +4,15 @@ orphan: true # 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. +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} -context_managers 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..e0ee05c43 --- /dev/null +++ b/docs/topics/pane_interaction.md @@ -0,0 +1,354 @@ +(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 +``` + +## 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 5493ae7c4..214bbc861 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -136,7 +136,12 @@ True ## Filtering and Finding Objects -Find windows by index: +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 @@ -150,6 +155,109 @@ Get a specific pane by ID: Pane(%... Window(@... ..., Session($... ...))) ``` +### Partial Matching + +Use lookup suffixes like `__contains`, `__startswith`, `__endswith`: + +```python +>>> # 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() +``` + +### Case-Insensitive Matching + +Prefix any lookup with `i` for case-insensitive matching: + +```python +>>> # 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() +``` + +### Regex Filtering + +For complex patterns, use `__regex` or `__iregex`: + +```python +>>> # 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() +``` + +### Chaining Filters + +Multiple conditions can be combined: + +```python +>>> # 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() +``` + +### Get with Default + +Avoid exceptions when an object might not exist: + +```python +>>> # Returns None instead of raising ObjectDoesNotExist +>>> session.windows.get(window_name="nonexistent", default=None) is None +True +``` + ## Checking Relationships Check if objects are related: 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 3f60b27ee..2462b9592 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "libtmux" -version = "0.49.0" +version = "0.50.0" description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/libtmux/__about__.py b/src/libtmux/__about__.py index 6175e07c8..ec485ab9f 100644 --- a/src/libtmux/__about__.py +++ b/src/libtmux/__about__.py @@ -4,7 +4,7 @@ __title__ = "libtmux" __package_name__ = "libtmux" -__version__ = "0.49.0" +__version__ = "0.50.0" __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..99693c958 --- /dev/null +++ b/src/libtmux/_internal/constants.py @@ -0,0 +1,589 @@ +"""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, +): + 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, +): + 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, +): + 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, +): + 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, +): + 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/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/common.py b/src/libtmux/common.py index 056c888fc..7da066eb7 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -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.""" @@ -453,37 +467,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. - - There are 3 different types of option errors: - - - unknown option - - invalid option - - ambiguous 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/hooks.py b/src/libtmux/hooks.py new file mode 100644 index 000000000..c94fc6755 --- /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 : 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/options.py b/src/libtmux/options.py new file mode 100644 index 000000000..75ed85e69 --- /dev/null +++ b/src/libtmux/options.py @@ -0,0 +1,1256 @@ +# 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