Skip to content

Commit ed7206c

Browse files
committed
Engines(feat): Add the command execution seam
why: Every tmux command forks the binary inline, so an alternative transport -- control mode, a recording, an in-memory fake -- cannot be substituted without copying the library, which is what the downstream work had to do. Connection flags were built in three places that disagreed, so config_file= and colors= reached tmux on some paths and not others. what: - Route dispatch through a TmuxEngine protocol, defaulting to a subprocess engine that forks exactly as before - Accept engine= on Server, and let an engine that names no server of its own adopt the server's connection rather than the ambient one - Derive one ServerConnection for cmd(), raise_if_dead() and fetch_objs() - Keep cmd() returning tmux_cmd, and arguments reaching tmux unchanged, so the default path behaves as it did
1 parent c4a980b commit ed7206c

13 files changed

Lines changed: 1723 additions & 100 deletions

File tree

CHANGES

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,68 @@ $ uvx --from 'libtmux' --prerelease allow python
4545
_Notes on the upcoming release will go here._
4646
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
4747

48+
### Breaking changes
49+
50+
#### `raise_if_dead()` no longer echoes tmux's error
51+
52+
{meth}`Server.raise_if_dead() <libtmux.Server.raise_if_dead>` previously let
53+
tmux write its message straight to the terminal. It now captures that text onto
54+
the raised {exc}`subprocess.CalledProcessError`. The exception type is
55+
unchanged.
56+
57+
### What's new
58+
59+
#### Pluggable command engines
60+
61+
Every tmux command libtmux runs now goes through an *engine* — an object that
62+
takes a rendered argv and returns a structured result. The default,
63+
{class}`~libtmux.engines.subprocess.SubprocessEngine`, forks the tmux binary
64+
exactly as before, so existing code is unaffected.
65+
66+
Pass `engine=` to {class}`~libtmux.Server` and every command on that server runs
67+
through your object instead. {class}`~libtmux.engines.base.TmuxEngine` is a
68+
{class}`typing.Protocol`, so any object with `run()` and `run_batch()` qualifies
69+
— there is no base class to inherit. That makes it possible to drive libtmux
70+
against a recorded or in-memory tmux with no server running, and it is the seam
71+
the control-mode, asyncio, and native-protocol engines plug into.
72+
73+
This ships the seam only. {meth}`Server.cmd() <libtmux.Server.cmd>` still
74+
returns a {class}`~libtmux.common.tmux_cmd`, arguments still reach tmux
75+
unchanged, and nothing about the default path is new — an engine is the one
76+
thing you can now replace.
77+
78+
An engine that names no tmux server of its own adopts the server's connection,
79+
so injecting one into a socket-scoped {class}`~libtmux.Server` cannot silently
80+
dispatch to the ambient tmux server. Engines that name a server keep it.
81+
82+
{class}`~libtmux.engines.connection.ServerConnection` is now the single place
83+
the tmux binary and the `-L`/`-S`/`-f`/`-2`/`-8` flags are computed; three
84+
separate copies previously disagreed about which flags to emit. It is derived
85+
from the server's public attributes on each use, so reassigning `socket_name`
86+
takes effect on the next command, and it memoizes its {func}`shutil.which`
87+
lookup instead of re-walking `$PATH` for every command.
88+
89+
See {ref}`engines` for the guide and {ref}`engines-api` for the reference.
90+
91+
### Fixes
92+
93+
#### Listing queries honor `config_file` and `colors`
94+
95+
{meth}`Server.raise_if_dead() <libtmux.Server.raise_if_dead>` and the listing
96+
queries behind {attr}`~libtmux.Server.sessions` built their own connection flags
97+
and emitted only `-L`/`-S`, so a server constructed with `config_file=` or
98+
`colors=` passed those flags on some commands and not others. All paths now
99+
share one connection. A `colors=` value other than `256` or `88` raises
100+
{exc}`~libtmux.exc.UnknownColorOption` on those paths as well.
101+
48102
### Documentation
49103

