diff --git a/CHANGES b/CHANGES index 6a60334b7..792466009 100644 --- a/CHANGES +++ b/CHANGES @@ -34,6 +34,149 @@ $ uvx --from 'libtmux' --prerelease allow python _Future release notes will be placed here_ +### 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 +191,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/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/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/index.md b/docs/topics/index.md index 0653bb57b..88bfc9860 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -10,4 +10,5 @@ Explore libtmux’s core functionalities and underlying principles at a high lev context_managers traversal +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/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