Skip to content

Commit 9dd8a01

Browse files
committed
feat(engines): add control-mode engine support
1 parent 94020cd commit 9dd8a01

File tree

9 files changed

+311
-53
lines changed

9 files changed

+311
-53
lines changed

CHANGES

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ $ pip install --user --upgrade --pre libtmux
1313

1414
<!-- To maintainers and contributors: Please add notes for the forthcoming version below -->
1515

16-
- _Future release notes will be placed here_
16+
- Server now accepts pluggable command engines, defaulting to the existing subprocess runner, so future backends (e.g., control mode) can be injected without breaking compatibility.
17+
- Added a control mode engine (`ControlModeEngine`) that executes commands via `tmux -C`, paving the way for control-mode powered workflows while keeping the existing API and defaults intact.
1718

1819
## libtmux 0.46.2 (2025-05-26)
1920

src/libtmux/common.py

Lines changed: 61 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99

1010
import logging
1111
import re
12-
import shutil
13-
import subprocess
1412
import sys
1513
import typing as t
1614

15+
from libtmux.engines import CommandResult, SubprocessEngine, TmuxEngine
16+
1717
from . import exc
1818
from ._compat import LooseVersion
1919

@@ -190,8 +190,23 @@ def getenv(self, name: str) -> str | bool | None:
190190
return opts_dict.get(name)
191191

192192

193+
_default_engine: TmuxEngine = SubprocessEngine()
194+
195+
196+
def _apply_result(target: tmux_cmd | None, result: CommandResult) -> tmux_cmd:
197+
cmd_obj: tmux_cmd = object.__new__(tmux_cmd) if target is None else target
198+
199+
cmd_obj.cmd = list(result.cmd)
200+
cmd_obj.stdout = result.stdout
201+
cmd_obj.stderr = result.stderr
202+
cmd_obj.returncode = result.returncode
203+
cmd_obj.process = result.process
204+
205+
return cmd_obj
206+
207+
193208
class tmux_cmd:
194-
"""Run any :term:`tmux(1)` command through :py:mod:`subprocess`.
209+
"""Run any :term:`tmux(1)` command through the configured engine.
195210
196211
Examples
197212
--------
@@ -219,52 +234,50 @@ class tmux_cmd:
219234
Renamed from ``tmux`` to ``tmux_cmd``.
220235
"""
221236

237+
cmd: list[str]
238+
stdout: list[str]
239+
stderr: list[str]
240+
returncode: int
241+
process: object | None
242+
222243
def __init__(self, *args: t.Any) -> None:
223-
tmux_bin = shutil.which("tmux")
224-
if not tmux_bin:
225-
raise exc.TmuxCommandNotFound
226-
227-
cmd = [tmux_bin]
228-
cmd += args # add the command arguments to cmd
229-
cmd = [str(c) for c in cmd]
230-
231-
self.cmd = cmd
232-
233-
try:
234-
self.process = subprocess.Popen(
235-
cmd,
236-
stdout=subprocess.PIPE,
237-
stderr=subprocess.PIPE,
238-
text=True,
239-
errors="backslashreplace",
240-
)
241-
stdout, stderr = self.process.communicate()
242-
returncode = self.process.returncode
243-
except Exception:
244-
logger.exception(f"Exception for {subprocess.list2cmdline(cmd)}")
245-
raise
246-
247-
self.returncode = returncode
248-
249-
stdout_split = stdout.split("\n")
250-
# remove trailing newlines from stdout
251-
while stdout_split and stdout_split[-1] == "":
252-
stdout_split.pop()
253-
254-
stderr_split = stderr.split("\n")
255-
self.stderr = list(filter(None, stderr_split)) # filter empty values
256-
257-
if "has-session" in cmd and len(self.stderr) and not stdout_split:
258-
self.stdout = [self.stderr[0]]
259-
else:
260-
self.stdout = stdout_split
261-
262-
logger.debug(
263-
"self.stdout for {cmd}: {stdout}".format(
264-
cmd=" ".join(cmd),
265-
stdout=self.stdout,
266-
),
267-
)
244+
result = _default_engine.run(*args)
245+
_apply_result(self, result)
246+
247+
@classmethod
248+
def from_result(cls, result: CommandResult) -> tmux_cmd:
249+
"""Create a :class:`tmux_cmd` instance from a raw result."""
250+
return _apply_result(None, result)
251+
252+
253+
def get_default_engine() -> TmuxEngine:
254+
"""Return the process-based default engine."""
255+
return _default_engine
256+
257+
258+
def set_default_engine(engine: TmuxEngine) -> None:
259+
"""Override the process-based default engine.
260+
261+
Parameters
262+
----------
263+
engine : TmuxEngine
264+
Engine implementation that should back :class:`tmux_cmd` and helper functions.
265+
"""
266+
global _default_engine
267+
_default_engine = engine
268+
269+
270+
def run_tmux_command(engine: TmuxEngine, *args: t.Any) -> tmux_cmd:
271+
"""Execute a tmux command using the provided engine.
272+
273+
Falls back to the global default engine when ``engine`` matches the default,
274+
preserving legacy monkeypatching that targets :class:`tmux_cmd` directly.
275+
"""
276+
if engine is _default_engine:
277+
return tmux_cmd(*args)
278+
279+
result = engine.run(*args)
280+
return tmux_cmd.from_result(result)
268281

269282

270283
def get_version() -> LooseVersion:

src/libtmux/engines/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Engine implementations for libtmux."""
2+
3+
from __future__ import annotations
4+
5+
from .base import CommandResult, TmuxEngine
6+
from .control_mode import ControlModeEngine
7+
from .subprocess import SubprocessEngine
8+
9+
__all__ = [
10+
"CommandResult",
11+
"ControlModeEngine",
12+
"SubprocessEngine",
13+
"TmuxEngine",
14+
]