104+
#### Engines guide and API reference
105+
106+
{ref}`engines` covers what an engine is, writing one, the optional capability
107+
protocols, and explicit command separators. {ref}`engines-api` documents the
108+
module.
109+
50110
#### Cleaner `from_env` examples (#719)
51111

52112
The rendered examples for {meth}`Pane.from_env() <libtmux.Pane.from_env>` and

docs/api/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ Base classes and command execution.
9696
Dataclass-based query interface.
9797
:::
9898

99+
:::{grid-item-card} Engine
100+
:link: libtmux.engines
101+
:link-type: doc
102+
How tmux commands are executed, and how to swap that out.
103+
:::
104+
99105
:::{grid-item-card} Options
100106
:link: libtmux.options
101107
:link-type: doc
@@ -173,6 +179,7 @@ Window <libtmux.window>
173179
Pane <libtmux.pane>
174180
Client <libtmux.client>
175181
Common <libtmux.common>
182+
Engine <libtmux.engines>
176183
Neo <libtmux.neo>
177184
Options <libtmux.options>
178185
Hooks <libtmux.hooks>

docs/api/libtmux.engines.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
(engines-api)=
2+
3+
# Engines
4+
5+
An *engine* is the object that actually runs a tmux command. Every dispatch in
6+
libtmux — {meth}`Server.cmd() <libtmux.Server.cmd>`, the listing queries behind
7+
{attr}`~libtmux.Server.sessions`, and {meth}`Server.raise_if_dead()
8+
<libtmux.Server.raise_if_dead>` — goes through one, and by default that is
9+
{class}`~libtmux.engines.subprocess.SubprocessEngine`, which forks the tmux
10+
binary exactly as libtmux always has.
11+
12+
The engine is swappable. Pass `engine=` to {class}`~libtmux.Server` and every
13+
command on that server runs through your object instead, which is how you drive
14+
libtmux against a recorded or in-memory tmux without a running server.
15+
16+
See {ref}`engines` for the guide, with worked examples.
17+
18+
Every symbol below is re-exported from `libtmux.engines`, so
19+
`from libtmux.engines import SubprocessEngine` works regardless of which
20+
submodule defines it.
21+
22+
## Requests and results
23+
24+
A {class}`~libtmux.engines.base.CommandRequest` is a rendered tmux argv; a
25+
{class}`~libtmux.engines.base.CommandResult` is the structured outcome. A
26+
tmux-side failure is *data* here — it sets `returncode` and `stderr` rather than
27+
raising. Only an engine-broken condition (missing binary, lost connection)
28+
raises.
29+
30+
{class}`~libtmux.engines.base.TmuxEngine` is a {class}`typing.Protocol`, so any
31+
object with `run()` and `run_batch()` is an engine; there is no base class to
32+
inherit. The `Supports*` protocols are optional capabilities an engine may
33+
also implement.
34+
35+
```{eval-rst}
36+
.. automodule:: libtmux.engines.base
37+
:members:
38+
```
39+
40+
## Connections
41+
42+
A {class}`~libtmux.engines.connection.ServerConnection` is the pair every engine
43+
needs before it can dispatch anything: which tmux *binary* to run, and the
44+
connection flags (`-L`/`-S`/`-f`/`-2`/`-8`) naming one tmux server. It is the
45+
single place either is computed.
46+
47+
```{eval-rst}
48+
.. automodule:: libtmux.engines.connection
49+
:members:
50+
```
51+
52+
## The default engine
53+
54+
```{eval-rst}
55+
.. automodule:: libtmux.engines.subprocess
56+
:members:
57+
```

