Skip to content

Commit 8c87acc

Browse files
committed
refactor(engine): Abstract subprocess execution into pluggable command runner
Add engine abstraction to support multiple tmux command execution backends (subprocess now, control mode in future) while maintaining 100% backward compatibility. New files: - src/libtmux/_internal/command_runner.py: Protocol definitions for CommandRunner and CommandResult interfaces - src/libtmux/_internal/engines/: Engine implementations - subprocess_engine.py: SubprocessCommandRunner wraps existing tmux_cmd - src/libtmux/pytest_plugin.py: Add command_runner fixture for DI in tests Modified files: - src/libtmux/server.py: - Add optional command_runner parameter to Server.__init__() - Add command_runner property with lazy initialization - Modify Server.cmd() to use self.command_runner.run() - src/libtmux/pytest_plugin.py: - Add command_runner fixture (returns None by default) - Update server and TestServer fixtures to accept command_runner Key design decisions: - Protocol-based design (structural typing, no inheritance required) - Optional parameter with lazy initialization (backward compatible) - All existing code works unchanged - SubprocessCommandRunner wraps existing tmux_cmd for compatibility Passes all quality checks: - mypy (strict mode): ✓ - ruff (linting): ✓ - All 409 existing tests pass unchanged
1 parent 8090ed7 commit 8c87acc

File tree

5 files changed

+203
-2
lines changed

5 files changed

