Skip to content

Commit 0cc59f2

Browse files
committed
feat!(hooks): HooksMixin
1 parent 1de1ad3 commit 0cc59f2

File tree

1 file changed

+346
-0
lines changed

1 file changed

+346
-0
lines changed

src/libtmux/hooks.py

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
"""Helpers for tmux hooks."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import shlex
7+
import typing as t
8+
import warnings
9+
10+
from libtmux._internal.constants import (
11+
Hooks,
12+
)
13+
from libtmux.common import CmdMixin, has_lt_version
14+
from libtmux.constants import (
15+
DEFAULT_OPTION_SCOPE,
16+
HOOK_SCOPE_FLAG_MAP,
17+
OptionScope,
18+
_DefaultOptionScope,
19+
)
20+
from libtmux.options import handle_option_error
21+
22+
if t.TYPE_CHECKING:
23+
from typing_extensions import Self
24+
25+
HookDict = dict[str, t.Any]
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class HooksMixin(CmdMixin):
31+
"""Mixin for manager scoped hooks in tmux.
32+
33+
Require tmux 3.1+. For older versions, use raw commands.
34+
"""
35+
36+
default_hook_scope: OptionScope | None
37+
hooks: Hooks
38+
39+
def __init__(self, default_hook_scope: OptionScope | None) -> None:
40+
"""When not a user (custom) hook, scope can be implied."""
41+
self.default_hook_scope = default_hook_scope
42+
self.hooks = Hooks()
43+
44+
def run_hook(
45+
self,
46+
hook: str,
47+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
48+
) -> Self:
49+
"""Run a hook immediately. Useful for testing."""
50+
if scope is DEFAULT_OPTION_SCOPE:
51+
scope = self.default_hook_scope
52+
53+
flags: list[str] = ["-R"]
54+
55+
if scope is not None and not isinstance(scope, _DefaultOptionScope):
56+
assert scope in HOOK_SCOPE_FLAG_MAP
57+
58+
flag = HOOK_SCOPE_FLAG_MAP[scope]
59+
if flag in {"-p", "-w"} and has_lt_version("3.2"):
60+
warnings.warn(
61+
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
62+
stacklevel=2,
63+
)
64+
else:
65+
flags += (flag,)
66+
67+
cmd = self.cmd(
68+
"set-hook",
69+
*flags,
70+
hook,
71+
)
72+
73+
if isinstance(cmd.stderr, list) and len(cmd.stderr):
74+
handle_option_error(cmd.stderr[0])
75+
76+
return self
77+
78+
def set_hook(
79+
self,
80+
hook: str,
81+
value: int | str,
82+
_format: bool | None = None,
83+
unset: bool | None = None,
84+
run: bool | None = None,
85+
prevent_overwrite: bool | None = None,
86+
ignore_errors: bool | None = None,
87+
append: bool | None = None,
88+
g: bool | None = None,
89+
_global: bool | None = None,
90+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
91+
) -> Self:
92+
"""Set hook for tmux target.
93+
94+
Wraps ``$ tmux set-hook <hook> <value>``.
95+
96+
Parameters
97+
----------
98+
hook : str
99+
hook to set, e.g. 'aggressive-resize'
100+
value : str
101+
hook command.
102+
103+
Raises
104+
------
105+
:exc:`exc.OptionError`, :exc:`exc.UnknownOption`,
106+
:exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption`
107+
"""
108+
if scope is DEFAULT_OPTION_SCOPE:
109+
scope = self.default_hook_scope
110+
111+
flags: list[str] = []
112+
113+
if unset is not None and unset:
114+
assert isinstance(unset, bool)
115+
flags.append("-u")
116+
117+
if run is not None and run:
118+
assert isinstance(run, bool)
119+
flags.append("-R")
120+
121+
if _format is not None and _format:
122+
assert isinstance(_format, bool)
123+
flags.append("-F")
124+
125+
if prevent_overwrite is not None and prevent_overwrite:
126+
assert isinstance(prevent_overwrite, bool)
127+
flags.append("-o")
128+
129+
if ignore_errors is not None and ignore_errors:
130+
assert isinstance(ignore_errors, bool)
131+
flags.append("-q")
132+
133+
if append is not None and append:
134+
assert isinstance(append, bool)
135+
flags.append("-a")
136+
137+
if _global is not None and _global:
138+
assert isinstance(_global, bool)
139+
flags.append("-g")
140+
141+
if scope is not None and not isinstance(scope, _DefaultOptionScope):
142+
assert scope in HOOK_SCOPE_FLAG_MAP
143+
144+
flag = HOOK_SCOPE_FLAG_MAP[scope]
145+
if flag in {"-p", "-w"} and has_lt_version("3.2"):
146+
warnings.warn(
147+
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
148+
stacklevel=2,
149+
)
150+
else:
151+
flags += (flag,)
152+
153+
cmd = self.cmd(
154+
"set-hook",
155+
*flags,
156+
hook,
157+
value,
158+
)
159+
160+
if isinstance(cmd.stderr, list) and len(cmd.stderr):
161+
handle_option_error(cmd.stderr[0])
162+
163+
return self
164+
165+
def unset_hook(
166+
self,
167+
hook: str,
168+
_global: bool | None = None,
169+
ignore_errors: bool | None = None,
170+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
171+
) -> Self:
172+
"""Unset hook for tmux target.
173+
174+
Wraps ``$ tmux set-hook -u <hook>`` / ``$ tmux set-hook -U <hook>``
175+
176+
Parameters
177+
----------
178+
hook : str
179+
hook to unset, e.g. 'after-show-environment'
180+
181+
Raises
182+
------
183+
:exc:`exc.OptionError`, :exc:`exc.UnknownOption`,
184+
:exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption`
185+
"""
186+
if scope is DEFAULT_OPTION_SCOPE:
187+
scope = self.default_hook_scope
188+
189+
flags: list[str] = ["-u"]
190+
191+
if ignore_errors is not None and ignore_errors:
192+
assert isinstance(ignore_errors, bool)
193+
flags.append("-q")
194+
195+
if _global is not None and _global:
196+
assert isinstance(_global, bool)
197+
flags.append("-g")
198+
199+
if scope is not None and not isinstance(scope, _DefaultOptionScope):
200+
assert scope in HOOK_SCOPE_FLAG_MAP
201+
202+
flag = HOOK_SCOPE_FLAG_MAP[scope]
203+
if flag in {"-p", "-w"} and has_lt_version("3.2"):
204+
warnings.warn(
205+
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
206+
stacklevel=2,
207+
)
208+
else:
209+
flags += (flag,)
210+
211+
cmd = self.cmd(
212+
"set-hook",
213+
*flags,
214+
hook,
215+
)
216+
217+
if isinstance(cmd.stderr, list) and len(cmd.stderr):
218+
handle_option_error(cmd.stderr[0])
219+
220+
return self
221+
222+
def show_hooks(
223+
self,
224+
_global: bool | None = False,
225+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
226+
ignore_errors: bool | None = None,
227+
) -> HookDict:
228+
"""Return a dict of hooks for the target."""
229+
if scope is DEFAULT_OPTION_SCOPE:
230+
scope = self.default_hook_scope
231+
232+
flags: tuple[str, ...] = ()
233+
234+
if _global:
235+
flags += ("-g",)
236+
237+
if scope is not None and not isinstance(scope, _DefaultOptionScope):
238+
assert scope in HOOK_SCOPE_FLAG_MAP
239+
240+
flag = HOOK_SCOPE_FLAG_MAP[scope]
241+
if flag in {"-p", "-w"} and has_lt_version("3.2"):
242+
warnings.warn(
243+
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
244+
stacklevel=2,
245+
)
246+
else:
247+
flags += (flag,)
248+
249+
if ignore_errors is not None and ignore_errors:
250+
assert isinstance(ignore_errors, bool)
251+
flags += ("-q",)
252+
253+
cmd = self.cmd("show-hooks", *flags)
254+
output = cmd.stdout
255+
hooks: HookDict = {}
256+
for item in output:
257+
try:
258+
key, val = shlex.split(item)
259+
except ValueError:
260+
logger.warning(f"Error extracting hook: {item}")
261+
key, val = item, None
262+
assert isinstance(key, str)
263+
assert isinstance(val, str) or val is None
264+
265+
if isinstance(val, str) and val.isdigit():
266+
hooks[key] = int(val)
267+
268+
return hooks
269+
270+
def _show_hook(
271+
self,
272+
hook: str,
273+
_global: bool = False,
274+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
275+
ignore_errors: bool | None = None,
276+
) -> list[str] | None:
277+
"""Return value for the hook.
278+
279+
Parameters
280+
----------
281+
hook : str
282+
283+
Raises
284+
------
285+
:exc:`exc.OptionError`, :exc:`exc.UnknownOption`,
286+
:exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption`
287+
"""
288+
if scope is DEFAULT_OPTION_SCOPE:
289+
scope = self.default_hook_scope
290+
291+
flags: tuple[str | int, ...] = ()
292+
293+
if _global:
294+
flags += ("-g",)
295+
296+
if scope is not None and not isinstance(scope, _DefaultOptionScope):
297+
assert scope in HOOK_SCOPE_FLAG_MAP
298+
299+
flag = HOOK_SCOPE_FLAG_MAP[scope]
300+
if flag in {"-p", "-w"} and has_lt_version("3.2"):
301+
warnings.warn(
302+
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
303+
stacklevel=2,
304+
)
305+
else:
306+
flags += (flag,)
307+
308+
if ignore_errors is not None and ignore_errors:
309+
flags += ("-q",)
310+
311+
flags += (hook,)
312+
313+
cmd = self.cmd("show-hooks", *flags)
314+
315+
if len(cmd.stderr):
316+
handle_option_error(cmd.stderr[0])
317+
318+
return cmd.stdout
319+
320+
def show_hook(
321+
self,
322+
hook: str,
323+
_global: bool = False,
324+
scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE,
325+
ignore_errors: bool | None = None,
326+
) -> str | int | None:
327+
"""Return value for the hook.
328+
329+
Parameters
330+
----------
331+
hook : str
332+
333+
Raises
334+
------
335+
:exc:`exc.OptionError`, :exc:`exc.UnknownOption`,
336+
:exc:`exc.InvalidOption`, :exc:`exc.AmbiguousOption`
337+
"""
338+
hooks_output = self._show_hook(
339+
hook=hook,
340+
scope=scope,
341+
ignore_errors=ignore_errors,
342+
)
343+
if hooks_output is None:
344+
return None
345+
hooks = Hooks.from_stdout(hooks_output)
346+
return getattr(hooks, hook.replace("-", "_"), None)

0 commit comments

Comments
 (0)