src/libtmux/engines/base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Core abstractions for libtmux command engines."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Sequence
6+
from dataclasses import dataclass
7+
from typing import Protocol
8+
9+
10+
@dataclass
11+
class CommandResult:
12+
"""Result of executing a tmux command."""
13+
14+
cmd: Sequence[str]
15+
stdout: list[str]
16+
stderr: list[str]
17+
returncode: int
18+
process: object | None = None
19+
20+
21+
class TmuxEngine(Protocol):
22+
"""Protocol for components that can execute tmux commands."""
23+
24+
def run(self, *args: str | int) -> CommandResult: # pragma: no cover
25+
"""Execute a tmux command and return a :class:`CommandResult`.
26+
27+
Implementations may rely on structural typing rather than inheritance.
28+
"""
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Control-mode backed tmux engine."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import shutil
7+
import subprocess
8+
9+
from libtmux import exc
10+
from libtmux.engines.base import CommandResult, TmuxEngine
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def _decode_control_payload(payload: str) -> str:
16+
"""Decode tmux control mode payload sequences."""
17+
if not payload:
18+
return ""
19+
# Control mode escapes mirror C-style escapes. ``unicode_escape`` handles both
20+
# simple escapes and octal sequences (e.g. ``\012``).
21+
try:
22+
return bytes(payload, "utf-8").decode("unicode_escape")
23+
except Exception: # pragma: no cover - defensive, should not happen
24+
logger.debug("Failed to decode control payload %r", payload, exc_info=True)
25+
return payload
26+
27+
28+
class ControlModeEngine(TmuxEngine):
29+
"""Execute tmux commands via control mode (``tmux -C``)."""
30+
31+
def run(self, *args: str | int) -> CommandResult:
32+
"""Execute a tmux command using control mode and return structured output."""
33+
tmux_bin = shutil.which("tmux")
34+
if tmux_bin is None:
35+
raise exc.TmuxCommandNotFound
36+
37+
cmd: list[str] = [tmux_bin, "-C"]
38+
cmd.extend(str(value) for value in args)
39+
40+
try:
41+
process = subprocess.Popen(
42+
cmd,
43+
stdout=subprocess.PIPE,
44+
stderr=subprocess.PIPE,
45+
text=True,
46+
errors="backslashreplace",
47+
)
48+
stdout, stderr = process.communicate()
49+
returncode = process.returncode
50+
except Exception:
51+
logger.exception("Exception for %s", subprocess.list2cmdline(cmd))
52+
raise
53+
54+
stdout_lines: list[str] = []
55+
stderr_lines: list[str] = []
56+
57+
for line in stdout.splitlines():
58+
if line.startswith("%output"):
59+
parts = line.split(" ", 4)
60+
payload = parts[4] if len(parts) >= 5 else ""
61+
stdout_lines.append(_decode_control_payload(payload))
62+
elif line.startswith("%error"):
63+
# ``%error time client flags code message``
64+
parts = line.split(" ", 5)
65+
payload = parts[5] if len(parts) >= 6 else ""
66+
stderr_lines.append(_decode_control_payload(payload))
67+
elif line.startswith(("%begin", "%end", "%exit")):
68+
continue
69+
else:
70+
# Unexpected line: keep behaviour predictable by surfacing it
71+
stdout_lines.append(line)
72+
73+
# Combine regular stderr with parsed control errors
74+
stderr_lines.extend(line for line in stderr.splitlines() if line)
75+
76+
logger.debug("control-mode stdout for %s: %s", " ".join(cmd), stdout_lines)
77+
78+
return CommandResult(
79+
cmd=cmd,
80+
stdout=stdout_lines,
81+
stderr=stderr_lines,
82+
returncode=returncode,
83+
process=None,
84+
)
85+
86+
87+
__all__ = ["ControlModeEngine"]

