Skip to content

Commit 953e3f3

Browse files
committed
hooks(feat): add bulk operations API for managing indexed hooks
why: Enable easier management of multiple indexed hooks at once what: - Add get_hook_indices() to get sorted list of existing indices - Add get_hook_values() to get all values as SparseArray - Add set_hooks_bulk() to set multiple hooks from dict/list/SparseArray - Add clear_hook() to unset all indexed values for a hook - Add append_hook() to append at next available index - Add module docstring with version compatibility notes (3.0-3.5+) - All methods support method chaining via Self return type
1 parent ecdae60 commit 953e3f3

File tree

1 file changed

+287
-1
lines changed

1 file changed

+287
-1
lines changed

src/libtmux/hooks.py

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,38 @@
1-
"""Helpers for tmux hooks."""
1+
"""Helpers for tmux hooks.
2+
3+
tmux Hook Version Compatibility
4+
-------------------------------
5+
Hook array support requires tmux 3.0+.
6+
7+
**tmux 3.0+**:
8+
- Hooks are array options (e.g., ``session-renamed[0]``, ``session-renamed[1]``)
9+
- Sparse indices supported (can have gaps: ``[0]``, ``[5]``, ``[10]``)
10+
- Session-level hooks available
11+
12+
**tmux 3.2+**:
13+
- Window-level hooks via ``-w`` flag
14+
- Pane-level hooks via ``-p`` flag
15+
- Hook scope separation (session vs window vs pane)
16+
17+
**tmux 3.3+**:
18+
- ``client-active`` hook
19+
- ``window-resized`` hook
20+
21+
**tmux 3.5+**:
22+
- ``pane-title-changed`` hook
23+
- ``client-light-theme`` / ``client-dark-theme`` hooks
24+
- ``command-error`` hook
25+
26+
Bulk Operations API
27+
-------------------
28+
This module provides bulk operations for managing multiple indexed hooks:
29+
30+
- :meth:`~HooksMixin.get_hook_indices` - Get list of existing indices for a hook
31+
- :meth:`~HooksMixin.get_hook_values` - Get all values as SparseArray
32+
- :meth:`~HooksMixin.set_hooks_bulk` - Set multiple hooks at once
33+
- :meth:`~HooksMixin.clear_hook` - Unset all indexed values for a hook
34+
- :meth:`~HooksMixin.append_hook` - Append at next available index
35+
"""
236

337
from __future__ import annotations
438