+203
-2
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Command runner protocols for tmux execution engines."""
2+
3+
from __future__ import annotations
4+
5+
import typing as t
6+
from typing import Protocol
7+
8+
if t.TYPE_CHECKING:
9+
from libtmux.common import tmux_cmd
10+
11+
12+
class CommandResult(Protocol):
13+
"""Protocol for command execution results.
14+
15+
Any object conforming to this protocol can be returned by a CommandRunner.
16+
The existing tmux_cmd class automatically conforms to this protocol.
17+
18+
Attributes
19+
----------
20+
stdout : list[str]
21+
Command standard output, split by lines
22+
stderr : list[str]
23+
Command standard error, split by lines
24+
returncode : int
25+
Command return code
26+
cmd : list[str]
27+
The command that was executed (for debugging)
28+
"""
29+
30+
@property
31+
def stdout(self) -> list[str]:
32+
"""Command standard output, split by lines."""
33+
...
34+
35+
@property
36+
def stderr(self) -> list[str]:
37+
"""Command standard error, split by lines."""
38+
...
39+
40+
@property
41+
def returncode(self) -> int:
42+
"""Command return code."""
43+
...
44+
45+
@property
46+
def cmd(self) -> list[str]:
47+
"""The command that was executed (for debugging)."""
48+
...
49+
50+
51+
class CommandRunner(Protocol):
52+
"""Protocol for tmux command execution engines.
53+
54+
Implementations must provide a run() method that executes tmux commands
55+
and returns a CommandResult.
56+
57+
Examples
58+
--------
59+
>>> from libtmux._internal.engines import SubprocessCommandRunner
60+
>>> runner = SubprocessCommandRunner()
61+
>>> result = runner.run("-V")
62+
>>> assert hasattr(result, 'stdout')
63+
>>> assert hasattr(result, 'stderr')
64+
>>> assert hasattr(result, 'returncode')
65+
"""
66+
67+
def run(self, *args: str) -> tmux_cmd:
68+
"""Execute a tmux command.
69+
70+
Parameters
71+
----------
72+
*args : str
73+
Command arguments to pass to tmux binary
74+
75+
Returns
76+
-------
77+
CommandResult
78+
Object with stdout, stderr, returncode, cmd attributes
79+
"""
80+
...
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Command execution engines for libtmux."""
2+
3+
from __future__ import annotations
4+
5+
__all__ = ("SubprocessCommandRunner",)
6+
7+
from libtmux._internal.engines.subprocess_engine import SubprocessCommandRunner
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Subprocess-based command execution engine."""
2+
3+
from __future__ import annotations
4+
5+
import typing as t
6+
7+
if t.TYPE_CHECKING:
8+
from libtmux.common import tmux_cmd
9+
10+
11+
class SubprocessCommandRunner:
12+
"""Command runner that uses subprocess to execute tmux binary.
13+
14+
This is the default command runner and wraps the existing tmux_cmd
15+
implementation for backward compatibility.
16+
17+
Examples
18+
--------
19+
>>> runner = SubprocessCommandRunner()
20+
>>> result = runner.run("-V")
21+
>>> assert hasattr(result, 'stdout')
22+
>>> assert hasattr(result, 'stderr')
23+
>>> assert hasattr(result, 'returncode')
24+
"""
25+
26+
def run(self, *args: str) -> tmux_cmd:
27+
"""Execute tmux command via subprocess.
28+
29+
Parameters
30+
----------
31+
*args : str
32+
Arguments to pass to tmux binary
33+
34+
Returns
35+
-------
36+
tmux_cmd
37+
Command result with stdout, stderr, returncode
38+
"""
39+
from libtmux.common import tmux_cmd
40+
41+
return tmux_cmd(*args)

src/libtmux/pytest_plugin.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
if t.TYPE_CHECKING:
2020
import pathlib
2121

22+
from libtmux._internal.command_runner import CommandRunner
2223
from libtmux.session import Session
2324

2425
logger = logging.getLogger(__name__)
@@ -110,11 +111,38 @@ def clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
110111
monkeypatch.delenv(k)
111112

112113

114+
@pytest.fixture
115+
def command_runner() -> CommandRunner | None:
116+
"""Command runner for tests.
117+
118+
Override this fixture in conftest.py to provide a custom command runner
119+
for all tests.
120+
121+
Returns
122+
-------
123+
CommandRunner or None
124+
Custom command runner, or None to use default subprocess runner
125+
126+
Examples
127+
--------
128+
>>> def test_with_default(command_runner):
129+
... assert command_runner is None # Default uses subprocess
130+
131+
To use a custom runner:
132+
133+
>>> @pytest.fixture
134+
... def command_runner():
135+
... return MyCustomRunner()
136+
"""
137+
return None
138+
139+
113140
@pytest.fixture
114141
def server(
115142
request: pytest.FixtureRequest,
116143
monkeypatch: pytest.MonkeyPatch,
117144
config_file: pathlib.Path,
145+
command_runner: CommandRunner | None,
118146
) -> Server:
119147
"""Return new, temporary :class:`libtmux.Server`.
120148
@@ -141,7 +169,10 @@ def server(
141169
142170
>>> result.assert_outcomes(passed=1)
143171
"""
144-
server = Server(socket_name=f"libtmux_test{next(namer)}")
172+
server = Server(
173+
socket_name=f"libtmux_test{next(namer)}",
174+
command_runner=command_runner,
175+
)
145176

146177
def fin() -> None:
147178
server.kill()
@@ -263,6 +294,7 @@ def session(
263294
@pytest.fixture
264295
def TestServer(
265296
request: pytest.FixtureRequest,
297+
command_runner: CommandRunner | None,
266298
) -> type[Server]:
267299
"""Create a temporary tmux server that cleans up after itself.
268300
@@ -309,6 +341,7 @@ def fin() -> None:
309341
"type[Server]",
310342
functools.partial(
311343
Server,
344+
command_runner=command_runner,
312345
on_init=on_init,
313346
socket_name_factory=socket_name_factory,
314347
),

src/libtmux/server.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
from typing_extensions import Self
4040

41+
from libtmux._internal.command_runner import CommandRunner
4142
from libtmux._internal.types import StrPath
4243

4344
DashLiteral: TypeAlias = t.Literal["-"]
@@ -64,6 +65,8 @@ class Server(EnvironmentMixin):
6465
socket_path : str, optional
6566
config_file : str, optional
6667
colors : str, optional
68+
command_runner : CommandRunner, optional
69+
Custom command execution engine. Defaults to subprocess-based runner.
6770
on_init : callable, optional
6871
socket_name_factory : callable, optional
6972
@@ -124,13 +127,15 @@ def __init__(
124127
socket_path: str | pathlib.Path | None = None,
125128
config_file: str | None = None,
126129
colors: int | None = None,
130+
command_runner: CommandRunner | None = None,
127131
on_init: t.Callable[[Server], None] | None = None,
128132
socket_name_factory: t.Callable[[], str] | None = None,
129133
**kwargs: t.Any,
130134
) -> None:
131135
EnvironmentMixin.__init__(self, "-g")
132136
self._windows: list[WindowDict] = []
133137
self._panes: list[PaneDict] = []
138+
self._command_runner = command_runner
134139

135140
if socket_path is not None:
136141
self.socket_path = socket_path
@@ -188,6 +193,39 @@ def __exit__(
188193
if self.is_alive():
189194
self.kill()
190195

196+
@property
197+
def command_runner(self) -> CommandRunner:
198+
"""Get or lazily initialize the command runner.
199+
200+
Returns
201+
-------
202+
CommandRunner
203+
The command execution engine for this server
204+
205+
Examples
206+
--------
207+
>>> server = Server(socket_name="default")
208+
>>> assert server.command_runner is not None
209+
>>> type(server.command_runner).__name__
210+
'SubprocessCommandRunner'
211+
"""
212+
if self._command_runner is None:
213+
from libtmux._internal.engines import SubprocessCommandRunner
214+
215+
self._command_runner = SubprocessCommandRunner()
216+
return self._command_runner
217+
218+
@command_runner.setter
219+
def command_runner(self, value: CommandRunner) -> None:
220+
"""Set the command runner.
221+
222+
Parameters
223+
----------
224+
value : CommandRunner
225+
New command execution engine
226+
"""
227+
self._command_runner = value
228+
191229
def is_alive(self) -> bool:
192230
"""Return True if tmux server alive.
193231
@@ -298,7 +336,9 @@ def cmd(
298336

299337
cmd_args = ["-t", str(target), *args] if target is not None else [*args]
300338

301-
return tmux_cmd(*svr_args, *cmd_args)
339+
# Convert all arguments to strings for the command runner
340+
all_args = [str(arg) for arg in [*svr_args, *cmd_args]]
341+
return self.command_runner.run(*all_args)
302342

303343
@property
304344
def attached_sessions(self) -> list[Session]:

0 commit comments

Comments
 (0)