Skip to content

Commit d76260b

Browse files
committed
Engines(refactor): Share Core's engine values
why: The experimental engines carried their own CommandRequest, CommandResult and ServerConnection, so an engine written against them returned values Core's object API could not read, and the two copies could drift apart on the flags they emit. what: - Import the request, result, separator and engine protocols from libtmux.engines instead of redefining them - Reduce the connection module to Core's ServerConnection - Keep what only the experimental transports need: the argv encoders, control-mode rendering, EngineSpec and the async protocol
1 parent 06566a4 commit d76260b

2 files changed

Lines changed: 48 additions & 377 deletions

File tree

src/libtmux/experimental/engines/base.py

Lines changed: 39 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
"""Core engine abstractions: requests, results, and the engine protocols.
2-
3-
A :class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a
4-
:class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and
5-
:class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with
6-
the right methods is an engine -- including a live :class:`libtmux.Server` for
7-
the classic case -- without inheriting a base class.
1+
"""Engine abstractions the experimental layer adds on top of Core's seam.
2+
3+
:class:`~libtmux.engines.base.CommandRequest`,
4+
:class:`~libtmux.engines.base.CommandResult`,
5+
:class:`~libtmux.engines.base.TmuxEngine` and the separator values live in
6+
Core's :mod:`libtmux.engines` and are re-exported here, so an experimental
7+
engine and Core's object API exchange the same values -- an engine written
8+
against either import path plugs into :class:`libtmux.Server`.
9+
10+
What remains local is what only the experimental transports need: the argv
11+
encoders that split tmux's client-global options from its command argv,
12+
control-mode line rendering and unescaping, the serializable
13+
:class:`EngineSpec` selector, and the asynchronous
14+
:class:`AsyncTmuxEngine` protocol.
815
"""
916

1017
from __future__ import annotations
@@ -15,11 +22,18 @@
1522
import typing as t
1623
from dataclasses import dataclass
1724

25+
from libtmux.engines.base import (
26+
CommandRequest,
27+
CommandResult,
28+
CommandSeparator,
29+
SupportsTmuxVersion,
30+
TmuxEngine,
31+
is_command_separator,
32+
)
33+
1834
if t.TYPE_CHECKING:
19-
import pathlib
2035
from collections.abc import Sequence
2136

22-
from typing_extensions import Self
2337

2438
#: tmux escapes a byte in ``%output`` as a backslash plus three octal digits.
2539
_CONTROL_OCTAL = re.compile(rb"\\([0-7]{3})")
@@ -32,39 +46,22 @@
3246
{"2", "8", "C", "D", "d", "h", "l", "N", "q", "u", "U", "v", "V"},
3347
)
3448

35-
36-
class CommandSeparator(str):
37-
"""A planner-authored command boundary, distinct from a literal ``";"``.
38-
39-
Examples
40-
--------
41-
>>> CommandSeparator(";")
42-
';'
43-
>>> CommandSeparator("kill-server")
44-
Traceback (most recent call last):
45-
...
46-
ValueError: a command separator must be exactly ';'
47-
"""
48-
49-
def __new__(cls, value: str) -> Self:
50-
"""Construct the one legal structural token."""
51-
if value != ";":
52-
msg = "a command separator must be exactly ';'"
53-
raise ValueError(msg)
54-
return super().__new__(cls, value)
55-
56-
57-
def is_command_separator(token: str) -> bool:
58-
"""Return whether *token* is an intentional tmux command boundary.
59-
60-
Examples
61-
--------
62-
>>> is_command_separator(CommandSeparator(";"))
63-
True
64-
>>> is_command_separator(";")
65-
False
66-
"""
67-
return type(token) is CommandSeparator and token == ";"
49+
__all__ = (
50+
"AsyncTmuxEngine",
51+
"CommandRequest",
52+
"CommandResult",
53+
"CommandSeparator",
54+
"DirectArgv",
55+
"EngineKind",
56+
"EngineSpec",
57+
"SupportsTmuxVersion",
58+
"TmuxEngine",
59+
"encode_direct_argv",
60+
"is_command_separator",
61+
"render_control_line",
62+
"split_direct_argv",
63+
"unescape_control_output",
64+
)
6865

6966

7067
class DirectArgv(t.NamedTuple):
@@ -244,92 +241,6 @@ def unescape_control_output(payload: str) -> bytes:
244241
return _CONTROL_OCTAL.sub(lambda m: bytes((int(m.group(1), 8),)), raw)
245242

246243

247-
@dataclass(frozen=True)
248-
class CommandRequest:
249-
"""A rendered tmux command, ready for an engine to execute.
250-
251-
Attributes
252-
----------
253-
args : tuple[str, ...]
254-
The tmux argv *after* the binary (e.g. ``("split-window", "-t", "%1")``).
255-
tmux_bin : str or None
256-
Override the tmux binary for this request; ``None`` lets the engine
257-
decide.
258-
259-
Examples
260-
--------
261-
>>> CommandRequest.from_args("split-window", "-t", "%1")
262-
CommandRequest(args=('split-window', '-t', '%1'), tmux_bin=None)
263-
>>> CommandRequest.from_args("kill-window", "-t", 2).args
264-
('kill-window', '-t', '2')
265-
"""
266-
267-
args: tuple[str, ...]
268-
tmux_bin: str | None = None
269-
270-
def __post_init__(self) -> None:
271-
r"""Reject arguments that cannot survive tmux's C-string transports.
272-
273-
Examples
274-
--------
275-
>>> CommandRequest(args=("display-message", "a\0b"))
276-
Traceback (most recent call last):
277-
...
278-
ValueError: tmux command arguments cannot contain NUL
279-
"""
280-
if any(
281-
type(arg) is CommandSeparator and not is_command_separator(arg)
282-
for arg in self.args
283-
):
284-
msg = "a command separator must be exactly ';'"
285-
raise ValueError(msg)
286-
normalized = tuple(
287-
arg if is_command_separator(arg) else str.__str__(arg) for arg in self.args
288-
)
289-
if any("\0" in arg for arg in normalized):
290-
msg = "tmux command arguments cannot contain NUL"
291-
raise ValueError(msg)
292-
object.__setattr__(self, "args", normalized)
293-
294-
@classmethod
295-
def from_args(
296-
cls,
297-
*args: t.Any,
298-
tmux_bin: str | pathlib.Path | None = None,
299-
) -> CommandRequest:
300-
"""Build a request from arbitrary tokens, stringifying each."""
301-
return cls(
302-
args=tuple(arg if isinstance(arg, str) else str(arg) for arg in args),
303-
tmux_bin=str(tmux_bin) if tmux_bin is not None else None,
304-
)
305-
306-
307-
@dataclass(frozen=True)
308-
class CommandResult:
309-
"""The structured outcome of executing a :class:`CommandRequest`.
310-
311-
A tmux-side failure (``%error`` / nonzero exit) is *data* here -- it sets
312-
``returncode`` and ``stderr`` rather than raising. Only engine-broken
313-
conditions (missing binary, lost connection, protocol desync) raise.
314-
315-
Attributes
316-
----------
317-
cmd : tuple[str, ...]
318-
The full argv that ran (including the tmux binary).
319-
stdout : tuple[str, ...]
320-
Captured standard-output lines.
321-
stderr : tuple[str, ...]
322-
Captured standard-error lines.
323-
returncode : int
324-
tmux exit code (``-1`` when unknown).
325-
"""
326-
327-
cmd: tuple[str, ...]
328-
stdout: tuple[str, ...] = ()
329-
stderr: tuple[str, ...] = ()
330-
returncode: int = 0
331-
332-
333244
class EngineKind(str, enum.Enum):
334245
"""Named engine families."""
335246

@@ -394,26 +305,6 @@ def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec:
394305
return cls(kind=EngineKind.IMSG, protocol_version=protocol_version)
395306

396307

397-
@t.runtime_checkable
398-
class TmuxEngine(t.Protocol):
399-
"""A synchronous executor of tmux commands."""
400-
401-
def run(self, request: CommandRequest) -> CommandResult:
402-
"""Execute one tmux command and return its structured result."""
403-
...
404-
405-
def run_batch(
406-
self,
407-
requests: Sequence[CommandRequest],
408-
) -> list[CommandResult]:
409-
"""Execute requests in order, returning one result per request.
410-
411-
Persistent-connection engines (control mode) override this to pipeline;
412-
stateless engines implement it as a loop over :meth:`run`.
413-
"""
414-
...
415-
416-
417308
@t.runtime_checkable
418309
class AsyncTmuxEngine(t.Protocol):
419310
"""An asynchronous executor of tmux commands."""
@@ -428,21 +319,3 @@ async def run_batch(
428319
) -> list[CommandResult]:
429320
"""Execute requests in order, returning one result per request."""
430321
...
431-
432-
433-
@t.runtime_checkable
434-
class SupportsTmuxVersion(t.Protocol):
435-
"""An engine that can report the tmux version it targets.
436-
437-
Optional engine capability. The executors
438-
(:func:`~libtmux.experimental.ops.execute.run` / ``arun`` and the
439-
:class:`~libtmux.experimental.ops.plan.LazyPlan` drivers) call
440-
:meth:`tmux_version` to resolve the version for version-gated rendering when
441-
the caller passes none. Engines that cannot know their version -- in-memory
442-
or fake engines -- simply do not implement it, and resolution falls back to
443-
"assume latest".
444-
"""
445-
446-
def tmux_version(self) -> str | None:
447-
"""Return the engine's tmux version string, or ``None`` if unknown."""
448-
...

0 commit comments

Comments
 (0)