Skip to content

Commit 822a2c9

Browse files
authored
feat: Pane.set_title(), configurable tmux_bin, debug logging (#636)
Three independent improvements, plus a bug fix for invalid binary paths. **Pane.set_title()** wraps `select-pane -T` and returns the `Pane` for method chaining. A `Pane.title` property aliases `pane_title` for convenience. The `pane_title` format variable is now included in libtmux's pane format queries (it had been commented out in formats.py with an incorrect "removed in 3.1+" note — pane titles are available in all supported tmux versions). **Configurable tmux binary** — `Server` accepts a `tmux_bin` parameter to point at an alternative binary (e.g. wemux, byobu, or a custom build). The value is threaded through `Server.cmd()`, `Server.raise_if_dead()`, `fetch_objs()` in `neo.py`, all version-check functions (`get_version`, `has_version`, `has_gte_version`, etc.), and `HooksMixin`'s version guards. Child objects (Session, Window, Pane) inherit it automatically via `server.tmux_bin`. Falls back to `shutil.which("tmux")` when not set. **Pre-execution DEBUG logging** — `tmux_cmd` now emits a structured log record with `extra={"tmux_cmd": ...}` before invoking subprocess, using `shlex.join` for POSIX-correct quoting. Existing post-execution records are updated to the same format. Complements the post-execution stdout log and enables a future dry-run mode. **Bug fix** — passing a non-existent path as `tmux_bin` previously surfaced as a raw `FileNotFoundError` from subprocess. Both `tmux_cmd` and `raise_if_dead` now catch `FileNotFoundError` and raise `TmuxCommandNotFound` consistently.
2 parents 3be0433 + 1b5a99b commit 822a2c9

13 files changed

Lines changed: 279 additions & 39 deletions

File tree

CHANGES

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,58 @@ $ uvx --from 'libtmux' --prerelease allow python
3636
_Notes on the upcoming release will go here._
3737
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
3838

39+
### What's new
40+
41+
#### Pane.set_title() (#636)
42+
43+
New {meth}`~libtmux.Pane.set_title` method wraps `select-pane -T`, and
44+
{attr}`~libtmux.Pane.title` provides a short alias for
45+
{attr}`~libtmux.Pane.pane_title`:
46+
47+
```python
48+
pane.set_title("my-worker")
49+
pane.pane_title
50+
# 'my-worker'
51+
pane.title
52+
# 'my-worker'
53+
```
54+
55+
The `pane_title` format variable is now included in libtmux's format queries
56+
(it was previously excluded).
57+
58+
#### Configurable tmux binary path (#636)
59+
60+
{class}`~libtmux.Server` now accepts a `tmux_bin` parameter to use an
61+
alternative tmux binary (e.g. wemux, byobu, or a custom build):
62+
63+
```python
64+
server = Server(socket_name="myserver", tmux_bin="/usr/local/bin/tmux-next")
65+
```
66+
67+
The path is threaded through {meth}`~libtmux.Server.cmd`,
68+
{meth}`~libtmux.Server.raise_if_dead`, {func}`~libtmux.neo.fetch_objs`,
69+
all version-check functions ({func}`~libtmux.common.has_version`,
70+
{func}`~libtmux.common.has_gte_version`, etc.), and hook scope guards in
71+
{class}`~libtmux.hooks.HooksMixin`. All child objects (Session, Window,
72+
Pane) inherit it automatically.
73+
Falls back to ``shutil.which("tmux")`` at execution time when not set.
74+
75+
#### Pre-execution command logging (#636)
76+
77+
{class}`~libtmux.common.tmux_cmd` now logs the full command line at
78+
`DEBUG` level *before* execution, complementing the existing post-execution
79+
stdout logging. This enables diagnostic output and is a prerequisite for a
80+
future dry-run mode.
81+
82+
### Bug fixes
83+
84+
#### `TmuxCommandNotFound` raised for invalid `tmux_bin` path (#636)
85+
86+
Previously, passing a non-existent binary path raised a raw
87+
`FileNotFoundError` from `subprocess`. It now raises
88+
{exc}`~libtmux.exc.TmuxCommandNotFound` consistently, in both
89+
`tmux_cmd` and `raise_if_dead`.
90+
3991
## libtmux 0.54.0 (2026-03-07)
4092

4193
### What's new

src/libtmux/common.py

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import logging
1111
import re
12+
import shlex
1213
import shutil
1314
import subprocess
1415
import sys
@@ -248,17 +249,24 @@ class tmux_cmd:
248249
Renamed from ``tmux`` to ``tmux_cmd``.
249250
"""
250251

251-
def __init__(self, *args: t.Any) -> None:
252-
tmux_bin = shutil.which("tmux")
253-
if not tmux_bin:
252+
def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None:
253+
resolved = tmux_bin or shutil.which("tmux")
254+
if not resolved:
254255
raise exc.TmuxCommandNotFound
255256

256-
cmd = [tmux_bin]
257+
cmd = [resolved]
257258
cmd += args # add the command arguments to cmd
258259
cmd = [str(c) for c in cmd]
259260

260261
self.cmd = cmd
261262

263+
if logger.isEnabledFor(logging.DEBUG):
264+
cmd_str = shlex.join(cmd)
265+
logger.debug(
266+
"tmux command dispatched",
267+
extra={"tmux_cmd": cmd_str},
268+
)
269+
262270
try:
263271
self.process = subprocess.Popen(
264272
cmd,
@@ -269,11 +277,13 @@ def __init__(self, *args: t.Any) -> None:
269277
)
270278
stdout, stderr = self.process.communicate()
271279
returncode = self.process.returncode
280+
except FileNotFoundError:
281+
raise exc.TmuxCommandNotFound from None
272282
except Exception:
273283
logger.error( # noqa: TRY400
274284
"tmux subprocess failed",
275285
extra={
276-
"tmux_cmd": subprocess.list2cmdline(cmd),
286+
"tmux_cmd": shlex.join(cmd),
277287
},
278288
)
279289
raise
@@ -297,7 +307,7 @@ def __init__(self, *args: t.Any) -> None:
297307
logger.debug(
298308
"tmux command completed",
299309
extra={
300-
"tmux_cmd": subprocess.list2cmdline(cmd),
310+
"tmux_cmd": shlex.join(cmd),
301311
"tmux_exit_code": self.returncode,
302312
"tmux_stdout": self.stdout[:100],
303313
"tmux_stderr": self.stderr[:100],
@@ -307,7 +317,7 @@ def __init__(self, *args: t.Any) -> None:
307317
)
308318

309319

310-
def get_version() -> LooseVersion:
320+
def get_version(tmux_bin: str | None = None) -> LooseVersion:
311321
"""Return tmux version.
312322
313323
If tmux is built from git master, the version returned will be the latest
@@ -316,12 +326,19 @@ def get_version() -> LooseVersion:
316326
If using OpenBSD's base system tmux, the version will have ``-openbsd``
317327
appended to the latest version, e.g. ``2.4-openbsd``.
318328
329+
Parameters
330+
----------
331+
tmux_bin : str, optional
332+
Path to tmux binary. If *None*, uses the system tmux from
333+
:func:`shutil.which`.
334+
319335
Returns
320336
-------
321337
:class:`distutils.version.LooseVersion`
322-
tmux version according to :func:`shtuil.which`'s tmux
338+
tmux version according to *tmux_bin* if provided, otherwise the
339+
system tmux from :func:`shutil.which`
323340
"""
324-
proc = tmux_cmd("-V")
341+
proc = tmux_cmd("-V", tmux_bin=tmux_bin)
325342
if proc.stderr:
326343
if proc.stderr[0] == "tmux: unknown option -- V":
327344
if sys.platform.startswith("openbsd"): # openbsd has no tmux -V
@@ -346,93 +363,105 @@ def get_version() -> LooseVersion:
346363
return LooseVersion(version)
347364

348365

349-
def has_version(version: str) -> bool:
366+
def has_version(version: str, tmux_bin: str | None = None) -> bool:
350367
"""Return True if tmux version installed.
351368
352369
Parameters
353370
----------
354371
version : str
355372
version number, e.g. '3.2a'
373+
tmux_bin : str, optional
374+
Path to tmux binary. If *None*, uses the system tmux.
356375
357376
Returns
358377
-------
359378
bool
360379
True if version matches
361380
"""
362-
return get_version() == LooseVersion(version)
381+
return get_version(tmux_bin=tmux_bin) == LooseVersion(version)
363382

364383

365-
def has_gt_version(min_version: str) -> bool:
384+
def has_gt_version(min_version: str, tmux_bin: str | None = None) -> bool:
366385
"""Return True if tmux version greater than minimum.
367386
368387
Parameters
369388
----------
370389
min_version : str
371390
tmux version, e.g. '3.2a'
391+
tmux_bin : str, optional
392+
Path to tmux binary. If *None*, uses the system tmux.
372393
373394
Returns
374395
-------
375396
bool
376397
True if version above min_version
377398
"""
378-
return get_version() > LooseVersion(min_version)
399+
return get_version(tmux_bin=tmux_bin) > LooseVersion(min_version)
379400

380401

381-
def has_gte_version(min_version: str) -> bool:
402+
def has_gte_version(min_version: str, tmux_bin: str | None = None) -> bool:
382403
"""Return True if tmux version greater or equal to minimum.
383404
384405
Parameters
385406
----------
386407
min_version : str
387408
tmux version, e.g. '3.2a'
409+
tmux_bin : str, optional
410+
Path to tmux binary. If *None*, uses the system tmux.
388411
389412
Returns
390413
-------
391414
bool
392415
True if version above or equal to min_version
393416
"""
394-
return get_version() >= LooseVersion(min_version)
417+
return get_version(tmux_bin=tmux_bin) >= LooseVersion(min_version)
395418

396419

397-
def has_lte_version(max_version: str) -> bool:
420+
def has_lte_version(max_version: str, tmux_bin: str | None = None) -> bool:
398421
"""Return True if tmux version less or equal to minimum.
399422
400423
Parameters
401424
----------
402425
max_version : str
403426
tmux version, e.g. '3.2a'
427+
tmux_bin : str, optional
428+
Path to tmux binary. If *None*, uses the system tmux.
404429
405430
Returns
406431
-------
407432
bool
408433
True if version below or equal to max_version
409434
"""
410-
return get_version() <= LooseVersion(max_version)
435+
return get_version(tmux_bin=tmux_bin) <= LooseVersion(max_version)
411436

412437

413-
def has_lt_version(max_version: str) -> bool:
438+
def has_lt_version(max_version: str, tmux_bin: str | None = None) -> bool:
414439
"""Return True if tmux version less than minimum.
415440
416441
Parameters
417442
----------
418443
max_version : str
419444
tmux version, e.g. '3.2a'
445+
tmux_bin : str, optional
446+
Path to tmux binary. If *None*, uses the system tmux.
420447
421448
Returns
422449
-------
423450
bool
424451
True if version below max_version
425452
"""
426-
return get_version() < LooseVersion(max_version)
453+
return get_version(tmux_bin=tmux_bin) < LooseVersion(max_version)
427454

428455

429-
def has_minimum_version(raises: bool = True) -> bool:
456+
def has_minimum_version(raises: bool = True, tmux_bin: str | None = None) -> bool:
430457
"""Return True if tmux meets version requirement. Version >= 3.2a.
431458
432459
Parameters
433460
----------
434461
raises : bool
435462
raise exception if below minimum version requirement
463+
tmux_bin : str, optional
464+
Path to tmux binary. If *None*, uses the system tmux.
436465
437466
Returns
438467
-------
@@ -456,12 +485,13 @@ def has_minimum_version(raises: bool = True) -> bool:
456485
Versions will now remove trailing letters per
457486
`Issue 55 <https://github.com/tmux-python/tmuxp/issues/55>`_.
458487
"""
459-
if get_version() < LooseVersion(TMUX_MIN_VERSION):
488+
current_version = get_version(tmux_bin=tmux_bin)
489+
if current_version < LooseVersion(TMUX_MIN_VERSION):
460490
if raises:
461491
msg = (
462492
f"libtmux only supports tmux {TMUX_MIN_VERSION} and greater. This "
463-
f"system has {get_version()} installed. Upgrade your tmux to use "
464-
"libtmux, or use libtmux v0.48.x for older tmux versions."
493+
f"system has {current_version} installed. Upgrade your "
494+
"tmux to use libtmux, or use libtmux v0.48.x for older tmux versions."
465495
)
466496
raise exc.VersionTooLow(msg)
467497
return False

src/libtmux/formats.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
libtmux.formats
44
~~~~~~~~~~~~~~~
55
6+
:data:`FORMAT_SEPARATOR` is used at runtime by ``neo``, ``pane``, and
7+
``session``. The ``*_FORMATS`` lists (``SESSION_FORMATS``,
8+
``WINDOW_FORMATS``, ``PANE_FORMATS``, etc.) are **reference documentation
9+
only** — they are not imported or consumed at runtime. The active format
10+
mechanism is :class:`libtmux.neo.Obj`, whose dataclass fields are
11+
introspected by :func:`libtmux.neo.get_output_format` to build tmux format
12+
strings dynamically.
13+
614
For reference: https://github.com/tmux/tmux/blob/master/format.c
715
816
"""
@@ -67,7 +75,7 @@
6775
"pane_index",
6876
"pane_width",
6977
"pane_height",
70-
# "pane_title", # removed in 3.1+
78+
"pane_title",
7179
"pane_id",
7280
"pane_active",
7381
"pane_dead",

src/libtmux/hooks.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ def __init__(self, default_hook_scope: OptionScope | None) -> None:
7070
self.default_hook_scope = default_hook_scope
7171
self.hooks = Hooks()
7272

73+
@property
74+
def _tmux_bin(self) -> str | None:
75+
"""Resolve tmux_bin from self (Server) or self.server (Session/Window/Pane)."""
76+
return getattr(self, "tmux_bin", None) or getattr(
77+
getattr(self, "server", None), "tmux_bin", None
78+
)
79+
7380
def run_hook(
7481
self,
7582
hook: str,
@@ -89,7 +96,7 @@ def run_hook(
8996
assert scope in HOOK_SCOPE_FLAG_MAP
9097

9198
flag = HOOK_SCOPE_FLAG_MAP[scope]
92-
if flag in {"-p", "-w"} and has_lt_version("3.2"):
99+
if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin):
93100
warnings.warn(
94101
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
95102
stacklevel=2,
@@ -168,7 +175,7 @@ def set_hook(
168175
assert scope in HOOK_SCOPE_FLAG_MAP
169176

170177
flag = HOOK_SCOPE_FLAG_MAP[scope]
171-
if flag in {"-p", "-w"} and has_lt_version("3.2"):
178+
if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin):
172179
warnings.warn(
173180
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
174181
stacklevel=2,
@@ -221,7 +228,7 @@ def unset_hook(
221228
assert scope in HOOK_SCOPE_FLAG_MAP
222229

223230
flag = HOOK_SCOPE_FLAG_MAP[scope]
224-
if flag in {"-p", "-w"} and has_lt_version("3.2"):
231+
if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin):
225232
warnings.warn(
226233
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
227234
stacklevel=2,
@@ -286,7 +293,7 @@ def show_hooks(
286293
assert scope in HOOK_SCOPE_FLAG_MAP
287294

288295
flag = HOOK_SCOPE_FLAG_MAP[scope]
289-
if flag in {"-p", "-w"} and has_lt_version("3.2"):
296+
if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin):
290297
warnings.warn(
291298
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
292299
stacklevel=2,
@@ -344,7 +351,7 @@ def _show_hook(
344351
assert scope in HOOK_SCOPE_FLAG_MAP
345352

346353
flag = HOOK_SCOPE_FLAG_MAP[scope]
347-
if flag in {"-p", "-w"} and has_lt_version("3.2"):
354+
if flag in {"-p", "-w"} and has_lt_version("3.2", tmux_bin=self._tmux_bin):
348355
warnings.warn(
349356
"Scope flag '-w' and '-p' requires tmux 3.2+. Ignoring.",
350357
stacklevel=2,

src/libtmux/neo.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class Obj:
103103
pane_start_command: str | None = None
104104
pane_start_path: str | None = None
105105
pane_tabs: str | None = None
106+
pane_title: str | None = None
106107
pane_top: str | None = None
107108
pane_tty: str | None = None
108109
pane_width: str | None = None
@@ -318,7 +319,10 @@ def fetch_objs(
318319
},
319320
)
320321

321-
proc = tmux_cmd(*tmux_cmds) # output
322+
proc = tmux_cmd(
323+
*tmux_cmds,
324+
tmux_bin=server.tmux_bin,
325+
)
322326

323327
if proc.stderr:
324328
raise exc.LibTmuxException(proc.stderr)

0 commit comments

Comments
 (0)