src/libtmux/engines/subprocess.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Subprocess-backed tmux engine."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import shutil
7+
import subprocess
8+
9+
from libtmux import exc
10+
from libtmux.engines.base import CommandResult, TmuxEngine
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class SubprocessEngine(TmuxEngine):
16+
"""Execute tmux commands via the tmux CLI binary."""
17+
18+
def run(self, *args: str | int) -> CommandResult:
19+
"""Execute a tmux command via subprocess and return structured output."""
20+
tmux_bin = shutil.which("tmux")
21+
if tmux_bin is None:
22+
raise exc.TmuxCommandNotFound
23+
24+
cmd: list[str] = [tmux_bin]
25+
cmd.extend(str(value) for value in args)
26+
27+
try:
28+
process = subprocess.Popen(
29+
cmd,
30+
stdout=subprocess.PIPE,
31+
stderr=subprocess.PIPE,
32+
text=True,
33+
errors="backslashreplace",
34+
)
35+
stdout, stderr = process.communicate()
36+
returncode = process.returncode
37+
except Exception:
38+
logger.exception("Exception for %s", subprocess.list2cmdline(cmd))
39+
raise
40+
41+
stdout_lines = stdout.split("\n")
42+
while stdout_lines and stdout_lines[-1] == "":
43+
stdout_lines.pop()
44+
45+
stderr_lines = [line for line in stderr.split("\n") if line]
46+
47+
if "has-session" in cmd and stderr_lines and not stdout_lines:
48+
stdout_lines = [stderr_lines[0]]
49+
50+
logger.debug(
51+
"self.stdout for %s: %s",
52+
" ".join(cmd),
53+
stdout_lines,
54+
)
55+
56+
return CommandResult(
57+
cmd=cmd,
58+
stdout=stdout_lines,
59+
stderr=stderr_lines,
60+
returncode=returncode,
61+
process=process,
62+
)
63+
64+
65+
__all__ = ["SubprocessEngine"]

src/libtmux/neo.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections.abc import Iterable
99

1010
from libtmux import exc
11-
from libtmux.common import tmux_cmd
11+
from libtmux.common import run_tmux_command
1212
from libtmux.formats import FORMAT_SEPARATOR
1313

1414
if t.TYPE_CHECKING:
@@ -216,7 +216,7 @@ def fetch_objs(
216216

217217
tmux_cmds.append("-F{}".format("".join(tmux_formats)))
218218

219-
proc = tmux_cmd(*tmux_cmds) # output
219+
proc = run_tmux_command(server.engine, *tmux_cmds)
220220

221221
if proc.stderr:
222222
raise exc.LibTmuxException(proc.stderr)

0 commit comments

Comments
 (0)