diff --git a/CHANGES b/CHANGES index 7a691b0cc..e00ebb59d 100644 --- a/CHANGES +++ b/CHANGES @@ -45,8 +45,90 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Breaking changes + +#### `raise_if_dead()` no longer echoes tmux's error (#739) + +{meth}`Server.raise_if_dead() ` previously let +tmux write its message straight to the terminal. It now captures that text onto +the raised {exc}`subprocess.CalledProcessError`. The exception type is +unchanged. + +#### `tmux_cmd.process` is a property (#739) + +{attr}`~libtmux.common.tmux_cmd.process` was a plain attribute holding the +{class}`subprocess.Popen` that ran the command; it is now a read-only property. +Reading it is unchanged under the default engine. Assigning to it no longer +works, and reading it after a command ran through an engine that forks no +process raises {exc}`~libtmux.exc.LibTmuxException` rather than returning +`None`. + +### What's new + +#### Pluggable command engines (#739) + +Every tmux command libtmux runs now goes through an *engine* — an object that +takes a rendered argv and returns a structured result. The default, +{class}`~libtmux.engines.subprocess.SubprocessEngine`, forks the tmux binary +exactly as before, so existing code is unaffected. + +Pass `engine=` to {class}`~libtmux.Server` and every command on that server runs +through your object instead. {class}`~libtmux.engines.base.TmuxEngine` is a +{class}`typing.Protocol`, so any object with `run()` and `run_batch()` qualifies +— there is no base class to inherit. That makes it possible to drive libtmux +against a recorded or in-memory tmux with no server running, and it is the seam +the control-mode, asyncio, and native-protocol engines plug into. + +This ships the seam only. {meth}`Server.cmd() ` still +returns a {class}`~libtmux.common.tmux_cmd`, arguments still reach tmux +unchanged, and nothing about the default path is new — an engine is the one +thing you can now replace. + +An engine that names no tmux server of its own adopts the server's connection, +so injecting one into a socket-scoped {class}`~libtmux.Server` cannot silently +dispatch to the ambient tmux server. Engines that name a server keep it. A +custom `tmux_bin` selects a program rather than a server, so an engine carrying +only one adopts the server's flags and keeps its own binary. + +{class}`~libtmux.engines.connection.ServerConnection` is now the single place +the tmux binary and the `-L`/`-S`/`-f`/`-2`/`-8` flags are computed; three +separate copies previously disagreed about which flags to emit. It is derived +from the server's public attributes on each use, so reassigning `socket_name` +takes effect on the next command, and it memoizes its {func}`shutil.which` +lookup instead of re-walking `$PATH` for every command. It can also report the +tmux version it targets via +{meth}`~libtmux.engines.connection.ServerConnection.tmux_version`, memoizing one +`tmux -V` probe; an engine that forwards it satisfies the optional +{class}`~libtmux.engines.base.SupportsTmuxVersion` capability, which callers +rendering version-gated argv read to decide whether a flag is safe to send. + +An engine that folds several commands into one dispatch needs to know which `;` +in an argv is a boundary and which is data. +{class}`~libtmux.engines.base.CommandSeparator` marks the boundary and +{func}`~libtmux.engines.base.is_command_separator` finds it, so a `;` a caller +passes as an ordinary argument can never become one by accident. + +See {ref}`engines` for the guide and {ref}`engines-api` for the reference. + +### Fixes + +#### Listing queries honor `config_file` and `colors` (#739) + +{meth}`Server.raise_if_dead() ` and the listing +queries behind {attr}`~libtmux.Server.sessions` built their own connection flags +and emitted only `-L`/`-S`, so a server constructed with `config_file=` or +`colors=` passed those flags on some commands and not others. All paths now +share one connection. A `colors=` value other than `256` or `88` raises +{exc}`~libtmux.exc.UnknownColorOption` on those paths as well. + ### Documentation +#### Engines guide and API reference (#739) + +{ref}`engines` covers what an engine is, writing one, the optional capability +protocols, and explicit command separators. {ref}`engines-api` documents the +module. + #### Cleaner `from_env` examples (#719) The rendered examples for {meth}`Pane.from_env() ` and diff --git a/docs/api/index.md b/docs/api/index.md index 23cd9043b..8507cab75 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -96,6 +96,12 @@ Base classes and command execution. Dataclass-based query interface. ::: +:::{grid-item-card} Engine +:link: libtmux.engines +:link-type: doc +How tmux commands are executed, and how to swap that out. +::: + :::{grid-item-card} Options :link: libtmux.options :link-type: doc @@ -173,6 +179,7 @@ Window Pane Client Common +Engine Neo Options Hooks diff --git a/docs/api/libtmux.engines.md b/docs/api/libtmux.engines.md new file mode 100644 index 000000000..f55500158 --- /dev/null +++ b/docs/api/libtmux.engines.md @@ -0,0 +1,57 @@ +(engines-api)= + +# Engines + +An *engine* is the object that actually runs a tmux command. Every dispatch in +libtmux — {meth}`Server.cmd() `, the listing queries behind +{attr}`~libtmux.Server.sessions`, and {meth}`Server.raise_if_dead() +` — goes through one, and by default that is +{class}`~libtmux.engines.subprocess.SubprocessEngine`, which forks the tmux +binary exactly as libtmux always has. + +The engine is swappable. Pass `engine=` to {class}`~libtmux.Server` and every +command on that server runs through your object instead, which is how you drive +libtmux against a recorded or in-memory tmux without a running server. + +See {ref}`engines` for the guide, with worked examples. + +Every symbol below is re-exported from `libtmux.engines`, so +`from libtmux.engines import SubprocessEngine` works regardless of which +submodule defines it. + +## Requests and results + +A {class}`~libtmux.engines.base.CommandRequest` is a rendered tmux argv; a +{class}`~libtmux.engines.base.CommandResult` is the structured outcome. A +tmux-side failure is *data* here — it sets `returncode` and `stderr` rather than +raising. Only an engine-broken condition (missing binary, lost connection) +raises. + +{class}`~libtmux.engines.base.TmuxEngine` is a {class}`typing.Protocol`, so any +object with `run()` and `run_batch()` is an engine; there is no base class to +inherit. The `Supports*` protocols are optional capabilities an engine may +also implement. + +```{eval-rst} +.. automodule:: libtmux.engines.base + :members: +``` + +## Connections + +A {class}`~libtmux.engines.connection.ServerConnection` is the pair every engine +needs before it can dispatch anything: which tmux *binary* to run, and the +connection flags (`-L`/`-S`/`-f`/`-2`/`-8`) naming one tmux server. It is the +single place either is computed. + +```{eval-rst} +.. automodule:: libtmux.engines.connection + :members: +``` + +## The default engine + +```{eval-rst} +.. automodule:: libtmux.engines.subprocess + :members: +``` diff --git a/docs/topics/engines.md b/docs/topics/engines.md new file mode 100644 index 000000000..3100072a2 --- /dev/null +++ b/docs/topics/engines.md @@ -0,0 +1,266 @@ +(engines)= + +# Engines + +Every tmux command libtmux runs goes through an **engine**. An engine takes a +rendered argv and returns a structured result — that is its whole job. + +By default that engine is +{class}`~libtmux.engines.subprocess.SubprocessEngine`, which forks the tmux +binary once per command. You never have to know it exists. But because it is a +seam rather than hard-wired code, you can replace it — to test without tmux +running, to record what libtmux would do, or to point one `Server` at a +different tmux binary than another. + +## The default path + +Nothing changes if you ignore engines entirely: + +```python +>>> server.cmd("display-message", "-p", "#{session_name}").stdout +['libtmux_...'] +``` + +Under that call, {class}`~libtmux.Server` built a +{class}`~libtmux.engines.connection.ServerConnection` from its own +`socket_name`, `socket_path`, `config_file`, and `colors`, handed it to a +`SubprocessEngine`, and asked the engine to run the command: + +```python +>>> from libtmux.engines import SubprocessEngine +>>> server.connection.args +('-L...',) +>>> isinstance(server.engine, SubprocessEngine) +True +``` + +The connection is *derived*, not frozen at construction, so moving a server to a +different socket is picked up on the next command: + +```python +>>> from libtmux.server import Server +>>> tmux = Server(socket_name="engines_doc_a") +>>> tmux.connection.args +('-Lengines_doc_a',) +>>> tmux.socket_name = "engines_doc_b" +>>> tmux.connection.args +('-Lengines_doc_b',) +``` + +## Requests and results + +An engine speaks two value types. +{class}`~libtmux.engines.base.CommandRequest` is the argv *after* the binary and +connection flags. {class}`~libtmux.engines.base.CommandResult` is what came +back. + +```python +>>> from libtmux.engines import CommandRequest +>>> CommandRequest.from_args("kill-window", "-t", 2) +CommandRequest(args=('kill-window', '-t', '2'), tmux_bin=None) +``` + +A tmux-side failure is **data**, not an exception. An engine sets `returncode` +and `stderr`; it does not raise. Only an engine-broken condition — a missing +binary, a dropped connection — raises: + +```python +>>> from libtmux.engines import CommandResult +>>> result = CommandResult( +... cmd=("tmux", "kill-window"), +... stderr=("no such window",), +... returncode=1, +... ) +>>> result.returncode, result.stderr +(1, ('no such window',)) +``` + +## Writing an engine + +{class}`~libtmux.engines.base.TmuxEngine` is a {class}`typing.Protocol`. There +is no base class to inherit — any object with `run()` and `run_batch()` is an +engine. + +`run()` and the optional `command_line()` must be synchronous: libtmux +dispatches every command from ordinary, non-`async` code and cannot await a +coroutine. Because {class}`~libtmux.engines.base.TmuxEngine` is checked by name +only, an `async def run()` satisfies it and would otherwise fail much later, +with a bare `AttributeError` naming neither the engine nor the mismatch: + +```python +>>> from libtmux.engines import CommandResult +>>> from libtmux.server import Server + +>>> class AsyncEngine: +... async def run(self, request): +... return CommandResult(cmd=("tmux", *request.args)) +... def run_batch(self, requests): +... return [self.run(request) for request in requests] + +>>> Server(engine=AsyncEngine()).cmd("display-message", "-p", "#S") +Traceback (most recent call last): + ... +libtmux.exc.AsyncEngineMismatch: AsyncEngine.run() returned an awaitable: ... +``` + +Await such an engine from your own async code instead, or write a synchronous +`run()`. + +Here is a complete one that runs nothing, records everything, and answers from a +canned script. Hand it to a server and no tmux process is involved: + +```python +>>> from libtmux.engines import CommandResult +>>> from libtmux.server import Server + +>>> class RecordingEngine: +... """Record every dispatch; answer from a canned script.""" +... +... def __init__(self, stdout=()): +... self.requests = [] +... self._stdout = tuple(stdout) +... +... def run(self, request): +... self.requests.append(request.args) +... return CommandResult(cmd=("tmux", *request.args), stdout=self._stdout) +... +... def run_batch(self, requests): +... return [self.run(request) for request in requests] + +>>> recorder = RecordingEngine(stdout=("my_session",)) +>>> offline = Server(engine=recorder) +>>> offline.cmd("display-message", "-p", "#{session_name}").stdout +['my_session'] +>>> recorder.requests +[('display-message', '-p', '#{session_name}')] +``` + +This works because the socket flags live on the *engine*, not in the request, so +your `run()` only ever sees the tmux subcommand — never a `-L` to parse back +out: + +```python +>>> from libtmux.engines import CommandResult +>>> from libtmux.server import Server + +>>> class Recorder: +... def __init__(self): +... self.requests = [] +... def run(self, request): +... self.requests.append(request.args) +... return CommandResult(cmd=("tmux", *request.args)) +... def run_batch(self, requests): +... return [self.run(request) for request in requests] + +>>> recorder = Recorder() +>>> _ = Server(socket_name="engines_doc_scoped", engine=recorder).cmd("list-sessions") +>>> recorder.requests +[('list-sessions',)] +``` + +## Injected engines and sockets + +An engine that names no tmux server of its own **adopts** the server's +connection. Without that rule, injecting a bare engine into a socket-scoped +server would silently dispatch to whichever server a flagless `tmux` reaches: + +```python +>>> from libtmux.engines import SubprocessEngine +>>> from libtmux.server import Server +>>> scoped = Server(socket_name="engines_doc_c", engine=SubprocessEngine()) +>>> scoped.engine.server_args +('-Lengines_doc_c',) +``` + +An engine that *does* name a server is left exactly as you built it: + +```python +>>> from libtmux.engines import SubprocessEngine +>>> from libtmux.server import Server +>>> pinned = SubprocessEngine.of(server_args=("-Lengines_doc_pinned",)) +>>> Server(socket_name="engines_doc_c", engine=pinned).engine.server_args +('-Lengines_doc_pinned',) +``` + +An in-memory engine has no connection at all, so neither rule applies and it is +used untouched. + +## Optional capabilities + +An engine may implement extra protocols. Each is optional; libtmux checks with +{func}`isinstance` and degrades gracefully when absent. + +{class}`~libtmux.engines.base.SupportsCommandLine` renders the argv an engine +*would* run, which is how the full command line reaches the debug log before +dispatch. {class}`~libtmux.engines.base.SupportsConnection` marks an engine that +dispatches over a named server and can be rebound — the protocol behind the +adoption rule above. + +```python +>>> from libtmux.engines import ( +... SubprocessEngine, +... SupportsCommandLine, +... SupportsConnection, +... ) +>>> engine = SubprocessEngine() +>>> isinstance(engine, SupportsCommandLine), isinstance(engine, SupportsConnection) +(True, True) +``` + +An engine that implements neither simply is not matched: + +```python +>>> from libtmux.engines import CommandResult, SupportsCommandLine +>>> class Bare: +... def run(self, request): +... return CommandResult(cmd=("tmux", *request.args)) +... def run_batch(self, requests): +... return [self.run(request) for request in requests] +>>> isinstance(Bare(), SupportsCommandLine) +False +``` + +{class}`~libtmux.engines.base.SupportsTmuxVersion` reports the tmux version an +engine targets, which a caller rendering version-gated argv reads to decide +whether a flag is safe to send. An engine that cannot know its version — an +in-memory fake — omits it, and the caller assumes the newest tmux. + +## Explicit command separators + +tmux treats a bare `;` argument as a boundary between two commands, but only +when it arrives unquoted. A `;` that is *data* — a pane title, a shell fragment +bound for `send-keys` — must not be mistaken for one. Guessing from the string +alone cannot tell them apart, so the intent rides in the type: +{class}`~libtmux.engines.base.CommandSeparator` marks a real boundary, and +{func}`~libtmux.engines.base.is_command_separator` finds it. + +```python +>>> from libtmux.engines import CommandRequest, CommandSeparator, is_command_separator +>>> request = CommandRequest.from_args( +... "rename-window", "a;b", CommandSeparator(";"), "kill-window", "@2" +... ) +>>> [is_command_separator(arg) for arg in request.args] +[False, False, True, False, False] +``` + +A plain `";"` is data and stays data, so nothing an existing caller passes can +become a boundary by accident: + +```python +>>> from libtmux.engines import is_command_separator +>>> is_command_separator(";") +False +``` + +The marker survives normalization, so an engine that chains commands into one +dispatch can find the boundaries while every other engine ignores them. The +default {class}`~libtmux.engines.subprocess.SubprocessEngine` sends one command +per dispatch and has no use for them. + +## What an engine does not change + +An engine chooses *how* a command runs, not what libtmux does with the answer. +Arguments reach tmux exactly as they always have, results read exactly as they +always have, and {meth}`Server.cmd() ` still returns a +{class}`~libtmux.common.tmux_cmd`. Under the default engine there is nothing new +to learn and nothing to migrate. diff --git a/docs/topics/index.md b/docs/topics/index.md index c955e5857..5b4653c3d 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -61,6 +61,12 @@ Common patterns for scripting and automation. Automatic cleanup with temporary sessions and windows. ::: +:::{grid-item-card} Engines +:link: engines +:link-type: doc +Swap how tmux commands execute: record, fake, or retarget the binary. +::: + :::{grid-item-card} Options & Hooks :link: options_and_hooks :link-type: doc @@ -97,6 +103,7 @@ workspace_setup automation_patterns context_managers options_and_hooks +engines clients format-tokens ``` diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 287154770..c1832c051 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,21 +7,26 @@ from __future__ import annotations +import contextlib import functools +import inspect import logging import re import shlex -import shutil -import subprocess import sys import typing as t from . import exc from ._compat import LooseVersion +from .engines.base import CommandRequest, SupportsCommandLine +from .engines.subprocess import SubprocessEngine if t.TYPE_CHECKING: + import subprocess from collections.abc import Callable + from .engines.base import CommandResult, TmuxEngine + logger = logging.getLogger(__name__) @@ -280,8 +285,179 @@ def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: ) +def _guard_sync( + engine: object, + member: Callable[[CommandRequest], t.Any], + method: str, + request: CommandRequest, +) -> t.Any: + """Call one synchronous engine capability, guarding the result. + + The single call site every engine capability -- ``run()`` and the + optional ``command_line()`` -- is invoked through. + :class:`~libtmux.engines.base.TmuxEngine` and + :class:`~libtmux.engines.base.SupportsCommandLine` are + :func:`~typing.runtime_checkable` :class:`typing.Protocol` classes, so + ``isinstance()`` accepts an engine on attribute *names* alone -- never + signatures, never async-ness -- and an ``async def run`` (or ``async def + command_line``) engine passes structurally and reaches here. Routing + every dispatch through this one function means the guard below only has + to be written once: a call site added later inherits it instead of + needing its own copy. + + Parameters + ---------- + engine : object + The engine the capability belongs to; named in the error. + member : :class:`~collections.abc.Callable` + The already-resolved bound method to invoke. + method : str + Its name, ``"run"`` or ``"command_line"``, for the error message. + request : CommandRequest + Forwarded as the sole positional argument. + + Returns + ------- + typing.Any + Whatever *method* returned. Never an awaitable. + + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + *method* returned an awaitable instead of the value its protocol + promises. + + Notes + ----- + Declared-``async def`` members are rejected *before* the call, so the + common shape never creates a coroutine at all and nothing is left to warn + about. That check cannot be complete on its own -- CPython says as much in + :mod:`unittest.async_case`, whose case 3 is a "regular ``def`` that + returns an awaitable object" -- so the value is tested too. + + A coroutine that did get created is closed, which is safe precisely + because it has never been started: :c:func:`gen_close` on a frame still in + ``FRAME_CREATED`` clears it without running a line of the body, and + ``"coroutine ... was never awaited"`` is only warned for a frame still in + that state at collection. Closing is best-effort -- guarded against + :class:`BaseException`, since :exc:`asyncio.CancelledError` is not an + :class:`Exception` -- so a hostile awaitable cannot replace the + diagnostic with an error of its own. + + Only genuine coroutines are closed. A :class:`asyncio.Task` or + :class:`asyncio.Future` is dropped untouched: one bound to another + thread's event loop silently fails to receive + :meth:`~asyncio.Task.cancel` (that needs ``loop.call_soon_threadsafe``), + and cancelling one shared with another awaiter would destroy that + awaiter's result. An eager-started ``Task`` (3.12+) has already run its + body synchronously before ``run()`` returned, so nothing here could have + prevented that side effect either way. + """ + if inspect.iscoroutinefunction(member): + raise exc.AsyncEngineMismatch(engine, method) + + result = member(request) + if inspect.isawaitable(result): + if inspect.iscoroutine(result): + with contextlib.suppress(BaseException): + result.close() + raise exc.AsyncEngineMismatch(engine, method) + return result + + +def _dispatch_run(engine: TmuxEngine, request: CommandRequest) -> CommandResult: + """Run one command through *engine*, guarding the result. + + Parameters + ---------- + engine : TmuxEngine + The engine to dispatch through. + request : CommandRequest + The command. + + Returns + ------- + CommandResult + Whatever ``run()`` returned. Never an awaitable. + + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + ``run`` is asynchronous. + """ + return t.cast( + "CommandResult", + _guard_sync(engine, engine.run, "run", request), + ) + + +def _dispatch_command_line( + engine: SupportsCommandLine, + request: CommandRequest, +) -> tuple[str, ...]: + """Render *request*'s argv through *engine*, guarding the result. + + Parameters + ---------- + engine : SupportsCommandLine + The engine to ask. + request : CommandRequest + The command. + + Returns + ------- + tuple[str, ...] + The argv. Never an awaitable. + + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + ``command_line`` is asynchronous. + """ + return t.cast( + "tuple[str, ...]", + _guard_sync(engine, engine.command_line, "command_line", request), + ) + + class tmux_cmd: - """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. + """Run any :term:`tmux(1)` command, returning list-shaped output. + + Dispatches through a :class:`~libtmux.engines.base.TmuxEngine` -- + :class:`~libtmux.engines.subprocess.SubprocessEngine` unless one is passed -- + and adapts the engine's :class:`~libtmux.engines.base.CommandResult` to the + ``list``-of-``str`` attributes libtmux's wrappers read. + + Parameters + ---------- + *args : typing.Any + tmux argv. Connection flags may be included inline (``"-Lwork"``); an + engine supplies its own, so :meth:`libtmux.Server.cmd` passes only the + subcommand. + tmux_bin : str, optional + Path to the tmux binary. Ignored when *engine* is given -- the engine + owns its binary. + engine : :class:`~libtmux.engines.base.TmuxEngine`, optional + Executor to dispatch through. + + Attributes + ---------- + cmd : list[str] + The full argv that ran, tmux binary first. + stdout : list[str] + Standard output, one line per item. + stderr : list[str] + Standard error, one line per item, blanks removed. + returncode : int + tmux exit code. + + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + *engine* is asynchronous -- its ``run()`` (or ``command_line()``, + while rendering a DEBUG log line) handed back an awaitable, which + this synchronous dispatch cannot await. Both calls route through + :func:`_guard_sync`, the one place this is checked. Examples -------- @@ -309,66 +485,57 @@ class tmux_cmd: Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: - resolved = tmux_bin or shutil.which("tmux") - if not resolved: - raise exc.TmuxCommandNotFound - - cmd = [resolved] - cmd += args # add the command arguments to cmd - cmd = [str(c) for c in cmd] - - self.cmd = cmd + def __init__( + self, + *args: t.Any, + tmux_bin: str | None = None, + engine: TmuxEngine | None = None, + ) -> None: + runner: TmuxEngine = ( + engine if engine is not None else SubprocessEngine.of(tmux_bin) + ) + request = CommandRequest.from_args(*args) if logger.isEnabledFor(logging.DEBUG): - cmd_str = shlex.join(cmd) logger.debug( "tmux command dispatched", - extra={"tmux_cmd": cmd_str}, - ) - - try: - self.process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="backslashreplace", - ) - stdout, stderr = self.process.communicate() - returncode = self.process.returncode - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None - except Exception: - logger.error( # noqa: TRY400 - "tmux subprocess failed", extra={ - "tmux_cmd": shlex.join(cmd), + "tmux_cmd": shlex.join( + _dispatch_command_line(runner, request) + if isinstance(runner, SupportsCommandLine) + else request.args, + ), + "tmux_subcommand": request.subcommand, }, ) - raise - - self.returncode = returncode - - stdout_split = stdout.split("\n") - # remove trailing newlines from stdout - while stdout_split and stdout_split[-1] == "": - stdout_split.pop() - - stderr_split = stderr.split("\n") - self.stderr = list(filter(None, stderr_split)) # filter empty values - if "has-session" in cmd and len(self.stderr) and not stdout_split: - self.stdout = [self.stderr[0]] - else: - self.stdout = stdout_split + result = _dispatch_run(runner, request) + + self.cmd = list(result.cmd) + self.returncode = result.returncode + self.stderr = list(result.stderr) + # Read defensively: ``process`` is the one field of ``CommandResult`` + # that no protocol declares, so an engine returning its own + # result type -- which ``TmuxEngine`` permits -- need not carry it. + process: subprocess.Popen[str] | None = getattr(result, "process", None) + self._process = process + + # tmux writes ``has-session``'s answer to stderr; the wrappers have + # always read it off stdout. Adapted here, not in an engine, so every + # engine stays a plain executor. + stdout = list(result.stdout) + self.stdout = ( + [self.stderr[0]] + if "has-session" in self.cmd and self.stderr and not stdout + else stdout + ) if logger.isEnabledFor(logging.DEBUG): logger.debug( "tmux command completed", extra={ - "tmux_cmd": shlex.join(cmd), + "tmux_cmd": shlex.join(self.cmd), + "tmux_subcommand": request.subcommand, "tmux_exit_code": self.returncode, "tmux_stdout": self.stdout[:100], "tmux_stderr": self.stderr[:100], @@ -377,6 +544,31 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: }, ) + @property + def process(self) -> subprocess.Popen[str]: + """Return the finished :class:`subprocess.Popen`. + + Returns + ------- + subprocess.Popen + The process the default engine forked. + + Raises + ------ + :exc:`~libtmux.exc.LibTmuxException` + The engine that ran the command never forked a process. Only an + injected engine can do that; the default engine always forks. + + Examples + -------- + >>> server.cmd("display-message", "-p", "hi").process.returncode + 0 + """ + if self._process is None: + msg = "engine did not fork a subprocess; tmux_cmd.process is unavailable" + raise exc.LibTmuxException(msg) + return self._process + class _TmuxVersionUnavailable(Exception): """Internal signal: this tmux predates the ``-V`` flag (pre-1.7).""" diff --git a/src/libtmux/engines/__init__.py b/src/libtmux/engines/__init__.py new file mode 100644 index 000000000..01eaabe4c --- /dev/null +++ b/src/libtmux/engines/__init__.py @@ -0,0 +1,65 @@ +"""Engines: the seam between libtmux's object API and tmux itself. + +An *engine* answers one question -- how does a tmux command actually get run? +:class:`~libtmux.engines.subprocess.SubprocessEngine` is the default and forks the +tmux CLI, which is what libtmux has always done. Because +:class:`~libtmux.engines.base.TmuxEngine` is a +:class:`typing.Protocol`, an in-memory fake, a recorder, or a control-mode client +can take its place: + +>>> from libtmux.engines import CommandRequest, CommandResult, TmuxEngine +>>> class SpyEngine: +... def __init__(self): +... self.seen: list[tuple[str, ...]] = [] +... +... def run(self, request): +... self.seen.append(request.args) +... return CommandResult(cmd=("tmux", *request.args), stdout=("$1",)) +... +... def run_batch(self, requests): +... return [self.run(request) for request in requests] +>>> engine = SpyEngine() +>>> isinstance(engine, TmuxEngine) +True + +Injection happens at the :class:`~libtmux.Server` boundary: + +>>> from libtmux.server import Server +>>> Server(socket_name="engine_docs", engine=engine).cmd("list-sessions").stdout +['$1'] +>>> engine.seen +[('list-sessions',)] + +The connection flags (``-L``/``-S``/``-f``/``-2``/``-8``) are *not* part of a +request: they belong to the engine's +:class:`~libtmux.engines.connection.ServerConnection`, so every engine sees the +same request regardless of which tmux server it targets. +""" + +from __future__ import annotations + +from libtmux.engines.base import ( + CommandRequest, + CommandResult, + CommandSeparator, + SupportsCommandLine, + SupportsConnection, + SupportsTmuxVersion, + TmuxEngine, + is_command_separator, +) +from libtmux.engines.connection import ServerConnection +from libtmux.engines.subprocess import SubprocessEngine + +__all__ = ( + "CommandRequest", + "CommandResult", + "CommandSeparator", + "ServerConnection", + "SubprocessEngine", + "SupportsCommandLine", + "SupportsConnection", + "SupportsTmuxVersion", + "TmuxEngine", + "is_command_separator", +) diff --git a/src/libtmux/engines/base.py b/src/libtmux/engines/base.py new file mode 100644 index 000000000..36c727c9c --- /dev/null +++ b/src/libtmux/engines/base.py @@ -0,0 +1,362 @@ +"""Core engine values: requests, results, and the protocols. + +A :class:`CommandRequest` is a tmux argv (the subcommand and its arguments, +*without* connection flags); a :class:`CommandResult` is the structured outcome. +:class:`TmuxEngine` is a :class:`typing.Protocol`, so any object with ``run`` and +``run_batch`` is an engine -- an in-memory fake, a control-mode client, a +recorder -- without inheriting a base class. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + import pathlib + import subprocess + from collections.abc import Sequence + + from typing_extensions import Self + + +class CommandSeparator(str): + """A caller-authored command boundary, distinct from a literal ``";"``. + + tmux treats a bare ``;`` argument as a command separator only when it + arrives unquoted, so a ``";"`` that is *data* -- a pane title, a shell + fragment passed to ``send-keys`` -- must not be mistaken for one. Marking + the boundary with its own type keeps the distinction in the value rather + than in a parsing convention, so an engine that chains commands can find + the real boundaries and every other engine can ignore them. + + Examples + -------- + >>> CommandSeparator(";") + ';' + >>> CommandSeparator("kill-server") + Traceback (most recent call last): + ... + ValueError: a command separator must be exactly ';' + """ + + def __new__(cls, value: str) -> Self: + """Construct the one legal structural token. + + Parameters + ---------- + value : str + Must be exactly ``";"``. + + Returns + ------- + CommandSeparator + The separator. + + Raises + ------ + ValueError + *value* is anything other than ``";"``. + """ + if value != ";": + msg = "a command separator must be exactly ';'" + raise ValueError(msg) + return super().__new__(cls, value) + + +def is_command_separator(token: str) -> bool: + """Return whether *token* is an intentional tmux command boundary. + + A plain ``";"`` is data and answers ``False``; only a + :class:`CommandSeparator` answers ``True``. + + Parameters + ---------- + token : str + The argv token to test. + + Returns + ------- + bool + + Examples + -------- + >>> is_command_separator(CommandSeparator(";")) + True + >>> is_command_separator(";") + False + """ + return type(token) is CommandSeparator and token == ";" + + +@dataclass(frozen=True) +class CommandRequest: + """A tmux command, ready for an engine to execute. + + Carries the subcommand and its arguments only. Connection flags + (``-L``/``-S``/``-f``/``-2``/``-8``) belong to the engine's + :class:`~libtmux.engines.connection.ServerConnection`, so every engine sees + the same request no matter which tmux server it targets. + + Attributes + ---------- + args : tuple[str, ...] + The tmux argv (e.g. ``("split-window", "-t", "%1")``). + tmux_bin : str or None + Override the tmux binary for this one request; ``None`` lets the engine + decide. + + Examples + -------- + >>> CommandRequest.from_args("split-window", "-t", "%1") + CommandRequest(args=('split-window', '-t', '%1'), tmux_bin=None) + >>> CommandRequest.from_args("kill-window", "-t", 2).args + ('kill-window', '-t', '2') + """ + + args: tuple[str, ...] + tmux_bin: str | None = None + + def __post_init__(self) -> None: + r"""Reject arguments that cannot survive tmux's C-string transports. + + Examples + -------- + >>> CommandRequest(args=("display-message", "a\0b")) + Traceback (most recent call last): + ... + ValueError: tmux command arguments cannot contain NUL + + A :class:`CommandSeparator` keeps its type through normalization, so a + chaining engine can still find the boundary: + + >>> request = CommandRequest(args=("kill-window", CommandSeparator(";"))) + >>> [is_command_separator(arg) for arg in request.args] + [False, True] + + A separator whose value was forged past :meth:`CommandSeparator.__new__` + is rejected rather than passed through as structural, so it cannot + smuggle a second command into a chained dispatch: + + >>> CommandRequest.from_args("display-message", str.__new__( + ... CommandSeparator, "\nkill-server")) + Traceback (most recent call last): + ... + ValueError: a command separator must be exactly ';' + """ + if any( + type(arg) is CommandSeparator and not is_command_separator(arg) + for arg in self.args + ): + msg = "a command separator must be exactly ';'" + raise ValueError(msg) + normalized = tuple( + arg if is_command_separator(arg) else str.__str__(arg) for arg in self.args + ) + if any("\0" in arg for arg in normalized): + msg = "tmux command arguments cannot contain NUL" + raise ValueError(msg) + object.__setattr__(self, "args", normalized) + + @classmethod + def from_args( + cls, + *args: t.Any, + tmux_bin: str | pathlib.Path | None = None, + ) -> CommandRequest: + """Build a request from arbitrary tokens, stringifying each. + + Parameters + ---------- + *args : typing.Any + Tokens; non-strings are rendered with :func:`str`, matching what + :class:`~libtmux.common.tmux_cmd` has always accepted. + tmux_bin : str or pathlib.Path, optional + Per-request tmux binary override. + + Returns + ------- + CommandRequest + The request. + + Examples + -------- + >>> CommandRequest.from_args("resize-pane", "-t", "%3", "-x", 80).args + ('resize-pane', '-t', '%3', '-x', '80') + """ + return cls( + args=tuple(arg if isinstance(arg, str) else str(arg) for arg in args), + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + ) + + @property + def subcommand(self) -> str: + """Return the tmux subcommand, or ``""`` for an empty request. + + Returns + ------- + str + First argv token. + + Examples + -------- + >>> CommandRequest.from_args("list-sessions", "-F#S").subcommand + 'list-sessions' + >>> CommandRequest.from_args().subcommand + '' + """ + return self.args[0] if self.args else "" + + +@dataclass(frozen=True) +class CommandResult: + """The structured outcome of executing a :class:`CommandRequest`. + + A tmux-side failure (nonzero exit, message on stderr) is *data* here: it + sets ``returncode`` and ``stderr`` rather than raising. Only a broken engine + (missing binary, lost connection) raises. + + Attributes + ---------- + cmd : tuple[str, ...] + The full argv that ran, including the tmux binary and connection flags. + stdout : tuple[str, ...] + Captured standard-output lines, trailing blanks removed. + stderr : tuple[str, ...] + Captured standard-error lines, blanks removed. + returncode : int + tmux exit code. + process : subprocess.Popen or None + The OS process, when the engine forked one. ``None`` for engines that + never touch the operating system, which is why + :attr:`libtmux.common.tmux_cmd.process` can only be a best-effort + accessor. Excluded from equality and :func:`repr`. + + Examples + -------- + >>> CommandResult(cmd=("tmux", "display-message", "-p", "hi"), stdout=("hi",)) + CommandResult(cmd=('tmux', 'display-message', '-p', 'hi'), stdout=('hi',), + stderr=(), returncode=0) + """ + + cmd: tuple[str, ...] + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + returncode: int = 0 + process: subprocess.Popen[str] | None = field( + default=None, + compare=False, + repr=False, + ) + + +@t.runtime_checkable +class TmuxEngine(t.Protocol): + """A synchronous executor of tmux commands. + + Structural: an object is an engine when it has ``run`` and ``run_batch``. + + Examples + -------- + >>> from libtmux.engines import CommandRequest, CommandResult, TmuxEngine + >>> class EchoEngine: + ... def run(self, request): + ... return CommandResult(cmd=("tmux", *request.args), stdout=("ok",)) + ... + ... def run_batch(self, requests): + ... return [self.run(request) for request in requests] + >>> isinstance(EchoEngine(), TmuxEngine) + True + >>> EchoEngine().run(CommandRequest.from_args("list-sessions")).stdout + ('ok',) + """ + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute requests in order, returning one result per request. + + Persistent-connection engines override this to pipeline; stateless + engines implement it as a loop over :meth:`run`. + """ + ... + + +@t.runtime_checkable +class SupportsCommandLine(t.Protocol): + """An engine that can render the argv it *would* run, without running it. + + Optional capability. :class:`~libtmux.common.tmux_cmd` uses it to log the + full command line before dispatch; engines without a command line (in-memory + fakes) simply do not implement it. + + Examples + -------- + >>> from libtmux.engines import SupportsCommandLine, SubprocessEngine + >>> isinstance(SubprocessEngine.for_server(server), SupportsCommandLine) + True + """ + + def command_line(self, request: CommandRequest) -> tuple[str, ...]: + """Return the full argv, binary first, that *request* would run as.""" + ... + + +@t.runtime_checkable +class SupportsConnection(t.Protocol): + """An engine that dispatches over a named tmux server and can be rebound. + + Optional capability. :attr:`Server.engine ` reads it + so an injected engine that names no server of its own adopts the server's + connection instead of silently reaching the ambient tmux server. In-memory + engines have no connection and simply do not implement it. + + Examples + -------- + >>> from libtmux.engines import SubprocessEngine, SupportsConnection + >>> isinstance(SubprocessEngine(), SupportsConnection) + True + + An engine with no notion of a socket does not implement it: + + >>> class InMemoryEngine: + ... def run(self, request): + ... return CommandResult(cmd=("tmux", *request.args)) + ... def run_batch(self, requests): + ... return [self.run(r) for r in requests] + >>> isinstance(InMemoryEngine(), SupportsConnection) + False + """ + + @property + def connection(self) -> t.Any: + """Return the tmux binary and flags this engine dispatches over.""" + ... + + def with_connection(self, connection: t.Any) -> TmuxEngine: + """Return an equivalent engine bound to *connection*.""" + ... + + +@t.runtime_checkable +class SupportsTmuxVersion(t.Protocol): + """An engine that can report the tmux version it targets. + + Optional capability. Callers that render version-gated argv -- dropping a + flag an older tmux cannot accept -- read it to resolve the version when + none is passed. Engines that cannot know their version, such as in-memory + fakes, simply do not implement it, and resolution falls back to assuming + the newest tmux. + + Examples + -------- + >>> from libtmux.engines import SubprocessEngine, SupportsTmuxVersion + >>> isinstance(SubprocessEngine.for_server(server), SupportsTmuxVersion) + True + """ + + def tmux_version(self) -> str | None: + """Return the engine's tmux version string, or ``None`` if unknown.""" + ... diff --git a/src/libtmux/engines/connection.py b/src/libtmux/engines/connection.py new file mode 100644 index 000000000..318c4619e --- /dev/null +++ b/src/libtmux/engines/connection.py @@ -0,0 +1,362 @@ +"""The connection an engine talks to: which tmux binary, which tmux server. + +Every engine needs the same two things before it can dispatch anything: a tmux +*binary* to exec, and the *connection flags* (``-L``/``-S``/``-f``/``-2``/``-8``) +that point at one particular tmux server. :class:`ServerConnection` is that pair +as one frozen value, and it is the only place in libtmux where either is +computed -- :meth:`libtmux.Server.cmd`, :meth:`libtmux.Server.raise_if_dead` and +:func:`libtmux.neo.fetch_objs` all read their flags from here. + +:meth:`ServerConnection.resolve_bin` is the single door to a tmux binary path: it +memoizes :func:`shutil.which` and raises +:exc:`~libtmux.exc.TmuxCommandNotFound` when tmux is absent, so no engine ships +an unguarded ``shutil.which("tmux")`` of its own. +""" + +from __future__ import annotations + +import shutil +import typing as t +from dataclasses import dataclass, field + +from libtmux import exc + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + +class _BinaryResolver: + """Memoized tmux-binary resolution and ``tmux -V`` probe. + + Owned by a :class:`ServerConnection`; never constructed by engines. Holding + the mutable cache here keeps :class:`ServerConnection` a frozen, comparable + value. + """ + + __slots__ = ("_declared", "_resolved", "_version", "_version_probed") + + def __init__(self, tmux_bin: str | None = None) -> None: + self._declared = tmux_bin + self._resolved: str | None = None + self._version: str | None = None + self._version_probed = False + + def resolve(self) -> str: + """Return the tmux binary path, memoized for this connection. + + An explicit binary wins. Otherwise :func:`shutil.which` walks ``$PATH`` + once and the answer is cached. A *failure* is not cached, so a tmux + installed after the miss is picked up. + """ + if self._declared is not None: + return self._declared + if self._resolved is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved = resolved + return self._resolved + + def version(self) -> str | None: + """Return the tmux version string, memoized; ``None`` when unknowable. + + ``None`` (missing binary, unparseable output) lets version resolution + degrade to "assume latest" rather than exploding. + """ + if not self._version_probed: + self._version_probed = True + # Imported here, not at module scope: libtmux.common's tmux_cmd + # dispatches through this package, so a module-level import would + # close an import cycle. + from libtmux.common import get_version + + try: + self._version = str(get_version(self.resolve())) + except exc.LibTmuxException: + self._version = None + return self._version + + +@dataclass(frozen=True) +class ServerConnection: + """Which tmux binary, and which tmux server, an engine talks to. + + Attributes + ---------- + tmux_bin : str or None + An explicit tmux binary. ``None`` means "resolve from ``$PATH``", which + :meth:`resolve_bin` does once and memoizes. + args : tuple[str, ...] + Connection flags placed before the tmux subcommand (e.g. ``("-Lwork",)``). + _resolver : _BinaryResolver + Memoized resolver for the binary path and tmux version. Built in + ``__post_init__``; excluded from equality, hashing and :func:`repr`. + + Examples + -------- + The default connection targets the ambient tmux server: + + >>> ServerConnection() + ServerConnection(tmux_bin=None, args=()) + + :meth:`from_server` reads the flags off a live :class:`libtmux.Server`: + + >>> conn = ServerConnection.from_server(server) + >>> conn.args[0].startswith(("-L", "-S")) + True + + It duck-types, so any object with the same attributes works: + + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_name="work", colors=256) + ... ) + ServerConnection(tmux_bin=None, args=('-2', '-Lwork')) + + :meth:`argv` prepends the binary and the flags to a command: + + >>> ServerConnection.of(tmux_bin="tmux", args=("-Lwork",)).argv( + ... "kill-window", "-t", "@1" + ... ) + ('tmux', '-Lwork', 'kill-window', '-t', '@1') + """ + + tmux_bin: str | None = None + args: tuple[str, ...] = () + _resolver: _BinaryResolver = field( + init=False, + repr=False, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + """Normalize *args* and build the connection's binary resolver. + + Examples + -------- + >>> ServerConnection(args=["-Lwork"]).args + ('-Lwork',) + """ + object.__setattr__(self, "args", tuple(self.args)) + object.__setattr__(self, "_resolver", _BinaryResolver(self.tmux_bin)) + + @classmethod + def of( + cls, + tmux_bin: str | pathlib.Path | None = None, + args: Sequence[str] = (), + ) -> ServerConnection: + """Build a connection, stringifying a :class:`pathlib.Path` binary. + + Parameters + ---------- + tmux_bin : str or pathlib.Path, optional + Explicit tmux binary. + args : Sequence[str] + Connection flags. + + Returns + ------- + ServerConnection + The connection. + + Examples + -------- + >>> import pathlib + >>> ServerConnection.of(pathlib.Path("/usr/bin/tmux")).tmux_bin + '/usr/bin/tmux' + >>> ServerConnection.of(args=["-L", "test"]).args + ('-L', 'test') + """ + return cls( + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + args=tuple(args), + ) + + @classmethod + def from_server(cls, server: t.Any) -> ServerConnection: + """Build the connection a live :class:`libtmux.Server` talks over. + + Flags are emitted in tmux's documented order of significance and in the + order :meth:`libtmux.Server.cmd` has always emitted them: color depth, + ``-f`` config file, ``-S`` socket path, ``-L`` socket name. + + Parameters + ---------- + server : typing.Any + Any object exposing ``socket_name``, ``socket_path``, + ``config_file``, ``colors`` and ``tmux_bin``. Missing attributes are + treated as unset. + + Returns + ------- + ServerConnection + The connection. + + Raises + ------ + :exc:`~libtmux.exc.UnknownColorOption` + ``colors`` is truthy but is neither ``256`` nor ``88``. + + Examples + -------- + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_path="/tmp/s", config_file="/tmp/c") + ... ) + ServerConnection(tmux_bin=None, args=('-f/tmp/c', '-S/tmp/s')) + + >>> from libtmux import exc + >>> try: + ... ServerConnection.from_server(types.SimpleNamespace(colors=16)) + ... except exc.UnknownColorOption as e: + ... print(e) + Server.colors must equal 88 or 256 + """ + args: list[str] = [] + + colors = getattr(server, "colors", None) + if colors: + if colors == 256: + args.append("-2") + elif colors == 88: + args.append("-8") + else: + raise exc.UnknownColorOption + + if getattr(server, "config_file", None): + args.append(f"-f{server.config_file}") + if getattr(server, "socket_path", None): + args.append(f"-S{server.socket_path}") + if getattr(server, "socket_name", None): + args.append(f"-L{server.socket_name}") + + return cls.of(tmux_bin=getattr(server, "tmux_bin", None), args=args) + + @property + def is_unconfigured(self) -> bool: + """Whether this connection carries nothing at all -- no flags, no binary. + + :attr:`Server.engine ` reads this on the + *server's* side of adoption: a server that names neither a socket nor a + binary has nothing to bind onto an injected engine, so it leaves the + engine alone. + + Returns + ------- + bool + + Examples + -------- + >>> ServerConnection().is_unconfigured + True + >>> ServerConnection.of(args=("-Lwork",)).is_unconfigured + False + >>> ServerConnection.of(tmux_bin="/usr/bin/tmux").is_unconfigured + False + """ + return not self.args and self.tmux_bin is None + + @property + def names_server(self) -> bool: + """Whether this connection carries connection flags of its own. + + :attr:`Server.engine ` reads this on the + *engine's* side of adoption: an engine that already carries flags knows + which tmux server it talks to and is left alone, while one that carries + none is bound to the server's flags so it cannot silently dispatch to + the ambient server. + + :attr:`tmux_bin` deliberately does not count. It selects which tmux + *program* to exec, which says nothing about which server that program + connects to -- a custom binary with no ``-L``/``-S`` reaches the same + ambient server as the stock one. + + Returns + ------- + bool + + Examples + -------- + >>> ServerConnection.of(args=("-Lwork",)).names_server + True + >>> ServerConnection().names_server + False + + A binary is a program, not a server: + + >>> ServerConnection.of(tmux_bin="/usr/bin/tmux").names_server + False + """ + return bool(self.args) + + def resolve_bin(self) -> str: + """Return the tmux binary path (memoized). + + Returns + ------- + str + Path to tmux. + + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandNotFound` + tmux is not on ``$PATH`` and none was declared. + + Examples + -------- + >>> ServerConnection.of(tmux_bin="/usr/bin/tmux").resolve_bin() + '/usr/bin/tmux' + """ + return self._resolver.resolve() + + def tmux_version(self) -> str | None: + """Return this connection's tmux version (memoized), or ``None``. + + Probes ``tmux -V`` once. Answers ``None`` when the binary is missing or + its version cannot be parsed, so a caller rendering version-gated argv + can fall back to assuming the newest tmux rather than failing. + + Returns + ------- + str or None + The version, e.g. ``"3.4"``. + + Examples + -------- + >>> ServerConnection().tmux_version() is not None + True + + The probe is memoized, so repeated reads cost one ``tmux -V``: + + >>> conn = ServerConnection() + >>> conn.tmux_version() == conn.tmux_version() + True + """ + return self._resolver.version() + + def argv(self, *args: str, tmux_bin: str | None = None) -> tuple[str, ...]: + """Render a full command line: binary, connection flags, then *args*. + + Parameters + ---------- + *args : str + The tmux subcommand and its arguments. + tmux_bin : str, optional + Override this connection's binary for one command. + + Returns + ------- + tuple[str, ...] + The full argv. + + Examples + -------- + >>> ServerConnection.of("tmux", ("-Lwork",)).argv("list-sessions") + ('tmux', '-Lwork', 'list-sessions') + >>> ServerConnection.of("tmux").argv("list-sessions", tmux_bin="/opt/tmux") + ('/opt/tmux', 'list-sessions') + """ + return (tmux_bin or self.resolve_bin(), *self.args, *args) diff --git a/src/libtmux/engines/subprocess.py b/src/libtmux/engines/subprocess.py new file mode 100644 index 000000000..886dded42 --- /dev/null +++ b/src/libtmux/engines/subprocess.py @@ -0,0 +1,312 @@ +"""The default engine: one ``fork``/``exec`` of the tmux CLI per command. + +Mirrors the output handling libtmux has always had -- ``backslashreplace`` +decoding, trailing-blank stripping on stdout, blank filtering on stderr. A +tmux-side failure comes back as data (nonzero ``returncode`` plus ``stderr``); +only a missing binary raises. +""" + +from __future__ import annotations + +import logging +import shlex +import subprocess +import typing as t + +from libtmux import exc +from libtmux.engines.base import CommandResult +from libtmux.engines.connection import ServerConnection + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.engines.base import CommandRequest + +logger = logging.getLogger(__name__) + + +class SubprocessEngine: + """Execute tmux commands by forking the tmux CLI binary. + + Parameters + ---------- + connection : ServerConnection, optional + The tmux binary and connection flags to dispatch through. Defaults to + the ambient tmux server on ``$PATH``. + + Examples + -------- + >>> from libtmux.engines import CommandRequest, SubprocessEngine + >>> engine = SubprocessEngine.for_server(server) + >>> engine.run(CommandRequest.from_args("display-message", "-p", "hi")).stdout + ('hi',) + """ + + def __init__(self, connection: ServerConnection | None = None) -> None: + self._conn = connection if connection is not None else ServerConnection() + + @classmethod + def of( + cls, + tmux_bin: str | pathlib.Path | None = None, + server_args: Sequence[str] = (), + ) -> SubprocessEngine: + """Build an engine from a binary path and raw connection flags. + + Parameters + ---------- + tmux_bin : str or pathlib.Path, optional + Explicit tmux binary; resolved from ``$PATH`` when ``None``. + server_args : Sequence[str] + Connection flags, e.g. ``("-Lwork",)``. + + Returns + ------- + SubprocessEngine + The engine. + + Examples + -------- + >>> SubprocessEngine.of(server_args=["-Lwork"]).server_args + ('-Lwork',) + """ + return cls(ServerConnection.of(tmux_bin, server_args)) + + def with_connection(self, connection: ServerConnection) -> SubprocessEngine: + """Return an equivalent engine dispatching over *connection*. + + Engines are immutable with respect to their connection, so this returns + a new engine rather than rebinding this one. + :attr:`Server.engine ` calls it to bind an engine + that names no server of its own. + + Parameters + ---------- + connection : ServerConnection + The connection the returned engine dispatches over. + + Returns + ------- + SubprocessEngine + A new engine; this one is left untouched. + + Examples + -------- + >>> from libtmux.engines import ServerConnection + >>> engine = SubprocessEngine() + >>> engine.server_args + () + >>> engine.with_connection(ServerConnection.of(args=("-Lwork",))).server_args + ('-Lwork',) + >>> engine.server_args + () + """ + return type(self)(connection) + + @classmethod + def for_server(cls, server: t.Any) -> SubprocessEngine: + """Build an engine bound to a live :class:`libtmux.Server`'s socket. + + Parameters + ---------- + server : typing.Any + Any object shaped like a :class:`libtmux.Server`. + + Returns + ------- + SubprocessEngine + An engine reaching the same tmux server as the object API. + + Examples + -------- + >>> SubprocessEngine.for_server(server).server_args[0].startswith("-L") + True + """ + return cls(ServerConnection.from_server(server)) + + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through. + + Returns + ------- + ServerConnection + The connection. + + Examples + -------- + >>> SubprocessEngine.of("tmux").connection.tmux_bin + 'tmux' + """ + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any. + + Returns + ------- + str or None + The declared binary; ``None`` when resolved from ``$PATH``. + + Examples + -------- + >>> SubprocessEngine.of("/usr/bin/tmux").tmux_bin + '/usr/bin/tmux' + """ + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before every tmux subcommand. + + Returns + ------- + tuple[str, ...] + The flags. + + Examples + -------- + >>> SubprocessEngine.of(server_args=("-Ltest",)).server_args + ('-Ltest',) + """ + return self._conn.args + + def tmux_version(self) -> str | None: + """Report the tmux version this engine dispatches to (memoized). + + Satisfies :class:`~libtmux.engines.base.SupportsTmuxVersion`. + + Returns + ------- + str or None + The version, or ``None`` when the binary is missing or unparseable. + + Examples + -------- + >>> SubprocessEngine.for_server(server).tmux_version() is not None + True + """ + return self._conn.tmux_version() + + def command_line(self, request: CommandRequest) -> tuple[str, ...]: + r"""Return the full argv *request* would run as, without running it. + + Parameters + ---------- + request : CommandRequest + The command. + + Returns + ------- + tuple[str, ...] + Binary, connection flags, then the encoded command argv. + + Examples + -------- + >>> from libtmux.engines import CommandRequest + >>> SubprocessEngine.of("tmux", ("-Lwork",)).command_line( + ... CommandRequest.from_args("send-keys", "echo hi") + ... ) + ('tmux', '-Lwork', 'send-keys', 'echo hi') + """ + return self._conn.argv(*request.args, tmux_bin=request.tmux_bin) + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command via :mod:`subprocess` and return its result. + + Parameters + ---------- + request : CommandRequest + The command. + + Returns + ------- + CommandResult + Structured output, carrying the :class:`subprocess.Popen` that ran. + + Raises + ------ + :exc:`~libtmux.exc.TmuxCommandNotFound` + The tmux binary is missing or not executable. + + Examples + -------- + >>> from libtmux.engines import CommandRequest + >>> engine = SubprocessEngine.for_server(server) + >>> engine.run(CommandRequest.from_args("has-session", "-t", "nope")).returncode + 1 + """ + cmd = self.command_line(request) + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="backslashreplace", + ) + stdout, stderr = process.communicate() + returncode = process.returncode + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + except Exception: + logger.error( # noqa: TRY400 + "tmux subprocess failed", + extra={"tmux_cmd": shlex.join(cmd)}, + ) + raise + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + + result = CommandResult( + cmd=cmd, + stdout=tuple(stdout_lines), + stderr=tuple(line for line in stderr.split("\n") if line), + returncode=returncode, + process=process, + ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "tmux subprocess completed", + extra={ + "tmux_cmd": shlex.join(cmd), + "tmux_subcommand": request.subcommand, + "tmux_exit_code": returncode, + "tmux_stdout_len": len(result.stdout), + "tmux_stderr_len": len(result.stderr), + }, + ) + return result + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order, one fork per command. + + Parameters + ---------- + requests : Sequence[CommandRequest] + Commands to run. + + Returns + ------- + list[CommandResult] + One result per request, in order. + + Examples + -------- + >>> from libtmux.engines import CommandRequest + >>> results = SubprocessEngine.for_server(server).run_batch( + ... [ + ... CommandRequest.from_args("display-message", "-p", "one"), + ... CommandRequest.from_args("display-message", "-p", "two"), + ... ] + ... ) + >>> [result.stdout[0] for result in results] + ['one', 'two'] + """ + return [self.run(request) for request in requests] diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102..0dd2326cd 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -99,6 +99,67 @@ class TmuxCommandNotFound(LibTmuxException): """Application binary for tmux not found.""" +class AsyncEngineMismatch(LibTmuxException): + """A synchronous dispatch path received an engine call that returned an awaitable. + + :class:`~libtmux.engines.base.TmuxEngine` and + :class:`~libtmux.engines.base.SupportsCommandLine` are + :func:`typing.runtime_checkable` :class:`typing.Protocol` classes, which + check attribute *names* only -- never signatures or async-ness. An engine + declared with ``async def run`` (or ``async def command_line``) still + satisfies ``isinstance(engine, TmuxEngine)`` and reaches + :class:`~libtmux.common.tmux_cmd`, whose dispatch is synchronous and + cannot await it. + + Raised from what the call actually returned, not from inspecting the + method beforehand, so it also catches a method that is not itself + declared ``async`` but still hands back an awaitable -- an engine that + wraps its coroutine in an :class:`asyncio.Task` or :class:`asyncio.Future` + before returning it. + + Parameters + ---------- + engine : object + The engine instance whose method returned an awaitable. + method : str + Name of the method that returned it -- ``"run"`` or + ``"command_line"``. + *args : object + Forwarded to :class:`LibTmuxException`. + + Examples + -------- + >>> from libtmux import exc + >>> class AsyncEngine: + ... async def run(self, request): ... + ... async def run_batch(self, requests): ... + >>> print( # doctest: +NORMALIZE_WHITESPACE + ... exc.AsyncEngineMismatch(AsyncEngine(), "run") + ... ) + AsyncEngine.run() returned an awaitable: libtmux dispatches tmux commands + synchronously and cannot await it. Await this engine directly from your + own async code, or pass a synchronous engine. + + It is part of the :exc:`LibTmuxException` hierarchy: + + >>> issubclass(exc.AsyncEngineMismatch, exc.LibTmuxException) + True + + .. versionadded:: 0.63 + """ + + def __init__(self, engine: object, method: str, *args: object) -> None: + self.engine = engine + self.method = method + msg = ( + f"{type(engine).__name__}.{method}() returned an awaitable: " + "libtmux dispatches tmux commands synchronously and cannot " + "await it. Await this engine directly from your own async " + "code, or pass a synchronous engine." + ) + super().__init__(msg, *args) + + class NotInsideTmux(LibTmuxException): """Raised when the process is not running inside a tmux pane. diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa..feb8bb0e4 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1098,17 +1098,7 @@ def fetch_objs( tmux_version = str(get_version(tmux_bin=server.tmux_bin)) _fields, format_string = get_output_format(list_cmd, tmux_version) - cmd_args: list[str | int] = [] - - if server.socket_name: - cmd_args.insert(0, f"-L{server.socket_name}") - if server.socket_path: - cmd_args.insert(0, f"-S{server.socket_path}") - - tmux_cmds = [ - *cmd_args, - list_cmd, - ] + tmux_cmds: list[str | int] = [list_cmd] if list_extra_args is not None and isinstance(list_extra_args, Iterable): tmux_cmds.extend(list(list_extra_args)) @@ -1130,10 +1120,7 @@ def fetch_objs( }, ) - proc = tmux_cmd( - *tmux_cmds, - tmux_bin=server.tmux_bin, - ) + proc = tmux_cmd(*tmux_cmds, engine=server.engine) raise_if_stderr(proc, list_cmd) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f5961..8c4cce6a9 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -336,6 +336,11 @@ def cmd( Returns ------- :meth:`server.cmd` + + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + The server's engine is asynchronous; see :meth:`Server.cmd`. """ if target is None: target = self.pane_id diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c3..3758dce2b 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -10,7 +10,6 @@ import logging import os import pathlib -import shutil import subprocess import typing as t import warnings @@ -21,6 +20,9 @@ from libtmux.client import Client from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope +from libtmux.engines.base import SupportsConnection +from libtmux.engines.connection import ServerConnection +from libtmux.engines.subprocess import SubprocessEngine from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output from libtmux.pane import Pane @@ -43,6 +45,7 @@ from typing_extensions import Self from libtmux._internal.types import StrPath + from libtmux.engines.base import TmuxEngine DashLiteral: TypeAlias = t.Literal["-"] @@ -108,6 +111,10 @@ class Server( on_init : callable, optional socket_name_factory : callable, optional tmux_bin : str or pathlib.Path, optional + engine : :class:`~libtmux.engines.base.TmuxEngine`, optional + Executor every tmux command runs through. Defaults to + :class:`~libtmux.engines.subprocess.SubprocessEngine` bound to this + server's :attr:`connection`. Examples -------- @@ -167,6 +174,17 @@ class Server( tmux_bin: str | None = None """Custom path to tmux binary. Falls back to ``shutil.which("tmux")``.""" + _engine: TmuxEngine | None = None + """Caller-supplied executor, or ``None`` for the default subprocess engine.""" + _default_engine: SubprocessEngine | None = None + """Lazily built default engine, rebuilt whenever :attr:`connection` changes.""" + _connection: ServerConnection | None = None + """Cached connection, valid while :attr:`_connection_key` still matches.""" + _connection_key: tuple[t.Any, ...] | None = None + """Snapshot of the public connection attributes the cache was built from.""" + _adopted_engine: tuple[ServerConnection, TmuxEngine] | None = None + """Injected engine rebound to :attr:`connection`, with the connection it used.""" + def __init__( self, socket_name: str | None = None, @@ -176,10 +194,16 @@ def __init__( on_init: t.Callable[[Server], None] | None = None, socket_name_factory: t.Callable[[], str] | None = None, tmux_bin: str | pathlib.Path | None = None, + engine: TmuxEngine | None = None, **kwargs: t.Any, ) -> None: EnvironmentMixin.__init__(self, "-g") self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self._engine = engine + self._default_engine = None + self._adopted_engine = None + self._connection = None + self._connection_key = None self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] @@ -199,6 +223,132 @@ def __init__( if on_init is not None: on_init(self) + @property + def connection(self) -> ServerConnection: + """Return the tmux binary and connection flags this server dispatches on. + + :attr:`socket_name`, :attr:`socket_path`, :attr:`config_file`, + :attr:`colors` and :attr:`tmux_bin` are public and writable, and + :meth:`__eq__` reads two of them, so a connection captured once at + construction would silently keep pointing at the old socket after a + write. The connection is therefore *derived*, and cached against a + snapshot of exactly those five attributes: reassigning any of them + invalidates the cache on the next command, while an unchanged server + keeps one memoized :func:`shutil.which` lookup for its whole life. + + Returns + ------- + :class:`~libtmux.engines.connection.ServerConnection` + Flags in the order tmux receives them: color depth, ``-f``, ``-S``, + ``-L``. + + Raises + ------ + :exc:`~libtmux.exc.UnknownColorOption` + :attr:`colors` is set to something other than ``256`` or ``88``. + + Examples + -------- + >>> tmux = Server(socket_name="engine_conn_docs") + >>> tmux.connection.args + ('-Lengine_conn_docs',) + + A later write is picked up: + + >>> tmux.socket_name = "engine_conn_docs_moved" + >>> tmux.connection.args + ('-Lengine_conn_docs_moved',) + + .. versionadded:: 0.63 + """ + key = ( + self.socket_name, + None if self.socket_path is None else str(self.socket_path), + self.config_file, + self.colors, + self.tmux_bin, + ) + if self._connection is None or self._connection_key != key: + self._connection = ServerConnection.from_server(self) + self._connection_key = key + return self._connection + + @property + def engine(self) -> TmuxEngine: + """Return the executor every tmux command on this server runs through. + + With no ``engine=``, a + :class:`~libtmux.engines.subprocess.SubprocessEngine` is built from + :attr:`connection` and rebuilt whenever that connection changes. + + A caller-supplied ``engine=`` carrying connection flags of its own + already names a tmux server, and is returned untouched. One carrying + none -- a bare ``SubprocessEngine()`` -- *adopts* this server's + :attr:`connection`, because returning it untouched would dispatch to + whichever server a flagless ``tmux`` reaches rather than to this one. A + ``tmux_bin`` does not count as naming a server: it selects which tmux + program to exec, so an engine carrying only a binary adopts this + server's flags and keeps its own binary. Engines with no connection at + all, such as in-memory fakes, are always returned untouched. + + Returns + ------- + :class:`~libtmux.engines.base.TmuxEngine` + The engine. + + Examples + -------- + >>> from libtmux.engines import SubprocessEngine + >>> isinstance(server.engine, SubprocessEngine) + True + + An injected engine that names no server adopts this one's socket: + + >>> tmux = Server(socket_name="engine_adopt_docs", engine=SubprocessEngine()) + >>> tmux.engine.server_args + ('-Lengine_adopt_docs',) + + An engine that names a server keeps it: + + >>> pinned = SubprocessEngine.of(server_args=("-Lelsewhere",)) + >>> Server(socket_name="engine_adopt_docs", engine=pinned).engine.server_args + ('-Lelsewhere',) + + A binary names no server, so an engine carrying only one still binds, + and keeps that binary: + + >>> custom = SubprocessEngine.of(tmux_bin="/nonexistent/tmux") + >>> bound = Server(socket_name="engine_adopt_docs", engine=custom).engine + >>> bound.server_args, bound.tmux_bin + (('-Lengine_adopt_docs',), '/nonexistent/tmux') + + .. versionadded:: 0.63 + """ + connection = self.connection + engine = self._engine + if engine is not None: + if not isinstance(engine, SupportsConnection): + return engine + engine_connection = engine.connection + if connection.is_unconfigured or engine_connection.names_server: + return engine + adopted = self._adopted_engine + if adopted is None or adopted[0] is not connection: + target = connection + if engine_connection.tmux_bin is not None: + target = ServerConnection.of( + engine_connection.tmux_bin, + connection.args, + ) + adopted = (connection, engine.with_connection(target)) + self._adopted_engine = adopted + return adopted[1] + default = self._default_engine + if default is None or default.connection is not connection: + default = SubprocessEngine(connection) + self._default_engine = default + return default + @classmethod def from_env(cls, env: t.Mapping[str, str] | None = None) -> Server: """Return the tmux server this process's pane is attached to. @@ -302,37 +452,45 @@ def is_alive(self) -> bool: def raise_if_dead(self) -> None: """Raise if server not connected. + The engine captures tmux's diagnostic rather than letting it reach the + terminal, so it rides on the exception as + :attr:`~subprocess.CalledProcessError.stderr` -- otherwise an exit code + is all the caller ever sees of why the server is unreachable. + + Dispatches through :meth:`Server.cmd`, the same path every other tmux + command on this server takes, rather than calling :attr:`Server.engine` + directly -- one dispatch site instead of two. + Raises ------ + :exc:`~libtmux.exc.UnknownColorOption` + :attr:`colors` is set to something other than ``256`` or ``88``. :exc:`exc.TmuxCommandNotFound` When the tmux binary cannot be found or executed. + :exc:`~libtmux.exc.AsyncEngineMismatch` + An injected engine's ``run()`` returned an awaitable; this path + cannot await it. :class:`subprocess.CalledProcessError` When the tmux server is not running (non-zero exit from - ``list-sessions``). + ``list-sessions``), carrying tmux's own message. >>> tmux = Server(socket_name="no_exist") >>> try: ... tmux.raise_if_dead() ... except Exception as e: ... print(type(e)) + ... print("no_exist" in e.stderr) + True """ - resolved = self.tmux_bin or shutil.which("tmux") - if resolved is None: - raise exc.TmuxCommandNotFound - - cmd_args: list[str] = ["list-sessions"] - if self.socket_name: - cmd_args.insert(0, f"-L{self.socket_name}") - if self.socket_path: - cmd_args.insert(0, f"-S{self.socket_path}") - if self.config_file: - cmd_args.insert(0, f"-f{self.config_file}") - - try: - subprocess.check_call([resolved, *cmd_args]) - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None + result = self.cmd("list-sessions") + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + result.cmd, + output="\n".join(result.stdout), + stderr="\n".join(result.stderr), + ) # # Command @@ -384,31 +542,28 @@ def cmd( ------- :class:`common.tmux_cmd` + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + The engine is asynchronous -- its ``run()`` (or ``command_line()``, + while rendering a DEBUG log line) returned an awaitable, which this + synchronous dispatch cannot await. + Notes ----- + Dispatches through :attr:`Server.engine`; the connection flags come + from :attr:`Server.connection`, so this method and every other tmux + call on this server target the same socket. + .. versionchanged:: 0.8 Renamed from ``.tmux`` to ``.cmd``. """ - svr_args: list[str | int] = [cmd] - cmd_args: list[str | int] = [] - if self.socket_name: - svr_args.insert(0, f"-L{self.socket_name}") - if self.socket_path: - svr_args.insert(0, f"-S{self.socket_path}") - if self.config_file: - svr_args.insert(0, f"-f{self.config_file}") - if self.colors: - if self.colors == 256: - svr_args.insert(0, "-2") - elif self.colors == 88: - svr_args.insert(0, "-8") - else: - raise exc.UnknownColorOption - - cmd_args = ["-t", str(target), *args] if target is not None else [*args] + cmd_args: list[str | int] = ( + ["-t", str(target), *args] if target is not None else [*args] + ) - return tmux_cmd(*svr_args, *cmd_args, tmux_bin=self.tmux_bin) + return tmux_cmd(cmd, *cmd_args, engine=self.engine) @property def attached_sessions(self) -> list[Session]: @@ -2412,12 +2567,18 @@ def sessions(self) -> QueryList[Session]: missing socket, a permission error, or a subprocess failure. To distinguish "no sessions" from "tmux unreachable", call :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + + :exc:`~libtmux.exc.AsyncEngineMismatch` is not a tmux failure -- it + means the injected engine cannot be dispatched synchronously at all -- + so it is not part of that leniency and always propagates. """ try: sessions: list[Session] = [ Session(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-sessions") ] + except exc.AsyncEngineMismatch: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(sessions) @@ -2474,6 +2635,10 @@ def clients(self) -> QueryList[Client]: distinguish "no clients attached" from "tmux unreachable", call :meth:`Server.is_alive` or :meth:`Server.raise_if_dead`. + :exc:`~libtmux.exc.AsyncEngineMismatch` is not a tmux failure -- it + means the injected engine cannot be dispatched synchronously at all -- + so it is not part of that leniency and always propagates. + Returns ------- :class:`~libtmux._internal.query_list.QueryList` of :class:`Client` @@ -2490,6 +2655,8 @@ def clients(self) -> QueryList[Client]: Client(server=self, **obj) for obj in fetch_objs(server=self, list_cmd="list-clients") ] + except exc.AsyncEngineMismatch: + raise except exc.LibTmuxException: return QueryList([]) return QueryList(clients) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a3..3a325cdec 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -444,6 +444,11 @@ def cmd( ------- :meth:`server.cmd` + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + The server's engine is asynchronous; see :meth:`Server.cmd`. + Notes ----- .. versionchanged:: 0.34 diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db9969..d25cb4c90 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -317,6 +317,9 @@ def linked_sessions(self) -> QueryList[Session]: holders takes two list commands total, independent of how many there are. If either listing fails, the result is empty. + :exc:`~libtmux.exc.AsyncEngineMismatch` is not a listing failure -- + it means the server's engine cannot be dispatched synchronously at + all -- so it always propagates instead. Returns ------- @@ -363,6 +366,8 @@ def linked_sessions(self) -> QueryList[Session]: server=self.server, list_cmd="list-sessions", ) + except exc.AsyncEngineMismatch: + raise except exc.LibTmuxException: return QueryList([]) @@ -490,6 +495,11 @@ def cmd( Returns ------- :meth:`server.cmd` + + Raises + ------ + :exc:`~libtmux.exc.AsyncEngineMismatch` + The server's engine is asynchronous; see :meth:`Server.cmd`. """ if target is None: target = self.window_id diff --git a/tests/test_engines.py b/tests/test_engines.py new file mode 100644 index 000000000..fccd2232a --- /dev/null +++ b/tests/test_engines.py @@ -0,0 +1,604 @@ +"""Tests for :mod:`libtmux.engines`, the tmux command execution seam.""" + +from __future__ import annotations + +import asyncio +import gc +import logging +import subprocess +import typing as t + +import pytest + +from libtmux import exc +from libtmux.common import tmux_cmd +from libtmux.engines import ( + CommandRequest, + CommandResult, + ServerConnection, + SubprocessEngine, + SupportsCommandLine, + TmuxEngine, +) +from libtmux.neo import fetch_objs +from libtmux.server import Server + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.session import Session + + +class CannedEngine: + """An in-memory engine: records requests, replays canned stdout. + + Satisfies :class:`~libtmux.engines.base.TmuxEngine` structurally, without + inheritance and without a tmux binary. + """ + + def __init__(self, stdout: Sequence[str] = ()) -> None: + self.requests: list[CommandRequest] = [] + self._stdout = tuple(stdout) + + def run(self, request: CommandRequest) -> CommandResult: + """Record *request* and return the canned result.""" + self.requests.append(request) + return CommandResult( + cmd=("canned-tmux", *request.args), + stdout=self._stdout, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Run each request in order.""" + return [self.run(request) for request in requests] + + +def test_canned_engine_satisfies_protocol() -> None: + """A plain class with run/run_batch is a TmuxEngine.""" + assert isinstance(CannedEngine(), TmuxEngine) + assert not isinstance(CannedEngine(), SupportsCommandLine) + + +def test_server_drives_injected_engine_without_tmux() -> None: + """``Server(engine=...)`` routes ``cmd()`` through the injected engine. + + No tmux fixture: the point is that an injected engine never forks tmux, so + the canned stdout is what ``Server.cmd`` returns. + """ + engine = CannedEngine(stdout=("$9",)) + server = Server(socket_name="canned_never_started", engine=engine) + + proc = server.cmd("new-session", "-P", "-F#{session_id}") + + assert proc.stdout == ["$9"] + assert proc.returncode == 0 + assert proc.cmd == ["canned-tmux", "new-session", "-P", "-F#{session_id}"] + assert [request.args for request in engine.requests] == [ + ("new-session", "-P", "-F#{session_id}"), + ] + assert server.engine is engine + + +def test_injected_engine_receives_target_flag() -> None: + """``target=`` is rendered into the request, not the connection.""" + engine = CannedEngine() + server = Server(socket_name="canned_target", engine=engine) + + server.cmd("kill-window", target="@3") + + assert engine.requests[0].args == ("kill-window", "-t", "@3") + + +def test_process_raises_on_engine_without_subprocess() -> None: + """``.process`` is unavailable when no OS process was forked.""" + server = Server(socket_name="canned_process", engine=CannedEngine()) + proc = server.cmd("list-sessions") + + with pytest.raises(exc.LibTmuxException): + _ = proc.process + + +class ForeignResult(t.NamedTuple): + """A result shaped like :class:`CommandResult` but of another type. + + An out-of-tree engine has no reason to import libtmux's result class, and + :class:`~libtmux.engines.base.TmuxEngine` never says it must. ``process`` is + absent here on purpose: it is the one field no protocol declares. + """ + + cmd: tuple[str, ...] + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + returncode: int = 0 + + +class ForeignResultEngine: + """An engine returning a result type libtmux does not own.""" + + def run(self, request: CommandRequest) -> t.Any: + """Return a structurally-compatible result of a foreign type.""" + return ForeignResult(cmd=("foreign-tmux", *request.args), stdout=("$7",)) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[t.Any]: + """Run each request in order.""" + return [self.run(request) for request in requests] + + +def test_server_drives_engine_returning_a_foreign_result() -> None: + """An engine may return any structurally-compatible result, not only ours. + + ``TmuxEngine`` is structural, so an out-of-tree engine that never imports + :class:`CommandResult` still qualifies. Reading ``process`` off such a + result must degrade to the documented exception rather than raising + :exc:`AttributeError` from inside dispatch. + """ + server = Server(socket_name="foreign_result", engine=ForeignResultEngine()) + + proc = server.cmd("new-session", "-P", "-F#{session_id}") + + assert proc.stdout == ["$7"] + assert proc.returncode == 0 + assert proc.cmd == ["foreign-tmux", "new-session", "-P", "-F#{session_id}"] + with pytest.raises(exc.LibTmuxException): + _ = proc.process + + +def test_process_is_popen_under_default_engine(session: Session) -> None: + """``.process`` reads exactly as it did before the seam existed.""" + proc = session.server.cmd("display-message", "-p", "hi") + + assert isinstance(proc.process, subprocess.Popen) + assert proc.process.returncode == 0 + + +def test_connection_follows_socket_name_mutation() -> None: + """A post-construction write to ``socket_name`` changes the flags used. + + ``Server.socket_name`` is public and writable, so the connection is derived + per command rather than captured at construction. + """ + server = Server(socket_name="mutation_before") + assert server.connection.args == ("-Lmutation_before",) + first = server.connection + + server.socket_name = "mutation_after" + + assert server.connection.args == ("-Lmutation_after",) + assert server.connection is not first + assert server.cmd("has-session", "-t", "nothing").cmd[1] == "-Lmutation_after" + + +def test_connection_is_cached_while_unchanged(server: Server) -> None: + """An untouched server reuses one connection, and so one binary lookup.""" + assert server.connection is server.connection + assert server.engine is server.engine + + +def test_default_engine_rebuilt_after_mutation() -> None: + """The default engine is rebuilt when the connection it wraps changes.""" + server = Server(socket_name="engine_rebuild_before") + first = server.engine + + server.socket_name = "engine_rebuild_after" + second = server.engine + + assert first is not second + assert isinstance(second, SubprocessEngine) + assert second.server_args == ("-Lengine_rebuild_after",) + + +def test_injected_engine_survives_mutation() -> None: + """An injected engine is user-owned: libtmux never swaps it out.""" + engine = CannedEngine() + server = Server(socket_name="injected_before", engine=engine) + + server.socket_name = "injected_after" + + assert server.engine is engine + + +def test_engine_carrying_only_a_binary_still_adopts_the_socket() -> None: + """A tmux binary names a *program*, not a server, so the socket still binds. + + Left unbound, such an engine runs `` list-sessions`` with no + ``-L``, reaching whichever server a flagless tmux finds rather than this + one -- the silent ambient dispatch adoption exists to prevent. + """ + engine = SubprocessEngine.of(tmux_bin="/nonexistent/tmux") + server = Server(socket_name="bin_only_adopts", engine=engine) + + adopted = server.engine + + assert isinstance(adopted, SubprocessEngine) + assert adopted.command_line(CommandRequest.from_args("list-sessions")) == ( + "/nonexistent/tmux", + "-Lbin_only_adopts", + "list-sessions", + ) + + +def test_adoption_keeps_the_engines_own_binary() -> None: + """Adoption takes the server's flags without discarding the engine's binary.""" + engine = SubprocessEngine.of(tmux_bin="/nonexistent/tmux") + server = Server(socket_name="bin_kept", tmux_bin="/other/tmux", engine=engine) + + adopted = server.engine + + assert isinstance(adopted, SubprocessEngine) + assert adopted.tmux_bin == "/nonexistent/tmux" + assert adopted.server_args == ("-Lbin_kept",) + + +def test_server_binary_reaches_an_engine_that_declares_none() -> None: + """An engine with no binary of its own still inherits the server's.""" + server = Server( + socket_name="bin_inherited", + tmux_bin="/other/tmux", + engine=SubprocessEngine(), + ) + + adopted = server.engine + + assert isinstance(adopted, SubprocessEngine) + assert adopted.tmux_bin == "/other/tmux" + assert adopted.server_args == ("-Lbin_inherited",) + + +def test_engine_naming_a_server_is_left_alone() -> None: + """Connection flags of the engine's own win over the server's.""" + engine = SubprocessEngine.of(server_args=("-Lelsewhere",)) + server = Server(socket_name="not_elsewhere", engine=engine) + + assert server.engine is engine + + +class ArgvRecordingEngine: + """Render argv against a real connection, record it, run nothing. + + Lets a test read the command line each dispatch path *would* have used, + without a tmux server and without special-casing any one path. + """ + + def __init__(self, connection: ServerConnection) -> None: + self.connection = connection + self.command_lines: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Record the rendered argv and return an empty success.""" + cmd = (self.connection.tmux_bin or "tmux", *self.connection.args, *request.args) + self.command_lines.append(cmd) + return CommandResult(cmd=cmd) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Run each request in order.""" + return [self.run(request) for request in requests] + + +def test_flag_builders_agree() -> None: + """cmd(), raise_if_dead() and fetch_objs() emit identical flags. + + All three paths formerly built ``-L``/``-S``/``-f``/``-2`` themselves, from + three different rules. They now read one + :class:`~libtmux.engines.connection.ServerConnection`. + """ + attrs: dict[str, t.Any] = { + "socket_name": "flag_agreement", + "config_file": "/dev/null", + "colors": 256, + } + expected = Server(**attrs).connection.args + assert expected == ("-2", "-f/dev/null", "-Lflag_agreement") + + engine = ArgvRecordingEngine(Server(**attrs).connection) + server = Server(**attrs, engine=engine) + + server.cmd("list-sessions") + server.raise_if_dead() + fetch_objs(server=server, list_cmd="list-sessions") + + assert len(engine.command_lines) == 3 + assert {line[1 : 1 + len(expected)] for line in engine.command_lines} == {expected} + + +def test_unknown_color_raises_on_every_path() -> None: + """An unknown ``colors`` value raises, matching ``Server.cmd``'s contract.""" + server = Server(socket_name="bad_colors") + server.colors = 16 + + with pytest.raises(exc.UnknownColorOption): + server.cmd("list-sessions") + with pytest.raises(exc.UnknownColorOption): + server.raise_if_dead() + with pytest.raises(exc.UnknownColorOption): + fetch_objs(server=server, list_cmd="list-sessions") + + +def test_raise_if_dead_carries_tmuxs_message() -> None: + """The dead-server diagnostic rides on the exception instead of vanishing. + + The engine captures tmux's stderr rather than letting it reach the + terminal, so dropping it would leave the caller with an exit code and + nothing to explain it. + """ + server = Server(socket_name="raise_if_dead_message") + + with pytest.raises(subprocess.CalledProcessError) as excinfo: + server.raise_if_dead() + + assert excinfo.value.stderr is not None + assert "raise_if_dead_message" in excinfo.value.stderr + + +def test_command_request_rejects_nul() -> None: + """NUL cannot survive tmux's C-string argv.""" + with pytest.raises(ValueError, match="NUL"): + CommandRequest.from_args("display-message", "a\0b") + + +def test_connection_from_server_duck_types() -> None: + """``from_server`` reads any object with the five connection attributes.""" + conn = ServerConnection.from_server( + Server(socket_path="/tmp/spike-sock", config_file="/tmp/spike-conf"), + ) + assert conn.args == ("-f/tmp/spike-conf", "-S/tmp/spike-sock") + + +def test_missing_binary_raises_tmux_command_not_found() -> None: + """A declared-but-absent tmux binary raises, on every path.""" + engine = SubprocessEngine.of("/nonexistent/tmux") + with pytest.raises(exc.TmuxCommandNotFound): + engine.run(CommandRequest.from_args("list-sessions")) + with pytest.raises(exc.TmuxCommandNotFound): + tmux_cmd("list-sessions", tmux_bin="/nonexistent/tmux") + + +def test_run_batch_preserves_order(session: Session) -> None: + """``run_batch`` returns one result per request, in order.""" + results = SubprocessEngine.for_server(session.server).run_batch( + [ + CommandRequest.from_args("display-message", "-p", "a"), + CommandRequest.from_args("display-message", "-p", "b"), + ], + ) + assert [result.stdout[0] for result in results] == ["a", "b"] + + +class AsyncEngine: + """Structurally a :class:`TmuxEngine`, but both methods are ``async def``. + + ``TmuxEngine`` checks attribute names only, so this still satisfies + ``isinstance(..., TmuxEngine)``. + """ + + async def run(self, request: CommandRequest) -> CommandResult: + """Never actually awaited by libtmux; dispatch must reject this.""" + return CommandResult(cmd=("tmux", *request.args)) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Unused by any in-tree dispatch path.""" + return [CommandResult(cmd=("tmux", *r.args)) for r in requests] + + +def _async_engine() -> TmuxEngine: + """Hand back an :class:`AsyncEngine`, typed as a plain ``TmuxEngine``. + + ``AsyncEngine`` does not satisfy ``TmuxEngine`` *statically* -- its + methods return ``Coroutine``, not the protocol's declared return types -- + which is exactly what makes the bug this module tests real: a type + checker would reject it, but ``isinstance()`` at runtime does not. The + cast documents that gap instead of hiding it behind a broader type on + ``AsyncEngine`` itself. + """ + return t.cast("TmuxEngine", AsyncEngine()) + + +def test_async_engine_run_raises_named_error() -> None: + """``run()`` returning an awaitable raises ``AsyncEngineMismatch``. + + Not ``AttributeError`` from treating a coroutine as a + :class:`CommandResult`. + """ + server = Server(socket_name="async_engine_run", engine=_async_engine()) + + with pytest.raises(exc.AsyncEngineMismatch): + server.cmd("list-sessions") + + +def test_async_engine_raise_if_dead_raises_the_same_error() -> None: + """``raise_if_dead()`` shares :meth:`Server.cmd`'s single dispatch site. + + It no longer calls ``self.engine.run()`` on its own, so it inherits the + guard instead of needing a second copy of it. + """ + server = Server(socket_name="async_engine_dead", engine=_async_engine()) + + with pytest.raises(exc.AsyncEngineMismatch): + server.raise_if_dead() + + +def test_async_engine_fetch_objs_raises_the_same_error() -> None: + """:func:`~libtmux.neo.fetch_objs` dispatches through the same guard.""" + server = Server(socket_name="async_engine_fetch_objs", engine=_async_engine()) + + with pytest.raises(exc.AsyncEngineMismatch): + fetch_objs(server=server, list_cmd="list-sessions") + + +class AsyncCommandLineEngine: + """A synchronous ``run()`` paired with an asynchronous ``command_line()``. + + Isolates the DEBUG-log-only dispatch site: ``command_line()`` is only + ever called to render the log line in :class:`tmux_cmd`, never to build + the actual result. + """ + + def run(self, request: CommandRequest) -> CommandResult: + """Behave like an ordinary synchronous engine.""" + return CommandResult(cmd=("tmux", *request.args)) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Run each request in order.""" + return [self.run(r) for r in requests] + + async def command_line(self, request: CommandRequest) -> tuple[str, ...]: + """Return the argv, the one async method on an otherwise sync engine.""" + return ("tmux", *request.args) + + +def test_async_command_line_raises_named_error_under_debug_logging( + caplog: pytest.LogCaptureFixture, +) -> None: + """``command_line()`` only runs when DEBUG logging is enabled. + + Previously this bypassed the guard entirely and raised + ``TypeError: 'coroutine' object is not iterable`` from ``shlex.join``. + """ + server = Server( + socket_name="async_command_line", + engine=AsyncCommandLineEngine(), + ) + + with ( + caplog.at_level(logging.DEBUG, logger="libtmux.common"), + pytest.raises(exc.AsyncEngineMismatch), + ): + server.cmd("list-sessions") + + +@pytest.mark.parametrize("attr", ["sessions", "clients", "attached_sessions"]) +def test_async_engine_list_accessors_do_not_swallow_the_error(attr: str) -> None: + """``AsyncEngineMismatch`` is not a tmux failure, so it is not lenient here. + + :attr:`Server.sessions`, :attr:`Server.clients`, and + :attr:`Server.attached_sessions` return an empty + :class:`~libtmux._internal.query_list.QueryList` for an actual tmux + failure (no daemon, bad socket, permission error). An engine that cannot + be dispatched synchronously at all is a different kind of problem -- + surfacing it as "no sessions" would hide a broken engine behind a + misleading empty result. + """ + server = Server(socket_name=f"async_engine_{attr}", engine=_async_engine()) + + with pytest.raises(exc.AsyncEngineMismatch): + getattr(server, attr) + + +class HostileGetattrAwaitable: + """An awaitable that detonates on any ``close``/``cancel`` lookup. + + Cleanup that reached for those attributes would surface this object's + ``RuntimeError`` in place of the diagnostic, which is the failure mode + the guard's shape exists to avoid. + """ + + def __await__(self) -> t.Generator[None, None, None]: + """Satisfy :func:`inspect.isawaitable` without ever being awaited.""" + yield + + def __getattr__(self, name: str) -> t.Any: + """Raise for the cleanup lookups, ``AttributeError`` for the rest.""" + if name in {"close", "cancel"}: + msg = "cleanup lookup blew up" + raise RuntimeError(msg) + raise AttributeError(name) + + +class HostileCloseCoroutine: + """An awaitable whose ``close()`` raises a :class:`BaseException`. + + :exc:`asyncio.CancelledError` derives from :class:`BaseException`, not + :class:`Exception`, so an ``except Exception`` around cleanup would let + it escape and mask the diagnostic. + """ + + def __await__(self) -> t.Generator[None, None, None]: + """Satisfy :func:`inspect.isawaitable` without ever being awaited.""" + yield + + def close(self) -> None: + """Raise the exception an ``except Exception`` would not catch.""" + raise asyncio.CancelledError + + +def _engine_returning(value: t.Any) -> TmuxEngine: + """Build a sync engine whose ``run()`` hands back *value*.""" + + class Returns: + def run(self, request: CommandRequest) -> t.Any: + return value + + def run_batch(self, requests: Sequence[CommandRequest]) -> t.Any: + return [value for _ in requests] + + return t.cast("TmuxEngine", Returns()) + + +@pytest.mark.parametrize( + "awaitable", + [HostileGetattrAwaitable(), HostileCloseCoroutine()], + ids=["hostile-getattr", "cancelled-error-on-close"], +) +def test_hostile_awaitable_cannot_mask_the_mismatch(awaitable: t.Any) -> None: + """A hostile awaitable never replaces the diagnostic with its own error. + + Only genuine coroutines are closed, and that close is guarded against + :class:`BaseException`, so neither an exploding attribute lookup nor a + :exc:`asyncio.CancelledError` reaches the caller. + """ + server = Server( + socket_name="hostile_awaitable", engine=_engine_returning(awaitable) + ) + + with pytest.raises(exc.AsyncEngineMismatch): + server.cmd("list-sessions") + + +async def _never_awaited() -> None: + """Do nothing; this body must never run.""" + + +class ReturnsCoroutineEngine: + """A plain ``def`` engine that manufactures a coroutine anyway. + + The shape CPython documents as uncatchable by a callable-level check -- + ``run`` is not declared ``async``, so only its return value gives it away. + """ + + def run(self, request: CommandRequest) -> t.Any: + """Hand back an unstarted coroutine instead of a result.""" + return _never_awaited() + + def run_batch(self, requests: Sequence[CommandRequest]) -> t.Any: + """Hand back one unstarted coroutine per request.""" + return [_never_awaited() for _ in requests] + + +@pytest.mark.parametrize( + ("label", "engine_factory"), + [ + ("declared-async", _async_engine), + ("returns-coroutine", lambda: t.cast("TmuxEngine", ReturnsCoroutineEngine())), + ], +) +def test_no_never_awaited_warning_escapes( + label: str, + engine_factory: t.Callable[[], TmuxEngine], + recwarn: pytest.WarningsRecorder, +) -> None: + """Neither async shape leaves a ``coroutine ... was never awaited`` behind. + + A declared ``async def run`` is rejected before it is ever called, so no + coroutine is created. A plain ``def`` that manufactures one is caught from + its return value, and that coroutine is closed while still unstarted. + """ + server = Server(socket_name=f"warnfree_{label}", engine=engine_factory()) + + with pytest.raises(exc.AsyncEngineMismatch): + server.cmd("list-sessions") + + gc.collect() + + assert [w for w in recwarn.list if issubclass(w.category, RuntimeWarning)] == []