@@ -10,6 +44,7 @@
1044
from libtmux._internal.constants import (
1145
Hooks,
1246
)
47+
from libtmux._internal.sparse_array import SparseArray
1348
from libtmux.common import CmdMixin, has_lt_version
1449
from libtmux.constants import (
1550
DEFAULT_OPTION_SCOPE,
@@ -23,6 +58,7 @@
2358
from typing_extensions import Self
2459

2560
HookDict = dict[str, t.Any]
61+
HookValues = dict[int, str] | SparseArray[str] | list[str]
2662

2763
logger = logging.getLogger(__name__)
2864

@@ -344,3 +380,253 @@ def show_hook(
344380
return None
345381
hooks = Hooks.from_stdout(hooks_output)
346382
return getattr(hooks, hook.replace("-", "_"), None)
383+
384+
# --- Bulk operations API ---
385+
386+
def get_hook_indices(
387+
self,
388+
hook: str,
389+
_global: bool = False,
390+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
391+
ignore_errors: bool | None = None,
392+
) -> list[int]:
393+
"""Get list of indices that exist for a hook.
394+
395+
Parameters
396+
----------
397+
hook : str
398+
Hook name, e.g. 'session-renamed'
399+
_global : bool
400+
Use global hooks
401+
scope : OptionScope | None
402+
Scope for the hook
403+
ignore_errors : bool | None
404+
Suppress errors
405+
406+
Returns
407+
-------
408+
list[int]
409+
Sorted list of indices that exist for this hook.
410+
411+
Examples
412+
--------
413+
>>> # After setting hooks at indices 0, 1, 5:
414+
>>> # session.get_hook_indices('session-renamed')
415+
>>> # [0, 1, 5]
416+
"""
417+
hooks_output = self._show_hook(
418+
hook=hook,
419+
_global=_global,
420+
scope=scope,
421+
ignore_errors=ignore_errors,
422+
)
423+
if hooks_output is None:
424+
return []
425+
hooks = Hooks.from_stdout(hooks_output)
426+
hook_array = getattr(hooks, hook.replace("-", "_"), None)
427+
if hook_array is None or not isinstance(hook_array, SparseArray):
428+
return []
429+
return sorted(hook_array.keys())
430+
431+
def get_hook_values(
432+
self,
433+
hook: str,
434+
_global: bool = False,
435+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
436+
ignore_errors: bool | None = None,
437+
) -> SparseArray[str]:
438+
"""Get all indexed values for a hook as SparseArray.
439+
440+
Parameters
441+
----------
442+
hook : str
443+
Hook name, e.g. 'session-renamed'
444+
_global : bool
445+
Use global hooks
446+
scope : OptionScope | None
447+
Scope for the hook
448+
ignore_errors : bool | None
449+
Suppress errors
450+
451+
Returns
452+
-------
453+
SparseArray[str]
454+
SparseArray containing hook values at their original indices.
455+
456+
Examples
457+
--------
458+
>>> # values = session.get_hook_values('session-renamed')
459+
>>> # for val in values.iter_values():
460+
>>> # print(val)
461+
"""
462+
hooks_output = self._show_hook(
463+
hook=hook,
464+
_global=_global,
465+
scope=scope,
466+
ignore_errors=ignore_errors,
467+
)
468+
if hooks_output is None:
469+
return SparseArray()
470+
hooks = Hooks.from_stdout(hooks_output)
471+
hook_array = getattr(hooks, hook.replace("-", "_"), None)
472+
if hook_array is None or not isinstance(hook_array, SparseArray):
473+
return SparseArray()
474+
# Type narrowing doesn't work with isinstance for generics, so cast
475+
result: SparseArray[str] = hook_array
476+
return result
477+
478+
def set_hooks_bulk(
479+
self,
480+
hook: str,
481+
values: HookValues,
482+
*,
483+
clear_existing: bool = False,
484+
_global: bool | None = None,
485+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
486+
) -> Self:
487+
"""Set multiple indexed hooks at once.
488+
489+
Parameters
490+
----------
491+
hook : str
492+
Hook name, e.g. 'session-renamed'
493+
values : HookValues
494+
Values to set. Can be:
495+
- dict[int, str]: {0: 'cmd1', 1: 'cmd2'} - explicit indices
496+
- SparseArray[str]: preserves indices from another hook
497+
- list[str]: ['cmd1', 'cmd2'] - sequential indices starting at 0
498+
clear_existing : bool
499+
If True, unset all existing hook values first
500+
_global : bool | None
501+
Use global hooks
502+
scope : OptionScope | None
503+
Scope for the hook
504+
505+
Returns
506+
-------
507+
Self
508+
Returns self for method chaining.
509+
510+
Examples
511+
--------
512+
Set hooks with explicit indices:
513+
514+
>>> # session.set_hooks_bulk('session-renamed', {
515+
>>> # 0: 'display-message "hook 0"',
516+
>>> # 1: 'display-message "hook 1"',
517+
>>> # })
518+
519+
Set hooks from a list (sequential indices):
520+
521+
>>> # session.set_hooks_bulk('after-new-window', [
522+
>>> # 'select-pane -t 0',
523+
>>> # 'send-keys "clear" Enter',
524+
>>> # ])
525+
526+
Replace all existing hooks:
527+
528+
>>> # session.set_hooks_bulk('session-renamed', [...], clear_existing=True)
529+
"""
530+
if clear_existing:
531+
self.clear_hook(hook, _global=_global, scope=scope)
532+
533+
# Convert list to dict with sequential indices
534+
if isinstance(values, list):
535+
values = dict(enumerate(values))
536+
537+
for index, value in values.items():
538+
self.set_hook(
539+
f"{hook}[{index}]",
540+
value,
541+
_global=_global,
542+
scope=scope,
543+
)
544+
545+
return self
546+
547+
def clear_hook(
548+
self,
549+
hook: str,
550+
_global: bool | None = None,
551+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
552+
ignore_errors: bool | None = None,
553+
) -> Self:
554+
"""Unset all indexed values for a hook.
555+
556+
Parameters
557+
----------
558+
hook : str
559+
Hook name, e.g. 'session-renamed'
560+
_global : bool | None
561+
Use global hooks
562+
scope : OptionScope | None
563+
Scope for the hook
564+
ignore_errors : bool | None
565+
Suppress errors when unsetting
566+
567+
Returns
568+
-------
569+
Self
570+
Returns self for method chaining.
571+
572+
Examples
573+
--------
574+
>>> # session.clear_hook('session-renamed')
575+
"""
576+
indices = self.get_hook_indices(
577+
hook,
578+
_global=_global if _global is not None else False,
579+
scope=scope,
580+
ignore_errors=ignore_errors,
581+
)
582+
for index in indices:
583+
self.unset_hook(
584+
f"{hook}[{index}]",
585+
_global=_global,
586+
scope=scope,
587+
ignore_errors=ignore_errors,
588+
)
589+
return self
590+
591+
def append_hook(
592+
self,
593+
hook: str,
594+
value: str,
595+
_global: bool | None = None,
596+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
597+
) -> Self:
598+
"""Append a hook value at the next available index.
599+
600+
Parameters
601+
----------
602+
hook : str
603+
Hook name, e.g. 'session-renamed'
604+
value : str
605+
Hook command to append
606+
_global : bool | None
607+
Use global hooks
608+
scope : OptionScope | None
609+
Scope for the hook
610+
611+
Returns
612+
-------
613+
Self
614+
Returns self for method chaining.
615+
616+
Examples
617+
--------
618+
>>> # session.append_hook('session-renamed', 'display-message "appended"')
619+
"""
620+
indices = self.get_hook_indices(
621+
hook,
622+
_global=_global if _global is not None else False,
623+
scope=scope,
624+
)
625+
next_index = max(indices) + 1 if indices else 0
626+
self.set_hook(
627+
f"{hook}[{next_index}]",
628+
value,
629+
_global=_global,
630+
scope=scope,
631+
)
632+
return self

0 commit comments

Comments
 (0)