docs/topics/engines.md

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
(engines)=
2+
3+
# Engines
4+
5+
Every tmux command libtmux runs goes through an **engine**. An engine takes a
6+
rendered argv and returns a structured result — that is its whole job.
7+
8+
By default that engine is
9+
{class}`~libtmux.engines.subprocess.SubprocessEngine`, which forks the tmux
10+
binary once per command. You never have to know it exists. But because it is a
11+
seam rather than hard-wired code, you can replace it — to test without tmux
12+
running, to record what libtmux would do, or to point one `Server` at a
13+
different tmux binary than another.
14+
15+
## The default path
16+
17+
Nothing changes if you ignore engines entirely:
18+
19+
```python
20+
>>> server.cmd("display-message", "-p", "#{session_name}").stdout
21+
['libtmux_...']
22+
```
23+
24+
Under that call, {class}`~libtmux.Server` built a
25+
{class}`~libtmux.engines.connection.ServerConnection` from its own
26+
`socket_name`, `socket_path`, `config_file`, and `colors`, handed it to a
27+
`SubprocessEngine`, and asked the engine to run the command:
28+
29+
```python
30+
>>> from libtmux.engines import SubprocessEngine
31+
>>> server.connection.args
32+
('-L...',)
33+
>>> isinstance(server.engine, SubprocessEngine)
34+
True
35+
```
36+
37+
The connection is *derived*, not frozen at construction, so moving a server to a
38+
different socket is picked up on the next command:
39+
40+
```python
41+
>>> from libtmux.server import Server
42+
>>> tmux = Server(socket_name="engines_doc_a")
43+
>>> tmux.connection.args
44+
('-Lengines_doc_a',)
45+
>>> tmux.socket_name = "engines_doc_b"
46+
>>> tmux.connection.args
47+
('-Lengines_doc_b',)
48+
```
49+
50+
## Requests and results
51+
52+
An engine speaks two value types.
53+
{class}`~libtmux.engines.base.CommandRequest` is the argv *after* the binary and
54+
connection flags. {class}`~libtmux.engines.base.CommandResult` is what came
55+
back.
56+
57+
```python
58+
>>> from libtmux.engines import CommandRequest
59+
>>> CommandRequest.from_args("kill-window", "-t", 2)
60+
CommandRequest(args=('kill-window', '-t', '2'), tmux_bin=None)
61+
```
62+
63+
A tmux-side failure is **data**, not an exception. An engine sets `returncode`
64+
and `stderr`; it does not raise. Only an engine-broken condition — a missing
65+
binary, a dropped connection — raises:
66+
67+
```python
68+
>>> from libtmux.engines import CommandResult
69+
>>> result = CommandResult(
70+
... cmd=("tmux", "kill-window"),
71+
... stderr=("no such window",),
72+
... returncode=1,
73+
... )
74+
>>> result.returncode, result.stderr
75+
(1, ('no such window',))
76+
```
77+
78+
## Writing an engine
79+
80+
{class}`~libtmux.engines.base.TmuxEngine` is a {class}`typing.Protocol`. There
81+
is no base class to inherit — any object with `run()` and `run_batch()` is an
82+
engine.
83+
84+
Here is a complete one that runs nothing, records everything, and answers from a
85+
canned script. Hand it to a server and no tmux process is involved:
86+
87+
```python
88+
>>> from libtmux.engines import CommandResult
89+
>>> from libtmux.server import Server
90+
91+
>>> class RecordingEngine:
92+
... """Record every dispatch; answer from a canned script."""
93+
...
94+
... def __init__(self, stdout=()):
95+
... self.requests = []
96+
... self._stdout = tuple(stdout)
97+
...
98+
... def run(self, request):
99+
... self.requests.append(request.args)
100+
... return CommandResult(cmd=("tmux", *request.args), stdout=self._stdout)
101+
...
102+
... def run_batch(self, requests):
103+
... return [self.run(request) for request in requests]
104+
105+
>>> recorder = RecordingEngine(stdout=("my_session",))
106+
>>> offline = Server(engine=recorder)
107+
>>> offline.cmd("display-message", "-p", "#{session_name}").stdout
108+
['my_session']
109+
>>> recorder.requests
110+
[('display-message', '-p', '#{session_name}')]
111+
```
112+
113+
This works because the socket flags live on the *engine*, not in the request, so
114+
your `run()` only ever sees the tmux subcommand — never a `-L` to parse back
115+
out:
116+
117+
```python
118+
>>> from libtmux.engines import CommandResult
119+
>>> from libtmux.server import Server
120+
121+
>>> class Recorder:
122+
... def __init__(self):
123+
... self.requests = []
124+
... def run(self, request):
125+
... self.requests.append(request.args)
126+
... return CommandResult(cmd=("tmux", *request.args))
127+
... def run_batch(self, requests):
128+
... return [self.run(request) for request in requests]
129+
130+
>>> recorder = Recorder()
131+
>>> _ = Server(socket_name="engines_doc_scoped", engine=recorder).cmd("list-sessions")
132+
>>> recorder.requests
133+
[('list-sessions',)]
134+
```
135+
136+
## Injected engines and sockets
137+
138+
An engine that names no tmux server of its own **adopts** the server's
139+
connection. Without that rule, injecting a bare engine into a socket-scoped
140+
server would silently dispatch to whichever server a flagless `tmux` reaches:
141+
142+
```python
143+
>>> from libtmux.engines import SubprocessEngine
144+
>>> from libtmux.server import Server
145+
>>> scoped = Server(socket_name="engines_doc_c", engine=SubprocessEngine())
146+
>>> scoped.engine.server_args
147+
('-Lengines_doc_c',)
148+
```
149+
150+
An engine that *does* name a server is left exactly as you built it:
151+
152+
```python
153+
>>> from libtmux.engines import SubprocessEngine
154+
>>> from libtmux.server import Server
155+
>>> pinned = SubprocessEngine.of(server_args=("-Lengines_doc_pinned",))
156+
>>> Server(socket_name="engines_doc_c", engine=pinned).engine.server_args
157+
('-Lengines_doc_pinned',)
158+
```
159+
160+
An in-memory engine has no connection at all, so neither rule applies and it is
161+
used untouched.
162+
163+
## Optional capabilities
164+
165+
An engine may implement extra protocols. Each is optional; libtmux checks with
166+
{func}`isinstance` and degrades gracefully when absent.
167+
168+
{class}`~libtmux.engines.base.SupportsCommandLine` renders the argv an engine
169+
*would* run, which is how the full command line reaches the debug log before
170+
dispatch. {class}`~libtmux.engines.base.SupportsConnection` marks an engine that
171+
dispatches over a named server and can be rebound — the protocol behind the
172+
adoption rule above.
173+
174+
```python
175+
>>> from libtmux.engines import (
176+
... SubprocessEngine,
177+
... SupportsCommandLine,
178+
... SupportsConnection,
179+
... )
180+
>>> engine = SubprocessEngine()
181+
>>> isinstance(engine, SupportsCommandLine), isinstance(engine, SupportsConnection)
182+
(True, True)
183+
```
184+
185+
An engine that implements neither simply is not matched:
186+
187+
```python
188+
>>> from libtmux.engines import CommandResult, SupportsCommandLine
189+
>>> class Bare:
190+
... def run(self, request):
191+
... return CommandResult(cmd=("tmux", *request.args))
192+
... def run_batch(self, requests):
193+
... return [self.run(request) for request in requests]
194+
>>> isinstance(Bare(), SupportsCommandLine)
195+
False
196+
```
197+
198+
## What an engine does not change
199+
200+
An engine chooses *how* a command runs, not what libtmux does with the answer.
201+
Arguments reach tmux exactly as they always have, results read exactly as they
202+
always have, and {meth}`Server.cmd() <libtmux.Server.cmd>` still returns a
203+
{class}`~libtmux.common.tmux_cmd`. Under the default engine there is nothing new
204+
to learn and nothing to migrate.

docs/topics/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ Common patterns for scripting and automation.
6161
Automatic cleanup with temporary sessions and windows.
6262
:::
6363

64+
:::{grid-item-card} Engines
65+
:link: engines
66+
:link-type: doc
67+
Swap how tmux commands execute: record, fake, or retarget the binary.
68+
:::
69+
6470
:::{grid-item-card} Options & Hooks
6571
:link: options_and_hooks
6672
:link-type: doc
@@ -97,6 +103,7 @@ workspace_setup
97103
automation_patterns
98104
context_managers
99105
options_and_hooks
106+
engines
100107
clients
101108
format-tokens
102109
```

0 commit comments

Comments
 (0)