From 049363f103d78f3bd5ae90e143a6d542bbb52be0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:02:03 -0500 Subject: [PATCH 001/154] Ops(feat): Add typed operation and engine spine why: Operationalizes the typed-operations/engines architecture (issues 688, 689) with the pure substrate that was absent from every prototype branch: an inert, statically-typed operation value that renders tmux commands, carries its result type, and serializes without a live tmux server. Engines stay transport-agnostic over it. None of this touches or changes existing public APIs. what: - Add libtmux.experimental.{ops,engines} packages (experimental, not under the versioning policy) - ops: frozen Operation[ResultT] with class-level metadata as the single source of truth; pure render() with declarative version gating (LooseVersion); build_result() adapting raw output to typed results - ops: typed Result base + raise_for_status() (CPython/requests precedent), SplitWindowResult/CapturePaneResult payloads - ops: closed Target sum (PaneId/WindowId/SessionId/ClientName/NameRef/ IndexRef/Special/SlotRef) with fail-closed validation - ops: fail-closed OperationRegistry keyed by kind, with OpSpec views and predicate listing; stdlib dict serialization with round-trips - ops: four seed operations (split-window, capture-pane, send-keys, select-layout) registered via @register - engines: TmuxEngine/AsyncTmuxEngine protocols, CommandRequest/ CommandResult, EngineSpec; run()/arun() execute bridge sharing one render/build path (sync vs await is the only divergence) - tests: 111 pure, fixture-parametrizable unit tests + doctests, all runnable without a tmux server --- src/libtmux/experimental/__init__.py | 19 + src/libtmux/experimental/engines/__init__.py | 31 ++ src/libtmux/experimental/engines/base.py | 179 ++++++++++ src/libtmux/experimental/ops/__init__.py | 115 ++++++ src/libtmux/experimental/ops/_ops/__init__.py | 20 ++ .../experimental/ops/_ops/capture_pane.py | 110 ++++++ .../experimental/ops/_ops/select_layout.py | 44 +++ .../experimental/ops/_ops/send_keys.py | 55 +++ .../experimental/ops/_ops/split_window.py | 107 ++++++ src/libtmux/experimental/ops/_types.py | 326 ++++++++++++++++++ src/libtmux/experimental/ops/exc.py | 121 +++++++ src/libtmux/experimental/ops/execute.py | 115 ++++++ src/libtmux/experimental/ops/operation.py | 254 ++++++++++++++ src/libtmux/experimental/ops/registry.py | 207 +++++++++++ src/libtmux/experimental/ops/results.py | 170 +++++++++ src/libtmux/experimental/ops/serialize.py | 181 ++++++++++ tests/experimental/__init__.py | 3 + tests/experimental/ops/__init__.py | 3 + tests/experimental/ops/test_execute.py | 107 ++++++ tests/experimental/ops/test_operation.py | 105 ++++++ tests/experimental/ops/test_registry.py | 74 ++++ tests/experimental/ops/test_results.py | 73 ++++ tests/experimental/ops/test_serialize.py | 106 ++++++ tests/experimental/ops/test_types.py | 82 +++++ 24 files changed, 2607 insertions(+) create mode 100644 src/libtmux/experimental/__init__.py create mode 100644 src/libtmux/experimental/engines/__init__.py create mode 100644 src/libtmux/experimental/engines/base.py create mode 100644 src/libtmux/experimental/ops/__init__.py create mode 100644 src/libtmux/experimental/ops/_ops/__init__.py create mode 100644 src/libtmux/experimental/ops/_ops/capture_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/select_layout.py create mode 100644 src/libtmux/experimental/ops/_ops/send_keys.py create mode 100644 src/libtmux/experimental/ops/_ops/split_window.py create mode 100644 src/libtmux/experimental/ops/_types.py create mode 100644 src/libtmux/experimental/ops/exc.py create mode 100644 src/libtmux/experimental/ops/execute.py create mode 100644 src/libtmux/experimental/ops/operation.py create mode 100644 src/libtmux/experimental/ops/registry.py create mode 100644 src/libtmux/experimental/ops/results.py create mode 100644 src/libtmux/experimental/ops/serialize.py create mode 100644 tests/experimental/__init__.py create mode 100644 tests/experimental/ops/__init__.py create mode 100644 tests/experimental/ops/test_execute.py create mode 100644 tests/experimental/ops/test_operation.py create mode 100644 tests/experimental/ops/test_registry.py create mode 100644 tests/experimental/ops/test_results.py create mode 100644 tests/experimental/ops/test_serialize.py create mode 100644 tests/experimental/ops/test_types.py diff --git a/src/libtmux/experimental/__init__.py b/src/libtmux/experimental/__init__.py new file mode 100644 index 000000000..1bcc80a96 --- /dev/null +++ b/src/libtmux/experimental/__init__.py @@ -0,0 +1,19 @@ +"""Experimental libtmux APIs. + +This package hosts work that is **not** covered by the project's versioning +policy. Anything under :mod:`libtmux.experimental` may change shape or be +removed between any two releases without notice. + +Current contents: + +- :mod:`libtmux.experimental.ops` -- inert, typed tmux *operation* values: the + pure source of truth that renders tmux commands, carries result types, and + serializes without a live tmux server. +- :mod:`libtmux.experimental.engines` -- *engine* protocols and + implementations that execute operations and return typed results. + +See the operationalization plan (``tmux-python/libtmux`` issue 689) and the +architecture proposal (issue 688) for background. +""" + +from __future__ import annotations diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py new file mode 100644 index 000000000..fa9961319 --- /dev/null +++ b/src/libtmux/experimental/engines/__init__.py @@ -0,0 +1,31 @@ +"""Execution engines for :mod:`libtmux.experimental.ops`. + +An *engine* executes a rendered tmux command and returns a structured result. +Engines are interchangeable behind the :class:`~.base.TmuxEngine` / +:class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run +through a subprocess, a persistent ``tmux -C`` control connection, an async +transport, an in-memory simulator, or (as an easter egg) tmux's native binary +peer protocol -- and return the *same* typed result. + +See the operationalization plan (``tmux-python/libtmux`` issue 689). +""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import ( + AsyncTmuxEngine, + CommandRequest, + CommandResult, + EngineKind, + EngineSpec, + TmuxEngine, +) + +__all__ = ( + "AsyncTmuxEngine", + "CommandRequest", + "CommandResult", + "EngineKind", + "EngineSpec", + "TmuxEngine", +) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py new file mode 100644 index 000000000..9195dbb61 --- /dev/null +++ b/src/libtmux/experimental/engines/base.py @@ -0,0 +1,179 @@ +"""Core engine abstractions: requests, results, and the engine protocols. + +Adapted from the ``libtmux-protocol-engines`` prototype. A +:class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a +:class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and +:class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with +the right methods is an engine -- including a live :class:`libtmux.Server` for +the classic case -- without inheriting a base class. +""" + +from __future__ import annotations + +import enum +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + +@dataclass(frozen=True) +class CommandRequest: + """A rendered tmux command, ready for an engine to execute. + + Parameters + ---------- + args : tuple[str, ...] + The tmux argv *after* the binary (e.g. ``("split-window", "-t", "%1")``). + tmux_bin : str or None + Override the tmux binary for this 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 + + @classmethod + def from_args( + cls, + *args: t.Any, + tmux_bin: str | pathlib.Path | None = None, + ) -> CommandRequest: + """Build a request from arbitrary tokens, stringifying each.""" + return cls( + args=tuple(map(str, args)), + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + ) + + +@dataclass(frozen=True) +class CommandResult: + """The structured outcome of executing a :class:`CommandRequest`. + + A tmux-side failure (``%error`` / nonzero exit) is *data* here -- it sets + ``returncode`` and ``stderr`` rather than raising. Only engine-broken + conditions (missing binary, lost connection, protocol desync) raise. + + Parameters + ---------- + cmd : tuple[str, ...] + The full argv that ran (including the tmux binary). + stdout, stderr : tuple[str, ...] + Captured output lines. + returncode : int + tmux exit code (``-1`` when unknown). + """ + + cmd: tuple[str, ...] + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + returncode: int = 0 + + +class EngineKind(str, enum.Enum): + """Named engine families.""" + + SUBPROCESS = "subprocess" + CONCRETE = "concrete" + CONTROL_MODE = "control_mode" + ASYNCIO = "asyncio" + IMSG = "imsg" + + +@dataclass(frozen=True) +class EngineSpec: + """A typed, serializable selector for an engine family. + + Examples + -------- + >>> EngineSpec.subprocess().kind + + >>> EngineSpec.imsg(protocol_version=8).protocol_version + 8 + >>> EngineSpec.subprocess(protocol_version=8) + Traceback (most recent call last): + ... + ValueError: protocol_version is only valid for the imsg engine + """ + + kind: EngineKind + protocol_version: int | None = None + extra: t.Mapping[str, t.Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Normalize and validate the spec.""" + kind = EngineKind(self.kind) + if kind is not EngineKind.IMSG and self.protocol_version is not None: + msg = "protocol_version is only valid for the imsg engine" + raise ValueError(msg) + object.__setattr__(self, "kind", kind) + + @classmethod + def subprocess(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build a subprocess (classic) engine spec.""" + return cls(kind=EngineKind.SUBPROCESS, protocol_version=protocol_version) + + @classmethod + def concrete(cls) -> EngineSpec: + """Build a concrete (in-memory) engine spec.""" + return cls(kind=EngineKind.CONCRETE) + + @classmethod + def control_mode(cls) -> EngineSpec: + """Build a control-mode engine spec.""" + return cls(kind=EngineKind.CONTROL_MODE) + + @classmethod + def asyncio(cls) -> EngineSpec: + """Build an asyncio engine spec.""" + return cls(kind=EngineKind.ASYNCIO) + + @classmethod + def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build an imsg (native binary) engine spec.""" + return cls(kind=EngineKind.IMSG, protocol_version=protocol_version) + + +@t.runtime_checkable +class TmuxEngine(t.Protocol): + """A synchronous executor of tmux commands.""" + + 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 (control mode) override this to pipeline; + stateless engines implement it as a loop over :meth:`run`. + """ + ... + + +@t.runtime_checkable +class AsyncTmuxEngine(t.Protocol): + """An asynchronous executor of tmux commands.""" + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request.""" + ... diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py new file mode 100644 index 000000000..c39f871d5 --- /dev/null +++ b/src/libtmux/experimental/ops/__init__.py @@ -0,0 +1,115 @@ +"""Inert, typed tmux operation values. + +This package is the pure source of truth for tmux commands: each +:class:`~.operation.Operation` renders a tmux argv, carries its result type and +metadata, and adapts raw output into a typed :class:`~.results.Result` -- all +without a live tmux server. Engines (:mod:`libtmux.experimental.engines`) +execute operations; :func:`run` / :func:`arun` bridge the two. + +Everything here is experimental and not covered by the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.ops import SplitWindow, run +>>> from libtmux.experimental.ops._types import PaneId +>>> from libtmux.experimental.engines import CommandResult +>>> SplitWindow(target=PaneId("%1"), horizontal=True).render() +('split-window', '-t', '%1', '-h', '-P', '-F', '#{pane_id}') +""" + +from __future__ import annotations + +from libtmux.experimental.ops._ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import ( + ClientName, + Effects, + IndexRef, + NameRef, + PaneId, + Safety, + Scope, + SessionId, + SlotRef, + Special, + Status, + Target, + WindowId, + render_target, +) +from libtmux.experimental.ops.exc import ( + DuplicateOperation, + OperationError, + TmuxCommandError, + UnknownOperation, + VersionUnsupported, +) +from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import ( + OperationRegistry, + OpSpec, + register, + registry, +) +from libtmux.experimental.ops.results import ( + CapturePaneResult, + Result, + SplitWindowResult, + status_for, +) +from libtmux.experimental.ops.serialize import ( + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + target_from_dict, + target_to_dict, +) + +__all__ = ( + "CapturePane", + "CapturePaneResult", + "ClientName", + "DuplicateOperation", + "Effects", + "IndexRef", + "NameRef", + "OpSpec", + "Operation", + "OperationError", + "OperationRegistry", + "PaneId", + "Result", + "Safety", + "Scope", + "SelectLayout", + "SendKeys", + "SessionId", + "SlotRef", + "Special", + "SplitWindow", + "SplitWindowResult", + "Status", + "Target", + "TmuxCommandError", + "UnknownOperation", + "VersionUnsupported", + "WindowId", + "arun", + "operation_from_dict", + "operation_to_dict", + "register", + "registry", + "render_target", + "result_from_dict", + "result_to_dict", + "run", + "status_for", + "target_from_dict", + "target_to_dict", +) diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py new file mode 100644 index 000000000..8c050ec44 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -0,0 +1,20 @@ +"""Concrete seed operations. + +Importing this package registers each operation in the default registry +(:data:`libtmux.experimental.ops.registry.registry`) as a side effect of the +``@register`` decorator on each class. +""" + +from __future__ import annotations + +from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.select_layout import SelectLayout +from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.split_window import SplitWindow + +__all__ = ( + "CapturePane", + "SelectLayout", + "SendKeys", + "SplitWindow", +) diff --git a/src/libtmux/experimental/ops/_ops/capture_pane.py b/src/libtmux/experimental/ops/_ops/capture_pane.py new file mode 100644 index 000000000..598e0a3da --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/capture_pane.py @@ -0,0 +1,110 @@ +"""The ``capture-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CapturePaneResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class CapturePane(Operation[CapturePaneResult]): + """Capture a pane's contents (a read-only operation). + + Parameters + ---------- + start, end : int or None + Start/end line for the capture (``-S`` / ``-E``). + escape_sequences : bool + Include escape sequences (``-e``). + join_wrapped : bool + Join wrapped lines (``-J``). + trim_trailing : bool + Trim trailing whitespace (``-T``; tmux 3.4+, dropped on older tmux). + mode_screen : bool + Capture the visible screen in copy mode (``-M``; tmux 3.6+). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> CapturePane(target=PaneId("%1")).render() + ('capture-pane', '-t', '%1', '-p') + + Version-gated flags are dropped on older tmux: + + >>> op = CapturePane(target=PaneId("%1"), trim_trailing=True) + >>> op.render(version="3.3") + ('capture-pane', '-t', '%1', '-p') + >>> op.render(version="3.4") + ('capture-pane', '-t', '%1', '-p', '-T') + + Captured stdout is exposed as typed lines: + + >>> result = op.build_result(returncode=0, stdout=("foo", "bar")) + >>> result.lines + ('foo', 'bar') + """ + + kind = "capture_pane" + command = "capture-pane" + scope = "pane" + result_cls = CapturePaneResult + safety = "readonly" + effects = Effects(read_only=True, reads_output=True, idempotent=True) + flag_version_map: t.ClassVar[Mapping[str, str]] = { + "trim_trailing": "3.4", + "mode_screen": "3.6", + } + + start: int | None = None + end: int | None = None + escape_sequences: bool = False + join_wrapped: bool = False + trim_trailing: bool = False + mode_screen: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``capture-pane`` flags (``-p`` prints to stdout).""" + out: list[str] = ["-p"] + if self.start is not None: + out.extend(("-S", str(self.start))) + if self.end is not None: + out.extend(("-E", str(self.end))) + if self.escape_sequences: + out.append("-e") + if self.join_wrapped: + out.append("-J") + if self.trim_trailing and self.flag_available("trim_trailing", version): + out.append("-T") + if self.mode_screen and self.flag_available("mode_screen", version): + out.append("-M") + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + ) -> CapturePaneResult: + """Expose captured stdout as typed :attr:`~.CapturePaneResult.lines`.""" + return CapturePaneResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + lines=stdout, + ) diff --git a/src/libtmux/experimental/ops/_ops/select_layout.py b/src/libtmux/experimental/ops/_ops/select_layout.py new file mode 100644 index 000000000..f17af37b4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_layout.py @@ -0,0 +1,44 @@ +"""The ``select-layout`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import Result + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectLayout(Operation[Result]): + """Apply a layout to a window. + + Parameters + ---------- + layout : str or None + A named layout (``even-horizontal``, ``main-vertical``, ...) or a custom + layout string. ``None`` re-applies the current layout. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SelectLayout(target=WindowId("@1"), layout="main-vertical").render() + ('select-layout', '-t', '@1', 'main-vertical') + >>> SelectLayout(target=WindowId("@1")).render() + ('select-layout', '-t', '@1') + """ + + kind = "select_layout" + command = "select-layout" + scope = "window" + result_cls = Result + safety = "mutating" + effects = Effects(idempotent=True, mutates_layout=True) + + layout: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional layout argument.""" + return (self.layout,) if self.layout is not None else () diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py new file mode 100644 index 000000000..81de969fa --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -0,0 +1,55 @@ +"""The ``send-keys`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import Result + + +@register +@dataclass(frozen=True, kw_only=True) +class SendKeys(Operation[Result]): + """Send keys (input) to a pane. + + Parameters + ---------- + keys : str + The key string to send. + enter : bool + Append a literal ``Enter`` key after the input. + literal : bool + Send keys literally without tmux key-name lookup (``-l``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SendKeys(target=PaneId("%1"), keys="echo hi", enter=True).render() + ('send-keys', '-t', '%1', 'echo hi', 'Enter') + >>> SendKeys(target=PaneId("%1"), keys="q", literal=True).render() + ('send-keys', '-t', '%1', '-l', 'q') + """ + + kind = "send_keys" + command = "send-keys" + scope = "pane" + result_cls = Result + safety = "mutating" + effects = Effects(writes_input=True) + + keys: str + enter: bool = False + literal: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``send-keys`` flags and the key string.""" + out: list[str] = [] + if self.literal: + out.append("-l") + out.append(self.keys) + if self.enter: + out.append("Enter") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/split_window.py b/src/libtmux/experimental/ops/_ops/split_window.py new file mode 100644 index 000000000..abaec4734 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/split_window.py @@ -0,0 +1,107 @@ +"""The ``split-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class SplitWindow(Operation[SplitWindowResult]): + """Split a pane, creating a new pane. + + By default the operation appends ``-P -F '#{pane_id}'`` so the new pane's id + is captured on stdout; :meth:`build_result` reads it into + :attr:`~.results.SplitWindowResult.new_pane_id`. + + Parameters + ---------- + horizontal : bool + Split left/right (``-h``) instead of top/bottom (``-v``). + start_directory : str or None + Working directory for the new pane (``-c``). + environment : Mapping[str, str] or None + Environment variables for the new pane (``-e``; tmux 3.0+). + shell : str or None + A shell command to run in the new pane instead of the default shell. + capture : bool + Append ``-P -F '#{pane_id}'`` to capture the new pane id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SplitWindow(target=PaneId("%1"), horizontal=True).render() + ('split-window', '-t', '%1', '-h', '-P', '-F', '#{pane_id}') + + The ``-e`` environment flag is dropped on tmux older than 3.0: + + >>> op = SplitWindow(target=PaneId("%1"), environment={"E": "1"}) + >>> op.render(version="2.9") + ('split-window', '-t', '%1', '-v', '-P', '-F', '#{pane_id}') + >>> op.render(version="3.3") + ('split-window', '-t', '%1', '-v', '-eE=1', '-P', '-F', '#{pane_id}') + + The created pane id is parsed into the typed result: + + >>> result = op.build_result(returncode=0, stdout=("%2",)) + >>> result.new_pane_id + '%2' + """ + + kind = "split_window" + command = "split-window" + scope = "window" + result_cls = SplitWindowResult + safety = "mutating" + effects = Effects(creates="pane") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + horizontal: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``split-window`` flags.""" + out: list[str] = ["-h" if self.horizontal else "-v"] + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.capture: + out.extend(("-P", "-F", "#{pane_id}")) + if self.shell is not None: + out.append(self.shell) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + ) -> SplitWindowResult: + """Parse the captured new-pane id into the typed result.""" + new_pane_id = stdout[0].strip() if status == "complete" and stdout else None + return SplitWindowResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_pane_id=new_pane_id, + ) diff --git a/src/libtmux/experimental/ops/_types.py b/src/libtmux/experimental/ops/_types.py new file mode 100644 index 000000000..6fc387ff9 --- /dev/null +++ b/src/libtmux/experimental/ops/_types.py @@ -0,0 +1,326 @@ +"""Typed primitives shared across :mod:`libtmux.experimental.ops`. + +These are the small, inert vocabulary types the operation substrate is built +from: the tmux object :data:`Scope`, the :data:`Safety` tier and execution +:data:`Status` enumerations, the :class:`Effects` descriptor, and the closed +:data:`Target` sum that types a ``-t`` target so an illegal target is a type +error rather than a runtime surprise. + +Everything here is pure: no tmux server is required to construct, render, or +compare any of these values. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +Scope: t.TypeAlias = t.Literal["server", "session", "window", "pane", "client"] +"""The tmux object scope an operation targets. + +``client`` is a view into a live attachment rather than part of the ownership +chain, but it still has operation scope because tmux exposes client-scoped +commands (``detach-client``, ``switch-client``). +""" + +Safety: t.TypeAlias = t.Literal["readonly", "mutating", "destructive"] +"""Coarse safety tier, mirroring the MCP tool-annotation vocabulary. + +``readonly`` reads state, ``mutating`` changes it reversibly, ``destructive`` +removes objects (``kill-session``, ``kill-window``). +""" + +Status: t.TypeAlias = t.Literal["complete", "failed", "skipped", "unknown"] +"""Execution status of a result. + +``complete`` + The command ran and tmux reported success. +``failed`` + The command ran and tmux reported an error. +``skipped`` + The operation was never dispatched (e.g. an earlier command in a chain + failed, or a lazy plan was inspected but not executed). +``unknown`` + The outcome could not be determined (e.g. a control-mode timeout). +""" + + +@dataclass(frozen=True) +class Effects: + """Declarative description of what an operation does to tmux state. + + Carrying effects as typed data (rather than a hand-maintained table in a + downstream consumer) lets MCP annotations and safety tiers derive directly + from the operation. + + Parameters + ---------- + read_only : bool + The operation does not change tmux state. + destructive : bool + The operation removes an object. + idempotent : bool + Re-running the operation has the same effect as running it once. + creates : Scope or None + The scope of the object the operation creates, if any (e.g. + ``split-window`` creates a ``pane``). + writes_input : bool + The operation sends input into a pane (e.g. ``send-keys``). + reads_output : bool + The operation captures output from a pane (e.g. ``capture-pane``). + mutates_layout : bool + The operation rearranges panes/windows (e.g. ``select-layout``). + + Examples + -------- + >>> Effects(read_only=True, idempotent=True) + Effects(read_only=True, destructive=False, idempotent=True, creates=None, + writes_input=False, reads_output=False, mutates_layout=False) + >>> Effects(creates="pane").creates + 'pane' + """ + + read_only: bool = False + destructive: bool = False + idempotent: bool = False + creates: Scope | None = None + writes_input: bool = False + reads_output: bool = False + mutates_layout: bool = False + + +@dataclass(frozen=True, slots=True) +class PaneId: + """A concrete tmux pane id, ``%N``. + + Examples + -------- + >>> PaneId("%1").render() + '%1' + >>> PaneId("1") + Traceback (most recent call last): + ... + ValueError: PaneId must start with '%', got '1' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("%"): + msg = f"PaneId must start with '%', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class WindowId: + """A concrete tmux window id, ``@N``. + + Examples + -------- + >>> WindowId("@2").render() + '@2' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("@"): + msg = f"WindowId must start with '@', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class SessionId: + """A concrete tmux session id, ``$N``. + + Examples + -------- + >>> SessionId("$0").render() + '$0' + """ + + value: str + + def __post_init__(self) -> None: + """Validate the id sigil (fail closed).""" + if not self.value.startswith("$"): + msg = f"SessionId must start with '$', got {self.value!r}" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class ClientName: + """A tmux client name (a tty path such as ``/dev/pts/3``). + + Examples + -------- + >>> ClientName("/dev/pts/3").render() + '/dev/pts/3' + """ + + value: str + + def __post_init__(self) -> None: + """Reject an empty client name (fail closed).""" + if not self.value: + msg = "ClientName must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class NameRef: + """A target addressed by name, optionally requiring an exact match. + + tmux matches names as a prefix by default; prefixing with ``=`` forces an + exact match. + + Examples + -------- + >>> NameRef("work").render() + 'work' + >>> NameRef("work", exact=True).render() + '=work' + """ + + name: str + exact: bool = False + + def __post_init__(self) -> None: + """Reject an empty name (fail closed).""" + if not self.name: + msg = "NameRef name must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return f"={self.name}" if self.exact else self.name + + +@dataclass(frozen=True, slots=True) +class IndexRef: + """A target addressed by integer index (window or session index). + + Examples + -------- + >>> IndexRef(0).render() + '0' + >>> IndexRef(2, parent="$1").render() + '$1:2' + """ + + index: int + parent: str | None = None + + def render(self) -> str: + """Render as a tmux ``-t`` target token, qualified by parent if set.""" + if self.parent is not None: + return f"{self.parent}:{self.index}" + return str(self.index) + + +@dataclass(frozen=True, slots=True) +class Special: + """A tmux special target token, e.g. ``{marked}``, ``{last}``, ``{up-of}``. + + The token vocabulary comes from tmux's target-resolution tables (see + ``cmd-find.c``). This wrapper keeps a symbolic target distinct from a + concrete id at the type level. + + Examples + -------- + >>> Special("{marked}").render() + '{marked}' + >>> Special("last").render() + 'last' + """ + + token: str + + def __post_init__(self) -> None: + """Reject an empty token (fail closed).""" + if not self.token: + msg = "Special token must be a non-empty string" + raise ValueError(msg) + + def render(self) -> str: + """Render as a tmux ``-t`` target token.""" + return self.token + + +@dataclass(frozen=True, slots=True) +class SlotRef: + """A deferred target: "the id of forward slot N", filled at resolve time. + + Carried by an operation built against an object that does not exist yet in + a multi-operation plan. A resolver replaces it with the captured concrete + id plus ``suffix`` before the operation renders; rendering an unresolved + :class:`SlotRef` is a planner bug and raises (see + :meth:`libtmux.experimental.ops.operation.Operation.render`). ``suffix`` + lets a command needing a qualified target -- e.g. ``new-window -t $N:`` -- + reuse a plain captured ``$N``. + + Examples + -------- + >>> SlotRef(0) + SlotRef(slot=0, suffix='') + >>> SlotRef(0, ":") + SlotRef(slot=0, suffix=':') + """ + + slot: int + suffix: str = "" + + def render(self) -> str: + """Raise -- an unresolved deferred ref cannot be rendered.""" + msg = "cannot render an unresolved SlotRef; resolve the plan first" + raise TypeError(msg) + + +Target: t.TypeAlias = ( + "PaneId | WindowId | SessionId | ClientName | NameRef | IndexRef " + "| Special | SlotRef" +) +"""The closed sum of everything that can appear as an operation ``-t`` target.""" + + +def render_target(target: Target | None) -> str | None: + """Render any :data:`Target` to its tmux token, or ``None`` for no target. + + Parameters + ---------- + target : Target or None + The typed target to render. + + Returns + ------- + str or None + The ``-t`` token string, or ``None`` when there is no target. + + Examples + -------- + >>> render_target(PaneId("%1")) + '%1' + >>> render_target(None) is None + True + """ + if target is None: + return None + return target.render() diff --git a/src/libtmux/experimental/ops/exc.py b/src/libtmux/experimental/ops/exc.py new file mode 100644 index 000000000..e9a87ee2e --- /dev/null +++ b/src/libtmux/experimental/ops/exc.py @@ -0,0 +1,121 @@ +"""Exceptions for :mod:`libtmux.experimental.ops`. + +All exceptions subclass :class:`libtmux.exc.LibTmuxException`, so existing +``except LibTmuxException`` handlers keep working while the operation layer +stays isolated under :mod:`libtmux.experimental`. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.exc import LibTmuxException + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + +class OperationError(LibTmuxException): + """Base class for problems building or registering operations.""" + + +class UnknownOperation(OperationError): + """No operation is registered under the requested ``kind``. + + Examples + -------- + >>> raise UnknownOperation("split_window") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.UnknownOperation: no operation registered for + kind 'split_window' + """ + + def __init__(self, kind: str) -> None: + self.kind = kind + msg = f"no operation registered for kind {kind!r}" + super().__init__(msg) + + +class DuplicateOperation(OperationError): + """An operation ``kind`` is already registered. + + Examples + -------- + >>> raise DuplicateOperation("split_window") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.DuplicateOperation: an operation is already + registered for kind 'split_window' + """ + + def __init__(self, kind: str) -> None: + self.kind = kind + msg = f"an operation is already registered for kind {kind!r}" + super().__init__(msg) + + +class VersionUnsupported(OperationError): + """An operation cannot render against the given tmux version. + + Examples + -------- + >>> raise VersionUnsupported("split_window", need="3.0", have="2.9") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.VersionUnsupported: operation 'split_window' + requires tmux >= 3.0 (have 2.9) + """ + + def __init__(self, kind: str, *, need: str, have: str) -> None: + self.kind = kind + self.need = need + self.have = have + msg = f"operation {kind!r} requires tmux >= {need} (have {have})" + super().__init__(msg) + + +class TmuxCommandError(LibTmuxException): + """A tmux command reported failure, raised by ``Result.raise_for_status()``. + + The constructor mirrors :class:`subprocess.CalledProcessError`'s argument + order (``returncode``, ``cmd``, ``stdout``, ``stderr``) so the failure + surface is familiar to anyone who has handled a subprocess error. + + Parameters + ---------- + returncode : int + The tmux process exit code (``-1`` when unknown, e.g. a timeout). + cmd : Sequence[str] + The rendered tmux argv that failed. + stdout : Sequence[str], optional + Captured stdout lines. + stderr : Sequence[str], optional + Captured stderr lines. + + Examples + -------- + >>> raise TmuxCommandError(1, ["split-window", "-t", "%999"], + ... stderr=["can't find pane %999"]) + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.TmuxCommandError: tmux 'split-window -t %999' + failed (exit 1): can't find pane %999 + """ + + def __init__( + self, + returncode: int, + cmd: Sequence[str], + stdout: Sequence[str] | None = None, + stderr: Sequence[str] | None = None, + ) -> None: + self.returncode = returncode + self.cmd = tuple(cmd) + self.stdout = tuple(stdout) if stdout is not None else () + self.stderr = tuple(stderr) if stderr is not None else () + detail = " ".join(self.stderr).strip() + suffix = f": {detail}" if detail else "" + rendered = " ".join(self.cmd) + msg = f"tmux {rendered!r} failed (exit {returncode}){suffix}" + super().__init__(msg) diff --git a/src/libtmux/experimental/ops/execute.py b/src/libtmux/experimental/ops/execute.py new file mode 100644 index 000000000..c6b12a345 --- /dev/null +++ b/src/libtmux/experimental/ops/execute.py @@ -0,0 +1,115 @@ +"""Execute an operation through an engine and get back its typed result. + +These two helpers are the whole bridge between the inert operation substrate and +the engines. They share the operation's pure :meth:`~.operation.Operation.render` +and :meth:`~.operation.Operation.build_result`; the *only* difference between the +sync and async paths is ``engine.run(...)`` versus ``await engine.run(...)`` -- +the same sans-I/O split the lazy plan resolver uses. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +ResultT = t.TypeVar("ResultT", bound="Result") + + +def run( + operation: Operation[ResultT], + engine: TmuxEngine, + *, + version: str | None = None, + tmux_bin: str | pathlib.Path | None = None, +) -> ResultT: + """Render *operation*, run it through *engine*, return its typed result. + + Parameters + ---------- + operation : Operation + The operation to execute. + engine : TmuxEngine + Any synchronous engine. + version : str or None + tmux version to render against (drops unsupported flags); ``None`` + renders every flag. + tmux_bin : str or pathlib.Path or None + Override the tmux binary for this call. + + Returns + ------- + ResultT + The operation's specialized result. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, run + >>> from libtmux.experimental.ops._types import PaneId + >>> from libtmux.experimental.engines import CommandResult + >>> class EchoEngine: + ... def run(self, request): + ... return CommandResult(cmd=("tmux", *request.args), returncode=0) + ... def run_batch(self, requests): + ... return [self.run(r) for r in requests] + >>> result = run(SendKeys(target=PaneId("%1"), keys="echo hi"), EchoEngine()) + >>> result.ok + True + >>> result.argv + ('send-keys', '-t', '%1', 'echo hi') + """ + rendered = operation.render(version=version) + raw = engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) + return operation.build_result( + argv=rendered, + returncode=raw.returncode, + stdout=raw.stdout, + stderr=raw.stderr, + ) + + +async def arun( + operation: Operation[ResultT], + engine: AsyncTmuxEngine, + *, + version: str | None = None, + tmux_bin: str | pathlib.Path | None = None, +) -> ResultT: + """Async sibling of :func:`run`, sharing the same render/build path. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import arun, CapturePane + >>> from libtmux.experimental.ops._types import PaneId + >>> from libtmux.experimental.engines import CommandResult + >>> class AsyncEchoEngine: + ... async def run(self, request): + ... return CommandResult( + ... cmd=("tmux", *request.args), + ... stdout=("line-1", "line-2"), + ... returncode=0, + ... ) + ... async def run_batch(self, requests): + ... return [await self.run(r) for r in requests] + >>> async def main(): + ... return await arun(CapturePane(target=PaneId("%1")), AsyncEchoEngine()) + >>> result = asyncio.run(main()) + >>> result.lines + ('line-1', 'line-2') + """ + rendered = operation.render(version=version) + raw = await engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) + return operation.build_result( + argv=rendered, + returncode=raw.returncode, + stdout=raw.stdout, + stderr=raw.stderr, + ) diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py new file mode 100644 index 000000000..f2c9b9c07 --- /dev/null +++ b/src/libtmux/experimental/ops/operation.py @@ -0,0 +1,254 @@ +"""The base :class:`Operation` value. + +An operation is an immutable, keyword-only dataclass that carries everything an +engine needs to render a tmux command, validate it against a tmux version, and +type its result -- but it never dispatches. Operation classes declare their +stable metadata (``kind``, ``command``, ``scope``, ``result_cls``, effects, +safety, version gates) as class variables, so the class itself is the single +source of truth that the registry, serializer, and docs catalog all read from. + +Rendering is pure: :meth:`Operation.render` produces an argv tuple from the +operation's fields, dropping version-gated flags an older tmux cannot accept, +and :meth:`Operation.build_result` adapts raw tmux output into the operation's +typed result -- both without touching a tmux server. +""" + +from __future__ import annotations + +import types +import typing as t +from dataclasses import dataclass + +from libtmux._compat import LooseVersion +from libtmux.experimental.ops._types import render_target +from libtmux.experimental.ops.exc import VersionUnsupported +from libtmux.experimental.ops.results import Result, status_for + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from libtmux.experimental.ops._types import ( + Effects, + Safety, + Scope, + Status, + Target, + ) + +ResultT = t.TypeVar("ResultT", bound=Result) + + +@dataclass(frozen=True, kw_only=True) +class Operation(t.Generic[ResultT]): + """Inert, typed description of one tmux command. + + Subclasses declare the class-level metadata and provide :meth:`args` (the + positional tokens after the target). The instance fields describe one + concrete invocation. + + Parameters + ---------- + target : Target or None + The ``-t`` target, or ``None`` for "no explicit target". + + Notes + ----- + Class variables (set by subclasses): + + ``kind`` + Stable discriminator, also the registry key (e.g. ``"split_window"``). + ``command`` + The tmux command name (e.g. ``"split-window"``). + ``scope`` + The tmux object scope (:data:`~._types.Scope`). + ``result_cls`` + The :class:`~.results.Result` subclass this operation returns. + ``chainable`` + Whether the command may be folded into a one-dispatch sequence. + ``primitive`` + ``True`` when the operation wraps one tmux command; ``False`` when it is + composed from others (e.g. a synthesized server-exists check). + ``safety`` + The :data:`~._types.Safety` tier. + ``effects`` + An :class:`~._types.Effects` descriptor. + ``min_version`` + Minimum tmux version the whole operation requires, if any. + ``flag_version_map`` + Maps a feature label to the minimum tmux version that supports it; the + operation consults this in :meth:`args` to drop unsupported flags. + """ + + target: Target | None = None + + kind: t.ClassVar[str] + command: t.ClassVar[str] + scope: t.ClassVar[Scope] + result_cls: t.ClassVar[type[Result]] + chainable: t.ClassVar[bool] = True + primitive: t.ClassVar[bool] = True + safety: t.ClassVar[Safety] = "mutating" + effects: t.ClassVar[Effects] + min_version: t.ClassVar[str | None] = None + flag_version_map: t.ClassVar[Mapping[str, str]] = types.MappingProxyType({}) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Return the positional argument tokens after the target. + + Override per operation. ``version`` is the tmux version the engine will + run against (``None`` means "assume latest"); use :meth:`flag_available` + to drop flags an older tmux cannot accept. + + Returns + ------- + tuple[str, ...] + """ + return () + + def render(self, *, version: str | None = None) -> tuple[str, ...]: + """Render this operation as a tmux argv tuple. + + Parameters + ---------- + version : str or None + The tmux version to render against. ``None`` renders every flag. + + Returns + ------- + tuple[str, ...] + ``(command, ["-t", target], *args)``. + + Raises + ------ + ~libtmux.experimental.ops.exc.VersionUnsupported + When the running tmux is older than :attr:`min_version`. + TypeError + When the target is an unresolved + :class:`~._types.SlotRef` (a planner bug). + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> SendKeys(target=PaneId("%1"), keys="echo hi", enter=True).render() + ('send-keys', '-t', '%1', 'echo hi', 'Enter') + """ + self.check_version(version) + rendered: list[str] = [self.command] + token = render_target(self.target) + if token is not None: + rendered.extend(("-t", token)) + rendered.extend(self.args(version=version)) + return tuple(rendered) + + def check_version(self, version: str | None) -> None: + """Raise if *version* is older than this operation's :attr:`min_version`. + + Parameters + ---------- + version : str or None + The running tmux version, or ``None`` to skip the check. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> SplitWindow(target=PaneId("%1")).check_version("3.4") + """ + if version is None or self.min_version is None: + return + if LooseVersion(version) < LooseVersion(self.min_version): + raise VersionUnsupported( + self.kind, + need=self.min_version, + have=version, + ) + + def flag_available(self, label: str, version: str | None) -> bool: + """Whether a version-gated feature is available on *version*. + + Parameters + ---------- + label : str + A key in :attr:`flag_version_map`. + version : str or None + The running tmux version, or ``None`` to assume latest. + + Returns + ------- + bool + ``True`` when the feature is ungated, unknown, or supported. + + Examples + -------- + >>> from libtmux.experimental.ops import CapturePane + >>> from libtmux.experimental.ops._types import PaneId + >>> op = CapturePane(target=PaneId("%1"), trim_trailing=True) + >>> op.flag_available("trim_trailing", "3.4") + True + >>> op.flag_available("trim_trailing", "3.0") + False + """ + need = self.flag_version_map.get(label) + if need is None or version is None: + return True + return LooseVersion(version) >= LooseVersion(need) + + def build_result( + self, + *, + returncode: int, + argv: tuple[str, ...] | None = None, + stdout: Sequence[str] = (), + stderr: Sequence[str] = (), + version: str | None = None, + ) -> ResultT: + """Adapt raw tmux output into this operation's typed result. + + Parameters + ---------- + returncode : int + tmux exit code. + argv : tuple[str, ...] or None + The argv that produced the output; rendered from this operation when + omitted. + stdout, stderr : Sequence[str] + Captured output lines. + version : str or None + Used only to render *argv* when it is omitted. + + Returns + ------- + ResultT + An instance of :attr:`result_cls`. + """ + rendered = argv if argv is not None else self.render(version=version) + status = status_for(returncode, stderr) + return self._make_result( + rendered, + status, + returncode, + tuple(stdout), + tuple(stderr), + ) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + ) -> ResultT: + """Construct the result instance; override to populate typed payloads.""" + return t.cast( + "ResultT", + self.result_cls( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + ), + ) diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py new file mode 100644 index 000000000..a929d79ef --- /dev/null +++ b/src/libtmux/experimental/ops/registry.py @@ -0,0 +1,207 @@ +"""The operation registry: one entry per operation ``kind``. + +The registry is the single source of truth that runtime dispatch, serialization, +and the (planned) docs catalog all read from. Each entry is an :class:`OpSpec` +derived from an :class:`~.operation.Operation` subclass's class variables, so the +operation class itself remains authoritative -- the registry just indexes it. + +Lookups fail closed: an unknown ``kind`` raises +:class:`~.exc.UnknownOperation`, and registering a duplicate raises +:class:`~.exc.DuplicateOperation` unless ``replace=True``. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.exc import DuplicateOperation, UnknownOperation + +if t.TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping + + from libtmux.experimental.ops._types import Effects, Safety, Scope + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +OpT = t.TypeVar("OpT", bound="type[Operation[t.Any]]") + + +@dataclass(frozen=True) +class OpSpec: + """Indexed metadata for one operation, derived from its class variables. + + Attributes mirror the operation class variables documented on + :class:`~.operation.Operation`. + """ + + kind: str + command: str + scope: Scope + operation_cls: type[Operation[t.Any]] + result_cls: type[Result] + chainable: bool + primitive: bool + safety: Safety + effects: Effects + min_version: str | None + flag_version_map: Mapping[str, str] + + @classmethod + def from_operation(cls, operation_cls: type[Operation[t.Any]]) -> OpSpec: + """Build a spec by reading an operation class's class variables. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> spec = OpSpec.from_operation(SplitWindow) + >>> spec.kind, spec.command, spec.scope + ('split_window', 'split-window', 'window') + """ + return cls( + kind=operation_cls.kind, + command=operation_cls.command, + scope=operation_cls.scope, + operation_cls=operation_cls, + result_cls=operation_cls.result_cls, + chainable=operation_cls.chainable, + primitive=operation_cls.primitive, + safety=operation_cls.safety, + effects=operation_cls.effects, + min_version=operation_cls.min_version, + flag_version_map=operation_cls.flag_version_map, + ) + + +class OperationRegistry: + """A fail-closed index of operations keyed by ``kind``. + + Examples + -------- + >>> from libtmux.experimental.ops import registry, SplitWindow + >>> "split_window" in registry + True + >>> registry.get("split_window").scope + 'window' + >>> registry.operation("split_window") is SplitWindow + True + >>> registry.get("does_not_exist") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.UnknownOperation: no operation registered for + kind 'does_not_exist' + """ + + def __init__(self) -> None: + self._specs: dict[str, OpSpec] = {} + + def register( + self, + operation_cls: type[Operation[t.Any]], + *, + replace: bool = False, + ) -> None: + """Register an operation class. + + Parameters + ---------- + operation_cls : type[Operation] + The operation class to index. + replace : bool + Allow replacing an existing registration for the same ``kind``. + + Raises + ------ + ~libtmux.experimental.ops.exc.DuplicateOperation + When ``kind`` is already registered and ``replace`` is ``False``. + """ + kind = operation_cls.kind + if not replace and kind in self._specs: + raise DuplicateOperation(kind) + self._specs[kind] = OpSpec.from_operation(operation_cls) + + def unregister(self, kind: str) -> None: + """Remove an operation registration. + + Raises + ------ + ~libtmux.experimental.ops.exc.UnknownOperation + When ``kind`` is not registered. + """ + if kind not in self._specs: + raise UnknownOperation(kind) + del self._specs[kind] + + def get(self, kind: str) -> OpSpec: + """Return the :class:`OpSpec` for ``kind`` or fail closed. + + Raises + ------ + ~libtmux.experimental.ops.exc.UnknownOperation + When ``kind`` is not registered. + """ + try: + return self._specs[kind] + except KeyError as error: + raise UnknownOperation(kind) from error + + def operation(self, kind: str) -> type[Operation[t.Any]]: + """Return the operation class registered for ``kind``.""" + return self.get(kind).operation_cls + + def list( + self, + predicate: Callable[[OpSpec], bool] | None = None, + ) -> list[OpSpec]: + """Return registered specs (optionally filtered), sorted by ``kind``. + + Parameters + ---------- + predicate : callable, optional + Keep only specs for which ``predicate(spec)`` is true. + + Examples + -------- + >>> from libtmux.experimental.ops import registry + >>> [s.kind for s in registry.list(lambda s: s.safety == "readonly")] + ['capture_pane'] + """ + specs = sorted(self._specs.values(), key=lambda spec: spec.kind) + if predicate is None: + return specs + return [spec for spec in specs if predicate(spec)] + + def kinds(self) -> tuple[str, ...]: + """Return all registered kinds, sorted.""" + return tuple(sorted(self._specs)) + + def __contains__(self, kind: object) -> bool: + """Whether ``kind`` is registered.""" + return kind in self._specs + + def __iter__(self) -> Iterator[OpSpec]: + """Iterate specs sorted by ``kind``.""" + return iter(self.list()) + + def __len__(self) -> int: + """Return the number of registered operations.""" + return len(self._specs) + + +registry = OperationRegistry() +"""The default, process-wide operation registry.""" + + +def register(operation_cls: OpT) -> OpT: + """Class decorator that registers an operation in the default registry. + + Returns the class unchanged, so it can decorate a class definition. + + Examples + -------- + >>> from libtmux.experimental.ops import registry + >>> "send_keys" in registry + True + """ + registry.register(operation_cls) + return operation_cls diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py new file mode 100644 index 000000000..70951a4e9 --- /dev/null +++ b/src/libtmux/experimental/ops/results.py @@ -0,0 +1,170 @@ +"""Typed result values for :mod:`libtmux.experimental.ops`. + +A :class:`Result` is the uniform shape every engine returns for the same +operation: the operation that produced it, the rendered argv, an execution +:data:`~libtmux.experimental.ops._types.Status`, and the captured tmux output. +Specialized payloads (a new pane id, captured lines) live on subclasses defined +next to their operations. + +Results never raise on construction. Raising is opt-in via +:meth:`Result.raise_for_status`, mirroring +:meth:`subprocess.CompletedProcess.check_returncode`. *How* an engine treats a +failed result is the engine's policy: the classic engine raises in its facade to +match today's behavior, while newer engines hand the result back and let the +caller decide. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops.exc import TmuxCommandError + +if t.TYPE_CHECKING: + from typing_extensions import Self + + from libtmux.experimental.ops._types import Status + from libtmux.experimental.ops.operation import Operation + + +def status_for(returncode: int, stderr: t.Sequence[str]) -> Status: + """Derive a result :data:`~._types.Status` from a tmux outcome. + + tmux frequently reports a failure as stderr text while still exiting ``0``, + so non-empty stderr counts as a failure here -- a deliberate divergence from + a returncode-only test (see :meth:`Result.raise_for_status`). + + Parameters + ---------- + returncode : int + The tmux process exit code. + stderr : Sequence[str] + Captured stderr lines. + + Returns + ------- + Status + + Examples + -------- + >>> status_for(0, []) + 'complete' + >>> status_for(1, []) + 'failed' + >>> status_for(0, ["no current session"]) + 'failed' + """ + if returncode == 0 and not stderr: + return "complete" + return "failed" + + +@dataclass(frozen=True) +class Result: + """Base result for an executed (or simulated) operation. + + Parameters + ---------- + operation : Operation + The operation this result came from. + argv : tuple[str, ...] + The rendered tmux argv that produced this result. + status : Status + Execution status. + returncode : int + tmux exit code (``-1`` when unknown, e.g. a timeout). + stdout, stderr : tuple[str, ...] + Captured output lines. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> result = SendKeys(target=PaneId("%1"), keys="echo hi").build_result( + ... argv=("send-keys", "-t", "%1", "echo hi"), + ... returncode=0, + ... ) + >>> result.ok + True + >>> result.raise_for_status() is result + True + + A failed result raises only when asked: + + >>> failed = SendKeys(target=PaneId("%9"), keys="x").build_result( + ... argv=("send-keys", "-t", "%9", "x"), + ... returncode=1, + ... stderr=("can't find pane %9",), + ... ) + >>> failed.ok + False + >>> failed.raise_for_status() + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.TmuxCommandError: tmux 'send-keys -t %9 x' + failed (exit 1): can't find pane %9 + """ + + operation: Operation[t.Any] + argv: tuple[str, ...] + status: Status + returncode: int + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + + @property + def ok(self) -> bool: + """Whether the operation completed successfully.""" + return self.status == "complete" + + @property + def failed(self) -> bool: + """Whether the operation ran and tmux reported failure.""" + return self.status == "failed" + + def raise_for_status(self) -> Self: + """Raise :class:`~.exc.TmuxCommandError` if the result is not OK. + + Returns ``self`` on success so it can be used fluently + (``result = run(op, engine).raise_for_status()``). A ``failed`` or + ``unknown`` status raises; ``complete`` and ``skipped`` do not. + + Returns + ------- + Self + + Raises + ------ + ~libtmux.experimental.ops.exc.TmuxCommandError + When :attr:`status` is ``failed`` or ``unknown``. + """ + if self.status in {"failed", "unknown"}: + raise TmuxCommandError( + self.returncode, + self.argv, + self.stdout, + self.stderr, + ) + return self + + +@dataclass(frozen=True) +class SplitWindowResult(Result): + """Result of a ``split-window`` operation. + + Adds the id of the pane tmux created, when it was captured. + """ + + new_pane_id: str | None = None + + +@dataclass(frozen=True) +class CapturePaneResult(Result): + """Result of a ``capture-pane`` operation. + + Adds the captured pane lines as :attr:`lines` (also available as + :attr:`stdout`). + """ + + lines: tuple[str, ...] = field(default_factory=tuple) diff --git a/src/libtmux/experimental/ops/serialize.py b/src/libtmux/experimental/ops/serialize.py new file mode 100644 index 000000000..d65dff506 --- /dev/null +++ b/src/libtmux/experimental/ops/serialize.py @@ -0,0 +1,181 @@ +"""Serialize operations and results to/from plain dicts. + +Serialized payloads contain only stable, JSON-friendly data -- a ``kind`` +discriminator, target descriptors, scalar fields, and captured output. They hold +no live :class:`~libtmux.Server`/:class:`~libtmux.Pane`, subprocess handles, or +event-loop objects, so an operation built in one process can be reconstructed in +another. Reconstruction goes through the registry, so only registered operations +can be revived (fail closed). +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.registry import registry + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + +_TARGET_TYPES: dict[str, type] = { + cls.__name__: cls + for cls in ( + PaneId, + WindowId, + SessionId, + ClientName, + NameRef, + IndexRef, + Special, + SlotRef, + ) +} + + +def target_to_dict(target: Target | None) -> dict[str, t.Any] | None: + """Serialize a :data:`~._types.Target` to a tagged dict (or ``None``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> target_to_dict(PaneId("%1")) + {'type': 'PaneId', 'value': '%1'} + >>> target_to_dict(None) is None + True + """ + if target is None: + return None + return {"type": type(target).__name__, **dataclasses.asdict(target)} + + +def target_from_dict(data: Mapping[str, t.Any] | None) -> Target | None: + """Reconstruct a :data:`~._types.Target` from :func:`target_to_dict` output. + + Examples + -------- + >>> target_from_dict({"type": "PaneId", "value": "%1"}) + PaneId(value='%1') + >>> target_from_dict(None) is None + True + """ + if data is None: + return None + cls = _TARGET_TYPES[data["type"]] + fields = {key: value for key, value in data.items() if key != "type"} + return t.cast("Target", cls(**fields)) + + +def _jsonify(value: t.Any) -> t.Any: + """Render a field value as JSON-friendly data.""" + if isinstance(value, tuple): + return list(value) + if isinstance(value, dict): + return dict(value) + return value + + +def operation_to_dict(operation: Operation[t.Any]) -> dict[str, t.Any]: + """Serialize an operation to a plain dict. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> data = operation_to_dict(SplitWindow(target=PaneId("%1"), horizontal=True)) + >>> data["kind"], data["target"], data["horizontal"] + ('split_window', {'type': 'PaneId', 'value': '%1'}, True) + """ + data: dict[str, t.Any] = {"kind": operation.kind} + for field in dataclasses.fields(operation): + value = getattr(operation, field.name) + if field.name == "target": + data["target"] = target_to_dict(value) + else: + data[field.name] = _jsonify(value) + return data + + +def operation_from_dict(data: Mapping[str, t.Any]) -> Operation[t.Any]: + """Reconstruct an operation from :func:`operation_to_dict` output. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> op = SplitWindow(target=PaneId("%1"), horizontal=True) + >>> operation_from_dict(operation_to_dict(op)) == op + True + """ + operation_cls = registry.operation(data["kind"]) + kwargs: dict[str, t.Any] = {} + for field in dataclasses.fields(operation_cls): + if field.name not in data: + continue + if field.name == "target": + kwargs["target"] = target_from_dict(data["target"]) + else: + kwargs[field.name] = data[field.name] + return operation_cls(**kwargs) + + +def _coerce_field(value: t.Any) -> t.Any: + """Coerce a JSON list back into the tuple a result field expects.""" + if isinstance(value, list): + return tuple(value) + return value + + +def result_to_dict(result: Result) -> dict[str, t.Any]: + """Serialize a result (and its operation) to a plain dict. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> r = SplitWindow(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> data = result_to_dict(r) + >>> data["status"], data["new_pane_id"] + ('complete', '%2') + """ + data: dict[str, t.Any] = {"operation": operation_to_dict(result.operation)} + for field in dataclasses.fields(result): + if field.name == "operation": + continue + data[field.name] = _jsonify(getattr(result, field.name)) + return data + + +def result_from_dict(data: Mapping[str, t.Any]) -> Result: + """Reconstruct a result from :func:`result_to_dict` output. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow + >>> from libtmux.experimental.ops._types import PaneId + >>> r = SplitWindow(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> result_from_dict(result_to_dict(r)) == r + True + """ + operation = operation_from_dict(data["operation"]) + result_cls = type(operation).result_cls + kwargs: dict[str, t.Any] = {"operation": operation} + for field in dataclasses.fields(result_cls): + if field.name == "operation" or field.name not in data: + continue + kwargs[field.name] = _coerce_field(data[field.name]) + return result_cls(**kwargs) diff --git a/tests/experimental/__init__.py b/tests/experimental/__init__.py new file mode 100644 index 000000000..a65199c59 --- /dev/null +++ b/tests/experimental/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.""" + +from __future__ import annotations diff --git a/tests/experimental/ops/__init__.py b/tests/experimental/ops/__init__.py new file mode 100644 index 000000000..ae42924a7 --- /dev/null +++ b/tests/experimental/ops/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.ops.""" + +from __future__ import annotations diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py new file mode 100644 index 000000000..017ad6ba1 --- /dev/null +++ b/tests/experimental/ops/test_execute.py @@ -0,0 +1,107 @@ +"""Tests for the :func:`run` / :func:`arun` execution bridge. + +These use in-memory fake engines so they need no tmux server -- the same +property that lets the contract suite run an operation through every engine. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.ops import SendKeys, SplitWindow, arun, run +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import TmuxCommandError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class FakeEngine: + """A synchronous fake engine that echoes argv and a canned stdout.""" + + def __init__(self, stdout: tuple[str, ...] = (), returncode: int = 0) -> None: + self.stdout = stdout + self.returncode = returncode + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> t.Any: + """Record the request and return a canned result.""" + from libtmux.experimental.engines.base import CommandResult + + self.calls.append(request.args) + return CommandResult( + cmd=("tmux", *request.args), + stdout=self.stdout, + stderr=() if self.returncode == 0 else ("boom",), + returncode=self.returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[t.Any]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +class AsyncFakeEngine: + """An asynchronous fake engine mirroring :class:`FakeEngine`.""" + + def __init__(self, stdout: tuple[str, ...] = (), returncode: int = 0) -> None: + self.stdout = stdout + self.returncode = returncode + + async def run(self, request: CommandRequest) -> t.Any: + """Return a canned result asynchronously.""" + from libtmux.experimental.engines.base import CommandResult + + return CommandResult( + cmd=("tmux", *request.args), + stdout=self.stdout, + returncode=self.returncode, + ) + + async def run_batch(self, requests: Sequence[CommandRequest]) -> list[t.Any]: + """Execute each request in order.""" + return [await self.run(req) for req in requests] + + +def test_run_returns_typed_result() -> None: + """``run`` renders, dispatches, and returns the operation's typed result.""" + engine = FakeEngine(stdout=("%9",)) + result = run(SplitWindow(target=WindowId("@1")), engine) + assert result.new_pane_id == "%9" + assert result.argv == ("split-window", "-t", "@1", "-v", "-P", "-F", "#{pane_id}") + assert engine.calls == [result.argv] + + +def test_run_does_not_raise_on_failure() -> None: + """A tmux failure is data on the result; ``run`` itself never raises.""" + engine = FakeEngine(returncode=1) + result = run(SendKeys(target=PaneId("%9"), keys="x"), engine) + assert result.failed + with pytest.raises(TmuxCommandError): + result.raise_for_status() + + +def test_run_version_threads_through() -> None: + """The ``version`` argument reaches operation rendering.""" + from libtmux.experimental.ops import CapturePane + + engine = FakeEngine() + result = run( + CapturePane(target=PaneId("%1"), trim_trailing=True), + engine, + version="3.3", + ) + assert "-T" not in result.argv + + +def test_arun_shares_render_and_build() -> None: + """``arun`` produces the same typed result as ``run`` via the async path.""" + engine = AsyncFakeEngine(stdout=("%5",)) + result = asyncio.run(arun(SplitWindow(target=WindowId("@1")), engine)) + assert result.new_pane_id == "%5" + assert result.ok diff --git a/tests/experimental/ops/test_operation.py b/tests/experimental/ops/test_operation.py new file mode 100644 index 000000000..80320c732 --- /dev/null +++ b/tests/experimental/ops/test_operation.py @@ -0,0 +1,105 @@ +"""Tests for the base :class:`~libtmux.experimental.ops.operation.Operation`.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +import pytest + +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import Effects, PaneId, WindowId +from libtmux.experimental.ops.exc import VersionUnsupported +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True, kw_only=True) +class _FutureOp(Operation[Result]): + """A synthetic operation gated to a future tmux version, for tests.""" + + kind = "_future_op_test" + command = "future-cmd" + scope = "server" + result_cls = Result + effects = Effects() + min_version = "99.0" + + +def test_render_includes_target_then_args() -> None: + """``render`` emits ``command -t target *args`` in order.""" + op = SendKeys(target=PaneId("%1"), keys="echo hi", enter=True) + assert op.render() == ("send-keys", "-t", "%1", "echo hi", "Enter") + + +def test_render_without_target() -> None: + """An operation with no target omits ``-t``.""" + op = SelectLayout(layout="tiled") + assert op.render() == ("select-layout", "tiled") + + +def test_version_gate_drops_unsupported_flag() -> None: + """A version-gated flag is dropped on an older tmux and kept on a newer one.""" + op = CapturePane(target=PaneId("%1"), trim_trailing=True) + assert op.render(version="3.3") == ("capture-pane", "-t", "%1", "-p") + assert op.render(version="3.4") == ("capture-pane", "-t", "%1", "-p", "-T") + assert op.render() == ("capture-pane", "-t", "%1", "-p", "-T") + + +def test_check_version_raises_when_too_low() -> None: + """An operation older tmux cannot satisfy raises on render.""" + op = _FutureOp() + with pytest.raises(VersionUnsupported, match="requires tmux >= 99"): + op.render(version="3.4") + + +def test_check_version_passes_when_satisfied() -> None: + """No version (or a satisfying one) renders without error.""" + op = _FutureOp() + assert op.render() == ("future-cmd",) + assert op.render(version="99.0") == ("future-cmd",) + + +def test_build_result_parses_payload() -> None: + """``split-window`` parses the captured new-pane id into its result.""" + op = SplitWindow(target=WindowId("@1")) + result = op.build_result(returncode=0, stdout=("%7",)) + assert result.new_pane_id == "%7" + assert result.ok + assert result.operation is op + + +def test_build_result_failure_status() -> None: + """A nonzero return code yields a ``failed`` result and no payload.""" + op = SplitWindow(target=WindowId("@1")) + result = op.build_result(returncode=1, stderr=("no space for new pane",)) + assert result.status == "failed" + assert result.new_pane_id is None + + +def test_operations_are_frozen() -> None: + """Operations are immutable values.""" + op = SendKeys(target=PaneId("%1"), keys="x") + with pytest.raises((AttributeError, TypeError)): + op.keys = "y" # type: ignore[misc] + + +@pytest.mark.parametrize( + "op", + [ + pytest.param(SplitWindow(target=WindowId("@1")), id="split_window"), + pytest.param(CapturePane(target=PaneId("%1")), id="capture_pane"), + pytest.param(SendKeys(target=PaneId("%1"), keys="x"), id="send_keys"), + pytest.param(SelectLayout(target=WindowId("@1")), id="select_layout"), + ], +) +def test_render_is_nonempty_argv(op: Operation[t.Any]) -> None: + """Every seed operation renders to a non-empty argv starting with command.""" + argv = op.render() + assert argv + assert argv[0] == op.command diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py new file mode 100644 index 000000000..b87261dd7 --- /dev/null +++ b/tests/experimental/ops/test_registry.py @@ -0,0 +1,74 @@ +"""Tests for the operation registry.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.ops import SplitWindow, registry +from libtmux.experimental.ops.exc import DuplicateOperation, UnknownOperation +from libtmux.experimental.ops.registry import OperationRegistry, OpSpec + + +def test_seed_operations_registered() -> None: + """All seed operations are present in the default registry.""" + assert set(registry.kinds()) >= { + "split_window", + "capture_pane", + "send_keys", + "select_layout", + } + + +def test_get_unknown_fails_closed() -> None: + """Looking up an unregistered kind raises :class:`UnknownOperation`.""" + with pytest.raises(UnknownOperation, match="does_not_exist"): + registry.get("does_not_exist") + + +def test_operation_lookup_returns_class() -> None: + """``operation`` returns the registered class for a kind.""" + assert registry.operation("split_window") is SplitWindow + + +def test_spec_from_operation_reads_classvars() -> None: + """An :class:`OpSpec` mirrors the operation's class variables.""" + spec = OpSpec.from_operation(SplitWindow) + assert spec.kind == "split_window" + assert spec.command == "split-window" + assert spec.scope == "window" + assert spec.result_cls is SplitWindow.result_cls + assert spec.effects.creates == "pane" + + +def test_list_predicate_filters() -> None: + """``list`` filters by a predicate and stays sorted by kind.""" + readonly = [ + spec.kind for spec in registry.list(lambda spec: spec.safety == "readonly") + ] + assert readonly == ["capture_pane"] + + +def test_register_duplicate_fails_closed() -> None: + """Registering an existing kind raises unless ``replace=True``.""" + local = OperationRegistry() + local.register(SplitWindow) + with pytest.raises(DuplicateOperation, match="split_window"): + local.register(SplitWindow) + local.register(SplitWindow, replace=True) + assert "split_window" in local + + +def test_unregister() -> None: + """Unregistering removes the kind; unregistering a missing kind raises.""" + local = OperationRegistry() + local.register(SplitWindow) + local.unregister("split_window") + assert "split_window" not in local + with pytest.raises(UnknownOperation): + local.unregister("split_window") + + +def test_len_and_iter() -> None: + """The default registry is sized and iterable in kind order.""" + assert len(registry) == len(registry.kinds()) + assert [spec.kind for spec in registry] == sorted(registry.kinds()) diff --git a/tests/experimental/ops/test_results.py b/tests/experimental/ops/test_results.py new file mode 100644 index 000000000..cfb43ebc2 --- /dev/null +++ b/tests/experimental/ops/test_results.py @@ -0,0 +1,73 @@ +"""Tests for results and the opt-in failure model.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.ops import SendKeys +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.exc import TmuxCommandError +from libtmux.experimental.ops.results import Result, status_for + + +@pytest.mark.parametrize( + ("returncode", "stderr", "expected"), + [ + pytest.param(0, [], "complete", id="clean"), + pytest.param(1, [], "failed", id="nonzero"), + pytest.param(0, ["no current session"], "failed", id="stderr-on-zero"), + ], +) +def test_status_for(returncode: int, stderr: list[str], expected: str) -> None: + """Tmux signalling failure via stderr counts as failed even on exit 0.""" + assert status_for(returncode, stderr) == expected + + +def _result(returncode: int, stderr: tuple[str, ...] = ()) -> Result: + """Build a send-keys result for the given outcome.""" + return SendKeys(target=PaneId("%1"), keys="x").build_result( + returncode=returncode, + stderr=stderr, + ) + + +def test_ok_result_does_not_raise() -> None: + """``raise_for_status`` returns the result itself when OK (fluent).""" + result = _result(0) + assert result.ok + assert result.raise_for_status() is result + + +def test_failed_result_raises_typed_error() -> None: + """A failed result raises :class:`TmuxCommandError` only when asked.""" + result = _result(1, ("can't find pane",)) + assert result.failed + with pytest.raises(TmuxCommandError) as excinfo: + result.raise_for_status() + assert excinfo.value.returncode == 1 + assert excinfo.value.stderr == ("can't find pane",) + + +def test_unknown_status_raises() -> None: + """An ``unknown`` (e.g. timeout) result also raises on demand.""" + base = _result(0) + unknown = Result( + operation=base.operation, + argv=base.argv, + status="unknown", + returncode=-1, + ) + with pytest.raises(TmuxCommandError): + unknown.raise_for_status() + + +def test_skipped_status_does_not_raise() -> None: + """A ``skipped`` operation is not a failure.""" + base = _result(0) + skipped = Result( + operation=base.operation, + argv=base.argv, + status="skipped", + returncode=0, + ) + assert skipped.raise_for_status() is skipped diff --git a/tests/experimental/ops/test_serialize.py b/tests/experimental/ops/test_serialize.py new file mode 100644 index 000000000..7c70e6520 --- /dev/null +++ b/tests/experimental/ops/test_serialize.py @@ -0,0 +1,106 @@ +"""Tests for operation/result serialization round-trips.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + Special, + WindowId, +) +from libtmux.experimental.ops.exc import UnknownOperation +from libtmux.experimental.ops.serialize import ( + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + target_from_dict, + target_to_dict, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + +_OPERATIONS = [ + pytest.param( + SplitWindow( + target=PaneId("%1"), + horizontal=True, + start_directory="/tmp", + environment={"FOO": "bar"}, + ), + id="split_window-full", + ), + pytest.param(CapturePane(target=PaneId("%2"), start=0, end=10), id="capture_pane"), + pytest.param( + SendKeys(target=PaneId("%3"), keys="echo hi", enter=True), + id="send_keys", + ), + pytest.param( + SelectLayout(target=WindowId("@4"), layout="tiled"), id="select_layout" + ), + pytest.param(SplitWindow(), id="split_window-no-target"), +] + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_operation_round_trip(operation: Operation[t.Any]) -> None: + """An operation survives a dict round-trip unchanged.""" + assert operation_from_dict(operation_to_dict(operation)) == operation + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_operation_dict_is_plain_data(operation: Operation[t.Any]) -> None: + """A serialized operation holds only stable, JSON-friendly scalars.""" + data = operation_to_dict(operation) + assert data["kind"] == operation.kind + assert isinstance(data["target"], (dict, type(None))) + + +@pytest.mark.parametrize("operation", _OPERATIONS) +def test_result_round_trip(operation: Operation[t.Any]) -> None: + """A result (with its operation and payload) survives a dict round-trip.""" + result = operation.build_result(returncode=0, stdout=("%9",)) + assert result_from_dict(result_to_dict(result)) == result + + +@pytest.mark.parametrize( + "target", + [ + pytest.param(PaneId("%1"), id="pane"), + pytest.param(WindowId("@1"), id="window"), + pytest.param(SessionId("$1"), id="session"), + pytest.param(ClientName("/dev/pts/1"), id="client"), + pytest.param(NameRef("work", exact=True), id="name"), + pytest.param(IndexRef(2, parent="$1"), id="index"), + pytest.param(Special("{marked}"), id="special"), + ], +) +def test_target_round_trip(target: Target) -> None: + """Every target type survives a dict round-trip.""" + assert target_from_dict(target_to_dict(target)) == target + + +def test_target_none_round_trip() -> None: + """A missing target round-trips as ``None``.""" + assert target_from_dict(target_to_dict(None)) is None + + +def test_from_dict_unknown_kind_fails_closed() -> None: + """Reviving an unregistered kind raises :class:`UnknownOperation`.""" + with pytest.raises(UnknownOperation): + operation_from_dict({"kind": "does_not_exist"}) diff --git a/tests/experimental/ops/test_types.py b/tests/experimental/ops/test_types.py new file mode 100644 index 000000000..3c72af786 --- /dev/null +++ b/tests/experimental/ops/test_types.py @@ -0,0 +1,82 @@ +"""Tests for the typed primitives in :mod:`libtmux.experimental.ops._types`.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops._types import ( + ClientName, + Effects, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, + render_target, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Target + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + pytest.param(PaneId("%1"), "%1", id="pane-id"), + pytest.param(WindowId("@2"), "@2", id="window-id"), + pytest.param(SessionId("$0"), "$0", id="session-id"), + pytest.param(ClientName("/dev/pts/3"), "/dev/pts/3", id="client-name"), + pytest.param(NameRef("work"), "work", id="name-ref"), + pytest.param(NameRef("work", exact=True), "=work", id="name-ref-exact"), + pytest.param(IndexRef(0), "0", id="index-ref"), + pytest.param(IndexRef(2, parent="$1"), "$1:2", id="index-ref-parent"), + pytest.param(Special("{marked}"), "{marked}", id="special"), + ], +) +def test_target_render(target: Target, expected: str) -> None: + """Each concrete target renders to its tmux ``-t`` token.""" + assert target.render() == expected + assert render_target(target) == expected + + +def test_render_target_none() -> None: + """``render_target(None)`` yields ``None`` (no target).""" + assert render_target(None) is None + + +@pytest.mark.parametrize( + ("ctor", "value"), + [ + pytest.param(PaneId, "1", id="pane-missing-sigil"), + pytest.param(WindowId, "2", id="window-missing-sigil"), + pytest.param(SessionId, "0", id="session-missing-sigil"), + pytest.param(ClientName, "", id="client-empty"), + pytest.param(NameRef, "", id="name-empty"), + pytest.param(Special, "", id="special-empty"), + ], +) +def test_target_validation_fails_closed( + ctor: t.Callable[[str], object], + value: str, +) -> None: + """Malformed targets raise at construction rather than at tmux time.""" + with pytest.raises(ValueError, match="must"): + ctor(value) + + +def test_slot_ref_render_raises() -> None: + """An unresolved deferred ref cannot render -- that is a planner bug.""" + with pytest.raises(TypeError, match="unresolved SlotRef"): + SlotRef(0).render() + + +def test_effects_defaults() -> None: + """An empty :class:`Effects` is all-false / no-creates.""" + effects = Effects() + assert not effects.read_only + assert not effects.destructive + assert effects.creates is None From da87c7be68fa28d3a6b86fe22a3b53dddebbe3e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:08:01 -0500 Subject: [PATCH 002/154] Ops(feat): Add classic + concrete engines and contract suite why: Proves the operation/result contract is transport-agnostic -- the same typed result whether produced by a real tmux subprocess or an in-memory simulator -- and provides the offline engine that lets ops doctests and tests run without a tmux server (issue 689 phases 2-3). what: - engines.subprocess: classic SubprocessEngine mirroring tmux_cmd (has-session stderr fold, backslashreplace, trailing-blank strip; tmux failure returned as data, only missing binary raises), with for_server() deriving -L/-S/-f/-2 flags from a live Server - engines.concrete: deterministic in-memory engine (fabricated pane/ window/session ids, canned capture lines) for tests and docs - engines.registry: name-keyed engine registry (register/create/ available), seeded with subprocess + concrete - tests/experimental/contract: engine-agnostic operation contract run offline via concrete, plus classic-vs-concrete parity against a real tmux server (same result type + argv, payload may differ) --- src/libtmux/experimental/engines/__init__.py | 18 ++- src/libtmux/experimental/engines/concrete.py | 76 +++++++++++ src/libtmux/experimental/engines/registry.py | 68 ++++++++++ .../experimental/engines/subprocess.py | 120 ++++++++++++++++++ tests/experimental/contract/__init__.py | 3 + .../contract/test_classic_engine.py | 96 ++++++++++++++ .../contract/test_engine_contract.py | 67 ++++++++++ 7 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 src/libtmux/experimental/engines/concrete.py create mode 100644 src/libtmux/experimental/engines/registry.py create mode 100644 src/libtmux/experimental/engines/subprocess.py create mode 100644 tests/experimental/contract/__init__.py create mode 100644 tests/experimental/contract/test_classic_engine.py create mode 100644 tests/experimental/contract/test_engine_contract.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index fa9961319..96f8068db 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -3,9 +3,9 @@ An *engine* executes a rendered tmux command and returns a structured result. Engines are interchangeable behind the :class:`~.base.TmuxEngine` / :class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run -through a subprocess, a persistent ``tmux -C`` control connection, an async -transport, an in-memory simulator, or (as an easter egg) tmux's native binary -peer protocol -- and return the *same* typed result. +through a subprocess (classic), an in-memory simulator (concrete), a persistent +``tmux -C`` control connection, an async transport, or (as an easter egg) tmux's +native binary peer protocol -- and return the *same* typed result. See the operationalization plan (``tmux-python/libtmux`` issue 689). """ @@ -20,12 +20,24 @@ EngineSpec, TmuxEngine, ) +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.registry import ( + available_engines, + create_engine, + register_engine, +) +from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( "AsyncTmuxEngine", "CommandRequest", "CommandResult", + "ConcreteEngine", "EngineKind", "EngineSpec", + "SubprocessEngine", "TmuxEngine", + "available_engines", + "create_engine", + "register_engine", ) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py new file mode 100644 index 000000000..07461d630 --- /dev/null +++ b/src/libtmux/experimental/engines/concrete.py @@ -0,0 +1,76 @@ +"""A deterministic, in-memory engine for tests and docs (no tmux server). + +The concrete engine simulates just enough tmux behaviour to exercise the +operation contract without a live server: creation commands that ask for an id +(``-P -F '#{pane_id}'``) get a fabricated, monotonic id, ``capture-pane`` returns +canned lines, and everything else succeeds with empty output. This is what backs +doctests and the cross-engine contract suite, so examples run anywhere and the +"same typed result regardless of engine" invariant can be asserted offline. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class ConcreteEngine: + """Execute operations against an in-memory simulation. + + Parameters + ---------- + capture_lines : Sequence[str] + Lines that ``capture-pane`` returns. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, CapturePane, run + >>> from libtmux.experimental.ops._types import WindowId, PaneId + >>> engine = ConcreteEngine(capture_lines=("hello", "world")) + >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id + '%1' + >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id + '%2' + >>> run(CapturePane(target=PaneId("%1")), engine).lines + ('hello', 'world') + """ + + def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: + self.capture_lines = tuple(capture_lines) + self._counters = {"pane_id": 0, "window_id": 0, "session_id": 0} + + def _fabricate(self, fmt: str) -> str: + """Return the next fabricated id for a ``#{..._id}`` capture format.""" + for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): + if key in fmt: + self._counters[key] += 1 + return f"{sigil}{self._counters[key]}" + return "?" + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one request against the in-memory simulation.""" + argv = request.args + if "-P" in argv and "-F" in argv: + fmt = argv[argv.index("-F") + 1] + return CommandResult( + cmd=("tmux", *argv), + stdout=(self._fabricate(fmt),), + returncode=0, + ) + if argv and argv[0] == "capture-pane": + return CommandResult( + cmd=("tmux", *argv), + stdout=self.capture_lines, + returncode=0, + ) + return CommandResult(cmd=("tmux", *argv), returncode=0) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order (no batching benefit).""" + return [self.run(req) for req in requests] diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py new file mode 100644 index 000000000..0a7fb10d5 --- /dev/null +++ b/src/libtmux/experimental/engines/registry.py @@ -0,0 +1,68 @@ +"""A name-keyed registry of engine factories. + +Lets engines be created by name (or :class:`~.base.EngineSpec`) so downstream +code and the contract suite can select a transport without importing its class. +Fails closed on an unknown name. Adapted from the ``libtmux-protocol-engines`` +prototype. +""" + +from __future__ import annotations + +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import EngineKind +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.subprocess import SubprocessEngine + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + +EngineFactory = t.Callable[..., "TmuxEngine"] + +_engine_registry: dict[str, EngineFactory] = {} + + +def register_engine(name: str, factory: EngineFactory) -> None: + """Register an engine factory under a name.""" + _engine_registry[name] = factory + + +def available_engines() -> tuple[str, ...]: + """Return registered engine names, sorted. + + Examples + -------- + >>> from libtmux.experimental.engines import available_engines + >>> "concrete" in available_engines() + True + >>> "subprocess" in available_engines() + True + """ + return tuple(sorted(_engine_registry)) + + +def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: + """Instantiate a registered engine by name (fail closed). + + Examples + -------- + >>> from libtmux.experimental.engines import create_engine + >>> create_engine("concrete") + + >>> create_engine("nope") + Traceback (most recent call last): + ... + libtmux.exc.LibTmuxException: unknown tmux engine: nope + """ + engine_name = name.value if isinstance(name, EngineKind) else name + try: + factory = _engine_registry[engine_name] + except KeyError as error: + msg = f"unknown tmux engine: {engine_name}" + raise exc.LibTmuxException(msg) from error + return factory(**kwargs) + + +register_engine(EngineKind.SUBPROCESS.value, SubprocessEngine) +register_engine(EngineKind.CONCRETE.value, ConcreteEngine) diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py new file mode 100644 index 000000000..f76b59c41 --- /dev/null +++ b/src/libtmux/experimental/engines/subprocess.py @@ -0,0 +1,120 @@ +"""The classic subprocess engine. + +Executes tmux via the CLI binary, one fork per command, reproducing today's +:class:`libtmux.common.tmux_cmd` behaviour byte-for-byte: ``backslashreplace`` +decoding, trailing-blank stripping, and the ``has-session`` stderr-into-stdout +fold. A tmux-side failure is returned as data (nonzero ``returncode`` plus +``stderr``); only a missing binary raises. ``server_args`` carries the +connection flags (``-L``/``-S``/``-f``/``-2``) so the engine can target a +specific tmux server. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + +logger = logging.getLogger(__name__) + + +class SubprocessEngine: + """Execute tmux commands by forking the tmux CLI binary. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command (e.g. + ``("-L", "test")`` or ``("-Lmysocket",)``). + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.server_args = tuple(server_args) + self._resolved_bin: str | None = None + + def _resolve_bin(self) -> str: + """Return the tmux binary path, memoized for the engine instance.""" + if self.tmux_bin is not None: + return self.tmux_bin + if self._resolved_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_bin = resolved + return self._resolved_bin + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command via subprocess and return its result.""" + tmux_bin = request.tmux_bin or self._resolve_bin() + cmd = [tmux_bin, *self.server_args, *request.args] + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="backslashreplace", + ) + stdout, stderr = process.communicate() + returncode = process.returncode + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = [line for line in stderr.split("\n") if line] + + if "has-session" in cmd and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order (subprocess forks per call).""" + return [self.run(req) for req in requests] + + @classmethod + def for_server(cls, server: t.Any) -> SubprocessEngine: + """Build an engine bound to a live :class:`libtmux.Server`'s socket. + + Mirrors :meth:`libtmux.Server.cmd`'s connection-flag construction so the + engine talks to the same tmux server as the object API. + """ + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) diff --git a/tests/experimental/contract/__init__.py b/tests/experimental/contract/__init__.py new file mode 100644 index 000000000..5b55edb29 --- /dev/null +++ b/tests/experimental/contract/__init__.py @@ -0,0 +1,3 @@ +"""Cross-engine contract tests.""" + +from __future__ import annotations diff --git a/tests/experimental/contract/test_classic_engine.py b/tests/experimental/contract/test_classic_engine.py new file mode 100644 index 000000000..f3a889646 --- /dev/null +++ b/tests/experimental/contract/test_classic_engine.py @@ -0,0 +1,96 @@ +"""Classic engine against a real tmux server, and parity with concrete. + +These use the libtmux pytest fixtures (a live tmux server), so they exercise the +classic :class:`~libtmux.experimental.engines.subprocess.SubprocessEngine` path +end to end and assert it returns the *same typed result shape* the concrete +engine does. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.results import ( + CapturePaneResult, + Result, + SplitWindowResult, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_classic_split_creates_real_pane(session: Session) -> None: + """A classic split returns a typed result whose new pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + result = run(SplitWindow(target=WindowId(window.window_id)), engine) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert result.new_pane_id.startswith("%") + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_classic_send_keys_and_select_layout(session: Session) -> None: + """Classic send-keys and select-layout return successful typed results.""" + server = session.server + pane = session.active_pane + window = session.active_window + assert pane is not None + assert pane.pane_id is not None + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) + assert type(sent) is Result + assert sent.ok + + laid_out = run( + SelectLayout(target=WindowId(window.window_id), layout="even-horizontal"), + engine, + ) + assert laid_out.ok + + +def test_classic_capture_returns_lines(session: Session) -> None: + """Classic capture-pane returns a typed result carrying line data.""" + server = session.server + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + engine = SubprocessEngine.for_server(server) + + result = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert isinstance(result, CapturePaneResult) + assert result.ok + assert isinstance(result.lines, tuple) + + +def test_classic_concrete_parity(session: Session) -> None: + """Classic and concrete engines agree on result type and argv (not payload).""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + classic = run(operation, SubprocessEngine.for_server(server)) + concrete = run(operation, ConcreteEngine()) + + assert type(classic) is type(concrete) is SplitWindowResult + assert classic.argv == concrete.argv == operation.render() + assert classic.ok and concrete.ok diff --git a/tests/experimental/contract/test_engine_contract.py b/tests/experimental/contract/test_engine_contract.py new file mode 100644 index 000000000..34e60096c --- /dev/null +++ b/tests/experimental/contract/test_engine_contract.py @@ -0,0 +1,67 @@ +"""Engine-agnostic operation contract (runs offline via the concrete engine). + +These assertions hold for *any* engine because they are properties of the +operation executed through the engine: the result is the operation's typed +result class, its argv is the operation's render, and it serializes round-trip. +The concrete engine lets the whole matrix run without a tmux server. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + CapturePane, + SelectLayout, + SendKeys, + SplitWindow, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + +_CONTRACT_OPS = [ + pytest.param(SplitWindow(target=WindowId("@1")), id="split_window"), + pytest.param(CapturePane(target=PaneId("%1")), id="capture_pane"), + pytest.param(SendKeys(target=PaneId("%1"), keys="echo hi"), id="send_keys"), + pytest.param( + SelectLayout(target=WindowId("@1"), layout="tiled"), id="select_layout" + ), +] + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_type_matches_operation(operation: Operation[t.Any]) -> None: + """An engine returns the operation's declared result type.""" + result = run(operation, ConcreteEngine()) + assert type(result) is operation.result_cls + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_argv_is_render(operation: Operation[t.Any]) -> None: + """The result's argv equals the operation's pure render.""" + result = run(operation, ConcreteEngine()) + assert result.argv == operation.render() + assert result.ok + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_result_serialization_round_trip(operation: Operation[t.Any]) -> None: + """A result produced by an engine survives a dict round-trip.""" + result = run(operation, ConcreteEngine()) + assert result_from_dict(result_to_dict(result)) == result + + +@pytest.mark.parametrize("operation", _CONTRACT_OPS) +def test_same_result_across_engine_instances(operation: Operation[t.Any]) -> None: + """Two fresh engines yield equal typed results -- determinism contract.""" + first = run(operation, ConcreteEngine()) + second = run(operation, ConcreteEngine()) + assert first == second From ad074d1f151281065bdf9270dfa0b17eb83030d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:14:09 -0500 Subject: [PATCH 003/154] Ops(feat): Add async engine, lazy plans, and op catalog why: Completes the sync/async-symmetric execution story plus the deferred-execution and documentation mechanisms from issue 689 (phase 5 + docs), still without touching any existing API. what: - engines.asyncio: real AsyncSubprocessEngine on create_subprocess_exec (terminates the child on cancellation; not a thread wrapper), mirroring the classic engine's output handling so it returns the same typed result - ops.plan: LazyPlan records operations without touching tmux and resolves SlotRef forward refs at execute time via a sans-I/O generator; sync execute() and async aexecute() share one resolution core (run vs await arun is the only divergence); whole-plan serialization round-trips - ops.catalog: registry-driven CatalogEntry list (scope, version gates, effects, safety, result type, summary) -- the single source a docs domain renders, so runtime and docs cannot drift - tests: lazy resolution sync+async, plan serialization, catalog coverage, async-vs-sync classic parity against a real tmux server --- src/libtmux/experimental/engines/__init__.py | 2 + src/libtmux/experimental/engines/asyncio.py | 129 +++++++++++ src/libtmux/experimental/ops/__init__.py | 6 + src/libtmux/experimental/ops/catalog.py | 91 ++++++++ src/libtmux/experimental/ops/plan.py | 200 ++++++++++++++++++ .../contract/test_async_engine.py | 50 +++++ tests/experimental/ops/test_catalog.py | 35 +++ tests/experimental/ops/test_plan.py | 94 ++++++++ 8 files changed, 607 insertions(+) create mode 100644 src/libtmux/experimental/engines/asyncio.py create mode 100644 src/libtmux/experimental/ops/catalog.py create mode 100644 src/libtmux/experimental/ops/plan.py create mode 100644 tests/experimental/contract/test_async_engine.py create mode 100644 tests/experimental/ops/test_catalog.py create mode 100644 tests/experimental/ops/test_plan.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 96f8068db..8b23b9b20 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -12,6 +12,7 @@ from __future__ import annotations +from libtmux.experimental.engines.asyncio import AsyncSubprocessEngine from libtmux.experimental.engines.base import ( AsyncTmuxEngine, CommandRequest, @@ -29,6 +30,7 @@ from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( + "AsyncSubprocessEngine", "AsyncTmuxEngine", "CommandRequest", "CommandResult", diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py new file mode 100644 index 000000000..3880efd36 --- /dev/null +++ b/src/libtmux/experimental/engines/asyncio.py @@ -0,0 +1,129 @@ +"""A real asynchronous subprocess engine. + +Built on :func:`asyncio.create_subprocess_exec` -- genuine async process I/O, +not a thread wrapper around the sync engine. On cancellation it terminates the +child process before propagating :class:`asyncio.CancelledError`, so a cancelled +``arun`` leaks no tmux process. It mirrors the classic engine's output handling +(``backslashreplace`` decoding, trailing-blank stripping, ``has-session`` fold) +so it returns the *same* typed result the classic engine does. +""" + +from __future__ import annotations + +import asyncio +import shutil +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class AsyncSubprocessEngine: + """Execute tmux commands via :func:`asyncio.create_subprocess_exec`. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SendKeys, arun + >>> from libtmux.experimental.ops._types import PaneId + >>> engine = AsyncSubprocessEngine() + >>> hasattr(engine, "run") and hasattr(engine, "run_batch") + True + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.server_args = tuple(server_args) + self._resolved_bin: str | None = None + + def _resolve_bin(self) -> str: + """Return the tmux binary path, memoized for the engine instance.""" + if self.tmux_bin is not None: + return self.tmux_bin + if self._resolved_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_bin = resolved + return self._resolved_bin + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command asynchronously and return its result.""" + tmux_bin = request.tmux_bin or self._resolve_bin() + cmd = [tmux_bin, *self.server_args, *request.args] + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + try: + stdout_bytes, stderr_bytes = await process.communicate() + except asyncio.CancelledError: + process.terminate() + await process.wait() + raise + + stdout = stdout_bytes.decode(errors="backslashreplace") + stderr = stderr_bytes.decode(errors="backslashreplace") + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = [line for line in stderr.split("\n") if line] + + if "has-session" in cmd and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=process.returncode if process.returncode is not None else -1, + ) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests sequentially (preserving tmux command ordering).""" + return [await self.run(req) for req in requests] + + @classmethod + def for_server(cls, server: t.Any) -> AsyncSubprocessEngine: + """Build an async engine bound to a live :class:`libtmux.Server`'s socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls(tmux_bin=getattr(server, "tmux_bin", None), server_args=server_args) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index c39f871d5..0e069dbd6 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -41,6 +41,7 @@ WindowId, render_target, ) +from libtmux.experimental.ops.catalog import CatalogEntry, catalog from libtmux.experimental.ops.exc import ( DuplicateOperation, OperationError, @@ -50,6 +51,7 @@ ) from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.plan import LazyPlan, PlanResult from libtmux.experimental.ops.registry import ( OperationRegistry, OpSpec, @@ -74,16 +76,19 @@ __all__ = ( "CapturePane", "CapturePaneResult", + "CatalogEntry", "ClientName", "DuplicateOperation", "Effects", "IndexRef", + "LazyPlan", "NameRef", "OpSpec", "Operation", "OperationError", "OperationRegistry", "PaneId", + "PlanResult", "Result", "Safety", "Scope", @@ -101,6 +106,7 @@ "VersionUnsupported", "WindowId", "arun", + "catalog", "operation_from_dict", "operation_to_dict", "register", diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py new file mode 100644 index 000000000..b0a272e67 --- /dev/null +++ b/src/libtmux/experimental/ops/catalog.py @@ -0,0 +1,91 @@ +"""A registry-driven operation catalog (the documentation data source). + +:func:`catalog` walks the operation registry and emits one structured +:class:`CatalogEntry` per operation -- scope, version gates, effects, safety, +result type, and a one-line summary. This is the data a Sphinx ``tmuxop`` domain +directive renders into the operation reference, so the registry is the single +source of truth for both runtime *and* docs and the two cannot drift apart. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.registry import registry as default_registry + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Safety, Scope + from libtmux.experimental.ops.registry import OperationRegistry + + +def _summary(doc: str | None) -> str: + """Return the first non-empty line of a docstring.""" + if not doc: + return "" + for line in doc.strip().splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +@dataclass(frozen=True) +class CatalogEntry: + """One operation's catalog record, derived from its registry spec.""" + + kind: str + command: str + scope: Scope + safety: Safety + primitive: bool + chainable: bool + result_type: str + min_version: str | None + flag_version_gates: dict[str, str] + effects: dict[str, t.Any] + summary: str + + +def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: + """Build catalog entries for every registered operation, sorted by kind. + + Parameters + ---------- + registry : OperationRegistry or None + The registry to read; defaults to the process-wide registry. + + Returns + ------- + list[CatalogEntry] + + Examples + -------- + >>> from libtmux.experimental.ops import catalog + >>> entries = catalog() + >>> [entry.kind for entry in entries] + ['capture_pane', 'select_layout', 'send_keys', 'split_window'] + >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") + >>> capture.scope, capture.safety, capture.result_type + ('pane', 'readonly', 'CapturePaneResult') + >>> capture.flag_version_gates["trim_trailing"] + '3.4' + """ + reg = registry if registry is not None else default_registry + return [ + CatalogEntry( + kind=spec.kind, + command=spec.command, + scope=spec.scope, + safety=spec.safety, + primitive=spec.primitive, + chainable=spec.chainable, + result_type=spec.result_cls.__name__, + min_version=spec.min_version, + flag_version_gates=dict(spec.flag_version_map), + effects=dataclasses.asdict(spec.effects), + summary=_summary(spec.operation_cls.__doc__), + ) + for spec in reg.list() + ] diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py new file mode 100644 index 000000000..50d07d10a --- /dev/null +++ b/src/libtmux/experimental/ops/plan.py @@ -0,0 +1,200 @@ +"""Lazy, deferred-resolution plans over the typed operation spine. + +A :class:`LazyPlan` records operations without touching tmux, so a plan can be +inspected, serialized, and executed later. Operations may target the *result of +an earlier operation* via a :class:`~._types.SlotRef` (e.g. send keys to the pane +a split is about to create); the plan resolves those references from captured ids +at execution time. + +Resolution is a sans-I/O generator -- the same yield-operation / resume-with- +result trampoline the chainable-commands prototype uses. The sync +:meth:`LazyPlan.execute` and async :meth:`LazyPlan.aexecute` drivers differ only +in ``run(...)`` versus ``await arun(...)``; the resolution logic is written once. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops._types import ( + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict + +if t.TYPE_CHECKING: + from collections.abc import Generator, Iterator + + from typing_extensions import Self + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +def _target_from_id(value: str) -> Target: + """Map a captured concrete id back to its typed target.""" + if value.startswith("%"): + return PaneId(value) + if value.startswith("@"): + return WindowId(value) + if value.startswith("$"): + return SessionId(value) + return Special(value) + + +def _resolve( + operation: Operation[t.Any], + bindings: dict[int, str], +) -> Operation[t.Any]: + """Substitute a :class:`SlotRef` target with a captured concrete id.""" + target = operation.target + if not isinstance(target, SlotRef): + return operation + try: + concrete = bindings[target.slot] + target.suffix + except KeyError as error: + msg = ( + f"slot {target.slot} has no captured id yet; a plan step can only " + f"target an earlier step that creates an object" + ) + raise OperationError(msg) from error + return dataclasses.replace(operation, target=_target_from_id(concrete)) + + +@dataclass(frozen=True) +class PlanResult: + """The outcome of executing a :class:`LazyPlan`. + + Parameters + ---------- + results : tuple[Result, ...] + One result per recorded operation, in order. + bindings : dict[int, str] + Maps a creating step's index to the concrete id it produced. + """ + + results: tuple[Result, ...] + bindings: dict[int, str] = field(default_factory=dict) + + @property + def ok(self) -> bool: + """Whether every step completed successfully.""" + return all(result.ok for result in self.results) + + def raise_for_status(self) -> Self: + """Raise on the first failed step; return ``self`` when all are OK.""" + for result in self.results: + result.raise_for_status() + return self + + +class LazyPlan: + """Record operations now; resolve refs and execute them later. + + Examples + -------- + Build a plan that splits a window then types into the *new* pane, and run it + against the in-memory concrete engine (no tmux required): + + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import WindowId + >>> from libtmux.experimental.engines import ConcreteEngine + >>> plan = LazyPlan() + >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) + >>> _ = plan.add(SendKeys(target=pane, keys="vim", enter=True)) + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.bindings + {0: '%1'} + >>> outcome.results[1].argv + ('send-keys', '-t', '%1', 'vim', 'Enter') + """ + + def __init__(self) -> None: + self._operations: list[Operation[t.Any]] = [] + + def add(self, operation: Operation[t.Any]) -> SlotRef: + """Record an operation; return a :class:`SlotRef` to its eventual id. + + The returned ref can be used as the ``target`` of a later operation to + address the object this one creates. + """ + self._operations.append(operation) + return SlotRef(len(self._operations) - 1) + + @property + def operations(self) -> tuple[Operation[t.Any], ...]: + """The recorded operations, in order.""" + return tuple(self._operations) + + def __len__(self) -> int: + """Return the number of recorded operations.""" + return len(self._operations) + + def __iter__(self) -> Iterator[Operation[t.Any]]: + """Iterate recorded operations in order.""" + return iter(self._operations) + + def to_list(self) -> list[dict[str, t.Any]]: + """Serialize the whole plan to a list of plain operation dicts.""" + return [operation_to_dict(operation) for operation in self._operations] + + @classmethod + def from_list(cls, data: t.Sequence[t.Mapping[str, t.Any]]) -> LazyPlan: + """Reconstruct a plan from :meth:`to_list` output.""" + plan = cls() + plan._operations = [operation_from_dict(item) for item in data] + return plan + + def _drive( + self, + version: str | None, + ) -> Generator[Operation[t.Any], Result, PlanResult]: + """Sans-I/O resolution core: yield a resolved op, resume with its result.""" + bindings: dict[int, str] = {} + results: list[Result] = [] + for index, operation in enumerate(self._operations): + result = yield _resolve(operation, bindings) + results.append(result) + created = getattr(result, "new_pane_id", None) + if created is not None: + bindings[index] = created + return PlanResult(tuple(results), bindings) + + def execute( + self, + engine: TmuxEngine, + *, + version: str | None = None, + ) -> PlanResult: + """Resolve and execute the plan synchronously.""" + gen = self._drive(version) + try: + operation = next(gen) + while True: + operation = gen.send(run(operation, engine, version=version)) + except StopIteration as stop: + return t.cast("PlanResult", stop.value) + + async def aexecute( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + ) -> PlanResult: + """Resolve and execute the plan asynchronously (same resolution core).""" + gen = self._drive(version) + try: + operation = next(gen) + while True: + operation = gen.send(await arun(operation, engine, version=version)) + except StopIteration as stop: + return t.cast("PlanResult", stop.value) diff --git a/tests/experimental/contract/test_async_engine.py b/tests/experimental/contract/test_async_engine.py new file mode 100644 index 000000000..1c37eb07e --- /dev/null +++ b/tests/experimental/contract/test_async_engine.py @@ -0,0 +1,50 @@ +"""Async engine against a real tmux server, and parity with the classic engine. + +Uses :func:`asyncio.run` to drive :func:`arun` so the async transport is +exercised end to end without a pytest-asyncio dependency. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import AsyncSubprocessEngine, SubprocessEngine +from libtmux.experimental.ops import SplitWindow, arun, run +from libtmux.experimental.ops._types import WindowId +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_async_split_creates_real_pane(session: Session) -> None: + """An async split returns a typed result whose new pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + engine = AsyncSubprocessEngine.for_server(server) + + result = asyncio.run(arun(SplitWindow(target=WindowId(window.window_id)), engine)) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_async_sync_parity(session: Session) -> None: + """The async and sync classic engines agree on result type and argv.""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + sync_result = run(operation, SubprocessEngine.for_server(server)) + async_result = asyncio.run( + arun(operation, AsyncSubprocessEngine.for_server(server)), + ) + + assert type(sync_result) is type(async_result) is SplitWindowResult + assert sync_result.argv == async_result.argv == operation.render() + assert sync_result.ok and async_result.ok diff --git a/tests/experimental/ops/test_catalog.py b/tests/experimental/ops/test_catalog.py new file mode 100644 index 000000000..a62e3ce34 --- /dev/null +++ b/tests/experimental/ops/test_catalog.py @@ -0,0 +1,35 @@ +"""Tests for the registry-driven operation catalog.""" + +from __future__ import annotations + +from libtmux.experimental.ops import catalog, registry + + +def test_catalog_covers_every_registered_operation() -> None: + """The catalog has exactly one entry per registered kind.""" + entries = catalog() + assert [entry.kind for entry in entries] == sorted(registry.kinds()) + + +def test_catalog_entry_mirrors_spec() -> None: + """A catalog entry reflects the operation's registry metadata.""" + entries = {entry.kind: entry for entry in catalog()} + + split = entries["split_window"] + assert split.command == "split-window" + assert split.scope == "window" + assert split.result_type == "SplitWindowResult" + assert split.effects["creates"] == "pane" + assert split.flag_version_gates == {"environment": "3.0"} + assert split.summary + + capture = entries["capture_pane"] + assert capture.safety == "readonly" + assert capture.effects["read_only"] is True + assert capture.flag_version_gates["trim_trailing"] == "3.4" + + +def test_catalog_summary_is_first_docstring_line() -> None: + """Each entry's summary is the operation's one-line description.""" + entries = {entry.kind: entry for entry in catalog()} + assert entries["send_keys"].summary.startswith("Send keys") diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py new file mode 100644 index 000000000..bbba688e7 --- /dev/null +++ b/tests/experimental/ops/test_plan.py @@ -0,0 +1,94 @@ +"""Tests for the lazy plan and deferred-ref resolution.""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + LazyPlan, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + + +class _AsyncConcreteEngine: + """Async wrapper over :class:`ConcreteEngine` for plan.aexecute tests.""" + + def __init__(self) -> None: + self._inner = ConcreteEngine() + + async def run(self, request: CommandRequest) -> CommandResult: + """Run synchronously under the hood; awaitable for the async driver.""" + return self._inner.run(request) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute each request in order.""" + return [await self.run(req) for req in requests] + + +def test_plan_records_without_executing() -> None: + """Building a plan touches no engine; it just records operations.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=PaneId("%1"), keys="x")) + assert len(plan) == 2 + assert [op.kind for op in plan] == ["split_window", "send_keys"] + + +def test_plan_resolves_forward_ref() -> None: + """A later step can target the pane an earlier split creates.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + outcome = plan.execute(ConcreteEngine()) + + assert outcome.bindings == {0: "%1"} + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + assert outcome.ok + + +def test_plan_aexecute_matches_execute() -> None: + """The async driver resolves refs identically to the sync driver.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + outcome = asyncio.run(plan.aexecute(_AsyncConcreteEngine())) + + assert outcome.bindings == {0: "%1"} + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + + +def test_plan_serialization_round_trip() -> None: + """A plan (including its SlotRef targets) survives a list round-trip.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="x")) + + revived = LazyPlan.from_list(plan.to_list()) + + assert revived.operations == plan.operations + + +def test_plan_unresolvable_ref_fails_closed() -> None: + """Targeting a step that creates nothing raises a clear error.""" + plan = LazyPlan() + typed = plan.add(SendKeys(target=PaneId("%1"), keys="x")) # creates no id + plan.add(SendKeys(target=typed, keys="y")) + with pytest.raises(OperationError, match="no captured id"): + plan.execute(ConcreteEngine()) From 4478d364be582c52f648593b61135114fd55a1c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:18:16 -0500 Subject: [PATCH 004/154] Ops(feat): Add persistent control-mode engine why: Proves control mode is just another engine returning the same typed result (issue 689 phase 4) -- an operation run over a persistent tmux -C connection is indistinguishable, at the result level, from one run via fork-per-call subprocess. what: - engines.control_mode: ControlModeEngine over one persistent tmux -C connection; run_batch pipelines commands and parses each command's %begin/%end/%error block into a CommandResult; selectors-based nonblocking reads with timeout; startup-ACK discard; lifecycle via close()/context manager (lock-guarded teardown) - engines.control_mode: I/O-free ControlModeParser, unit-testable without tmux, adapted from the chain runner + protocol-engines parser - register control_mode in the engine registry and export it - tests: pure parser tests + real-tmux contract (split creates a real pane, batched commands, control-vs-concrete parity) --- src/libtmux/experimental/engines/__init__.py | 8 + .../experimental/engines/control_mode.py | 417 ++++++++++++++++++ src/libtmux/experimental/engines/registry.py | 2 + .../contract/test_control_engine.py | 77 ++++ tests/experimental/ops/test_control_parser.py | 49 ++ 5 files changed, 553 insertions(+) create mode 100644 src/libtmux/experimental/engines/control_mode.py create mode 100644 tests/experimental/contract/test_control_engine.py create mode 100644 tests/experimental/ops/test_control_parser.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 8b23b9b20..a8528c9d9 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -22,6 +22,11 @@ TmuxEngine, ) from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.control_mode import ( + ControlModeEngine, + ControlModeError, + ControlModeParser, +) from libtmux.experimental.engines.registry import ( available_engines, create_engine, @@ -35,6 +40,9 @@ "CommandRequest", "CommandResult", "ConcreteEngine", + "ControlModeEngine", + "ControlModeError", + "ControlModeParser", "EngineKind", "EngineSpec", "SubprocessEngine", diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py new file mode 100644 index 000000000..ae113b672 --- /dev/null +++ b/src/libtmux/experimental/engines/control_mode.py @@ -0,0 +1,417 @@ +"""A persistent control-mode (``tmux -C``) engine. + +Holds one long-lived ``tmux -C`` connection and pipelines command lines over it, +parsing each command's ``%begin``/``%end``/``%error`` block back into a +:class:`~.base.CommandResult`. Because it returns the same typed result the +subprocess engine does, an operation run through control mode is +indistinguishable -- at the result level -- from one run through a fork-per-call +subprocess. Adapted from the chainable-commands control runner and the +``libtmux-protocol-engines`` parser. + +The parser (:class:`ControlModeParser`) is I/O-free: it consumes bytes and emits +parsed blocks, so it is unit-testable without spawning tmux. ``run_batch`` writes +all command lines at once and collects one block per command, which is the +control engine's advantage over per-call subprocess startup. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import logging +import os +import selectors +import shlex +import shutil +import subprocess +import threading +import time +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + +logger = logging.getLogger(__name__) + +_BEGIN_PREFIX = b"%begin " +_END_PREFIX = b"%end " +_ERROR_PREFIX = b"%error " +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_GRACEFUL_EXIT_TIMEOUT = 0.5 +_TERMINATE_TIMEOUT = 1.0 +_GUARD_MIN_PARTS = 3 + + +class ControlModeError(exc.LibTmuxException): + """The control-mode engine failed (connection, protocol, or timeout).""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ControlModeBlock: + """One ``%begin``/``%end`` or ``%error`` control-mode command block.""" + + number: int + flags: int + is_error: bool + body: tuple[bytes, ...] + + +@dataclasses.dataclass(slots=True) +class _PendingBlock: + number: int + flags: int + body: list[bytes] + + +class ControlModeParser: + r"""I/O-free parser for the command-block subset of control mode. + + Examples + -------- + >>> parser = ControlModeParser() + >>> parser.feed(b"%begin 1 1 1\nhello\n%end 1 1 1\n") + >>> [block.body for block in parser.blocks()] + [(b'hello',)] + >>> parser.feed(b"%begin 2 2 1\nboom\n%error 2 2 1\n") + >>> block = parser.blocks()[0] + >>> block.is_error, block.body + (True, (b'boom',)) + """ + + __slots__ = ("_blocks", "_buffer", "_pending") + + def __init__(self) -> None: + self._buffer = bytearray() + self._blocks: list[ControlModeBlock] = [] + self._pending: _PendingBlock | None = None + + def feed(self, data: bytes) -> None: + """Consume bytes from tmux stdout.""" + if not data: + return + self._buffer.extend(data) + while True: + newline = self._buffer.find(b"\n") + if newline < 0: + return + line = bytes(self._buffer[:newline]) + del self._buffer[: newline + 1] + self._handle_line(line) + + def blocks(self) -> list[ControlModeBlock]: + """Drain parsed command blocks.""" + blocks, self._blocks = self._blocks, [] + return blocks + + def _handle_line(self, line: bytes) -> None: + if self._pending is not None: + if _matches_pending_close(line, self._pending.number): + self._close_block(line) + return + self._pending.body.append(line) + return + if line.startswith(_BEGIN_PREFIX): + self._open_block(line) + + def _open_block(self, line: bytes) -> None: + number, flags = _parse_guard(line, _BEGIN_PREFIX) + if number is None: + return + self._pending = _PendingBlock(number=number, flags=flags or 0, body=[]) + + def _close_block(self, line: bytes) -> None: + pending = self._pending + self._pending = None + if pending is None: + return + self._blocks.append( + ControlModeBlock( + number=pending.number, + flags=pending.flags, + is_error=line.startswith(_ERROR_PREFIX), + body=tuple(pending.body), + ), + ) + + +class ControlModeEngine: + """Execute tmux commands over one persistent ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to wait for a batch of result blocks before raising. + + Notes + ----- + The connection is opened lazily on first use. Call :meth:`close` (or use the + engine as a context manager) to tear it down. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self.tmux_bin = tmux_bin + self.server_args = tuple(server_args) + self.timeout = timeout + self._lock = threading.Lock() + self._parser = ControlModeParser() + self._proc: subprocess.Popen[bytes] | None = None + self._selector: selectors.DefaultSelector | None = None + self._startup_ack_pending = True + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command over the control connection.""" + return self.run_batch([request])[0] + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Pipeline a batch of commands; one result block per request.""" + if not requests: + return [] + rendered = [tuple(req.args) for req in requests] + with self._lock: + self._ensure_started() + payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + self._write(payload) + blocks = self._read_blocks(len(rendered)) + return [ + _result_from_block(block, argv) + for block, argv in zip(blocks, rendered, strict=True) + ] + + def close(self) -> None: + """Tear down the control-mode subprocess (lock-guarded).""" + with self._lock: + proc = self._proc + selector = self._selector + self._proc = None + self._selector = None + self._parser = ControlModeParser() + self._startup_ack_pending = True + if selector is not None: + with contextlib.suppress(Exception): + selector.close() + if proc is None: + return + if proc.stdin is not None and not proc.stdin.closed: + with contextlib.suppress(OSError): + proc.stdin.close() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + with contextlib.suppress(OSError): + proc.terminate() + if not _wait_for_exit(proc, _TERMINATE_TIMEOUT): + with contextlib.suppress(OSError): + proc.kill() + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=_TERMINATE_TIMEOUT) + + def __enter__(self) -> ControlModeEngine: + """Return this engine.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Tear down the connection on context exit.""" + self.close() + + def _ensure_started(self) -> None: + if self._proc is not None: + if self._proc.poll() is not None: + msg = f"tmux -C exited with code {self._proc.returncode}" + raise ControlModeError(msg) + return + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + if proc.stdin is None or proc.stdout is None or proc.stderr is None: + with contextlib.suppress(OSError): + proc.kill() + msg = "tmux -C subprocess pipes are unavailable" + raise ControlModeError(msg) + os.set_blocking(proc.stdout.fileno(), False) + os.set_blocking(proc.stderr.fileno(), False) + selector = selectors.DefaultSelector() + selector.register(proc.stdout, selectors.EVENT_READ, "stdout") + selector.register(proc.stderr, selectors.EVENT_READ, "stderr") + self._proc = proc + self._selector = selector + self._startup_ack_pending = True + + def _write(self, payload: bytes) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + try: + proc.stdin.write(payload) + proc.stdin.flush() + except (BrokenPipeError, OSError) as error: + msg = f"tmux control-mode write failed: {error}" + raise ControlModeError(msg) from error + + def _read_blocks(self, count: int) -> list[ControlModeBlock]: + proc = self._proc + selector = self._selector + if proc is None or selector is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + blocks: list[ControlModeBlock] = [] + deadline = time.monotonic() + self.timeout + while len(blocks) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + msg = ( + f"tmux control-mode timed out after {self.timeout}s " + f"waiting for {count} result blocks" + ) + raise ControlModeError(msg) + ready = selector.select(min(remaining, 0.1)) + if not ready: + if proc.poll() is not None: + msg = f"tmux -C exited with code {proc.returncode}" + raise ControlModeError(msg) + continue + for key, _events in ready: + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() + for block in self._parser.blocks(): + if self._startup_ack_pending: + self._startup_ack_pending = False + if block.flags == 0: + continue + blocks.append(block) + if len(blocks) == count: + break + return blocks + + def _read_stdout(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + chunk = os.read(proc.stdout.fileno(), _READ_CHUNK) + except BlockingIOError: + return + except OSError as error: + msg = f"tmux control-mode stdout read failed: {error}" + raise ControlModeError(msg) from error + if not chunk: + msg = "tmux -C closed stdout" + raise ControlModeError(msg) + self._parser.feed(chunk) + + def _read_stderr(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + try: + chunk = os.read(proc.stderr.fileno(), _READ_CHUNK) + except (BlockingIOError, OSError): + return + if chunk: + logger.debug( + "tmux control-mode stderr", + extra={"tmux_stderr": [chunk.decode(errors="replace")]}, + ) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> ControlModeEngine: + """Build a control-mode engine bound to a live server's socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls( + tmux_bin=getattr(server, "tmux_bin", None), + server_args=server_args, + **kwargs, + ) + + +def _wait_for_exit(proc: subprocess.Popen[bytes], timeout: float) -> bool: + """Wait up to *timeout* for the process to exit; return whether it did.""" + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return False + return True + + +def _parse_guard(line: bytes, prefix: bytes) -> tuple[int | None, int | None]: + """Parse a ``%begin``/``%end``/``%error`` guard's number and flags.""" + parts = line[len(prefix) :].split() + if len(parts) < _GUARD_MIN_PARTS: + return (None, None) + try: + return (int(parts[1]), int(parts[2])) + except ValueError: + return (None, None) + + +def _matches_pending_close(line: bytes, pending_number: int) -> bool: + """Whether *line* closes the pending block numbered *pending_number*.""" + for prefix in (_END_PREFIX, _ERROR_PREFIX): + if line.startswith(prefix): + number, _flags = _parse_guard(line, prefix) + return number == pending_number + return False + + +def _result_from_block( + block: ControlModeBlock, + argv: tuple[str, ...], +) -> CommandResult: + """Convert a parsed control-mode block into a :class:`CommandResult`.""" + lines = tuple(line.decode(errors="replace") for line in block.body) + cmd = ("tmux", "-C", *argv) + if block.is_error: + return CommandResult(cmd=cmd, stdout=(), stderr=_trim(lines), returncode=1) + return CommandResult(cmd=cmd, stdout=_trim(lines), stderr=(), returncode=0) + + +def _trim(lines: tuple[str, ...]) -> tuple[str, ...]: + """Drop trailing blank lines.""" + trimmed = list(lines) + while trimmed and not trimmed[-1].strip(): + trimmed.pop() + return tuple(trimmed) diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py index 0a7fb10d5..2809eb356 100644 --- a/src/libtmux/experimental/engines/registry.py +++ b/src/libtmux/experimental/engines/registry.py @@ -13,6 +13,7 @@ from libtmux import exc from libtmux.experimental.engines.base import EngineKind from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.control_mode import ControlModeEngine from libtmux.experimental.engines.subprocess import SubprocessEngine if t.TYPE_CHECKING: @@ -66,3 +67,4 @@ def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: register_engine(EngineKind.SUBPROCESS.value, SubprocessEngine) register_engine(EngineKind.CONCRETE.value, ConcreteEngine) +register_engine(EngineKind.CONTROL_MODE.value, ControlModeEngine) diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py new file mode 100644 index 000000000..07966326e --- /dev/null +++ b/tests/experimental/contract/test_control_engine.py @@ -0,0 +1,77 @@ +"""Control-mode engine against a real tmux server, and parity with concrete. + +Exercises the persistent ``tmux -C`` engine end to end and asserts it returns +the same typed result shape the other engines do. The engine is used as a +context manager so the control connection is always torn down. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, ControlModeEngine +from libtmux.experimental.ops import ( + CapturePane, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.results import ( + CapturePaneResult, + Result, + SplitWindowResult, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_control_split_creates_real_pane(session: Session) -> None: + """A control-mode split returns a typed result whose pane really exists.""" + server = session.server + window = session.active_window + assert window.window_id is not None + + with ControlModeEngine.for_server(server) as engine: + result = run(SplitWindow(target=WindowId(window.window_id)), engine) + + assert isinstance(result, SplitWindowResult) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_control_batches_multiple_commands(session: Session) -> None: + """run_batch pipelines several commands over one connection, one result each.""" + server = session.server + pane = session.active_pane + window = session.active_window + assert pane is not None + assert pane.pane_id is not None + assert window.window_id is not None + + with ControlModeEngine.for_server(server) as engine: + sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) + captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert type(sent) is Result + assert sent.ok + assert isinstance(captured, CapturePaneResult) + assert captured.ok + + +def test_control_concrete_parity(session: Session) -> None: + """Control-mode and concrete engines agree on result type and argv.""" + server = session.server + window = session.active_window + assert window.window_id is not None + operation = SplitWindow(target=WindowId(window.window_id)) + + with ControlModeEngine.for_server(server) as engine: + control = run(operation, engine) + concrete = run(operation, ConcreteEngine()) + + assert type(control) is type(concrete) is SplitWindowResult + assert control.argv == concrete.argv == operation.render() + assert control.ok and concrete.ok diff --git a/tests/experimental/ops/test_control_parser.py b/tests/experimental/ops/test_control_parser.py new file mode 100644 index 000000000..fc29bb9a4 --- /dev/null +++ b/tests/experimental/ops/test_control_parser.py @@ -0,0 +1,49 @@ +"""Pure (no-tmux) tests for the control-mode block parser.""" + +from __future__ import annotations + +from libtmux.experimental.engines import ControlModeParser + + +def test_parses_success_block() -> None: + """A ``%begin``/``%end`` pair yields one non-error block with its body.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nhello\nworld\n%end 1 1 1\n") + blocks = parser.blocks() + assert len(blocks) == 1 + assert not blocks[0].is_error + assert blocks[0].body == (b"hello", b"world") + + +def test_parses_error_block() -> None: + """A ``%error`` close marks the block as an error.""" + parser = ControlModeParser() + parser.feed(b"%begin 2 5 1\ncan't find pane\n%error 2 5 1\n") + block = parser.blocks()[0] + assert block.is_error + assert block.body == (b"can't find pane",) + + +def test_handles_split_chunks() -> None: + """Bytes split mid-line across feeds still parse into one block.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nhel") + parser.feed(b"lo\n%end 1 1 1\n") + assert parser.blocks()[0].body == (b"hello",) + + +def test_blocks_drains() -> None: + """``blocks`` returns parsed blocks once, then is empty.""" + parser = ControlModeParser() + parser.feed(b"%begin 1 1 1\nx\n%end 1 1 1\n") + assert len(parser.blocks()) == 1 + assert parser.blocks() == [] + + +def test_ignores_noise_outside_blocks() -> None: + """Notification lines outside a block are ignored by the command parser.""" + parser = ControlModeParser() + parser.feed(b"%output %1 hi\n%begin 1 1 1\nok\n%end 1 1 1\n") + blocks = parser.blocks() + assert len(blocks) == 1 + assert blocks[0].body == (b"ok",) From b1dcf85235908d3591116524c46d45724497736f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:23:02 -0500 Subject: [PATCH 005/154] Ops(feat): Add eager + lazy pane facades over the spine why: Demonstrates the "mode lives in the type" model from issue 689 -- EagerPane.split() returns a live EagerPane while LazyPane.split() returns a deferred LazyPane, each a single statically-known return type, both backed by the same SplitWindow operation. One Pane class with a runtime-bound engine could not type these return values distinctly. what: - facade.pane.EagerPane: executes immediately, returns live handles (split -> EagerPane), typed results for capture/send_keys - facade.pane.LazyPane: records into a LazyPlan, returns deferred handles (split -> LazyPane bound to the new pane's SlotRef), chainable - seed of the wider Server/Session/Window/Pane/Client x mode matrix - tests: eager live handles, lazy deferral + forward-ref resolution, and same-operation-backs-both-facades parity --- src/libtmux/experimental/facade/__init__.py | 17 ++ src/libtmux/experimental/facade/pane.py | 165 ++++++++++++++++++ tests/experimental/facade/__init__.py | 3 + tests/experimental/facade/test_pane_facade.py | 62 +++++++ 4 files changed, 247 insertions(+) create mode 100644 src/libtmux/experimental/facade/__init__.py create mode 100644 src/libtmux/experimental/facade/pane.py create mode 100644 tests/experimental/facade/__init__.py create mode 100644 tests/experimental/facade/test_pane_facade.py diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/facade/__init__.py new file mode 100644 index 000000000..965b8f4da --- /dev/null +++ b/src/libtmux/experimental/facade/__init__.py @@ -0,0 +1,17 @@ +"""Engine-typed facades over the operation spine. + +The execution mode lives in the facade *type* (eager vs lazy vs async vs +control), so each method has one statically-known return type, while the +operation definitions stay shared. This package currently ships the pane-scope +seed (:class:`EagerPane`, :class:`LazyPane`); the full Server/Session/Window/ +Pane/Client matrix is described in issue 689. +""" + +from __future__ import annotations + +from libtmux.experimental.facade.pane import EagerPane, LazyPane + +__all__ = ( + "EagerPane", + "LazyPane", +) diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/facade/pane.py new file mode 100644 index 000000000..47e803791 --- /dev/null +++ b/src/libtmux/experimental/facade/pane.py @@ -0,0 +1,165 @@ +"""Pane-scope facades demonstrating "mode lives in the type". + +Two thin facades over the *same* operation spine show why the execution mode +belongs in the class rather than a runtime flag: + +- :class:`EagerPane` executes immediately and returns *live* handles + (``split()`` -> :class:`EagerPane`), so its return types are concrete. +- :class:`LazyPane` records into a :class:`~libtmux.experimental.ops.plan.LazyPlan` + and returns *deferred* handles (``split()`` -> :class:`LazyPane`), executing + only when the plan runs. + +Each ``split()`` therefore has exactly one statically-known return type -- a +single ``Pane`` class with a runtime engine attribute could not express that. +The same :class:`~libtmux.experimental.ops.SplitWindow` operation backs both; +only the facade differs. This is the seed of the wider facade matrix +(``AsyncPane``, ``LazyControlWindow``, ...) described in issue 689. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops import ( + CapturePane, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import PaneId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import CapturePaneResult, Result + + +@dataclass(frozen=True) +class EagerPane: + """A live pane handle bound to an engine; methods execute immediately. + + Parameters + ---------- + engine : TmuxEngine + The engine commands run through. + pane_id : str + The concrete tmux pane id (``%N``). + version : str or None + tmux version to render against. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> pane = EagerPane(ConcreteEngine(), "%0") + >>> child = pane.split(horizontal=True) + >>> child.pane_id + '%1' + >>> isinstance(pane.capture().lines, tuple) + True + """ + + engine: TmuxEngine + pane_id: str + version: str | None = None + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> EagerPane: + """Split this pane and return a live handle to the new pane.""" + result = run( + SplitWindow( + target=PaneId(self.pane_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + + def send_keys(self, keys: str, *, enter: bool = False) -> Result: + """Send keys to this pane; return the typed result.""" + return run( + SendKeys(target=PaneId(self.pane_id), keys=keys, enter=enter), + self.engine, + version=self.version, + ) + + def capture( + self, *, start: int | None = None, end: int | None = None + ) -> CapturePaneResult: + """Capture this pane's contents; return the typed result.""" + return run( + CapturePane(target=PaneId(self.pane_id), start=start, end=end), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyPane: + """A deferred pane handle; methods record into a plan instead of running. + + Parameters + ---------- + plan : LazyPlan + The plan operations are recorded into. + ref : Target + The target this handle addresses (a concrete id, or a SlotRef for a + pane created earlier in the plan). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import PaneId + >>> plan = LazyPlan() + >>> root = LazyPane(plan, PaneId("%0")) + >>> child = root.split() + >>> _ = child.send_keys("vim", enter=True) + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.results[0].new_pane_id + '%1' + >>> outcome.results[1].argv + ('send-keys', '-t', '%1', 'vim', 'Enter') + """ + + plan: LazyPlan + ref: Target + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> LazyPane: + """Record a split; return a deferred handle to the pane it will create.""" + slot = self.plan.add( + SplitWindow( + target=self.ref, + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + ) + return LazyPane(self.plan, slot) + + def send_keys(self, keys: str, *, enter: bool = False) -> LazyPane: + """Record a send-keys against this handle; return self for chaining.""" + self.plan.add(SendKeys(target=self.ref, keys=keys, enter=enter)) + return self + + def capture(self, *, start: int | None = None, end: int | None = None) -> LazyPane: + """Record a capture against this handle; return self for chaining.""" + self.plan.add(CapturePane(target=self.ref, start=start, end=end)) + return self diff --git a/tests/experimental/facade/__init__.py b/tests/experimental/facade/__init__.py new file mode 100644 index 000000000..a260ebd4d --- /dev/null +++ b/tests/experimental/facade/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.facade.""" + +from __future__ import annotations diff --git a/tests/experimental/facade/test_pane_facade.py b/tests/experimental/facade/test_pane_facade.py new file mode 100644 index 000000000..d5508691a --- /dev/null +++ b/tests/experimental/facade/test_pane_facade.py @@ -0,0 +1,62 @@ +"""Tests for the eager and lazy pane facades.""" + +from __future__ import annotations + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.facade import EagerPane, LazyPane +from libtmux.experimental.ops import LazyPlan +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.results import SplitWindowResult + + +def test_eager_split_returns_live_pane() -> None: + """EagerPane.split executes now and returns a live EagerPane handle.""" + pane = EagerPane(ConcreteEngine(), "%0") + child = pane.split(horizontal=True) + assert isinstance(child, EagerPane) + assert child.pane_id == "%1" + + +def test_eager_capture_and_send() -> None: + """Eager capture/send-keys return typed results.""" + engine = ConcreteEngine(capture_lines=("a", "b")) + pane = EagerPane(engine, "%1") + assert pane.capture().lines == ("a", "b") + assert pane.send_keys("echo hi", enter=True).ok + + +def test_lazy_split_returns_deferred_handle_and_defers() -> None: + """LazyPane.split records into a plan and returns a deferred LazyPane.""" + plan = LazyPlan() + root = LazyPane(plan, PaneId("%0")) + child = root.split() + assert isinstance(child, LazyPane) + assert len(plan) == 1 # recorded, not executed + + +def test_lazy_chain_resolves_forward_ref_on_execute() -> None: + """A lazy chain resolves the new pane's id when the plan runs.""" + plan = LazyPlan() + root = LazyPane(plan, PaneId("%0")) + root.split().send_keys("vim", enter=True) + + outcome = plan.execute(ConcreteEngine()) + + first = outcome.results[0] + assert isinstance(first, SplitWindowResult) + assert first.new_pane_id == "%1" + assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") + + +def test_same_operation_backs_both_facades() -> None: + """Eager and lazy facades render the identical underlying operation argv.""" + eager_engine = ConcreteEngine() + eager = EagerPane(eager_engine, "%0") + # Capture the eager split's rendered argv via the engine-independent op. + plan = LazyPlan() + LazyPane(plan, PaneId("%0")).split(horizontal=True) + lazy_argv = plan.operations[0].render() + + eager_child = eager.split(horizontal=True) + assert eager_child.pane_id # executed + assert lazy_argv == ("split-window", "-t", "%0", "-h", "-P", "-F", "#{pane_id}") From ade0448318e2b5cfbee2316b34ad6abed8ff4cf6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 09:57:58 -0500 Subject: [PATCH 006/154] Ops(feat): Add async control-mode + concrete engines why: Closes the two async gaps from issue 689: control mode and concrete had no async sibling. The async control engine is the one async engine that earns its place -- it adds an event stream subprocess cannot -- and prior libtmux/mux control-mode work (surfaced across agent histories via agentgrep, plus the asyncio-2 branches) shaped its correlation design. what: - engines.async_control_mode: AsyncControlModeEngine over a persistent tmux -C (create_subprocess_exec + one reader task). FIFO future correlation with skip-when-empty so unsolicited %begin blocks (hook- triggered commands and the startup ACK) never desync results; the startup ACK is consumed synchronously in start() to close the correlation race our whole-block parser would otherwise have. DEAD state fails pending commands on reader EOF/error. Cancellation via asyncio.wait_for (3.10 floor: no asyncio.timeout/TaskGroup). Bounded subscribe() notification stream with drop-counting. for_server() helper - engines.control_mode: ControlModeParser now surfaces bare %-notification lines via notifications() (additive; the sync engine ignores them) - engines.concrete: AsyncConcreteEngine sibling over shared simulation; removes the async test shim - ControlNotification typed event value - tests: parser notification/drain; async control vs real tmux (split, pipelined batch, concrete parity, live event stream, lifecycle) --- src/libtmux/experimental/engines/__init__.py | 9 +- .../engines/async_control_mode.py | 358 ++++++++++++++++++ src/libtmux/experimental/engines/concrete.py | 103 +++-- .../experimental/engines/control_mode.py | 16 +- .../contract/test_async_control_engine.py | 98 +++++ tests/experimental/ops/test_control_parser.py | 19 +- tests/experimental/ops/test_plan.py | 28 +- 7 files changed, 571 insertions(+), 60 deletions(-) create mode 100644 src/libtmux/experimental/engines/async_control_mode.py create mode 100644 tests/experimental/contract/test_async_control_engine.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index a8528c9d9..6738d4716 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -12,6 +12,10 @@ from __future__ import annotations +from libtmux.experimental.engines.async_control_mode import ( + AsyncControlModeEngine, + ControlNotification, +) from libtmux.experimental.engines.asyncio import AsyncSubprocessEngine from libtmux.experimental.engines.base import ( AsyncTmuxEngine, @@ -21,7 +25,7 @@ EngineSpec, TmuxEngine, ) -from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.concrete import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.engines.control_mode import ( ControlModeEngine, ControlModeError, @@ -35,6 +39,8 @@ from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( + "AsyncConcreteEngine", + "AsyncControlModeEngine", "AsyncSubprocessEngine", "AsyncTmuxEngine", "CommandRequest", @@ -43,6 +49,7 @@ "ControlModeEngine", "ControlModeError", "ControlModeParser", + "ControlNotification", "EngineKind", "EngineSpec", "SubprocessEngine", diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py new file mode 100644 index 000000000..ccfaafdbe --- /dev/null +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -0,0 +1,358 @@ +"""An asynchronous control-mode (``tmux -C``) engine with an event stream. + +A real async control engine -- not an ``asyncio.to_thread`` wrapper around the +sync one. It holds a persistent ``tmux -C`` connection, reads it from a single +background task, correlates each command to an :class:`asyncio.Future`, and +exposes tmux's asynchronous notifications (``%output``, ``%window-add``, ...) as +an ``async for`` event stream. + +Design, informed by prior libtmux/mux control-mode work: + +- The I/O-free :class:`~.control_mode.ControlModeParser` is reused verbatim; only + the I/O layer differs from the sync engine (``await stdout.read`` instead of + ``selectors``). +- Command correlation is a FIFO of futures resolved in block-arrival order. A + block that arrives with *no* pending command is **unsolicited** (a hook- + triggered command, or the startup ACK) and is skipped, so correlation never + desyncs. The startup ACK is consumed synchronously in :meth:`start` before the + reader launches, closing the startup race. +- A reader failure or EOF marks the engine *dead* and fails every pending + command, rather than hanging. +- Notifications go to a bounded queue; on overflow the oldest is dropped and + counted (backpressure), mirroring control mode's own ``%pause`` philosophy. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import shlex +import shutil +import typing as t +from dataclasses import dataclass + +from libtmux import exc +from libtmux.experimental.engines.control_mode import ( + ControlModeError, + ControlModeParser, + _result_from_block, +) + +if t.TYPE_CHECKING: + import types + from collections.abc import AsyncIterator, Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 +_STOP_TIMEOUT = 2.0 + + +@dataclass(frozen=True) +class ControlNotification: + """An asynchronous tmux control-mode notification. + + Examples + -------- + >>> ControlNotification.parse(b"%window-add @3") + ControlNotification(kind='window-add', args=('@3',), raw='%window-add @3') + >>> ControlNotification.parse(b"%output %1 hello world").kind + 'output' + """ + + kind: str + args: tuple[str, ...] + raw: str + + @classmethod + def parse(cls, line: bytes) -> ControlNotification: + """Parse a raw ``%``-notification line.""" + text = line.decode(errors="replace") + body = text[1:] if text.startswith("%") else text + parts = body.split(" ") + kind = parts[0] if parts else "" + return cls(kind=kind, args=tuple(parts[1:]), raw=text) + + +@dataclass(slots=True) +class _PendingCommand: + future: asyncio.Future[CommandResult] + argv: tuple[str, ...] + + +class AsyncControlModeEngine: + """Execute tmux commands over one persistent async ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved via :func:`shutil.which` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to await a command's result before failing it. + event_queue_size : int + Bounded size of the notification queue (backpressure). + + Notes + ----- + The connection opens lazily on first use. Use the engine as an async context + manager, or call :meth:`aclose`, to tear it down. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + event_queue_size: int = 4096, + ) -> None: + self.tmux_bin = tmux_bin + self.server_args = tuple(server_args) + self.timeout = timeout + self._parser = ControlModeParser() + self._pending: collections.deque[_PendingCommand] = collections.deque() + self._events: asyncio.Queue[ControlNotification] = asyncio.Queue( + maxsize=event_queue_size, + ) + self._dropped_notifications = 0 + self._proc: asyncio.subprocess.Process | None = None + self._reader_task: asyncio.Task[None] | None = None + self._start_lock = asyncio.Lock() + self._write_lock = asyncio.Lock() + self._started = False + self._dead: BaseException | None = None + + async def start(self) -> None: + """Spawn ``tmux -C``, consume the startup ACK, and start the reader.""" + async with self._start_lock: + if self._started: + return + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + self._proc = proc + self._dead = None + await self._consume_startup() + self._reader_task = asyncio.create_task( + self._reader(), + name="libtmux-async-control-reader", + ) + self._started = True + + async def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before commands flow. + + Doing this synchronously (before the reader task launches and before any + command future is queued) means the startup block can never be matched + to a real command. + """ + proc = self._proc + if proc is None or proc.stdout is None: + return + loop = asyncio.get_running_loop() + deadline = loop.time() + _STARTUP_TIMEOUT + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return + try: + chunk = await asyncio.wait_for( + proc.stdout.read(_READ_CHUNK), + timeout=remaining, + ) + except asyncio.TimeoutError: + return + if not chunk: + return + self._parser.feed(chunk) + self._parser.notifications() # discard any startup notifications + if self._parser.blocks(): # startup ACK seen and discarded + return + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command over the control connection.""" + return (await self.run_batch([request]))[0] + + async def run_batch( + self, requests: Sequence[CommandRequest] + ) -> list[CommandResult]: + """Pipeline a batch of commands; one result per request, in order.""" + if not requests: + return [] + await self.start() + if self._dead is not None: + msg = "control-mode engine is dead" + raise ControlModeError(msg) from self._dead + + loop = asyncio.get_running_loop() + rendered = [tuple(req.args) for req in requests] + futures: list[asyncio.Future[CommandResult]] = [] + async with self._write_lock: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + for argv in rendered: + future: asyncio.Future[CommandResult] = loop.create_future() + self._pending.append(_PendingCommand(future, argv)) + futures.append(future) + payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + try: + proc.stdin.write(payload) + await proc.stdin.drain() + except (BrokenPipeError, OSError) as error: + msg = f"tmux control-mode write failed: {error}" + raise ControlModeError(msg) from error + + try: + return await asyncio.wait_for( + asyncio.gather(*futures), + timeout=self.timeout, + ) + except asyncio.TimeoutError as error: + # The futures stay queued (now cancelled); the reader drains their + # blocks on arrival, keeping FIFO correlation aligned. + msg = f"tmux control-mode timed out after {self.timeout}s" + raise ControlModeError(msg) from error + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield asynchronous tmux notifications as they arrive. + + The iterator runs until the engine is closed or cancelled by the caller. + """ + while True: + yield await self._events.get() + + @property + def dropped_notifications(self) -> int: + """How many notifications were dropped due to a full event queue.""" + return self._dropped_notifications + + async def aclose(self) -> None: + """Tear down the connection: cancel the reader, fail pending, kill proc.""" + if not self._started: + return + self._started = False + reader = self._reader_task + self._reader_task = None + if reader is not None: + reader.cancel() + with contextlib.suppress(asyncio.CancelledError): + await reader + self._fail_pending(ControlModeError("control-mode engine closed")) + proc = self._proc + self._proc = None + if proc is not None and proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=_STOP_TIMEOUT) + except asyncio.TimeoutError: + with contextlib.suppress(ProcessLookupError): + proc.kill() + await proc.wait() + + async def __aenter__(self) -> AsyncControlModeEngine: + """Start the engine on context entry.""" + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the engine on context exit.""" + await self.aclose() + + async def _reader(self) -> None: + """Background task: read tmux output, resolve futures, publish events.""" + proc = self._proc + if proc is None or proc.stdout is None: + return + stdout = proc.stdout + try: + while True: + chunk = await stdout.read(_READ_CHUNK) + if not chunk: + self._mark_dead(ControlModeError("tmux -C closed stdout")) + return + self._parser.feed(chunk) + for block in self._parser.blocks(): + self._dispatch_block(block) + for line in self._parser.notifications(): + self._publish(line) + except asyncio.CancelledError: + raise + except Exception as error: + self._mark_dead(ControlModeError(f"control-mode reader failed: {error}")) + + def _dispatch_block(self, block: t.Any) -> None: + """Resolve the next pending command, or skip an unsolicited block.""" + if not self._pending: + return # startup ACK or hook-triggered command: not ours, skip + pending = self._pending.popleft() + if not pending.future.done(): + pending.future.set_result(_result_from_block(block, pending.argv)) + + def _publish(self, line: bytes) -> None: + """Enqueue a notification, dropping the oldest on overflow.""" + notification = ControlNotification.parse(line) + try: + self._events.put_nowait(notification) + except asyncio.QueueFull: + self._dropped_notifications += 1 + with contextlib.suppress(asyncio.QueueEmpty): + self._events.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + self._events.put_nowait(notification) + + def _mark_dead(self, error: BaseException) -> None: + """Record the engine as dead and fail all pending commands.""" + if self._dead is None: + self._dead = error + self._fail_pending(error) + + def _fail_pending(self, error: BaseException) -> None: + """Fail every queued command future with *error*.""" + while self._pending: + pending = self._pending.popleft() + if not pending.future.done(): + pending.future.set_exception(error) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> AsyncControlModeEngine: + """Build an async control-mode engine bound to a live server's socket.""" + server_args: list[str] = [] + if getattr(server, "socket_name", None): + server_args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + server_args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + server_args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + server_args.append("-2") + elif colors == 88: + server_args.append("-8") + return cls( + tmux_bin=getattr(server, "tmux_bin", None), + server_args=server_args, + **kwargs, + ) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index 07461d630..841c4f8c8 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -1,11 +1,12 @@ -"""A deterministic, in-memory engine for tests and docs (no tmux server). +"""Deterministic, in-memory engines for tests and docs (no tmux server). -The concrete engine simulates just enough tmux behaviour to exercise the -operation contract without a live server: creation commands that ask for an id +The concrete engines simulate just enough tmux behaviour to exercise the +operation contract offline: creation commands that ask for an id (``-P -F '#{pane_id}'``) get a fabricated, monotonic id, ``capture-pane`` returns -canned lines, and everything else succeeds with empty output. This is what backs -doctests and the cross-engine contract suite, so examples run anywhere and the -"same typed result regardless of engine" invariant can be asserted offline. +canned lines, and everything else succeeds with empty output. A sync +(:class:`ConcreteEngine`) and async (:class:`AsyncConcreteEngine`) variant share +the same simulation, so the same operation returns the same typed result through +either, with no tmux required. """ from __future__ import annotations @@ -20,8 +21,40 @@ from libtmux.experimental.engines.base import CommandRequest +def _fabricate(fmt: str, counters: dict[str, int]) -> str: + """Return the next fabricated id for a ``#{..._id}`` capture format.""" + for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): + if key in fmt: + counters[key] += 1 + return f"{sigil}{counters[key]}" + return "?" + + +def _simulate( + argv: tuple[str, ...], + counters: dict[str, int], + capture_lines: tuple[str, ...], +) -> CommandResult: + """Produce a deterministic result for a rendered tmux command.""" + if "-P" in argv and "-F" in argv: + fmt = argv[argv.index("-F") + 1] + return CommandResult( + cmd=("tmux", *argv), + stdout=(_fabricate(fmt, counters),), + returncode=0, + ) + if argv and argv[0] == "capture-pane": + return CommandResult(cmd=("tmux", *argv), stdout=capture_lines, returncode=0) + return CommandResult(cmd=("tmux", *argv), returncode=0) + + +def _new_counters() -> dict[str, int]: + """Return a fresh id-counter map.""" + return {"pane_id": 0, "window_id": 0, "session_id": 0} + + class ConcreteEngine: - """Execute operations against an in-memory simulation. + """Execute operations against an in-memory simulation (synchronous). Parameters ---------- @@ -43,34 +76,42 @@ class ConcreteEngine: def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: self.capture_lines = tuple(capture_lines) - self._counters = {"pane_id": 0, "window_id": 0, "session_id": 0} - - def _fabricate(self, fmt: str) -> str: - """Return the next fabricated id for a ``#{..._id}`` capture format.""" - for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): - if key in fmt: - self._counters[key] += 1 - return f"{sigil}{self._counters[key]}" - return "?" + self._counters = _new_counters() def run(self, request: CommandRequest) -> CommandResult: """Execute one request against the in-memory simulation.""" - argv = request.args - if "-P" in argv and "-F" in argv: - fmt = argv[argv.index("-F") + 1] - return CommandResult( - cmd=("tmux", *argv), - stdout=(self._fabricate(fmt),), - returncode=0, - ) - if argv and argv[0] == "capture-pane": - return CommandResult( - cmd=("tmux", *argv), - stdout=self.capture_lines, - returncode=0, - ) - return CommandResult(cmd=("tmux", *argv), returncode=0) + return _simulate(request.args, self._counters, self.capture_lines) def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: """Execute each request in order (no batching benefit).""" return [self.run(req) for req in requests] + + +class AsyncConcreteEngine: + """Async sibling of :class:`ConcreteEngine` for offline async tests/docs. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SplitWindow, arun + >>> from libtmux.experimental.ops._types import WindowId + >>> async def main(): + ... return await arun(SplitWindow(target=WindowId("@1")), AsyncConcreteEngine()) + >>> asyncio.run(main()).new_pane_id + '%1' + """ + + def __init__(self, *, capture_lines: Sequence[str] = ()) -> None: + self.capture_lines = tuple(capture_lines) + self._counters = _new_counters() + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one request against the in-memory simulation.""" + return _simulate(request.args, self._counters, self.capture_lines) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute each request in order (no batching benefit).""" + return [await self.run(req) for req in requests] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index ae113b672..73f069661 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -85,11 +85,12 @@ class ControlModeParser: (True, (b'boom',)) """ - __slots__ = ("_blocks", "_buffer", "_pending") + __slots__ = ("_blocks", "_buffer", "_notifications", "_pending") def __init__(self) -> None: self._buffer = bytearray() self._blocks: list[ControlModeBlock] = [] + self._notifications: list[bytes] = [] self._pending: _PendingBlock | None = None def feed(self, data: bytes) -> None: @@ -110,6 +111,17 @@ def blocks(self) -> list[ControlModeBlock]: blocks, self._blocks = self._blocks, [] return blocks + def notifications(self) -> list[bytes]: + """Drain raw ``%``-notification lines seen outside command blocks. + + Control mode wraps *command output* in ``%begin``/``%end`` blocks but + emits asynchronous notifications (``%output``, ``%window-add``, ...) as + bare lines. The sync engine ignores these; the async engine routes them + to its event stream. + """ + notifications, self._notifications = self._notifications, [] + return notifications + def _handle_line(self, line: bytes) -> None: if self._pending is not None: if _matches_pending_close(line, self._pending.number): @@ -119,6 +131,8 @@ def _handle_line(self, line: bytes) -> None: return if line.startswith(_BEGIN_PREFIX): self._open_block(line) + elif line.startswith(b"%"): + self._notifications.append(line) def _open_block(self, line: bytes) -> None: number, flags = _parse_guard(line, _BEGIN_PREFIX) diff --git a/tests/experimental/contract/test_async_control_engine.py b/tests/experimental/contract/test_async_control_engine.py new file mode 100644 index 000000000..b6d9ec5ac --- /dev/null +++ b/tests/experimental/contract/test_async_control_engine.py @@ -0,0 +1,98 @@ +"""Async control-mode engine against a real tmux server. + +Drives the persistent async ``tmux -C`` engine end to end via :func:`asyncio.run` +and asserts it returns the same typed result the other engines do, plus that its +notification stream works. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import ( + AsyncConcreteEngine, + AsyncControlModeEngine, + ControlNotification, +) +from libtmux.experimental.ops import SplitWindow, arun +from libtmux.experimental.ops._types import WindowId +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_notification_parse() -> None: + """A raw notification line parses into a typed notification (no tmux).""" + notif = ControlNotification.parse(b"%window-add @3") + assert notif.kind == "window-add" + assert notif.args == ("@3",) + + +def test_async_control_split_creates_real_pane(session: Session) -> None: + """An async control-mode split returns a typed result; the pane exists.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> SplitWindowResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun(SplitWindow(target=WindowId(window_id)), engine) + + result = asyncio.run(main()) + assert result.ok + assert result.new_pane_id is not None + assert server.panes.get(pane_id=result.new_pane_id) is not None + + +def test_async_control_batches_pipelined(session: Session) -> None: + """run_batch pipelines several splits over one connection, one result each.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> tuple[str | None, str | None]: + async with AsyncControlModeEngine.for_server(server) as engine: + r1 = await arun(SplitWindow(target=WindowId(window_id)), engine) + r2 = await arun(SplitWindow(target=WindowId(window_id)), engine) + return r1.new_pane_id, r2.new_pane_id + + first, second = asyncio.run(main()) + assert first is not None + assert second is not None + assert first != second + + +def test_async_control_concrete_parity(session: Session) -> None: + """Async control-mode and concrete engines agree on result type and argv.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + operation = SplitWindow(target=WindowId(window_id)) + + async def main() -> SplitWindowResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun(operation, engine) + + control = asyncio.run(main()) + concrete = asyncio.run(arun(operation, AsyncConcreteEngine())) + assert type(control) is type(concrete) is SplitWindowResult + assert control.argv == concrete.argv == operation.render() + + +def test_async_control_event_stream(session: Session) -> None: + """A command that changes server state surfaces a notification on the stream.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + + async def main() -> ControlNotification: + async with AsyncControlModeEngine.for_server(server) as engine: + events = engine.subscribe() + await arun(SplitWindow(target=WindowId(window_id)), engine) + return await asyncio.wait_for(anext(events), timeout=10.0) + + notif = asyncio.run(main()) + assert notif.kind + assert notif.raw.startswith("%") diff --git a/tests/experimental/ops/test_control_parser.py b/tests/experimental/ops/test_control_parser.py index fc29bb9a4..7517e330c 100644 --- a/tests/experimental/ops/test_control_parser.py +++ b/tests/experimental/ops/test_control_parser.py @@ -41,9 +41,26 @@ def test_blocks_drains() -> None: def test_ignores_noise_outside_blocks() -> None: - """Notification lines outside a block are ignored by the command parser.""" + """Notification lines outside a block are not command blocks.""" parser = ControlModeParser() parser.feed(b"%output %1 hi\n%begin 1 1 1\nok\n%end 1 1 1\n") blocks = parser.blocks() assert len(blocks) == 1 assert blocks[0].body == (b"ok",) + + +def test_surfaces_notifications() -> None: + """Bare ``%`` lines outside blocks are surfaced as notifications.""" + parser = ControlModeParser() + parser.feed(b"%window-add @3\n%begin 1 1 1\nok\n%end 1 1 1\n%output %1 hi\n") + assert parser.notifications() == [b"%window-add @3", b"%output %1 hi"] + # block lines are not double-counted as notifications + assert parser.blocks()[0].body == (b"ok",) + + +def test_notifications_drain() -> None: + """``notifications`` returns each notification once, then is empty.""" + parser = ControlModeParser() + parser.feed(b"%window-close @3\n") + assert parser.notifications() == [b"%window-close @3"] + assert parser.notifications() == [] diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index bbba688e7..317a953cb 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -import typing as t import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.ops import ( LazyPlan, SendKeys, @@ -16,29 +15,6 @@ from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.exc import OperationError -if t.TYPE_CHECKING: - from collections.abc import Sequence - - from libtmux.experimental.engines.base import CommandRequest, CommandResult - - -class _AsyncConcreteEngine: - """Async wrapper over :class:`ConcreteEngine` for plan.aexecute tests.""" - - def __init__(self) -> None: - self._inner = ConcreteEngine() - - async def run(self, request: CommandRequest) -> CommandResult: - """Run synchronously under the hood; awaitable for the async driver.""" - return self._inner.run(request) - - async def run_batch( - self, - requests: Sequence[CommandRequest], - ) -> list[CommandResult]: - """Execute each request in order.""" - return [await self.run(req) for req in requests] - def test_plan_records_without_executing() -> None: """Building a plan touches no engine; it just records operations.""" @@ -68,7 +44,7 @@ def test_plan_aexecute_matches_execute() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) plan.add(SendKeys(target=pane, keys="vim", enter=True)) - outcome = asyncio.run(plan.aexecute(_AsyncConcreteEngine())) + outcome = asyncio.run(plan.aexecute(AsyncConcreteEngine())) assert outcome.bindings == {0: "%1"} assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") From c98b4795046c0af6fef9bc9a0d1a71a6162acbfd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 10:18:56 -0500 Subject: [PATCH 007/154] Ops(feat): Add AckResult for no-output operations why: Many tmux commands print nothing (rename-window, kill-pane, select-window, ...). tmux returns CMD_RETURN_NORMAL on success or calls cmdq_error on failure, framed in control mode as %end vs %error (see tmux cmd-queue.c) -- they never cmdq_print. They still need a typed result that records success/failure without inventing a payload. what: - results.AckResult: a typed acknowledgement (no payload) whose raise_for_status() still surfaces the error path; documents the tmux success/error mapping - retarget send-keys and select-layout to AckResult (both print nothing) - add no-output ops: rename-window (mutating), kill-window and kill-pane (destructive) -- exercising AckResult across scopes and safety tiers - export AckResult and the new ops; refresh the catalog doctest - tests: render + AckResult success/failure across the no-output ops and destructive safety metadata; update classic/control parity assertions --- src/libtmux/experimental/ops/__init__.py | 8 ++ src/libtmux/experimental/ops/_ops/__init__.py | 6 ++ .../experimental/ops/_ops/kill_pane.py | 41 ++++++++ .../experimental/ops/_ops/kill_window.py | 43 +++++++++ .../experimental/ops/_ops/rename_window.py | 41 ++++++++ .../experimental/ops/_ops/select_layout.py | 6 +- .../experimental/ops/_ops/send_keys.py | 6 +- src/libtmux/experimental/ops/catalog.py | 3 +- src/libtmux/experimental/ops/results.py | 27 ++++++ .../contract/test_classic_engine.py | 4 +- .../contract/test_control_engine.py | 4 +- tests/experimental/ops/test_ack_ops.py | 93 +++++++++++++++++++ 12 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/kill_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/kill_window.py create mode 100644 src/libtmux/experimental/ops/_ops/rename_window.py create mode 100644 tests/experimental/ops/test_ack_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 0e069dbd6..73c34617c 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -21,6 +21,9 @@ from libtmux.experimental.ops._ops import ( CapturePane, + KillPane, + KillWindow, + RenameWindow, SelectLayout, SendKeys, SplitWindow, @@ -59,6 +62,7 @@ registry, ) from libtmux.experimental.ops.results import ( + AckResult, CapturePaneResult, Result, SplitWindowResult, @@ -74,6 +78,7 @@ ) __all__ = ( + "AckResult", "CapturePane", "CapturePaneResult", "CatalogEntry", @@ -81,6 +86,8 @@ "DuplicateOperation", "Effects", "IndexRef", + "KillPane", + "KillWindow", "LazyPlan", "NameRef", "OpSpec", @@ -89,6 +96,7 @@ "OperationRegistry", "PaneId", "PlanResult", + "RenameWindow", "Result", "Safety", "Scope", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 8c050ec44..d5152fd0d 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -8,12 +8,18 @@ from __future__ import annotations from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.kill_pane import KillPane +from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.split_window import SplitWindow __all__ = ( "CapturePane", + "KillPane", + "KillWindow", + "RenameWindow", "SelectLayout", "SendKeys", "SplitWindow", diff --git a/src/libtmux/experimental/ops/_ops/kill_pane.py b/src/libtmux/experimental/ops/_ops/kill_pane.py new file mode 100644 index 000000000..7e835fc4f --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_pane.py @@ -0,0 +1,41 @@ +"""The ``kill-pane`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillPane(Operation[AckResult]): + """Kill a pane. Destructive; produces no output (:class:`AckResult`). + + Parameters + ---------- + others : bool + Kill all panes *except* the target (``-a``) instead of the target. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> KillPane(target=PaneId("%1")).render() + ('kill-pane', '-t', '%1') + """ + + kind = "kill_pane" + command = "kill-pane" + scope = "pane" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + others: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.others else () diff --git a/src/libtmux/experimental/ops/_ops/kill_window.py b/src/libtmux/experimental/ops/_ops/kill_window.py new file mode 100644 index 000000000..f70a5f987 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_window.py @@ -0,0 +1,43 @@ +"""The ``kill-window`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillWindow(Operation[AckResult]): + """Kill a window. Destructive; produces no output (:class:`AckResult`). + + Parameters + ---------- + others : bool + Kill all windows *except* the target (``-a``) instead of the target. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> KillWindow(target=WindowId("@1")).render() + ('kill-window', '-t', '@1') + >>> KillWindow(target=WindowId("@1"), others=True).render() + ('kill-window', '-t', '@1', '-a') + """ + + kind = "kill_window" + command = "kill-window" + scope = "window" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + others: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.others else () diff --git a/src/libtmux/experimental/ops/_ops/rename_window.py b/src/libtmux/experimental/ops/_ops/rename_window.py new file mode 100644 index 000000000..4cae1a96f --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rename_window.py @@ -0,0 +1,41 @@ +"""The ``rename-window`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RenameWindow(Operation[AckResult]): + """Rename a window. Produces no output; returns an :class:`AckResult`. + + Parameters + ---------- + name : str + The new window name. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RenameWindow(target=WindowId("@1"), name="build").render() + ('rename-window', '-t', '@1', 'build') + """ + + kind = "rename_window" + command = "rename-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + name: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the new name.""" + return (self.name,) diff --git a/src/libtmux/experimental/ops/_ops/select_layout.py b/src/libtmux/experimental/ops/_ops/select_layout.py index f17af37b4..01dfe1682 100644 --- a/src/libtmux/experimental/ops/_ops/select_layout.py +++ b/src/libtmux/experimental/ops/_ops/select_layout.py @@ -7,12 +7,12 @@ from libtmux.experimental.ops._types import Effects from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.registry import register -from libtmux.experimental.ops.results import Result +from libtmux.experimental.ops.results import AckResult @register @dataclass(frozen=True, kw_only=True) -class SelectLayout(Operation[Result]): +class SelectLayout(Operation[AckResult]): """Apply a layout to a window. Parameters @@ -33,7 +33,7 @@ class SelectLayout(Operation[Result]): kind = "select_layout" command = "select-layout" scope = "window" - result_cls = Result + result_cls = AckResult safety = "mutating" effects = Effects(idempotent=True, mutates_layout=True) diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py index 81de969fa..e7c009796 100644 --- a/src/libtmux/experimental/ops/_ops/send_keys.py +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -7,12 +7,12 @@ from libtmux.experimental.ops._types import Effects from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.registry import register -from libtmux.experimental.ops.results import Result +from libtmux.experimental.ops.results import AckResult @register @dataclass(frozen=True, kw_only=True) -class SendKeys(Operation[Result]): +class SendKeys(Operation[AckResult]): """Send keys (input) to a pane. Parameters @@ -36,7 +36,7 @@ class SendKeys(Operation[Result]): kind = "send_keys" command = "send-keys" scope = "pane" - result_cls = Result + result_cls = AckResult safety = "mutating" effects = Effects(writes_input=True) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index b0a272e67..d7012a325 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,7 +65,8 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'select_layout', 'send_keys', 'split_window'] + ['capture_pane', 'kill_pane', 'kill_window', 'rename_window', + 'select_layout', 'send_keys', 'split_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 70951a4e9..b275171b8 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -149,6 +149,33 @@ def raise_for_status(self) -> Self: return self +@dataclass(frozen=True) +class AckResult(Result): + """Result of an operation that returns no data -- only success or failure. + + Many tmux commands (``rename-window``, ``kill-pane``, ``select-window``, ...) + print nothing. In the CLI they exit ``0`` on success or write to stderr and + exit nonzero on failure; in control mode tmux frames them as ``%end`` + (success) or ``%error`` (failure) -- it never calls ``cmdq_print`` for them + (see tmux ``cmd-queue.c``). An :class:`AckResult` is the typed + acknowledgement for exactly that case: no payload, but + :meth:`~Result.raise_for_status` still surfaces the error path, because a + no-output command can still fail. + + Examples + -------- + >>> from libtmux.experimental.ops import RenameWindow + >>> from libtmux.experimental.ops._types import WindowId + >>> op = RenameWindow(target=WindowId("@1"), name="build") + >>> ok = op.build_result(returncode=0) + >>> type(ok).__name__, ok.ok + ('AckResult', True) + >>> failed = op.build_result(returncode=1, stderr=("can't find window @1",)) + >>> failed.ok + False + """ + + @dataclass(frozen=True) class SplitWindowResult(Result): """Result of a ``split-window`` operation. diff --git a/tests/experimental/contract/test_classic_engine.py b/tests/experimental/contract/test_classic_engine.py index f3a889646..927a6be3a 100644 --- a/tests/experimental/contract/test_classic_engine.py +++ b/tests/experimental/contract/test_classic_engine.py @@ -20,8 +20,8 @@ ) from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.results import ( + AckResult, CapturePaneResult, - Result, SplitWindowResult, ) @@ -56,7 +56,7 @@ def test_classic_send_keys_and_select_layout(session: Session) -> None: engine = SubprocessEngine.for_server(server) sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) - assert type(sent) is Result + assert type(sent) is AckResult assert sent.ok laid_out = run( diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py index 07966326e..73aa1a6f8 100644 --- a/tests/experimental/contract/test_control_engine.py +++ b/tests/experimental/contract/test_control_engine.py @@ -18,8 +18,8 @@ ) from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.results import ( + AckResult, CapturePaneResult, - Result, SplitWindowResult, ) @@ -55,7 +55,7 @@ def test_control_batches_multiple_commands(session: Session) -> None: sent = run(SendKeys(target=PaneId(pane.pane_id), keys="echo hi"), engine) captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) - assert type(sent) is Result + assert type(sent) is AckResult assert sent.ok assert isinstance(captured, CapturePaneResult) assert captured.ok diff --git a/tests/experimental/ops/test_ack_ops.py b/tests/experimental/ops/test_ack_ops.py new file mode 100644 index 000000000..2ccc63ee8 --- /dev/null +++ b/tests/experimental/ops/test_ack_ops.py @@ -0,0 +1,93 @@ +"""Tests for no-output operations and the AckResult type.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + KillPane, + KillWindow, + RenameWindow, + SelectLayout, + SendKeys, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import TmuxCommandError +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + + +@pytest.mark.parametrize( + ("operation", "expected_argv"), + [ + pytest.param( + RenameWindow(target=WindowId("@1"), name="build"), + ("rename-window", "-t", "@1", "build"), + id="rename_window", + ), + pytest.param( + KillWindow(target=WindowId("@1")), + ("kill-window", "-t", "@1"), + id="kill_window", + ), + pytest.param( + KillPane(target=PaneId("%1")), + ("kill-pane", "-t", "%1"), + id="kill_pane", + ), + pytest.param( + SendKeys(target=PaneId("%1"), keys="x"), + ("send-keys", "-t", "%1", "x"), + id="send_keys", + ), + pytest.param( + SelectLayout(target=WindowId("@1"), layout="tiled"), + ("select-layout", "-t", "@1", "tiled"), + id="select_layout", + ), + ], +) +def test_no_output_ops_return_ack( + operation: Operation[AckResult], + expected_argv: tuple[str, ...], +) -> None: + """No-output operations render correctly and yield an AckResult.""" + result = run(operation, ConcreteEngine()) + assert type(result) is AckResult + assert result.argv == expected_argv + assert result.ok + + +def test_ack_success_has_no_payload() -> None: + """A successful ack carries only status -- no extra fields beyond the base.""" + result = RenameWindow(target=WindowId("@1"), name="x").build_result(returncode=0) + assert isinstance(result, AckResult) + assert result.ok + assert result.stdout == () + + +def test_ack_failure_raises_on_demand() -> None: + """A no-output command can still fail; raise_for_status surfaces it.""" + result = KillWindow(target=WindowId("@9")).build_result( + returncode=1, + stderr=("can't find window @9",), + ) + assert result.failed + with pytest.raises(TmuxCommandError): + result.raise_for_status() + + +def test_destructive_safety_metadata() -> None: + """Kill operations are tagged destructive in the registry/catalog.""" + from libtmux.experimental.ops import catalog + + safety = {entry.kind: entry.safety for entry in catalog()} + assert safety["kill_window"] == "destructive" + assert safety["kill_pane"] == "destructive" + assert safety["rename_window"] == "mutating" From c8c69f82d46239be237db3b49ed6fea1aa5c240d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 10:18:56 -0500 Subject: [PATCH 008/154] Models(feat): Add pure object-graph snapshots why: A neo-like read model is useful, but neo.Obj is one flat ~200-field class fused to the query/dispatch pipeline. The experimental namespace lets us try a decoupled, immutable, serializable snapshot layer without any risk to the shipped ORM APIs. what: - libtmux.experimental.models: frozen PaneSnapshot / WindowSnapshot / SessionSnapshot / ServerSnapshot, each a typed core plus the full raw tmux-format tail in .fields (nothing tmux reported is lost) - from_format() builds one node from a format mapping; ServerSnapshot.from_pane_rows() groups a flat "list-panes -a -F" row set into an ordered session/window/pane tree - to_dict()/from_dict() round-trip the whole tree as plain data, with no live objects - pure tests (no tmux): value coercion, tree grouping/order, round-trip --- src/libtmux/experimental/models/__init__.py | 25 ++ src/libtmux/experimental/models/snapshots.py | 308 +++++++++++++++++++ tests/experimental/models/__init__.py | 3 + tests/experimental/models/test_snapshots.py | 134 ++++++++ 4 files changed, 470 insertions(+) create mode 100644 src/libtmux/experimental/models/__init__.py create mode 100644 src/libtmux/experimental/models/snapshots.py create mode 100644 tests/experimental/models/__init__.py create mode 100644 tests/experimental/models/test_snapshots.py diff --git a/src/libtmux/experimental/models/__init__.py b/src/libtmux/experimental/models/__init__.py new file mode 100644 index 000000000..82866f450 --- /dev/null +++ b/src/libtmux/experimental/models/__init__.py @@ -0,0 +1,25 @@ +"""Pure, immutable snapshots of the tmux object graph. + +A neo-like view of server/session/window/pane state as plain *values* -- no live +:class:`~libtmux.Server`, no command dispatch, no coupling to the existing ORM or +query pipeline. Snapshots are immutable, composable into a tree, and serializable, +so they are safe to experiment with under :mod:`libtmux.experimental` without +touching shipped APIs. See the operationalization plan (``tmux-python/libtmux`` +issue 689). +""" + +from __future__ import annotations + +from libtmux.experimental.models.snapshots import ( + PaneSnapshot, + ServerSnapshot, + SessionSnapshot, + WindowSnapshot, +) + +__all__ = ( + "PaneSnapshot", + "ServerSnapshot", + "SessionSnapshot", + "WindowSnapshot", +) diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py new file mode 100644 index 000000000..9a8e4e808 --- /dev/null +++ b/src/libtmux/experimental/models/snapshots.py @@ -0,0 +1,308 @@ +"""Pure, immutable snapshots of the tmux object graph. + +These are *values*, not live objects: a snapshot captures the state of a server, +session, window, or pane at one moment, with no reference to a +:class:`~libtmux.Server` and no ability to issue tmux commands. They resemble +:class:`libtmux.neo.Obj` but are decoupled from the query/dispatch pipeline and +from each other, so experimenting with them cannot affect the existing ORM APIs. + +Each snapshot keeps a typed *core* of the most-used fields plus the full raw +format mapping in :attr:`fields`, so nothing tmux reported is lost. Snapshots +compose into a tree (:class:`ServerSnapshot` → :class:`SessionSnapshot` → +:class:`WindowSnapshot` → :class:`PaneSnapshot`), can be built from a single +``list-panes -a -F`` style row set via :meth:`ServerSnapshot.from_pane_rows`, +and round-trip to plain dicts for serialization or diffing. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field, replace + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Mapping + +_TRUE = {"1", "on", "yes", "true"} + + +def _as_int(value: str | None) -> int | None: + """Coerce a tmux format value to ``int``, or ``None`` if absent/non-numeric. + + Examples + -------- + >>> _as_int("3") + 3 + >>> _as_int("") is None + True + >>> _as_int(None) is None + True + """ + if value is None or value == "": + return None + try: + return int(value) + except ValueError: + return None + + +def _as_bool(value: str | None) -> bool: + """Coerce a tmux flag value (``"1"``/``"0"``/``""``) to ``bool``. + + Examples + -------- + >>> _as_bool("1") + True + >>> _as_bool("0") + False + >>> _as_bool(None) + False + """ + return value is not None and value.lower() in _TRUE + + +@dataclass(frozen=True) +class PaneSnapshot: + """An immutable snapshot of one tmux pane. + + Examples + -------- + >>> pane = PaneSnapshot.from_format({ + ... "pane_id": "%1", "pane_index": "0", "window_id": "@1", + ... "session_id": "$0", "pane_active": "1", "pane_width": "80", + ... "pane_height": "24", "pane_current_command": "zsh", + ... }) + >>> pane.pane_id, pane.pane_index, pane.active, pane.width + ('%1', 0, True, 80) + >>> pane.current_command + 'zsh' + """ + + pane_id: str = "" + pane_index: int | None = None + window_id: str = "" + session_id: str = "" + active: bool = False + width: int | None = None + height: int | None = None + current_command: str | None = None + current_path: str | None = None + title: str | None = None + pid: int | None = None + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> PaneSnapshot: + """Build a pane snapshot from a raw tmux format mapping.""" + return cls( + pane_id=raw.get("pane_id", ""), + pane_index=_as_int(raw.get("pane_index")), + window_id=raw.get("window_id", ""), + session_id=raw.get("session_id", ""), + active=_as_bool(raw.get("pane_active")), + width=_as_int(raw.get("pane_width")), + height=_as_int(raw.get("pane_height")), + current_command=raw.get("pane_current_command"), + current_path=raw.get("pane_current_path"), + title=raw.get("pane_title"), + pid=_as_int(raw.get("pane_pid")), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict (raw fields only; typed core re-derives).""" + return {"fields": dict(self.fields)} + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> PaneSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return cls.from_format(data["fields"]) + + +@dataclass(frozen=True) +class WindowSnapshot: + """An immutable snapshot of one tmux window and its panes. + + Examples + -------- + >>> win = WindowSnapshot.from_format({ + ... "window_id": "@1", "window_index": "0", "window_name": "main", + ... "session_id": "$0", "window_active": "1", + ... }) + >>> win.window_id, win.window_index, win.name, win.active + ('@1', 0, 'main', True) + >>> win.panes + () + """ + + window_id: str = "" + window_index: int | None = None + name: str | None = None + session_id: str = "" + active: bool = False + layout: str | None = None + panes: tuple[PaneSnapshot, ...] = () + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> WindowSnapshot: + """Build a window snapshot from a raw tmux format mapping.""" + return cls( + window_id=raw.get("window_id", ""), + window_index=_as_int(raw.get("window_index")), + name=raw.get("window_name"), + session_id=raw.get("session_id", ""), + active=_as_bool(raw.get("window_active")), + layout=raw.get("window_layout"), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict, including child panes.""" + return { + "fields": dict(self.fields), + "panes": [pane.to_dict() for pane in self.panes], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> WindowSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return replace( + cls.from_format(data["fields"]), + panes=tuple(PaneSnapshot.from_dict(p) for p in data.get("panes", [])), + ) + + +@dataclass(frozen=True) +class SessionSnapshot: + """An immutable snapshot of one tmux session and its windows.""" + + session_id: str = "" + name: str | None = None + attached: bool = False + windows: tuple[WindowSnapshot, ...] = () + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> SessionSnapshot: + """Build a session snapshot from a raw tmux format mapping.""" + return cls( + session_id=raw.get("session_id", ""), + name=raw.get("session_name"), + attached=_as_bool(raw.get("session_attached")), + fields=dict(raw), + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a plain dict, including child windows.""" + return { + "fields": dict(self.fields), + "windows": [window.to_dict() for window in self.windows], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> SessionSnapshot: + """Reconstruct from :meth:`to_dict` output.""" + return replace( + cls.from_format(data["fields"]), + windows=tuple(WindowSnapshot.from_dict(w) for w in data.get("windows", [])), + ) + + +@dataclass(frozen=True) +class ServerSnapshot: + """An immutable snapshot of a tmux server's session/window/pane tree. + + Examples + -------- + Build the whole graph from a flat ``list-panes -a -F`` style row set: + + >>> rows = [ + ... {"session_id": "$0", "session_name": "work", "window_id": "@1", + ... "window_index": "0", "window_name": "main", "pane_id": "%1", + ... "pane_index": "0", "pane_active": "1"}, + ... {"session_id": "$0", "session_name": "work", "window_id": "@1", + ... "window_index": "0", "window_name": "main", "pane_id": "%2", + ... "pane_index": "1", "pane_active": "0"}, + ... ] + >>> server = ServerSnapshot.from_pane_rows(rows, socket_name="default") + >>> [s.name for s in server.sessions] + ['work'] + >>> [p.pane_id for p in server.sessions[0].windows[0].panes] + ['%1', '%2'] + """ + + socket_name: str | None = None + socket_path: str | None = None + sessions: tuple[SessionSnapshot, ...] = () + + @classmethod + def from_pane_rows( + cls, + rows: Iterable[Mapping[str, str]], + *, + socket_name: str | None = None, + socket_path: str | None = None, + ) -> ServerSnapshot: + """Group flat per-pane rows into a session/window/pane tree. + + Each row is one pane's format mapping carrying its ``session_*`` and + ``window_*`` fields too (as ``tmux list-panes -a -F`` yields). The first + row seen for a session/window supplies that level's fields; insertion + order is preserved. + """ + session_order: list[str] = [] + session_fields: dict[str, Mapping[str, str]] = {} + window_order: dict[str, list[str]] = {} + window_fields: dict[str, Mapping[str, str]] = {} + window_panes: dict[str, list[PaneSnapshot]] = {} + + for row in rows: + session_id = row.get("session_id", "") + window_id = row.get("window_id", "") + if session_id not in session_fields: + session_fields[session_id] = row + session_order.append(session_id) + window_order[session_id] = [] + if window_id not in window_fields: + window_fields[window_id] = row + window_order[session_id].append(window_id) + window_panes[window_id] = [] + window_panes[window_id].append(PaneSnapshot.from_format(row)) + + sessions = tuple( + replace( + SessionSnapshot.from_format(session_fields[session_id]), + windows=tuple( + replace( + WindowSnapshot.from_format(window_fields[window_id]), + panes=tuple(window_panes[window_id]), + ) + for window_id in window_order[session_id] + ), + ) + for session_id in session_order + ) + return cls( + socket_name=socket_name, + socket_path=socket_path, + sessions=sessions, + ) + + def to_dict(self) -> dict[str, t.Any]: + """Serialize the whole tree to plain data.""" + return { + "socket_name": self.socket_name, + "socket_path": self.socket_path, + "sessions": [session.to_dict() for session in self.sessions], + } + + @classmethod + def from_dict(cls, data: Mapping[str, t.Any]) -> ServerSnapshot: + """Reconstruct the whole tree from :meth:`to_dict` output.""" + return cls( + socket_name=data.get("socket_name"), + socket_path=data.get("socket_path"), + sessions=tuple( + SessionSnapshot.from_dict(s) for s in data.get("sessions", []) + ), + ) diff --git a/tests/experimental/models/__init__.py b/tests/experimental/models/__init__.py new file mode 100644 index 000000000..eb117f032 --- /dev/null +++ b/tests/experimental/models/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.models.""" + +from __future__ import annotations diff --git a/tests/experimental/models/test_snapshots.py b/tests/experimental/models/test_snapshots.py new file mode 100644 index 000000000..61238b7b5 --- /dev/null +++ b/tests/experimental/models/test_snapshots.py @@ -0,0 +1,134 @@ +"""Tests for the pure object-graph snapshots.""" + +from __future__ import annotations + +import pytest + +from libtmux.experimental.models import ( + PaneSnapshot, + ServerSnapshot, + WindowSnapshot, +) +from libtmux.experimental.models.snapshots import _as_bool, _as_int + + +@pytest.mark.parametrize( + ("value", "expected"), + [("3", 3), ("0", 0), ("", None), (None, None), ("nope", None)], +) +def test_as_int(value: str | None, expected: int | None) -> None: + """Format values coerce to int or None.""" + assert _as_int(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [("1", True), ("0", False), ("", False), (None, False), ("on", True)], +) +def test_as_bool(value: str | None, expected: bool) -> None: + """Flag values coerce to bool.""" + assert _as_bool(value) is expected + + +def test_pane_from_format_typed_core() -> None: + """A pane snapshot exposes a typed core derived from the raw mapping.""" + pane = PaneSnapshot.from_format( + { + "pane_id": "%1", + "pane_index": "2", + "pane_active": "1", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "vim", + }, + ) + assert pane.pane_id == "%1" + assert pane.pane_index == 2 + assert pane.active is True + assert pane.width == 80 + assert pane.current_command == "vim" + + +def test_raw_fields_preserved() -> None: + """The full raw mapping is retained even for un-promoted fields.""" + pane = PaneSnapshot.from_format({"pane_id": "%1", "pane_tty": "/dev/pts/3"}) + assert pane.fields["pane_tty"] == "/dev/pts/3" + + +def test_window_from_format_has_empty_panes() -> None: + """A window built from a format mapping starts with no panes.""" + window = WindowSnapshot.from_format({"window_id": "@1", "window_name": "main"}) + assert window.panes == () + + +def test_from_pane_rows_builds_tree_in_order() -> None: + """Flat pane rows group into an ordered session/window/pane tree.""" + rows = [ + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%2", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@2", + "window_index": "1", + "pane_id": "%3", + }, + { + "session_id": "$1", + "session_name": "b", + "window_id": "@3", + "window_index": "0", + "pane_id": "%4", + }, + ] + server = ServerSnapshot.from_pane_rows(rows, socket_name="test") + + assert server.socket_name == "test" + assert [s.session_id for s in server.sessions] == ["$0", "$1"] + first = server.sessions[0] + assert first.name == "a" + assert [w.window_id for w in first.windows] == ["@1", "@2"] + assert [p.pane_id for p in first.windows[0].panes] == ["%1", "%2"] + assert [p.pane_id for p in first.windows[1].panes] == ["%3"] + assert server.sessions[1].windows[0].panes[0].pane_id == "%4" + + +def test_empty_rows_yield_empty_server() -> None: + """No rows produces a server snapshot with no sessions.""" + assert ServerSnapshot.from_pane_rows([]).sessions == () + + +def test_tree_round_trips_through_dict() -> None: + """A full server tree survives a to_dict / from_dict round-trip.""" + rows = [ + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + "pane_active": "1", + }, + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%2", + "pane_active": "0", + }, + ] + server = ServerSnapshot.from_pane_rows(rows, socket_name="test") + assert ServerSnapshot.from_dict(server.to_dict()) == server From f98a720dd4e36c2b37cdf89842a1d44f69c5ae74 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 10:55:10 -0500 Subject: [PATCH 009/154] Ops(feat): Add read-seam list operations why: The list/show read commands overlap neo's reader. Rather than touch the ORM, add a parallel typed read surface in experimental.ops that yields immutable models snapshots. The render version must thread into result parsing first, because the -F template is version-gated and the parser must split against the same fields it was rendered with. what: - operation: thread `version` through build_result -> _make_result so payload parsing matches the version-gated render (backward compatible; existing overrides accept and ignore it); execute.run/arun pass it - ops._read: re-export neo.get_output_format / parse_output and formats.FORMAT_SEPARATOR as the single source of truth (no copies) - list-panes / list-windows / list-sessions ops (readonly, chainable=False) render the same -F template neo builds and parse rows into models snapshots - ListPanesResult/.../ store JSON-friendly rows and derive typed views (.panes/.server/.windows/.sessions) via properties, so results serialize and round-trip with no special-casing - tests: -F parity with neo, snapshot-tree build, serialize round-trip, and live list-panes/sessions/windows against a real tmux server --- src/libtmux/experimental/ops/__init__.py | 12 ++ src/libtmux/experimental/ops/_ops/__init__.py | 6 + .../experimental/ops/_ops/capture_pane.py | 1 + .../experimental/ops/_ops/list_panes.py | 92 +++++++++++++ .../experimental/ops/_ops/list_sessions.py | 73 +++++++++++ .../experimental/ops/_ops/list_windows.py | 81 ++++++++++++ .../experimental/ops/_ops/split_window.py | 1 + src/libtmux/experimental/ops/_read.py | 28 ++++ src/libtmux/experimental/ops/catalog.py | 4 +- src/libtmux/experimental/ops/execute.py | 2 + src/libtmux/experimental/ops/operation.py | 13 +- src/libtmux/experimental/ops/registry.py | 2 +- src/libtmux/experimental/ops/results.py | 55 ++++++++ tests/experimental/ops/test_read_ops.py | 121 ++++++++++++++++++ tests/experimental/ops/test_registry.py | 2 +- 15 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/list_panes.py create mode 100644 src/libtmux/experimental/ops/_ops/list_sessions.py create mode 100644 src/libtmux/experimental/ops/_ops/list_windows.py create mode 100644 src/libtmux/experimental/ops/_read.py create mode 100644 tests/experimental/ops/test_read_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 73c34617c..1947be8d5 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -23,6 +23,9 @@ CapturePane, KillPane, KillWindow, + ListPanes, + ListSessions, + ListWindows, RenameWindow, SelectLayout, SendKeys, @@ -64,6 +67,9 @@ from libtmux.experimental.ops.results import ( AckResult, CapturePaneResult, + ListPanesResult, + ListSessionsResult, + ListWindowsResult, Result, SplitWindowResult, status_for, @@ -89,6 +95,12 @@ "KillPane", "KillWindow", "LazyPlan", + "ListPanes", + "ListPanesResult", + "ListSessions", + "ListSessionsResult", + "ListWindows", + "ListWindowsResult", "NameRef", "OpSpec", "Operation", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index d5152fd0d..f0449faa4 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -10,6 +10,9 @@ from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.list_panes import ListPanes +from libtmux.experimental.ops._ops.list_sessions import ListSessions +from libtmux.experimental.ops._ops.list_windows import ListWindows from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys @@ -19,6 +22,9 @@ "CapturePane", "KillPane", "KillWindow", + "ListPanes", + "ListSessions", + "ListWindows", "RenameWindow", "SelectLayout", "SendKeys", diff --git a/src/libtmux/experimental/ops/_ops/capture_pane.py b/src/libtmux/experimental/ops/_ops/capture_pane.py index 598e0a3da..bd14f345a 100644 --- a/src/libtmux/experimental/ops/_ops/capture_pane.py +++ b/src/libtmux/experimental/ops/_ops/capture_pane.py @@ -97,6 +97,7 @@ def _make_result( returncode: int, stdout: tuple[str, ...], stderr: tuple[str, ...], + version: str | None = None, ) -> CapturePaneResult: """Expose captured stdout as typed :attr:`~.CapturePaneResult.lines`.""" return CapturePaneResult( diff --git a/src/libtmux/experimental/ops/_ops/list_panes.py b/src/libtmux/experimental/ops/_ops/list_panes.py new file mode 100644 index 000000000..702a4d740 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_panes.py @@ -0,0 +1,92 @@ +"""The ``list-panes`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListPanesResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListPanes(Operation[ListPanesResult]): + """List panes and return typed snapshots (a read operation). + + Renders the same ``-F`` template the ORM reader uses (via + :func:`libtmux.neo.get_output_format`) and parses each row into a + :class:`~libtmux.experimental.models.PaneSnapshot`; with ``all_panes`` the + result also exposes the full :class:`ServerSnapshot` tree. + + Parameters + ---------- + all_panes : bool + List panes across the whole server (``-a``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> op = ListPanes() + >>> op.render(version="3.6a")[:1] + ('list-panes',) + >>> "-a" in op.render(version="3.6a") + True + >>> result = run(op, ConcreteEngine(), version="3.6a") + >>> result.rows + () + >>> result.server.sessions + () + """ + + kind = "list_panes" + command = "list-panes" + scope = "server" + result_cls = ListPanesResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + all_panes: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-a`` (optional) and the ``-F`` format template.""" + _fields, fmt = get_output_format("list-panes", version or DEFAULT_LIST_VERSION) + out: list[str] = [] + if self.all_panes: + out.append("-a") + out.extend(("-F", fmt)) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListPanesResult: + """Parse each output row into a pane format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-panes", ver) for line in stdout if line) + return ListPanesResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_sessions.py b/src/libtmux/experimental/ops/_ops/list_sessions.py new file mode 100644 index 000000000..c120b06f1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_sessions.py @@ -0,0 +1,73 @@ +"""The ``list-sessions`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListSessionsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListSessions(Operation[ListSessionsResult]): + """List the server's sessions and return typed :class:`SessionSnapshot` rows. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListSessions(), ConcreteEngine(), version="3.6a").sessions + () + """ + + kind = "list_sessions" + command = "list-sessions" + scope = "server" + result_cls = ListSessionsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the ``-F`` format template (list-sessions is server-wide).""" + _fields, fmt = get_output_format( + "list-sessions", + version or DEFAULT_LIST_VERSION, + ) + return ("-F", fmt) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListSessionsResult: + """Parse each output row into a session format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple( + parse_output(line, "list-sessions", ver) for line in stdout if line + ) + return ListSessionsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_windows.py b/src/libtmux/experimental/ops/_ops/list_windows.py new file mode 100644 index 000000000..3126c58b3 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_windows.py @@ -0,0 +1,81 @@ +"""The ``list-windows`` operation -- a typed read returning snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListWindowsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListWindows(Operation[ListWindowsResult]): + """List windows and return typed :class:`WindowSnapshot` rows. + + Parameters + ---------- + all_windows : bool + List windows across the whole server (``-a``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListWindows(), ConcreteEngine(), version="3.6a").windows + () + """ + + kind = "list_windows" + command = "list-windows" + scope = "server" + result_cls = ListWindowsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + all_windows: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-a`` (optional) and the ``-F`` format template.""" + _fields, fmt = get_output_format( + "list-windows", version or DEFAULT_LIST_VERSION + ) + out: list[str] = [] + if self.all_windows: + out.append("-a") + out.extend(("-F", fmt)) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListWindowsResult: + """Parse each output row into a window format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-windows", ver) for line in stdout if line) + return ListWindowsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/split_window.py b/src/libtmux/experimental/ops/_ops/split_window.py index abaec4734..3179cedfa 100644 --- a/src/libtmux/experimental/ops/_ops/split_window.py +++ b/src/libtmux/experimental/ops/_ops/split_window.py @@ -93,6 +93,7 @@ def _make_result( returncode: int, stdout: tuple[str, ...], stderr: tuple[str, ...], + version: str | None = None, ) -> SplitWindowResult: """Parse the captured new-pane id into the typed result.""" new_pane_id = stdout[0].strip() if status == "complete" and stdout else None diff --git a/src/libtmux/experimental/ops/_read.py b/src/libtmux/experimental/ops/_read.py new file mode 100644 index 000000000..a05c4b98e --- /dev/null +++ b/src/libtmux/experimental/ops/_read.py @@ -0,0 +1,28 @@ +"""Shared helpers for read (list) operations. + +These re-export neo's format-template builder and output parser so the list +operations render the ``-F`` template and parse it with the *exact same* logic +the ORM's reader uses -- one source of truth, no drift. The list ops are a +separate, engine-pluggable read surface that yields immutable +:mod:`~libtmux.experimental.models` snapshots; neo itself is untouched. +""" + +from __future__ import annotations + +from libtmux.formats import FORMAT_SEPARATOR +from libtmux.neo import get_output_format, parse_output + +DEFAULT_LIST_VERSION = "3.2a" +"""tmux version assumed when the caller supplies none (the project floor). + +Rendering and parsing must use the *same* version, so a list op renders its +``-F`` template and parses its output at this version unless an explicit one is +threaded through. Older = a safe field subset on any newer server. +""" + +__all__ = ( + "DEFAULT_LIST_VERSION", + "FORMAT_SEPARATOR", + "get_output_format", + "parse_output", +) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index d7012a325..f7331ed84 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,8 +65,8 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'kill_pane', 'kill_window', 'rename_window', - 'select_layout', 'send_keys', 'split_window'] + ['capture_pane', 'kill_pane', 'kill_window', 'list_panes', 'list_sessions', + 'list_windows', 'rename_window', 'select_layout', 'send_keys', 'split_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/execute.py b/src/libtmux/experimental/ops/execute.py index c6b12a345..4197dcfce 100644 --- a/src/libtmux/experimental/ops/execute.py +++ b/src/libtmux/experimental/ops/execute.py @@ -72,6 +72,7 @@ def run( returncode=raw.returncode, stdout=raw.stdout, stderr=raw.stderr, + version=version, ) @@ -112,4 +113,5 @@ async def arun( returncode=raw.returncode, stdout=raw.stdout, stderr=raw.stderr, + version=version, ) diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index f2c9b9c07..701b4e1ed 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -215,7 +215,9 @@ def build_result( stdout, stderr : Sequence[str] Captured output lines. version : str or None - Used only to render *argv* when it is omitted. + The tmux version the output was produced against. Used to render + *argv* when omitted, and passed to :meth:`_make_result` so payload + parsing can match the version-gated render (e.g. a ``-F`` template). Returns ------- @@ -230,6 +232,7 @@ def build_result( returncode, tuple(stdout), tuple(stderr), + version=version, ) def _make_result( @@ -239,8 +242,14 @@ def _make_result( returncode: int, stdout: tuple[str, ...], stderr: tuple[str, ...], + version: str | None = None, ) -> ResultT: - """Construct the result instance; override to populate typed payloads.""" + """Construct the result instance; override to populate typed payloads. + + ``version`` is the tmux version the output was produced against; payload + parsers that depend on a version-gated render (read ops) use it. The base + implementation and simple overrides ignore it. + """ return t.cast( "ResultT", self.result_cls( diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index a929d79ef..c410c1936 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -164,7 +164,7 @@ def list( -------- >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.list(lambda s: s.safety == "readonly")] - ['capture_pane'] + ['capture_pane', 'list_panes', 'list_sessions', 'list_windows'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index b275171b8..9abe49beb 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -19,9 +19,17 @@ import typing as t from dataclasses import dataclass, field +from libtmux.experimental.models.snapshots import ( + PaneSnapshot, + ServerSnapshot, + SessionSnapshot, + WindowSnapshot, +) from libtmux.experimental.ops.exc import TmuxCommandError if t.TYPE_CHECKING: + from collections.abc import Mapping + from typing_extensions import Self from libtmux.experimental.ops._types import Status @@ -195,3 +203,50 @@ class CapturePaneResult(Result): """ lines: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class ListPanesResult(Result): + """Result of a ``list-panes`` operation. + + Stores the parsed per-pane format mappings as JSON-friendly :attr:`rows` + (so the result serializes without snapshot objects), and derives typed + :class:`~..models.snapshots.PaneSnapshot` / :class:`ServerSnapshot` views on + demand. + """ + + rows: tuple[Mapping[str, str], ...] = () + + @property + def panes(self) -> tuple[PaneSnapshot, ...]: + """One typed pane snapshot per row.""" + return tuple(PaneSnapshot.from_format(row) for row in self.rows) + + @property + def server(self) -> ServerSnapshot: + """The full session/window/pane tree built from the rows.""" + return ServerSnapshot.from_pane_rows(self.rows) + + +@dataclass(frozen=True) +class ListWindowsResult(Result): + """Result of a ``list-windows`` operation (typed :attr:`windows`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def windows(self) -> tuple[WindowSnapshot, ...]: + """One typed window snapshot per row.""" + return tuple(WindowSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class ListSessionsResult(Result): + """Result of a ``list-sessions`` operation (typed :attr:`sessions`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def sessions(self) -> tuple[SessionSnapshot, ...]: + """One typed session snapshot per row.""" + return tuple(SessionSnapshot.from_format(row) for row in self.rows) diff --git a/tests/experimental/ops/test_read_ops.py b/tests/experimental/ops/test_read_ops.py new file mode 100644 index 000000000..3a3ebb5eb --- /dev/null +++ b/tests/experimental/ops/test_read_ops.py @@ -0,0 +1,121 @@ +"""Tests for the read-seam list operations.""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.ops import ( + ListPanes, + ListSessions, + ListWindows, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + FORMAT_SEPARATOR, + get_output_format, +) +from libtmux.experimental.ops.results import ListPanesResult + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_list_panes_template_matches_neo() -> None: + """The op renders the identical -F template neo would build (no drift).""" + _fields, fmt = get_output_format("list-panes", "3.6a") + argv = ListPanes().render(version="3.6a") + assert "-a" in argv + assert fmt in argv # same template, byte for byte + + +def test_list_panes_parses_rows_into_snapshot_tree() -> None: + """A synthesized list-panes output parses into a ServerSnapshot tree.""" + fields, _ = get_output_format("list-panes", DEFAULT_LIST_VERSION) + + def row(**values: str) -> str: + cells = [values.get(name, "") for name in fields] + return FORMAT_SEPARATOR.join(cells) + FORMAT_SEPARATOR + + stdout = ( + row(session_id="$0", session_name="a", window_id="@1", pane_id="%1"), + row(session_id="$0", session_name="a", window_id="@1", pane_id="%2"), + ) + op = ListPanes() + result = op.build_result(returncode=0, stdout=stdout, version=DEFAULT_LIST_VERSION) + + assert isinstance(result, ListPanesResult) + assert [p.pane_id for p in result.panes] == ["%1", "%2"] + assert [s.session_id for s in result.server.sessions] == ["$0"] + assert [p.pane_id for p in result.server.sessions[0].windows[0].panes] == [ + "%1", + "%2", + ] + + +def test_list_result_serialization_round_trip() -> None: + """A list result round-trips via its JSON-friendly rows.""" + fields, _ = get_output_format("list-panes", DEFAULT_LIST_VERSION) + cells = [""] * len(fields) + cells[fields.index("pane_id")] = "%1" + line = FORMAT_SEPARATOR.join(cells) + FORMAT_SEPARATOR + result = ListPanes().build_result( + returncode=0, + stdout=(line,), + version=DEFAULT_LIST_VERSION, + ) + assert result_from_dict(result_to_dict(result)) == result + + +def test_empty_output_yields_empty_views() -> None: + """No panes -> empty rows, empty snapshot, no error.""" + result = run(ListPanes(), ConcreteEngine(), version="3.6a") + assert result.rows == () + assert result.server.sessions == () + assert result.ok + + +def test_list_panes_live(session: Session) -> None: + """Against real tmux, ListPanes builds a tree containing the fixture pane.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + + # No version -> the safe 3.2a-floor template (a field subset valid on any + # supported tmux); core ids (pane_id/session_id) are always present. + result = run(ListPanes(), engine) + + assert result.ok + pane_ids = {p.pane_id for p in result.panes} + active_pane = session.active_pane + assert active_pane is not None + assert active_pane.pane_id in pane_ids + # the snapshot tree includes the fixture session + session_ids = {s.session_id for s in result.server.sessions} + assert session.session_id in session_ids + + +def test_list_sessions_live(session: Session) -> None: + """Against real tmux, ListSessions returns the fixture session.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + result = run(ListSessions(), engine) + assert result.ok + assert session.session_id in {s.session_id for s in result.sessions} + + +def test_list_windows_live(session: Session) -> None: + """Against real tmux, ListWindows returns typed window snapshots.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + engine = SubprocessEngine.for_server(server) + result = run(ListWindows(), engine) + assert result.ok + assert all(w.window_id.startswith("@") for w in result.windows) diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index b87261dd7..0d6d4c766 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -45,7 +45,7 @@ def test_list_predicate_filters() -> None: readonly = [ spec.kind for spec in registry.list(lambda spec: spec.safety == "readonly") ] - assert readonly == ["capture_pane"] + assert readonly == ["capture_pane", "list_panes", "list_sessions", "list_windows"] def test_register_duplicate_fails_closed() -> None: From 1205c12798941d5efcecd25900297c74650eb0cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:02:28 -0500 Subject: [PATCH 010/154] docs(experimental): Add tmuxop-catalog directive why: The operation catalog is registry-derived data, so rendering it in docs keeps the operation reference from drifting from the code -- and the docs gate then exercises catalog() on every build. what: - docs/_ext/tmuxop.py: an in-repo Sphinx directive `tmuxop-catalog` that walks libtmux.experimental.ops.catalog() and emits a table, with :scope:/:safety:/:primitive-only: filters; warns (not raises) on empty - conf.py: add docs/_ext to sys.path and 'tmuxop' to extra_extensions - docs/experimental.md: an experimental ops/engines overview embedding the catalog (full + readonly + destructive views), in the index toctree --- docs/_ext/tmuxop.py | 120 +++++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 + docs/experimental.md | 37 +++++++++++++ docs/index.md | 1 + 4 files changed, 160 insertions(+) create mode 100644 docs/_ext/tmuxop.py create mode 100644 docs/experimental.md diff --git a/docs/_ext/tmuxop.py b/docs/_ext/tmuxop.py new file mode 100644 index 000000000..550c0bbd8 --- /dev/null +++ b/docs/_ext/tmuxop.py @@ -0,0 +1,120 @@ +"""Sphinx directive that renders the experimental operation catalog. + +``.. tmuxop-catalog::`` (or the MyST fenced form) walks +:func:`libtmux.experimental.ops.catalog` and emits a table of operations with +their scope, safety tier, result type, minimum tmux version, and summary. The +operation registry is the single source of truth, so the rendered reference +cannot drift from the code. + +Options +------- +``:scope:`` / ``:safety:`` + Filter to one scope (``pane``/``window``/``session``/``server``/``client``) + or safety tier (``readonly``/``mutating``/``destructive``). +``:primitive-only:`` + Show only operations that wrap a single tmux command. + +This is the in-repo renderer; a full gp-sphinx ``tmuxop`` domain (cross-reference +roles + an operations index) can later replace it under the same directive name. +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +_HEADERS = ("Operation", "Command", "Scope", "Safety", "Result", "Min tmux", "Summary") + + +def _row(cells: Sequence[str]) -> nodes.row: + """Build a docutils table row from string cells.""" + row = nodes.row() + for cell in cells: + entry = nodes.entry() + entry += nodes.paragraph(text=cell) + row += entry + return row + + +def _table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> nodes.table: + """Build a simple docutils table.""" + table = nodes.table() + tgroup = nodes.tgroup(cols=len(headers)) + table += tgroup + for _ in headers: + tgroup += nodes.colspec(colwidth=1) + thead = nodes.thead() + thead += _row(headers) + tgroup += thead + tbody = nodes.tbody() + for row in rows: + tbody += _row(row) + tgroup += tbody + return table + + +class TmuxopCatalogDirective(SphinxDirective): + """Render the operation catalog as a table.""" + + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "scope": directives.unchanged, + "safety": directives.unchanged, + "primitive-only": directives.flag, + } + + def run(self) -> list[nodes.Node]: + """Build the catalog table from the operation registry.""" + from libtmux.experimental.ops import catalog + + entries = catalog() + scope = self.options.get("scope") + safety = self.options.get("safety") + if scope: + entries = [entry for entry in entries if entry.scope == scope] + if safety: + entries = [entry for entry in entries if entry.safety == safety] + if "primitive-only" in self.options: + entries = [entry for entry in entries if entry.primitive] + + if not entries: + logger.warning( + "tmuxop-catalog: no operations matched the given filters", + location=self.get_location(), + ) + return [] + + rows = [ + ( + entry.kind, + entry.command, + entry.scope, + entry.safety, + entry.result_type, + entry.min_version or "-", + entry.summary, + ) + for entry in entries + ] + return [_table(_HEADERS, rows)] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the directive.""" + app.add_directive("tmuxop-catalog", TmuxopCatalogDirective) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/conf.py b/docs/conf.py index 379d0b3c6..5efc0c9a9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,7 @@ project_src = project_root / "src" sys.path.insert(0, str(project_src)) +sys.path.insert(0, str(cwd / "_ext")) # package data about: dict[str, str] = {} @@ -34,6 +35,7 @@ "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx.ext.todo", + "tmuxop", ], intersphinx_mapping={ "python": ("https://docs.python.org/", None), diff --git a/docs/experimental.md b/docs/experimental.md new file mode 100644 index 000000000..8cebf6145 --- /dev/null +++ b/docs/experimental.md @@ -0,0 +1,37 @@ +(experimental)= + +# Experimental: operations & engines + +```{warning} +Everything under {mod}`libtmux.experimental` is **not** covered by the +versioning policy and may change or be removed between any two releases. +``` + +`libtmux.experimental` hosts an inert, typed *operation* substrate and the +*engines* that execute it. An operation describes a tmux command (it renders +argv, carries its result type and metadata, and serializes) without dispatching; +an engine runs operations and returns typed results. The same operation returns +the same typed result whether executed by a subprocess, an in-memory simulator, +a persistent `tmux -C` control connection, or an async transport. + +See ``tmux-python/libtmux`` issue 689 for the operationalization plan. + +## Operation catalog + +The catalog below is generated from the operation registry, so it always matches +the code. + +```{tmuxop-catalog} +``` + +### Read-only operations + +```{tmuxop-catalog} +:safety: readonly +``` + +### Destructive operations + +```{tmuxop-catalog} +:safety: destructive +``` diff --git a/docs/index.md b/docs/index.md index 6da452c68..acd162488 100644 --- a/docs/index.md +++ b/docs/index.md @@ -100,6 +100,7 @@ api/index api/testing/index internals/index project/index +experimental history migration glossary From 72207f9b509bf2a46d50373f02b6ef6987e10df5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:10:26 -0500 Subject: [PATCH 011/154] ControlMode(fix): Consume startup ACK, drain unsolicited blocks why: The sync control engine skipped tmux's startup ACK with a fragile one-shot flags==0 heuristic and had no defense against hook-emitted %begin/%end blocks, so a stray block could desync request->result alignment. The async engine already handles this; backport the approach. what: - consume the startup ACK synchronously at connect (_consume_startup), dropping the one-shot _startup_ack_pending heuristic, so the startup block can never be conflated with a command's result block - drain buffered unsolicited blocks before each batch (_drain_unsolicited), so a hook-triggered command's block left over from a prior call is not mis-attributed to the next command - drain notifications during reads to keep the parser buffer bounded - regression test: many sequential commands stay aligned (first result is real; each call drains before reading its own block) A hook firing mid-pipelined-batch still needs per-command number correlation to disambiguate; single-command run() is robust. --- .../experimental/engines/control_mode.py | 55 ++++++++++++++++--- .../contract/test_control_engine.py | 23 ++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 73f069661..57e4f6f88 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -44,6 +44,7 @@ _ERROR_PREFIX = b"%error " _READ_CHUNK = 65536 _DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 _GRACEFUL_EXIT_TIMEOUT = 0.5 _TERMINATE_TIMEOUT = 1.0 _GUARD_MIN_PARTS = 3 @@ -187,7 +188,6 @@ def __init__( self._parser = ControlModeParser() self._proc: subprocess.Popen[bytes] | None = None self._selector: selectors.DefaultSelector | None = None - self._startup_ack_pending = True def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" @@ -200,6 +200,10 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: rendered = [tuple(req.args) for req in requests] with self._lock: self._ensure_started() + # Discard any unsolicited blocks (hook-triggered commands) left + # buffered from earlier activity, so they cannot be mis-attributed + # to this batch's commands. + self._drain_unsolicited() payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) self._write(payload) blocks = self._read_blocks(len(rendered)) @@ -216,7 +220,6 @@ def close(self) -> None: self._proc = None self._selector = None self._parser = ControlModeParser() - self._startup_ack_pending = True if selector is not None: with contextlib.suppress(Exception): selector.close() @@ -279,7 +282,48 @@ def _ensure_started(self) -> None: selector.register(proc.stderr, selectors.EVENT_READ, "stderr") self._proc = proc self._selector = selector - self._startup_ack_pending = True + self._consume_startup() + + def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before any command. + + Consuming it up front (instead of skipping the first block heuristically + at read time) means the startup block can never be conflated with a + command's result block. + """ + deadline = time.monotonic() + _STARTUP_TIMEOUT + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return + if self._proc is not None and self._proc.poll() is not None: + return + self._pump(remaining) + if self._parser.blocks(): # startup ACK seen and discarded + self._parser.notifications() + return + self._parser.notifications() + + def _drain_unsolicited(self) -> None: + """Discard any blocks/notifications already buffered (non-blocking).""" + selector = self._selector + if selector is None: + return + while selector.select(0): + self._pump(0) + self._parser.blocks() + self._parser.notifications() + + def _pump(self, timeout: float) -> None: + """Wait up to *timeout* for output and feed it to the parser.""" + selector = self._selector + if selector is None: + return + for key, _events in selector.select(timeout): + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() def _write(self, payload: bytes) -> None: proc = self._proc @@ -321,13 +365,10 @@ def _read_blocks(self, count: int) -> list[ControlModeBlock]: elif key.data == "stderr": self._read_stderr() for block in self._parser.blocks(): - if self._startup_ack_pending: - self._startup_ack_pending = False - if block.flags == 0: - continue blocks.append(block) if len(blocks) == count: break + self._parser.notifications() # sync engine ignores notifications return blocks def _read_stdout(self) -> None: diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py index 73aa1a6f8..dd280cc98 100644 --- a/tests/experimental/contract/test_control_engine.py +++ b/tests/experimental/contract/test_control_engine.py @@ -27,6 +27,29 @@ from libtmux.session import Session +def test_control_sequential_commands_stay_aligned(session: Session) -> None: + """Many sequential commands keep result alignment (drain-between-calls path). + + The first command must get the real result (not the consumed startup ACK), + and each subsequent call drains any unsolicited blocks before reading its own. + """ + server = session.server + pane = session.active_pane + assert pane is not None + assert pane.pane_id is not None + + with ControlModeEngine.for_server(server) as engine: + for index in range(5): + result = run( + SendKeys(target=PaneId(pane.pane_id), keys=f"# {index}"), engine + ) + assert result.ok + captured = run(CapturePane(target=PaneId(pane.pane_id)), engine) + + assert isinstance(captured, CapturePaneResult) + assert captured.ok + + def test_control_split_creates_real_pane(session: Session) -> None: """A control-mode split returns a typed result whose pane really exists.""" server = session.server From 4fbaeb440fa0dfac4be0f60edac5c4aa042ae9a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:19:24 -0500 Subject: [PATCH 012/154] Ops(feat): Add lazy-plan chainability (>> and ; folding) why: The chainable-commands prototype folds independent commands into one "tmux a ; b" dispatch. Our typed-op model is a better host for it -- the Operation already carries a `chainable` classvar and the result Status already reserves `skipped` for exactly the chain-drop case. So yes, lazy mode can adopt the prototype's chainability. what: - mark output/creation ops non-chainable (capture-pane, split-window; list-* already were) so a fold never drops captured data or an id - ops._chain: render_chain (join chainable ops with standalone ';', escaping a trailing-';' arg), ensure_chainable (fail closed), and attribute -- splitting one merged ';'-chain result into a typed result per op (success -> all complete; failure -> first failed, rest skipped, matching tmux cmd-queue.c cmdq_remove_group); plus OpChain with >>/then - Operation.__rshift__/then compose into an OpChain; result_with_status() builds a result with an explicit status (skipped/failed attribution) - LazyPlan.execute/aexecute gain fold=False (opt-in): maximal runs of chainable, resolved ops dispatch once via engine.run; the sans-I/O _drive yields _Single or _Chain so sync and async share the core; add_chain() records an OpChain - tests: >> composition, render_chain, fold=one dispatch, fold-off=N dispatches, failure attribution, creators stay unfolded, add_chain --- src/libtmux/experimental/ops/__init__.py | 2 + src/libtmux/experimental/ops/_chain.py | 138 ++++++++++++++++ .../experimental/ops/_ops/capture_pane.py | 1 + .../experimental/ops/_ops/split_window.py | 1 + src/libtmux/experimental/ops/operation.py | 35 ++++ src/libtmux/experimental/ops/plan.py | 116 +++++++++++-- tests/experimental/ops/test_chain.py | 155 ++++++++++++++++++ 7 files changed, 430 insertions(+), 18 deletions(-) create mode 100644 src/libtmux/experimental/ops/_chain.py create mode 100644 tests/experimental/ops/test_chain.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 1947be8d5..6e6f0df6a 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -19,6 +19,7 @@ from __future__ import annotations +from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._ops import ( CapturePane, KillPane, @@ -102,6 +103,7 @@ "ListWindows", "ListWindowsResult", "NameRef", + "OpChain", "OpSpec", "Operation", "OperationError", diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py new file mode 100644 index 000000000..0d62b12cb --- /dev/null +++ b/src/libtmux/experimental/ops/_chain.py @@ -0,0 +1,138 @@ +r"""Chaining and ``;``-folding for lazy plans. + +A run of *chainable* operations can render to a single ``tmux a \; b`` invocation +and dispatch once, instead of one process fork / control-mode command per +operation. This ports the chainable-commands prototype's fold onto the typed-op +model: only operations whose ``chainable`` class var is ``True`` (no captured +output, no created object) fold; the rest dispatch alone. + +tmux runs a ``;`` sequence up to the first error and drops the remainder +(``cmd-queue.c`` ``cmdq_remove_group``), returning one merged stdout/exit with no +per-command boundary. :func:`attribute` recovers a typed result per operation: +on success every member is ``complete``; on failure the first member is +``failed`` and the rest are ``skipped`` (the status the spine reserves for +exactly this case). +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + from libtmux.experimental.engines.base import CommandResult + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +def ensure_chainable(op: Operation[t.Any]) -> None: + """Raise if *op* cannot be folded into a ``;`` chain (fail closed).""" + if not op.chainable: + msg = ( + f"operation {op.kind!r} is not chainable; it produces output or " + f"creates an object and must dispatch on its own" + ) + raise OperationError(msg) + + +def _escape_arg(token: str) -> str: + r"""Escape a trailing ``;`` so tmux does not read the arg as a separator.""" + if token.endswith(";"): + return token[:-1] + "\\;" + return token + + +def render_chain( + ops: Sequence[Operation[t.Any]], + version: str | None = None, +) -> tuple[str, ...]: + r"""Render chainable ops to one argv with standalone ``;`` separators. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, RenameWindow + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> render_chain([ + ... SendKeys(target=PaneId("%1"), keys="vim", enter=True), + ... RenameWindow(target=WindowId("@1"), name="edit"), + ... ]) + ('send-keys', '-t', '%1', 'vim', 'Enter', ';', 'rename-window', '-t', '@1', 'edit') + """ + out: list[str] = [] + for index, op in enumerate(ops): + if index: + out.append(";") + out.extend(_escape_arg(token) for token in op.render(version=version)) + return tuple(out) + + +def attribute( + ops: Sequence[Operation[t.Any]], + merged: CommandResult, + version: str | None = None, +) -> list[Result]: + """Split one merged ``;``-chain result into a typed result per operation.""" + if merged.returncode == 0 and not merged.stderr: + return [op.result_with_status("complete", version=version) for op in ops] + first, *rest = ops + results: list[Result] = [ + first.result_with_status( + "failed", + version=version, + returncode=merged.returncode, + stdout=tuple(merged.stdout), + stderr=tuple(merged.stderr), + ), + ] + results.extend(op.result_with_status("skipped", version=version) for op in rest) + return results + + +@dataclass(frozen=True) +class OpChain: + """An ordered group of operations composed with :meth:`~.Operation.then`. + + A power-user, inspectable handle for explicit chaining. Add it to a + :class:`~.plan.LazyPlan` with :meth:`~.plan.LazyPlan.add_chain`; the plan's + folded execution (``execute(fold=True)``) batches chainable runs anyway. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys, RenameWindow + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> chain = ( + ... SendKeys(target=PaneId("%1"), keys="q") + ... >> RenameWindow(target=WindowId("@1"), name="done") + ... ) + >>> [op.kind for op in chain] + ['send_keys', 'rename_window'] + """ + + ops: tuple[Operation[t.Any], ...] + + def then(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Append an operation or chain.""" + return OpChain((*self.ops, *_as_ops(other))) + + def __rshift__(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Append with ``>>``.""" + return self.then(other) + + def __iter__(self) -> Iterator[Operation[t.Any]]: + """Iterate the operations in order.""" + return iter(self.ops) + + def __len__(self) -> int: + """Return the number of operations in the chain.""" + return len(self.ops) + + +def _as_ops(other: Operation[t.Any] | OpChain) -> tuple[Operation[t.Any], ...]: + """Normalize an operation or chain to a tuple of operations.""" + if isinstance(other, OpChain): + return other.ops + return (other,) diff --git a/src/libtmux/experimental/ops/_ops/capture_pane.py b/src/libtmux/experimental/ops/_ops/capture_pane.py index bd14f345a..684e3c6f6 100644 --- a/src/libtmux/experimental/ops/_ops/capture_pane.py +++ b/src/libtmux/experimental/ops/_ops/capture_pane.py @@ -60,6 +60,7 @@ class CapturePane(Operation[CapturePaneResult]): scope = "pane" result_cls = CapturePaneResult safety = "readonly" + chainable = False # produces stdout that must not be merged into a ; chain effects = Effects(read_only=True, reads_output=True, idempotent=True) flag_version_map: t.ClassVar[Mapping[str, str]] = { "trim_trailing": "3.4", diff --git a/src/libtmux/experimental/ops/_ops/split_window.py b/src/libtmux/experimental/ops/_ops/split_window.py index 3179cedfa..2bf534d21 100644 --- a/src/libtmux/experimental/ops/_ops/split_window.py +++ b/src/libtmux/experimental/ops/_ops/split_window.py @@ -64,6 +64,7 @@ class SplitWindow(Operation[SplitWindowResult]): scope = "window" result_cls = SplitWindowResult safety = "mutating" + chainable = False # captures a new pane id (-P -F); cannot fold into a ; chain effects = Effects(creates="pane") flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index 701b4e1ed..57479717f 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -27,6 +27,7 @@ if t.TYPE_CHECKING: from collections.abc import Mapping, Sequence + from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._types import ( Effects, Safety, @@ -235,6 +236,40 @@ def build_result( version=version, ) + def result_with_status( + self, + status: Status, + *, + version: str | None = None, + returncode: int = 0, + stdout: Sequence[str] = (), + stderr: Sequence[str] = (), + ) -> ResultT: + """Build a result with an explicit *status* (no status inference). + + Used when the status is known out of band -- e.g. a chained operation + whose ``;`` group ran but whose own outcome must be marked ``skipped`` + because an earlier member failed. + """ + return self._make_result( + self.render(version=version), + status, + returncode, + tuple(stdout), + tuple(stderr), + version=version, + ) + + def then(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Compose with another operation (or chain) into an :class:`OpChain`.""" + from libtmux.experimental.ops._chain import OpChain + + return OpChain((self,)).then(other) + + def __rshift__(self, other: Operation[t.Any] | OpChain) -> OpChain: + """Compose operations with ``>>`` into an :class:`OpChain`.""" + return self.then(other) + def _make_result( self, argv: tuple[str, ...], diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 50d07d10a..e83449cff 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -18,6 +18,8 @@ import typing as t from dataclasses import dataclass, field +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.ops._chain import attribute, render_chain from libtmux.experimental.ops._types import ( PaneId, SessionId, @@ -34,12 +36,31 @@ from typing_extensions import Self - from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.engines.base import ( + AsyncTmuxEngine, + CommandResult, + TmuxEngine, + ) + from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._types import Target from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.results import Result +@dataclass(frozen=True) +class _Single: + """Drive request: run one resolved operation and return its typed result.""" + + op: Operation[t.Any] + + +@dataclass(frozen=True) +class _Chain: + """Drive request: dispatch a folded ``;`` chain and return the merged result.""" + + argv: tuple[str, ...] + + def _target_from_id(value: str) -> Target: """Map a captured concrete id back to its typed target.""" if value.startswith("%"): @@ -154,47 +175,106 @@ def from_list(cls, data: t.Sequence[t.Mapping[str, t.Any]]) -> LazyPlan: plan._operations = [operation_from_dict(item) for item in data] return plan + def add_chain(self, chain: OpChain) -> None: + """Record every operation of an :class:`~._chain.OpChain` in order.""" + self._operations.extend(chain.ops) + def _drive( self, version: str | None, - ) -> Generator[Operation[t.Any], Result, PlanResult]: - """Sans-I/O resolution core: yield a resolved op, resume with its result.""" + fold: bool, + ) -> Generator[_Single | _Chain, t.Any, PlanResult]: + """Sans-I/O resolution core. + + Yields a :class:`_Single` (driver runs one op and returns its + :class:`~.results.Result`) or, when ``fold`` is set, a :class:`_Chain` + for a maximal run of chainable ops (driver returns the merged + :class:`~..engines.base.CommandResult`, attributed per op here). The + sync and async drivers differ only in ``run`` vs ``await arun`` and + ``engine.run`` vs ``await engine.run``. + """ bindings: dict[int, str] = {} - results: list[Result] = [] - for index, operation in enumerate(self._operations): - result = yield _resolve(operation, bindings) - results.append(result) - created = getattr(result, "new_pane_id", None) - if created is not None: - bindings[index] = created - return PlanResult(tuple(results), bindings) + results: dict[int, Result] = {} + index = 0 + total = len(self._operations) + while index < total: + operation = self._operations[index] + if fold and operation.chainable: + indices: list[int] = [] + group: list[Operation[t.Any]] = [] + cursor = index + while cursor < total and self._operations[cursor].chainable: + indices.append(cursor) + group.append(_resolve(self._operations[cursor], bindings)) + cursor += 1 + if len(group) == 1: + results[indices[0]] = yield _Single(group[0]) + else: + merged: CommandResult = yield _Chain(render_chain(group, version)) + results.update( + zip(indices, attribute(group, merged, version), strict=True), + ) + index = cursor + else: + result = yield _Single(_resolve(operation, bindings)) + results[index] = result + created = getattr(result, "new_pane_id", None) + if created is not None: + bindings[index] = created + index += 1 + ordered = tuple(results[slot] for slot in range(total)) + return PlanResult(ordered, bindings) def execute( self, engine: TmuxEngine, *, version: str | None = None, + fold: bool = False, ) -> PlanResult: - """Resolve and execute the plan synchronously.""" - gen = self._drive(version) + """Resolve and execute the plan synchronously. + + With ``fold=True``, maximal runs of chainable operations dispatch as one + ``tmux a ; b`` invocation (fewer forks / one control-mode command), + attributing a typed result per operation. ``fold`` defaults off because + folding changes observable semantics (fewer dispatches; a folded + failure marks the first member failed and the rest skipped). + """ + gen = self._drive(version, fold) try: - operation = next(gen) + request = next(gen) while True: - operation = gen.send(run(operation, engine, version=version)) + request = gen.send(self._dispatch(request, engine, version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) + def _dispatch( + self, + request: _Single | _Chain, + engine: TmuxEngine, + version: str | None, + ) -> t.Any: + """Run one drive request synchronously.""" + if isinstance(request, _Chain): + return engine.run(CommandRequest.from_args(*request.argv)) + return run(request.op, engine, version=version) + async def aexecute( self, engine: AsyncTmuxEngine, *, version: str | None = None, + fold: bool = False, ) -> PlanResult: """Resolve and execute the plan asynchronously (same resolution core).""" - gen = self._drive(version) + gen = self._drive(version, fold) try: - operation = next(gen) + request = next(gen) while True: - operation = gen.send(await arun(operation, engine, version=version)) + if isinstance(request, _Chain): + raw = await engine.run(CommandRequest.from_args(*request.argv)) + request = gen.send(raw) + else: + request = gen.send(await arun(request.op, engine, version=version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py new file mode 100644 index 000000000..635713da2 --- /dev/null +++ b/tests/experimental/ops/test_chain.py @@ -0,0 +1,155 @@ +"""Tests for lazy-plan chainability (>> composition and ; folding).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import CommandResult +from libtmux.experimental.ops import ( + CapturePane, + KillWindow, + LazyPlan, + OpChain, + RenameWindow, + SendKeys, + SplitWindow, +) +from libtmux.experimental.ops._chain import ensure_chainable, render_chain +from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops.exc import OperationError + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class _CountingEngine: + """An engine that counts dispatches and returns a canned result.""" + + def __init__(self, *, returncode: int = 0, stderr: tuple[str, ...] = ()) -> None: + self.returncode = returncode + self.stderr = stderr + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and return the canned result.""" + self.calls.append(request.args) + return CommandResult( + cmd=("tmux", *request.args), + stderr=self.stderr, + returncode=self.returncode, + ) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def test_rshift_builds_opchain() -> None: + """``>>`` composes operations into an ordered OpChain.""" + chain = SendKeys(target=PaneId("%1"), keys="q") >> RenameWindow( + target=WindowId("@1"), + name="done", + ) + assert isinstance(chain, OpChain) + assert [op.kind for op in chain] == ["send_keys", "rename_window"] + + +def test_ensure_chainable_rejects_output_ops() -> None: + """Output/creation ops are not chainable (fail closed).""" + ensure_chainable(SendKeys(target=PaneId("%1"), keys="q")) # ok + with pytest.raises(OperationError, match="not chainable"): + ensure_chainable(CapturePane(target=PaneId("%1"))) + with pytest.raises(OperationError, match="not chainable"): + ensure_chainable(SplitWindow(target=WindowId("@1"))) + + +def test_render_chain_joins_with_separator() -> None: + """Chainable ops render to one argv with standalone ';' separators.""" + argv = render_chain( + [ + SendKeys(target=PaneId("%1"), keys="vim", enter=True), + RenameWindow(target=WindowId("@1"), name="edit"), + ], + ) + assert argv == ( + "send-keys", + "-t", + "%1", + "vim", + "Enter", + ";", + "rename-window", + "-t", + "@1", + "edit", + ) + + +def test_fold_dispatches_once() -> None: + """A run of chainable ops folds into a single engine dispatch.""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + plan.add(KillWindow(target=WindowId("@2"))) + engine = _CountingEngine() + + outcome = plan.execute(engine, fold=True) + + assert len(engine.calls) == 1 # all three folded into one ';' dispatch + assert ";" in engine.calls[0] + assert outcome.ok + assert [r.status for r in outcome.results] == ["complete", "complete", "complete"] + + +def test_no_fold_dispatches_per_op() -> None: + """Without folding, each op dispatches on its own (default behaviour).""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + engine = _CountingEngine() + + plan.execute(engine) # fold defaults to False + + assert len(engine.calls) == 2 + + +def test_fold_failure_attributes_first_failed_rest_skipped() -> None: + """A folded failure marks the first op failed and the rest skipped.""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(RenameWindow(target=WindowId("@1"), name="x")) + plan.add(KillWindow(target=WindowId("@2"))) + engine = _CountingEngine(returncode=1, stderr=("boom",)) + + outcome = plan.execute(engine, fold=True) + + assert [r.status for r in outcome.results] == ["failed", "skipped", "skipped"] + assert not outcome.ok + + +def test_fold_keeps_creation_ops_unfolded() -> None: + """A non-chainable creator dispatches alone; chainable neighbours fold.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) # not chainable + plan.add(SendKeys(target=pane, keys="vim")) # chainable, targets new pane + plan.add(RenameWindow(target=WindowId("@1"), name="x")) # chainable + from libtmux.experimental.engines import ConcreteEngine + + outcome = plan.execute(ConcreteEngine(), fold=True) + + # split resolved the pane id; the send-keys folded with rename, retargeted + assert outcome.results[1].argv[:3] == ("send-keys", "-t", "%1") + assert outcome.ok + + +def test_add_chain() -> None: + """A composed OpChain can be added to a plan in order.""" + plan = LazyPlan() + plan.add_chain( + SendKeys(target=PaneId("%1"), keys="q") >> KillWindow(target=WindowId("@1")), + ) + assert [op.kind for op in plan] == ["send_keys", "kill_window"] From 21b59e739b5680e929965fcb64a4b0b5a510c149 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:28:29 -0500 Subject: [PATCH 013/154] Facade(feat): Add Server/Session/Window facades + creation ops why: Extend the mode-in-the-type facades beyond the pane seed so a typed return value distinguishes eager/lazy/async across scopes -- and add the few creation ops the cross-scope navigation needs. what: - ops: NewWindow / NewSession (CreateResult, capture the new id), KillSession, RenameSession; generalize binding capture via Result.created_id (base None; SplitWindowResult -> new_pane_id; CreateResult -> new_id) so lazy plans bind window/session creations too - facade: eager Server -> Session -> Window -> Pane navigation (EagerServer/EagerSession/EagerWindow); LazyWindow (records into a plan); AsyncPane / AsyncWindow (await arun) -- all over the same ops. Control mode stays an engine choice, not a separate facade family - EagerServer.for_server() binds the classic engine to a live Server - tests: offline navigation across scopes/modes (concrete engine), and a live eager Server -> Session -> Window -> Pane build against real tmux with cleanup --- src/libtmux/experimental/facade/__init__.py | 34 ++- src/libtmux/experimental/facade/pane.py | 71 ++++++- src/libtmux/experimental/facade/server.py | 56 +++++ src/libtmux/experimental/facade/session.py | 75 +++++++ src/libtmux/experimental/facade/window.py | 197 ++++++++++++++++++ src/libtmux/experimental/ops/__init__.py | 10 + src/libtmux/experimental/ops/_ops/__init__.py | 8 + .../experimental/ops/_ops/kill_session.py | 34 +++ .../experimental/ops/_ops/new_session.py | 84 ++++++++ .../experimental/ops/_ops/new_window.py | 89 ++++++++ .../experimental/ops/_ops/rename_session.py | 36 ++++ src/libtmux/experimental/ops/catalog.py | 6 +- src/libtmux/experimental/ops/plan.py | 5 +- src/libtmux/experimental/ops/results.py | 30 +++ .../experimental/facade/test_facade_matrix.py | 90 ++++++++ 15 files changed, 813 insertions(+), 12 deletions(-) create mode 100644 src/libtmux/experimental/facade/server.py create mode 100644 src/libtmux/experimental/facade/session.py create mode 100644 src/libtmux/experimental/facade/window.py create mode 100644 src/libtmux/experimental/ops/_ops/kill_session.py create mode 100644 src/libtmux/experimental/ops/_ops/new_session.py create mode 100644 src/libtmux/experimental/ops/_ops/new_window.py create mode 100644 src/libtmux/experimental/ops/_ops/rename_session.py create mode 100644 tests/experimental/facade/test_facade_matrix.py diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/facade/__init__.py index 965b8f4da..c034c34a7 100644 --- a/src/libtmux/experimental/facade/__init__.py +++ b/src/libtmux/experimental/facade/__init__.py @@ -1,17 +1,39 @@ """Engine-typed facades over the operation spine. -The execution mode lives in the facade *type* (eager vs lazy vs async vs -control), so each method has one statically-known return type, while the -operation definitions stay shared. This package currently ships the pane-scope -seed (:class:`EagerPane`, :class:`LazyPane`); the full Server/Session/Window/ -Pane/Client matrix is described in issue 689. +The execution mode lives in the facade *type* (eager vs lazy vs async), so each +method has one statically-known return type, while the operation definitions stay +shared. The facades form a small matrix over scope x mode: + +========== =========== ========== =========== +scope eager lazy async +========== =========== ========== =========== +server EagerServer -- -- +session EagerSession -- -- +window EagerWindow LazyWindow AsyncWindow +pane EagerPane LazyPane AsyncPane +========== =========== ========== =========== + +Eager handles execute immediately and return live handles; lazy handles record +into a :class:`~..ops.plan.LazyPlan`; async handles await an +:class:`~..engines.base.AsyncTmuxEngine`. "Control mode" is not a separate family +-- any eager/async facade bound to a ``ControlModeEngine`` already uses it. See +issue 689 for the full matrix. """ from __future__ import annotations -from libtmux.experimental.facade.pane import EagerPane, LazyPane +from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.facade.server import EagerServer +from libtmux.experimental.facade.session import EagerSession +from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow __all__ = ( + "AsyncPane", + "AsyncWindow", "EagerPane", + "EagerServer", + "EagerSession", + "EagerWindow", "LazyPane", + "LazyWindow", ) diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/facade/pane.py index 47e803791..61aec8d23 100644 --- a/src/libtmux/experimental/facade/pane.py +++ b/src/libtmux/experimental/facade/pane.py @@ -25,12 +25,13 @@ CapturePane, SendKeys, SplitWindow, + arun, run, ) from libtmux.experimental.ops._types import PaneId if t.TYPE_CHECKING: - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops._types import Target from libtmux.experimental.ops.plan import LazyPlan from libtmux.experimental.ops.results import CapturePaneResult, Result @@ -163,3 +164,71 @@ def capture(self, *, start: int | None = None, end: int | None = None) -> LazyPa """Record a capture against this handle; return self for chaining.""" self.plan.add(CapturePane(target=self.ref, start=start, end=end)) return self + + +@dataclass(frozen=True) +class AsyncPane: + """An async live pane handle: the eager pane, awaited. + + Identical in shape to :class:`EagerPane` -- same operations, same spine -- + but bound to an :class:`~..engines.base.AsyncTmuxEngine` and awaited. This is + why async is a sibling facade, not a transformation. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> async def main(): + ... pane = AsyncPane(AsyncConcreteEngine(), "%0") + ... child = await pane.split(horizontal=True) + ... return child.pane_id + >>> asyncio.run(main()) + '%1' + """ + + engine: AsyncTmuxEngine + pane_id: str + version: str | None = None + + async def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> AsyncPane: + """Split this pane and return a live async handle to the new pane.""" + result = await arun( + SplitWindow( + target=PaneId(self.pane_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + + async def send_keys(self, keys: str, *, enter: bool = False) -> Result: + """Send keys to this pane; return the typed result.""" + return await arun( + SendKeys(target=PaneId(self.pane_id), keys=keys, enter=enter), + self.engine, + version=self.version, + ) + + async def capture( + self, + *, + start: int | None = None, + end: int | None = None, + ) -> CapturePaneResult: + """Capture this pane's contents; return the typed result.""" + return await arun( + CapturePane(target=PaneId(self.pane_id), start=start, end=end), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/server.py b/src/libtmux/experimental/facade/server.py new file mode 100644 index 000000000..caf8de7a9 --- /dev/null +++ b/src/libtmux/experimental/facade/server.py @@ -0,0 +1,56 @@ +"""Server-scope eager facade -- the entry point for live navigation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.session import EagerSession +from libtmux.experimental.ops import NewSession, run + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + + +@dataclass(frozen=True) +class EagerServer: + """A live server handle; the root of eager facade navigation. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> server = EagerServer(ConcreteEngine()) + >>> session = server.new_session(name="work") + >>> session.session_id + '$1' + >>> window = session.new_window() + >>> pane = window.split() + >>> pane.pane_id + '%1' + """ + + engine: TmuxEngine + version: str | None = None + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> EagerSession: + """Create a detached session; return a live session handle.""" + result = run( + NewSession(session_name=name, start_directory=start_directory), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return EagerSession(self.engine, result.new_id, self.version) + + @classmethod + def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer: + """Bind an eager facade to a live :class:`libtmux.Server`'s classic engine.""" + from libtmux.experimental.engines import SubprocessEngine + + return cls(SubprocessEngine.for_server(server), version=version) diff --git a/src/libtmux/experimental/facade/session.py b/src/libtmux/experimental/facade/session.py new file mode 100644 index 000000000..c2cad8c57 --- /dev/null +++ b/src/libtmux/experimental/facade/session.py @@ -0,0 +1,75 @@ +"""Session-scope eager facade over the operation spine.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.window import EagerWindow +from libtmux.experimental.ops import ( + KillSession, + NewWindow, + RenameSession, + run, +) +from libtmux.experimental.ops._types import SessionId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerSession: + """A live session handle; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> session = EagerSession(ConcreteEngine(), "$0") + >>> window = session.new_window(name="build") + >>> window.window_id + '@1' + >>> session.rename("work").ok + True + """ + + engine: TmuxEngine + session_id: str + version: str | None = None + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> EagerWindow: + """Create a window in this session; return a live window handle.""" + result = run( + NewWindow( + target=SessionId(self.session_id), + name=name, + start_directory=start_directory, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return EagerWindow(self.engine, result.new_id, self.version) + + def rename(self, name: str) -> Result: + """Rename this session.""" + return run( + RenameSession(target=SessionId(self.session_id), name=name), + self.engine, + version=self.version, + ) + + def kill(self) -> Result: + """Kill this session.""" + return run( + KillSession(target=SessionId(self.session_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/window.py b/src/libtmux/experimental/facade/window.py new file mode 100644 index 000000000..32e4ed93a --- /dev/null +++ b/src/libtmux/experimental/facade/window.py @@ -0,0 +1,197 @@ +"""Window-scope facades (eager / lazy / async) over the operation spine. + +Mirrors the pane facades one scope up: an :class:`EagerWindow` executes now and +returns live handles (``split()`` -> :class:`~.pane.EagerPane`), a +:class:`LazyWindow` records into a plan, and an :class:`AsyncWindow` awaits. All +three drive the *same* window-scope operations; only the facade differs. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.ops import ( + KillWindow, + RenameWindow, + SelectLayout, + SplitWindow, + arun, + run, +) +from libtmux.experimental.ops._types import WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerWindow: + """A live window handle bound to an engine; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> window = EagerWindow(ConcreteEngine(), "@1") + >>> pane = window.split(horizontal=True) + >>> pane.pane_id + '%1' + >>> window.rename("build").ok + True + """ + + engine: TmuxEngine + window_id: str + version: str | None = None + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> EagerPane: + """Split this window's active pane; return a live pane handle.""" + result = run( + SplitWindow( + target=WindowId(self.window_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + + def rename(self, name: str) -> Result: + """Rename this window.""" + return run( + RenameWindow(target=WindowId(self.window_id), name=name), + self.engine, + version=self.version, + ) + + def select_layout(self, layout: str) -> Result: + """Apply a layout to this window.""" + return run( + SelectLayout(target=WindowId(self.window_id), layout=layout), + self.engine, + version=self.version, + ) + + def kill(self) -> Result: + """Kill this window.""" + return run( + KillWindow(target=WindowId(self.window_id)), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyWindow: + """A deferred window handle; methods record into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> window = LazyWindow(plan, WindowId("@1")) + >>> pane = window.split() + >>> _ = window.rename("build") + >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome.ok + True + """ + + plan: LazyPlan + ref: Target + + def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> LazyPane: + """Record a split; return a deferred pane handle to the new pane.""" + slot = self.plan.add( + SplitWindow( + target=self.ref, + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + ) + return LazyPane(self.plan, slot) + + def rename(self, name: str) -> LazyWindow: + """Record a rename; return self for chaining.""" + self.plan.add(RenameWindow(target=self.ref, name=name)) + return self + + def select_layout(self, layout: str) -> LazyWindow: + """Record a layout change; return self for chaining.""" + self.plan.add(SelectLayout(target=self.ref, layout=layout)) + return self + + def kill(self) -> LazyWindow: + """Record a kill; return self for chaining.""" + self.plan.add(KillWindow(target=self.ref)) + return self + + +@dataclass(frozen=True) +class AsyncWindow: + """An async live window handle: the eager window, awaited.""" + + engine: AsyncTmuxEngine + window_id: str + version: str | None = None + + async def split( + self, + *, + horizontal: bool = False, + start_directory: str | None = None, + shell: str | None = None, + ) -> AsyncPane: + """Split this window's active pane; return a live async pane handle.""" + result = await arun( + SplitWindow( + target=WindowId(self.window_id), + horizontal=horizontal, + start_directory=start_directory, + shell=shell, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + + async def rename(self, name: str) -> Result: + """Rename this window.""" + return await arun( + RenameWindow(target=WindowId(self.window_id), name=name), + self.engine, + version=self.version, + ) + + async def kill(self) -> Result: + """Kill this window.""" + return await arun( + KillWindow(target=WindowId(self.window_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 6e6f0df6a..75c986674 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -23,10 +23,14 @@ from libtmux.experimental.ops._ops import ( CapturePane, KillPane, + KillSession, KillWindow, ListPanes, ListSessions, ListWindows, + NewSession, + NewWindow, + RenameSession, RenameWindow, SelectLayout, SendKeys, @@ -68,6 +72,7 @@ from libtmux.experimental.ops.results import ( AckResult, CapturePaneResult, + CreateResult, ListPanesResult, ListSessionsResult, ListWindowsResult, @@ -90,10 +95,12 @@ "CapturePaneResult", "CatalogEntry", "ClientName", + "CreateResult", "DuplicateOperation", "Effects", "IndexRef", "KillPane", + "KillSession", "KillWindow", "LazyPlan", "ListPanes", @@ -103,6 +110,8 @@ "ListWindows", "ListWindowsResult", "NameRef", + "NewSession", + "NewWindow", "OpChain", "OpSpec", "Operation", @@ -110,6 +119,7 @@ "OperationRegistry", "PaneId", "PlanResult", + "RenameSession", "RenameWindow", "Result", "Safety", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index f0449faa4..101f22aa5 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -9,10 +9,14 @@ from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.kill_pane import KillPane +from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows +from libtmux.experimental.ops._ops.new_session import NewSession +from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys @@ -21,10 +25,14 @@ __all__ = ( "CapturePane", "KillPane", + "KillSession", "KillWindow", "ListPanes", "ListSessions", "ListWindows", + "NewSession", + "NewWindow", + "RenameSession", "RenameWindow", "SelectLayout", "SendKeys", diff --git a/src/libtmux/experimental/ops/_ops/kill_session.py b/src/libtmux/experimental/ops/_ops/kill_session.py new file mode 100644 index 000000000..a141de693 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_session.py @@ -0,0 +1,34 @@ +"""The ``kill-session`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillSession(Operation[AckResult]): + """Kill a session. Destructive; produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> KillSession(target=SessionId("$0")).render() + ('kill-session', '-t', '$0') + """ + + kind = "kill_session" + command = "kill-session" + scope = "session" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/new_session.py b/src/libtmux/experimental/ops/_ops/new_session.py new file mode 100644 index 000000000..74925d91b --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_session.py @@ -0,0 +1,84 @@ +"""The ``new-session`` operation (creates a session, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewSession(Operation[CreateResult]): + """Create a detached session; capture the new session's id. + + Examples + -------- + >>> NewSession(session_name="work").render() + ('new-session', '-d', '-s', 'work', '-P', '-F', '#{session_id}') + >>> NewSession().build_result(returncode=0, stdout=("$2",)).new_id + '$2' + """ + + kind = "new_session" + command = "new-session" + scope = "server" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="session") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + session_name: str | None = None + start_directory: str | None = None + environment: Mapping[str, str] | None = None + width: int | None = None + height: int | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-session`` flags (always detached for headless use).""" + out: list[str] = ["-d"] + if self.session_name is not None: + out.extend(("-s", self.session_name)) + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.width is not None: + out.extend(("-x", str(self.width))) + if self.height is not None: + out.extend(("-y", str(self.height))) + if self.capture: + out.extend(("-P", "-F", "#{session_id}")) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured new-session id.""" + new_id = stdout[0].strip() if status == "complete" and stdout else None + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=new_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/new_window.py b/src/libtmux/experimental/ops/_ops/new_window.py new file mode 100644 index 000000000..73091a437 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_window.py @@ -0,0 +1,89 @@ +"""The ``new-window`` operation (creates a window, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewWindow(Operation[CreateResult]): + """Create a window in a session; capture the new window's id. + + ``target`` is the session the window is created in. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> NewWindow(target=SessionId("$0"), name="build").render() + ('new-window', '-t', '$0', '-d', '-n', 'build', '-P', '-F', '#{window_id}') + >>> NewWindow(target=SessionId("$0")).build_result( + ... returncode=0, stdout=("@5",) + ... ).new_id + '@5' + """ + + kind = "new_window" + command = "new-window" + scope = "session" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="window") + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + name: str | None = None + start_directory: str | None = None + environment: Mapping[str, str] | None = None + detach: bool = True + capture: bool = True + window_shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-window`` flags.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.name is not None: + out.extend(("-n", self.name)) + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.capture: + out.extend(("-P", "-F", "#{window_id}")) + if self.window_shell is not None: + out.append(self.window_shell) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured new-window id.""" + new_id = stdout[0].strip() if status == "complete" and stdout else None + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=new_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/rename_session.py b/src/libtmux/experimental/ops/_ops/rename_session.py new file mode 100644 index 000000000..937a878e6 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rename_session.py @@ -0,0 +1,36 @@ +"""The ``rename-session`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RenameSession(Operation[AckResult]): + """Rename a session. Produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> RenameSession(target=SessionId("$0"), name="work").render() + ('rename-session', '-t', '$0', 'work') + """ + + kind = "rename_session" + command = "rename-session" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + name: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the new session name.""" + return (self.name,) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index f7331ed84..b12f8d363 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,8 +65,10 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'kill_pane', 'kill_window', 'list_panes', 'list_sessions', - 'list_windows', 'rename_window', 'select_layout', 'send_keys', 'split_window'] + ['capture_pane', 'kill_pane', 'kill_session', 'kill_window', 'list_panes', + 'list_sessions', 'list_windows', 'new_session', 'new_window', + 'rename_session', 'rename_window', 'select_layout', 'send_keys', + 'split_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index e83449cff..d408dbe13 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -218,9 +218,8 @@ def _drive( else: result = yield _Single(_resolve(operation, bindings)) results[index] = result - created = getattr(result, "new_pane_id", None) - if created is not None: - bindings[index] = created + if result.created_id is not None: + bindings[index] = result.created_id index += 1 ordered = tuple(results[slot] for slot in range(total)) return PlanResult(ordered, bindings) diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 9abe49beb..adddb3a18 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -131,6 +131,15 @@ def failed(self) -> bool: """Whether the operation ran and tmux reported failure.""" return self.status == "failed" + @property + def created_id(self) -> str | None: + """The id of an object this operation created, if any (else ``None``). + + Result subclasses for creation ops override this; a lazy plan reads it to + bind a forward :class:`~._types.SlotRef`. The base result creates nothing. + """ + return None + def raise_for_status(self) -> Self: """Raise :class:`~.exc.TmuxCommandError` if the result is not OK. @@ -193,6 +202,27 @@ class SplitWindowResult(Result): new_pane_id: str | None = None + @property + def created_id(self) -> str | None: + """The new pane's id.""" + return self.new_pane_id + + +@dataclass(frozen=True) +class CreateResult(Result): + """Result of an operation that creates an object and captures its id. + + Shared by ``new-window`` / ``new-session`` (and other ``-P -F``-capturing + creators); :attr:`new_id` holds the created object's id (``@N``/``$N``). + """ + + new_id: str | None = None + + @property + def created_id(self) -> str | None: + """The created object's id.""" + return self.new_id + @dataclass(frozen=True) class CapturePaneResult(Result): diff --git a/tests/experimental/facade/test_facade_matrix.py b/tests/experimental/facade/test_facade_matrix.py new file mode 100644 index 000000000..0e37df75f --- /dev/null +++ b/tests/experimental/facade/test_facade_matrix.py @@ -0,0 +1,90 @@ +"""Tests for the facade matrix (scope x mode) over the shared spine.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.facade import ( + AsyncPane, + AsyncWindow, + EagerPane, + EagerServer, + EagerWindow, + LazyWindow, +) +from libtmux.experimental.ops import LazyPlan +from libtmux.experimental.ops._types import WindowId + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_eager_full_navigation_offline() -> None: + """Eager Server->Session->Window->Pane navigation via the concrete engine.""" + server = EagerServer(ConcreteEngine()) + session = server.new_session(name="work") + assert session.session_id == "$1" + window = session.new_window(name="build") + assert window.window_id == "@1" + pane = window.split(horizontal=True) + assert isinstance(pane, EagerPane) + assert pane.pane_id == "%1" + + +def test_eager_window_methods() -> None: + """EagerWindow rename/select_layout/kill return successful results.""" + window = EagerWindow(ConcreteEngine(), "@1") + assert window.rename("x").ok + assert window.select_layout("tiled").ok + assert window.kill().ok + + +def test_lazy_window_records_and_executes() -> None: + """LazyWindow records ops and resolves the new pane on execute.""" + plan = LazyPlan() + window = LazyWindow(plan, WindowId("@1")) + window.split() + window.rename("build") + assert len(plan) == 2 + + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert outcome.results[0].created_id == "%1" + + +def test_async_window_and_pane() -> None: + """Async facades mirror the eager ones via await.""" + + async def main() -> tuple[str, bool]: + window = AsyncWindow(AsyncConcreteEngine(), "@1") + pane = await window.split() + assert isinstance(pane, AsyncPane) + sent = await pane.send_keys("echo hi", enter=True) + return pane.pane_id, sent.ok + + pane_id, ok = asyncio.run(main()) + assert pane_id == "%1" + assert ok + + +def test_eager_navigation_live(session: Session) -> None: + """Eager facade builds a real session/window/pane against tmux, then cleans up.""" + server = session.server + facade = EagerServer.for_server(server) + + created = facade.new_session(name="facade-matrix-test") + try: + assert created.session_id.startswith("$") + assert server.sessions.get(session_id=created.session_id) is not None + + window = created.new_window(name="built") + assert window.window_id.startswith("@") + assert server.windows.get(window_id=window.window_id) is not None + + pane = window.split(horizontal=True) + assert pane.pane_id.startswith("%") + assert server.panes.get(pane_id=pane.pane_id) is not None + finally: + created.kill() From 8ddf76080aba78fae6f243375633a1e9e42800f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 11:39:21 -0500 Subject: [PATCH 014/154] Imsg(feat): Add native imsg engine + live parity test why: The native binary peer-protocol engine is the strongest proof the operation/result contract is transport-agnostic -- the same typed CommandResult whether produced by a subprocess, tmux -C, or by speaking tmux's imsg protocol directly. Research confirmed it is pure-stdlib and CI-verifiable; the prototype it is ported from only ever tested against a fake socketpair server, never real tmux. what: - port engines/imsg/{types,v8,base}.py from libtmux-protocol-engines: ImsgEngine over AF_UNIX + sendmsg/recvmsg + SCM_RIGHTS fd-passing, and ProtocolV8Codec (=IIII header, IMSG_FD_MARK high bit of len, peerid=PROTOCOL_VERSION 8, IDENTIFY -> COMMAND -> WRITE_* -> EXIT handshake); posix_spawn local fallback for attach / start-server / no-server-running - adapt to the experimental tuple CommandResult (drop the process field); add imsg.exc (ImsgError / ImsgProtocolError / UnsupportedProtocolVersion) and select the v8 codec directly; keep the version-mismatch retry - register as the opt-in "imsg" engine; import-safe everywhere (AF_UNIX is only touched at runtime; tests skip without it) - tests: v8 codec round-trip + MSG_COMMAND framing (no tmux), plus the live parity test the prototype lacked -- ImsgEngine vs SubprocessEngine return identical stdout/returncode for read-only commands against a real tmux server (runs across the CI tmux matrix) --- src/libtmux/experimental/engines/__init__.py | 2 + .../experimental/engines/imsg/__init__.py | 29 + src/libtmux/experimental/engines/imsg/base.py | 884 ++++++++++++++++++ src/libtmux/experimental/engines/imsg/exc.py | 22 + .../experimental/engines/imsg/types.py | 28 + src/libtmux/experimental/engines/imsg/v8.py | 543 +++++++++++ tests/experimental/engines/__init__.py | 3 + tests/experimental/engines/test_imsg.py | 93 ++ 8 files changed, 1604 insertions(+) create mode 100644 src/libtmux/experimental/engines/imsg/__init__.py create mode 100644 src/libtmux/experimental/engines/imsg/base.py create mode 100644 src/libtmux/experimental/engines/imsg/exc.py create mode 100644 src/libtmux/experimental/engines/imsg/types.py create mode 100644 src/libtmux/experimental/engines/imsg/v8.py create mode 100644 tests/experimental/engines/__init__.py create mode 100644 tests/experimental/engines/test_imsg.py diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 6738d4716..9b756af4c 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -31,6 +31,7 @@ ControlModeError, ControlModeParser, ) +from libtmux.experimental.engines.imsg import ImsgEngine from libtmux.experimental.engines.registry import ( available_engines, create_engine, @@ -52,6 +53,7 @@ "ControlNotification", "EngineKind", "EngineSpec", + "ImsgEngine", "SubprocessEngine", "TmuxEngine", "available_engines", diff --git a/src/libtmux/experimental/engines/imsg/__init__.py b/src/libtmux/experimental/engines/imsg/__init__.py new file mode 100644 index 000000000..ee0426b94 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/__init__.py @@ -0,0 +1,29 @@ +"""Experimental native imsg engine -- an opt-in easter egg. + +Speaks tmux's binary peer protocol (imsg over the server's ``AF_UNIX`` socket) +directly, with no tmux CLI fork per command. It is the strongest proof that the +operation/result contract is transport-agnostic: it returns the *same* +:class:`~..base.CommandResult` as the subprocess and control-mode engines. + +Caveats (why it is opt-in and not the default): it depends on tmux's *internal* +protocol (``PROTOCOL_VERSION`` 8 only; upstream may bump it), it is POSIX-only +(``AF_UNIX`` + ``SCM_RIGHTS`` fd-passing), and it cannot host ``attach-session`` +(which falls back to a local spawn). Importing this triggers registration under +the ``imsg`` engine name. +""" + +from __future__ import annotations + +from libtmux.experimental.engines.imsg.base import ImsgEngine +from libtmux.experimental.engines.imsg.exc import ( + ImsgError, + ImsgProtocolError, + UnsupportedProtocolVersion, +) + +__all__ = ( + "ImsgEngine", + "ImsgError", + "ImsgProtocolError", + "UnsupportedProtocolVersion", +) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py new file mode 100644 index 000000000..00e56a9a8 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -0,0 +1,884 @@ +"""Shared primitives for tmux imsg protocol engines.""" + +from __future__ import annotations + +import array +import contextlib +import errno +import logging +import os +import pathlib +import selectors +import shutil +import socket +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandRequest, CommandResult +from libtmux.experimental.engines.imsg.exc import ( + ImsgProtocolError, + UnsupportedProtocolVersion, +) +from libtmux.experimental.engines.imsg.types import ImsgFrame, ImsgHeader +from libtmux.experimental.engines.imsg.v8 import ProtocolV8Codec +from libtmux.experimental.engines.registry import register_engine + + +def _select_codec(version: int | str) -> ImsgProtocolCodec: + """Return the codec for a tmux imsg protocol version (only v8 is supported).""" + if str(version) == ProtocolV8Codec.version: + return ProtocolV8Codec() + raise UnsupportedProtocolVersion(str(version)) + + +logger = logging.getLogger(__name__) + +_MAX_IMSGSIZE = 16384 +_IMSG_HEADER_SIZE = 16 +_ExitStatus = tuple[int, str | None] +_CLIENT_UTF8 = 0x10000 + + +class ImsgProtocolCodec(t.Protocol): + """Protocol for versioned tmux imsg codecs.""" + + version: str + + def pack_frame(self, frame: ImsgFrame) -> bytes: + """Return wire bytes for a typed imsg frame.""" + + def pack_message(self, msg_type: int, payload: bytes, *, peer_id: int) -> bytes: + """Return a framed tmux imsg message without an attached FD.""" + + def unpack_header(self, data: bytes) -> ImsgHeader: + """Decode a tmux imsg header.""" + + def identify_messages( + self, + *, + cwd: str, + term: str, + tty_name: str, + client_pid: int, + environ: dict[str, str], + flags: int = 0, + features: int = 0, + stdin_fd: int | None = None, + stdout_fd: int | None = None, + ) -> list[ImsgFrame]: + """Build the identify handshake messages for a tmux client.""" + + def command_message(self, argv: tuple[str, ...], *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_COMMAND`` frame.""" + + def parse_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + pid: int, + ) -> object: + """Parse a typed tmux message payload.""" + + def exit_status_from_message( + self, + message: object, + ) -> _ExitStatus | None: + """Return exit metadata if the parsed message encodes it.""" + + def write_open_stream(self, message: object) -> int | None: + """Return the declared stream id from a ``MSG_WRITE_OPEN`` message.""" + + def write_payload(self, message: object) -> tuple[int, bytes] | None: + """Return stream id and bytes from a ``MSG_WRITE`` message.""" + + def write_close_stream(self, message: object) -> int | None: + """Return the closed stream id from a ``MSG_WRITE_CLOSE`` message.""" + + def read_open_stream(self, message: object) -> int | None: + """Return the declared stream id from a ``MSG_READ_OPEN`` message.""" + + def write_ready_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_WRITE_READY`` reply.""" + + def read_done_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_READ_DONE`` reply.""" + + @property + def msg_version(self) -> int: + """Return the numeric ``MSG_VERSION`` message type.""" + + @property + def msg_ready(self) -> int: + """Return the numeric ``MSG_READY`` message type.""" + + @property + def msg_exit(self) -> int: + """Return the numeric ``MSG_EXIT`` message type.""" + + @property + def msg_exited(self) -> int: + """Return the numeric ``MSG_EXITED`` message type.""" + + @property + def msg_shutdown(self) -> int: + """Return the numeric ``MSG_SHUTDOWN`` message type.""" + + @property + def msg_flags(self) -> int: + """Return the numeric ``MSG_FLAGS`` message type.""" + + @property + def msg_write_open(self) -> int: + """Return the numeric ``MSG_WRITE_OPEN`` message type.""" + + @property + def msg_write(self) -> int: + """Return the numeric ``MSG_WRITE`` message type.""" + + @property + def msg_write_close(self) -> int: + """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" + + @property + def msg_read_open(self) -> int: + """Return the numeric ``MSG_READ_OPEN`` message type.""" + + @property + def msg_exiting(self) -> int: + """Return the numeric ``MSG_EXITING`` message type.""" + + def exiting_message(self, *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_EXITING`` notification.""" + + +class _ImsgCommandArgs(t.NamedTuple): + """Parsed tmux CLI arguments needed by the imsg engine.""" + + global_args: tuple[str, ...] + command_argv: tuple[str, ...] + socket_name: str | None + socket_path: str | None + config_file: str | None + command_name: str | None + + +class ImsgEngine: + """Execute tmux commands via the native binary imsg socket protocol.""" + + _startserver_commands = frozenset({"new-session", "start-server"}) + + # Subcommands that ultimately invoke tmux's ``spawn.c`` to start a + # shell. tmux uses the client's environ (built from + # ``MSG_IDENTIFY_ENVIRON`` frames per ``server-client.c:3685``) only + # when actually launching a shell process; for queries / metadata + # commands the environ is allocated, populated, then freed at + # ``MSG_EXIT`` without ever being read. Forwarding the full + # ``os.environ`` (typically ~50-100 vars) per call cost ~one frame + # per env var on the wire — net waste outside the spawn paths. + # See: ``cmd-new-session.c:273``, ``spawn.c:314-324``, + # ``cmd-{new,split,respawn}-{window,pane}.c``, + # ``cmd-display-popup.c``, ``source-file.c``. + _spawn_commands = frozenset( + { + "new-session", + "new-window", + "split-window", + "respawn-pane", + "respawn-window", + "display-popup", + # source-file can run arbitrary commands inside the loaded + # config, including ones that spawn — conservative include + # keeps user expectations stable across engines. + "source-file", + }, + ) + + # Env keys tmux looks up *by name* on the client environ outside + # the shell-spawning paths. Currently a single key: + # * ``TMUX_PANE`` — ``cmd-find.c:93``'s + # ``cmd_find_inside_pane`` fallback uses it to identify which + # pane the calling client is "inside of" when no ``-t`` target + # is given (nested-tmux scenarios — libtmux running from a + # pane inside an existing tmux server). Forwarding it for + # every command keeps the imsg engine's default-target + # resolution semantically identical to subprocess engine. + # ``TMUX`` is also looked up (``server-client.c:240``) but only + # by ``attach-session`` to refuse nested-attach; libtmux hard-routes + # ``attach-session`` through subprocess so the imsg engine never + # exercises that path. + _probe_env_keys = ("TMUX_PANE",) + + def __init__(self, protocol_version: str | int | None = None) -> None: + self.protocol_version = ( + str(protocol_version) if protocol_version is not None else None + ) + self._resolved_tmux_bin: str | None = None + + def run(self, request: CommandRequest) -> CommandResult: + """Execute a tmux command over the server socket.""" + tmux_bin = request.tmux_bin or self._resolve_tmux_bin() + parsed = self._parse_args(request.args) + cmd = [tmux_bin, *parsed.global_args, *parsed.command_argv] + + if parsed.command_name is None or parsed.command_name == "-V": + return self._run_local_command(cmd) + + socket_path = self._resolve_socket_path(parsed) + if parsed.command_name == "start-server": + return self._run_local_command(cmd) + if parsed.command_name in self._startserver_commands and not _server_available( + socket_path + ): + return self._run_local_command(cmd) + + peer_id = int(self.protocol_version or ProtocolV8Codec.version) + retries_remaining = 1 + + while True: + sock: socket.socket | None = None + codec = _select_codec(peer_id) + try: + sock = self._connect(socket_path=socket_path) + return self._run_socket_command( + sock=sock, + codec=codec, + peer_id=peer_id, + command_name=parsed.command_name, + command_argv=parsed.command_argv, + cmd=cmd, + ) + except _NoServerError as error: + if parsed.command_name in self._startserver_commands: + return self._run_local_command(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=(), + stderr=(error.message,), + returncode=1, + ) + except (BrokenPipeError, ConnectionResetError) as error: + # Server began shutdown between connect() and the first + # send/recv: the socket file still exists so connect succeeded, + # but the kernel returns EPIPE/ECONNRESET on the first I/O. + # Mirror tmux's CLIENT_EXIT_LOST_SERVER behavior — present a + # clean "no server running" CommandResult instead of leaking + # the transport exception. + if parsed.command_name in self._startserver_commands: + return self._run_local_command(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=(), + stderr=(self._no_server_message(socket_path, error),), + returncode=1, + ) + except _ProtocolVersionMismatch as mismatch: + if retries_remaining == 0: + raise UnsupportedProtocolVersion( + mismatch.server_version, + ) from None + retries_remaining -= 1 + peer_id = int(mismatch.server_version) + self.protocol_version = mismatch.server_version + finally: + if sock is not None: + sock.close() + + def _resolve_tmux_bin(self) -> str: + """Return the tmux binary path, memoized per engine instance. + + ``shutil.which`` walks ``$PATH`` on every call (~50µs); the engine + invokes it on the hot path of every command, so caching the + result for the lifetime of the engine instance is a free win. + ``TmuxCommandNotFound`` is intentionally not memoized. + """ + if self._resolved_tmux_bin is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved_tmux_bin = resolved + return self._resolved_tmux_bin + + def run_batch( + self, + requests: t.Sequence[CommandRequest], + ) -> list[CommandResult]: + """Loop over ``run`` — imsg opens a fresh socket per call. + + No batching benefit but provided for uniform API: callers can + use ``run_batch`` regardless of engine and get the right + ordered list of results. + """ + return [self.run(req) for req in requests] + + def _parse_args(self, args: tuple[str, ...]) -> _ImsgCommandArgs: + global_args: list[str] = [] + command_argv: list[str] = [] + socket_name: str | None = None + socket_path: str | None = None + config_file: str | None = None + + index = 0 + while index < len(args): + arg = args[index] + if arg == "-V": + command_argv.append(arg) + break + if arg in {"-L", "-S", "-f"}: + if index + 1 >= len(args): + command_argv.append(arg) + break + value = args[index + 1] + global_args.extend((arg, value)) + if arg == "-L": + socket_name = value + elif arg == "-S": + socket_path = value + else: + config_file = value + index += 2 + continue + if arg.startswith("-L") and len(arg) > 2: + socket_name = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg.startswith("-S") and len(arg) > 2: + socket_path = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg.startswith("-f") and len(arg) > 2: + config_file = arg[2:] + global_args.append(arg) + index += 1 + continue + if arg in {"-2", "-8"}: + global_args.append(arg) + index += 1 + continue + + command_argv.extend(args[index:]) + break + + command_name = command_argv[0] if command_argv else None + return _ImsgCommandArgs( + global_args=tuple(global_args), + command_argv=tuple(command_argv), + socket_name=socket_name, + socket_path=socket_path, + config_file=config_file, + command_name=command_name, + ) + + def _resolve_socket_path(self, parsed: _ImsgCommandArgs) -> str: + if parsed.socket_path is not None: + return parsed.socket_path + + socket_name = parsed.socket_name or "default" + tmux_tmpdir = pathlib.Path(os.getenv("TMUX_TMPDIR", "/tmp")) + return str(tmux_tmpdir / f"tmux-{os.geteuid()}" / socket_name) + + def _connect( + self, + *, + socket_path: str, + ) -> socket.socket: + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(socket_path) + except OSError as error: + sock.close() + if error.errno not in {errno.ENOENT, errno.ECONNREFUSED}: + raise + raise _NoServerError( + self._no_server_message(socket_path, error), + ) from error + return sock + + def _no_server_message(self, socket_path: str, error: OSError) -> str: + if error.errno == errno.ECONNREFUSED: + return f"error connecting to {socket_path}" + return f"no server running on {socket_path}" + + def _client_flags(self) -> int: + if os.environ.get("TMUX"): + return _CLIENT_UTF8 + + locale = ( + os.environ.get("LC_ALL") + or os.environ.get("LC_CTYPE") + or os.environ.get("LANG") + or "" + ) + locale = locale.upper() + if "UTF-8" in locale or "UTF8" in locale: + return _CLIENT_UTF8 + return 0 + + def _run_local_command(self, cmd: list[str]) -> CommandResult: + exit_code, stdout, stderr = _spawn_and_capture(cmd) + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout), + stderr=tuple(stderr), + returncode=exit_code, + ) + + def _run_socket_command( + self, + *, + sock: socket.socket, + codec: ImsgProtocolCodec, + peer_id: int, + command_name: str | None, + command_argv: tuple[str, ...], + cmd: list[str], + ) -> CommandResult: + stdin_fd = _duplicate_fd(0) + stdout_fd = _duplicate_fd(1) + # Gated env forwarding: tmux only reads ``c->environ`` from + # ``spawn.c`` (and its callers) when actually launching a + # shell — every other command path frees ``c->environ`` + # without reading it. Sending the full ``os.environ`` would + # cost one ``MSG_IDENTIFY_ENVIRON`` frame per env var (~89 on + # this host) per command; for non-spawning commands those + # frames are net waste. Forward the full env only when + # ``command_name`` is in :attr:`_spawn_commands`; for everything + # else, forward only :attr:`_probe_env_keys` so tmux's + # named-lookup paths (``cmd-find.c``'s ``TMUX_PANE`` fallback) + # keep matching subprocess-engine semantics. + if command_name in self._spawn_commands: + environ_to_send: dict[str, str] = dict(os.environ) + else: + environ_to_send = { + key: os.environ[key] + for key in self._probe_env_keys + if key in os.environ + } + identify_frames = codec.identify_messages( + cwd=str(pathlib.Path.cwd()), + term=os.environ.get("TERM", "unknown") or "unknown", + tty_name="", + client_pid=os.getpid(), + environ=environ_to_send, + flags=self._client_flags(), + features=0, + stdin_fd=stdin_fd, + stdout_fd=stdout_fd, + ) + logger.debug( + "sending imsg identify burst", + extra={ + "tmux_protocol_version": codec.version, + "tmux_identify_frames": len(identify_frames), + "tmux_command_argv": list(command_argv), + }, + ) + + stdout_streams: set[int] = set() + stderr_streams: set[int] = set() + stdout_buffer = bytearray() + stderr_buffer = bytearray() + exit_code = 0 + exit_message: str | None = None + seen_exit = False + + transport = _SelectorSocketTransport(sock) + try: + transport.send_frames(codec, identify_frames) + command_frame = codec.command_message(command_argv, peer_id=peer_id) + transport.send_frame(codec, command_frame) + + while True: + frame = transport.recv_frame(codec) + msg_type = frame.header.msg_type + peer = frame.header.peer_id + pid = frame.header.pid + payload = frame.payload + logger.debug( + "received imsg message", + extra={ + "tmux_protocol_version": codec.version, + "tmux_message_type": msg_type, + "tmux_message_peer": peer, + "tmux_message_pid": pid, + "tmux_message_len": len(payload), + "tmux_message_has_fd": frame.header.has_fd, + "tmux_command_argv": list(command_argv), + }, + ) + if msg_type == codec.msg_version: + _close_fd(frame.fd) + raise _ProtocolVersionMismatch(str(peer & 0xFF)) + + try: + message: object = codec.parse_message( + msg_type, + payload, + peer_id=peer, + pid=pid, + ) + finally: + _close_fd(frame.fd) + + if msg_type == codec.msg_ready: + continue + if msg_type == codec.msg_flags: + continue + + stream = codec.write_open_stream(message) + if stream is not None: + if stream == 2: + stderr_streams.add(stream) + else: + stdout_streams.add(stream) + transport.send_frame( + codec, + codec.write_ready_message(stream, 0, peer_id=peer_id), + ) + continue + + payload_data = codec.write_payload(message) + if payload_data is not None: + stream_id, data = payload_data + if stream_id in stderr_streams: + stderr_buffer.extend(data) + else: + stdout_buffer.extend(data) + continue + + close_stream = codec.write_close_stream(message) + if close_stream is not None: + continue + + read_stream = codec.read_open_stream(message) + if read_stream is not None: + transport.send_frame( + codec, + codec.read_done_message( + read_stream, + errno.EBADF, + peer_id=peer_id, + ), + ) + continue + + exit_status = codec.exit_status_from_message(message) + if exit_status is not None: + exit_code, exit_message = exit_status + seen_exit = True + transport.send_frame( + codec, + codec.exiting_message(peer_id=peer_id), + ) + continue + + if msg_type == codec.msg_shutdown: + exit_code = 1 + seen_exit = True + transport.send_frame( + codec, + codec.exiting_message(peer_id=peer_id), + ) + continue + + if msg_type == codec.msg_exited: + break + + if seen_exit: + break + finally: + transport.close() + + stdout_lines = _split_output(bytes(stdout_buffer)) + stderr_lines = _split_output(bytes(stderr_buffer)) + if exit_message: + stderr_lines.append(exit_message) + if "has-session" in cmd and stderr_lines and not stdout_lines: + stdout_lines = [stderr_lines[0]] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=exit_code, + ) + + +class _ProtocolVersionMismatch(RuntimeError): + """Internal signal for retrying with a negotiated protocol version.""" + + def __init__(self, server_version: str) -> None: + super().__init__(server_version) + self.server_version = server_version + + +class _NoServerError(RuntimeError): + """Internal signal for commands against a missing tmux socket.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class _SelectorSocketTransport: + """Selector-backed imsg transport for Unix domain sockets.""" + + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.sock.setblocking(False) + self._selector = selectors.DefaultSelector() + self._selector.register(sock, selectors.EVENT_READ) + self._buffer = bytearray() + self._pending_fds: list[int] = [] + + def close(self) -> None: + """Close selector state and any unclaimed descriptors.""" + with contextlib.suppress(KeyError, ValueError): + self._selector.unregister(self.sock) + self._selector.close() + for fd in self._pending_fds: + _close_fd(fd) + self._pending_fds.clear() + + def send_frames( + self, + codec: ImsgProtocolCodec, + frames: list[ImsgFrame], + ) -> None: + """Send a sequence of frames and close unsent descriptors on failure.""" + sent = 0 + try: + for frame in frames: + self.send_frame(codec, frame) + sent += 1 + finally: + for frame in frames[sent:]: + _close_fd(frame.fd) + + def send_frame(self, codec: ImsgProtocolCodec, frame: ImsgFrame) -> None: + """Send one imsg frame, including an optional SCM_RIGHTS descriptor.""" + data = codec.pack_frame(frame) + if frame.fd is not None: + self._send_frame_with_fd(data, frame.fd) + return + self._send_all(data) + + def recv_frame(self, codec: ImsgProtocolCodec) -> ImsgFrame: + """Receive one complete imsg frame.""" + while len(self._buffer) < _IMSG_HEADER_SIZE: + self._recv_more() + + header = codec.unpack_header(bytes(self._buffer[:_IMSG_HEADER_SIZE])) + while len(self._buffer) < header.length: + self._recv_more() + + payload = bytes(self._buffer[_IMSG_HEADER_SIZE : header.length]) + del self._buffer[: header.length] + + fd: int | None = None + if header.has_fd and self._pending_fds: + fd = self._pending_fds.pop(0) + + return ImsgFrame(header=header, payload=payload, fd=fd) + + def _wait_for(self, event: int) -> None: + self._selector.modify(self.sock, event) + self._selector.select() + + def _send_all(self, data: bytes) -> None: + # Optimistic send: tmux's imsg frames are tiny (≤16 KiB) and a + # fresh AF_UNIX SOCK_STREAM socket has plenty of buffer + # capacity, so the first send almost always succeeds. Hitting + # the selector only on real BlockingIOError replaces two + # syscalls per send (selector.modify + epoll_wait) with zero + # in the common case — a measurable win on the imsg engine + # since it issues ~100 sends per command. + offset = 0 + sock = self.sock + while offset < len(data): + try: + sent = sock.send(data[offset:]) + except BlockingIOError: + self._wait_for(selectors.EVENT_WRITE) + continue + if sent == 0: + msg = "tmux socket closed during protocol write" + raise ImsgProtocolError(msg) + offset += sent + + def _send_frame_with_fd(self, data: bytes, fd: int) -> None: + fds = array.array("i", [fd]) + ancillary = [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds.tobytes())] + try: + sock = self.sock + while True: + try: + sent = sock.sendmsg([data], ancillary) + except BlockingIOError: + self._wait_for(selectors.EVENT_WRITE) + continue + break + if sent != len(data): + msg = "tmux imsg frame with FD was partially written" + raise ImsgProtocolError(msg) + finally: + _close_fd(fd) + + def _recv_more(self) -> None: + # Symmetric optimistic recv: the wire half of every imsg + # exchange is paced by tmux's reply rate, so by the time + # Python re-enters the read loop the kernel buffer typically + # already has bytes. Skip the upfront selector wait and only + # block on real BlockingIOError. + fd_size = array.array("i").itemsize + sock = self.sock + while True: + try: + data, ancillary, _flags, _addr = sock.recvmsg( + 65535, + socket.CMSG_SPACE(fd_size), + ) + except BlockingIOError: + self._wait_for(selectors.EVENT_READ) + continue + break + if not data: + msg = "tmux socket closed during protocol exchange" + raise ImsgProtocolError(msg) + + self._buffer.extend(data) + for level, msg_type, cmsg_data in ancillary: + if level != socket.SOL_SOCKET or msg_type != socket.SCM_RIGHTS: + continue + fds = array.array("i") + fds.frombytes(cmsg_data[: len(cmsg_data) - (len(cmsg_data) % fd_size)]) + for index, fd in enumerate(fds): + if index == 0: + self._pending_fds.append(fd) + else: + _close_fd(fd) + + +def _spawn_and_capture(command: list[str]) -> tuple[int, list[str], list[str]]: + """Run a command without subprocess and capture its output.""" + stdout_read, stdout_write = os.pipe() + stderr_read, stderr_write = os.pipe() + file_actions = [ + (os.POSIX_SPAWN_DUP2, stdout_write, 1), + (os.POSIX_SPAWN_DUP2, stderr_write, 2), + (os.POSIX_SPAWN_CLOSE, stdout_read), + (os.POSIX_SPAWN_CLOSE, stderr_read), + ] + + try: + if "/" in command[0]: + pid = os.posix_spawn( + command[0], + command, + os.environ, + file_actions=file_actions, + ) + else: + pid = os.posix_spawnp( + command[0], + command, + os.environ, + file_actions=file_actions, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + finally: + os.close(stdout_write) + os.close(stderr_write) + + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + + os.set_blocking(stdout_read, False) + os.set_blocking(stderr_read, False) + + selector = selectors.DefaultSelector() + streams = { + stdout_read: stdout_chunks, + stderr_read: stderr_chunks, + } + selector.register(stdout_read, selectors.EVENT_READ) + selector.register(stderr_read, selectors.EVENT_READ) + + try: + while streams: + for key, _mask in selector.select(): + fd = key.fd + try: + chunk = os.read(fd, 65535) + except BlockingIOError: + continue + if chunk: + streams[fd].append(chunk) + continue + selector.unregister(fd) + del streams[fd] + finally: + selector.close() + + os.close(stdout_read) + os.close(stderr_read) + _pid, status = os.waitpid(pid, 0) + exit_code = os.waitstatus_to_exitcode(status) + + stdout_lines = _split_output(b"".join(stdout_chunks)) + stderr_lines = _split_output(b"".join(stderr_chunks)) + return exit_code, stdout_lines, stderr_lines + + +def _duplicate_fd(fd: int) -> int | None: + """Duplicate a descriptor for SCM_RIGHTS ownership transfer.""" + with contextlib.suppress(OSError): + return os.dup(fd) + return None + + +def _close_fd(fd: int | None) -> None: + """Close a descriptor if one is present.""" + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + + +def _split_output(data: bytes) -> list[str]: + """Split tmux output into newline-delimited text lines.""" + text = data.decode("utf-8", errors="backslashreplace") + lines = text.split("\n") + while lines and lines[-1] == "": + lines.pop() + return lines + + +def _server_available(socket_path: str) -> bool: + """Return whether a tmux server is currently listening on the socket path.""" + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.connect(socket_path) + except OSError: + return False + finally: + probe.close() + return True + + +register_engine("imsg", ImsgEngine) diff --git a/src/libtmux/experimental/engines/imsg/exc.py b/src/libtmux/experimental/engines/imsg/exc.py new file mode 100644 index 000000000..99d142e37 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/exc.py @@ -0,0 +1,22 @@ +"""Exceptions for the experimental imsg engine.""" + +from __future__ import annotations + +from libtmux.exc import LibTmuxException + + +class ImsgError(LibTmuxException): + """Base error for the native imsg engine.""" + + +class ImsgProtocolError(ImsgError): + """The imsg wire protocol was violated (bad frame, size, or framing).""" + + +class UnsupportedProtocolVersion(ImsgError): + """The tmux server speaks an imsg protocol version this engine lacks.""" + + def __init__(self, version: str) -> None: + self.version = version + msg = f"unsupported tmux imsg protocol version: {version}" + super().__init__(msg) diff --git a/src/libtmux/experimental/engines/imsg/types.py b/src/libtmux/experimental/engines/imsg/types.py new file mode 100644 index 000000000..0812cf26a --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/types.py @@ -0,0 +1,28 @@ +"""Typed imsg frame primitives shared by protocol versions.""" + +from __future__ import annotations + +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class ImsgHeader: + """Decoded imsg header. + + ``length`` is the full frame length without the imsg FD marker bit. + """ + + msg_type: int + length: int + peer_id: int + pid: int + has_fd: bool = False + + +@dataclasses.dataclass(frozen=True) +class ImsgFrame: + """A framed tmux imsg message plus an optional SCM_RIGHTS descriptor.""" + + header: ImsgHeader + payload: bytes = b"" + fd: int | None = None diff --git a/src/libtmux/experimental/engines/imsg/v8.py b/src/libtmux/experimental/engines/imsg/v8.py new file mode 100644 index 000000000..3221da5f5 --- /dev/null +++ b/src/libtmux/experimental/engines/imsg/v8.py @@ -0,0 +1,543 @@ +"""tmux imsg protocol version 8.""" + +from __future__ import annotations + +import dataclasses +import enum +import struct +import typing as t + +from libtmux.experimental.engines.imsg.exc import ImsgProtocolError +from libtmux.experimental.engines.imsg.types import ImsgFrame, ImsgHeader + +IMSG_HEADER_SIZE = 16 +MAX_IMSGSIZE = 16384 +IMSG_FD_MARK = 0x80000000 + +_HEADER = struct.Struct("=IIII") +_INT32 = struct.Struct("=i") +_UINT64 = struct.Struct("=Q") +_WRITE_OPEN = struct.Struct("=iii") +_WRITE_DATA = struct.Struct("=i") +_WRITE_READY = struct.Struct("=ii") +_WRITE_CLOSE = struct.Struct("=i") +_READ_OPEN = struct.Struct("=ii") +_READ_DONE = struct.Struct("=ii") +_ExitStatus = tuple[int, str | None] + + +class MessageType(enum.IntEnum): + """Known tmux protocol v8 message types from ``tmux-protocol.h``.""" + + MSG_VERSION = 12 + MSG_IDENTIFY_FLAGS = 100 + MSG_IDENTIFY_TERM = 101 + MSG_IDENTIFY_TTYNAME = 102 + MSG_IDENTIFY_OLDCWD = 103 + MSG_IDENTIFY_STDIN = 104 + MSG_IDENTIFY_ENVIRON = 105 + MSG_IDENTIFY_DONE = 106 + MSG_IDENTIFY_CLIENTPID = 107 + MSG_IDENTIFY_CWD = 108 + MSG_IDENTIFY_FEATURES = 109 + MSG_IDENTIFY_STDOUT = 110 + MSG_IDENTIFY_LONGFLAGS = 111 + MSG_IDENTIFY_TERMINFO = 112 + MSG_COMMAND = 200 + MSG_DETACH = 201 + MSG_DETACHKILL = 202 + MSG_EXIT = 203 + MSG_EXITED = 204 + MSG_EXITING = 205 + MSG_LOCK = 206 + MSG_READY = 207 + MSG_RESIZE = 208 + MSG_SHELL = 209 + MSG_SHUTDOWN = 210 + MSG_OLDSTDERR = 211 + MSG_OLDSTDIN = 212 + MSG_OLDSTDOUT = 213 + MSG_SUSPEND = 214 + MSG_UNLOCK = 215 + MSG_WAKEUP = 216 + MSG_EXEC = 217 + MSG_FLAGS = 218 + MSG_READ_OPEN = 300 + MSG_READ = 301 + MSG_READ_DONE = 302 + MSG_WRITE_OPEN = 303 + MSG_WRITE = 304 + MSG_WRITE_READY = 305 + MSG_WRITE_CLOSE = 306 + MSG_READ_CANCEL = 307 + + +@dataclasses.dataclass(frozen=True) +class WriteOpenMessage: + """Parsed ``MSG_WRITE_OPEN`` payload.""" + + stream: int + fd: int + flags: int + path: str + + +@dataclasses.dataclass(frozen=True) +class WriteDataMessage: + """Parsed ``MSG_WRITE`` payload.""" + + stream: int + data: bytes + + +@dataclasses.dataclass(frozen=True) +class WriteReadyMessage: + """Parsed ``MSG_WRITE_READY`` payload.""" + + stream: int + error: int + + +@dataclasses.dataclass(frozen=True) +class WriteCloseMessage: + """Parsed ``MSG_WRITE_CLOSE`` payload.""" + + stream: int + + +@dataclasses.dataclass(frozen=True) +class ReadOpenMessage: + """Parsed ``MSG_READ_OPEN`` payload.""" + + stream: int + fd: int + path: str + + +@dataclasses.dataclass(frozen=True) +class ReadDoneMessage: + """Parsed ``MSG_READ_DONE`` payload.""" + + stream: int + error: int + + +@dataclasses.dataclass(frozen=True) +class ExitMessage: + """Parsed ``MSG_EXIT`` payload.""" + + returncode: int + message: str | None + + +@dataclasses.dataclass(frozen=True) +class RawMessage: + """Payload for message types without a dedicated parser.""" + + payload: bytes + + +ParsedMessage: t.TypeAlias = ( + WriteOpenMessage + | WriteDataMessage + | WriteReadyMessage + | WriteCloseMessage + | ReadOpenMessage + | ReadDoneMessage + | ExitMessage + | RawMessage +) + + +class ProtocolV8Codec: + """Typed codec for tmux binary protocol version 8.""" + + version = "8" + + @property + def msg_version(self) -> int: + """Return the numeric ``MSG_VERSION`` message type.""" + return int(MessageType.MSG_VERSION) + + @property + def msg_ready(self) -> int: + """Return the numeric ``MSG_READY`` message type.""" + return int(MessageType.MSG_READY) + + @property + def msg_exit(self) -> int: + """Return the numeric ``MSG_EXIT`` message type.""" + return int(MessageType.MSG_EXIT) + + @property + def msg_exited(self) -> int: + """Return the numeric ``MSG_EXITED`` message type.""" + return int(MessageType.MSG_EXITED) + + @property + def msg_shutdown(self) -> int: + """Return the numeric ``MSG_SHUTDOWN`` message type.""" + return int(MessageType.MSG_SHUTDOWN) + + @property + def msg_flags(self) -> int: + """Return the numeric ``MSG_FLAGS`` message type.""" + return int(MessageType.MSG_FLAGS) + + @property + def msg_write_open(self) -> int: + """Return the numeric ``MSG_WRITE_OPEN`` message type.""" + return int(MessageType.MSG_WRITE_OPEN) + + @property + def msg_write(self) -> int: + """Return the numeric ``MSG_WRITE`` message type.""" + return int(MessageType.MSG_WRITE) + + @property + def msg_write_close(self) -> int: + """Return the numeric ``MSG_WRITE_CLOSE`` message type.""" + return int(MessageType.MSG_WRITE_CLOSE) + + @property + def msg_read_open(self) -> int: + """Return the numeric ``MSG_READ_OPEN`` message type.""" + return int(MessageType.MSG_READ_OPEN) + + @property + def msg_exiting(self) -> int: + """Return the numeric ``MSG_EXITING`` message type.""" + return int(MessageType.MSG_EXITING) + + def frame_message( + self, + msg_type: int | MessageType, + payload: bytes, + *, + peer_id: int, + fd: int | None = None, + ) -> ImsgFrame: + """Return a typed imsg frame.""" + length = IMSG_HEADER_SIZE + len(payload) + if length > MAX_IMSGSIZE: + msg = f"tmux imsg payload too large: {len(payload)} bytes" + raise ImsgProtocolError(msg) + return ImsgFrame( + header=ImsgHeader( + msg_type=int(msg_type), + length=length, + peer_id=peer_id, + pid=0, + has_fd=fd is not None, + ), + payload=payload, + fd=fd, + ) + + def pack_frame(self, frame: ImsgFrame) -> bytes: + """Return wire bytes for a typed imsg frame.""" + expected_payload_len = frame.header.length - IMSG_HEADER_SIZE + if expected_payload_len != len(frame.payload): + msg = ( + "tmux imsg frame length does not match payload size: " + f"{frame.header.length} != {IMSG_HEADER_SIZE + len(frame.payload)}" + ) + raise ImsgProtocolError(msg) + if frame.header.has_fd != (frame.fd is not None): + msg = "tmux imsg frame FD marker does not match descriptor" + raise ImsgProtocolError(msg) + + encoded_length = frame.header.length + if frame.header.has_fd: + encoded_length |= IMSG_FD_MARK + header = _HEADER.pack( + frame.header.msg_type, + encoded_length, + frame.header.peer_id, + frame.header.pid, + ) + return header + frame.payload + + def pack_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + ) -> bytes: + """Return a framed tmux imsg message without an attached FD.""" + return self.pack_frame( + self.frame_message(msg_type, payload, peer_id=peer_id), + ) + + def unpack_header(self, data: bytes) -> ImsgHeader: + """Decode and validate a tmux imsg header.""" + if len(data) != IMSG_HEADER_SIZE: + msg = f"tmux imsg header must be {IMSG_HEADER_SIZE} bytes" + raise ImsgProtocolError(msg) + + msg_type, encoded_length, peer_id, pid = _HEADER.unpack(data) + has_fd = bool(encoded_length & IMSG_FD_MARK) + length = encoded_length & ~IMSG_FD_MARK + if length < IMSG_HEADER_SIZE or length > MAX_IMSGSIZE: + msg = f"Invalid tmux imsg length: {length}" + raise ImsgProtocolError(msg) + return ImsgHeader( + msg_type=msg_type, + length=length, + peer_id=peer_id, + pid=pid, + has_fd=has_fd, + ) + + def identify_messages( + self, + *, + cwd: str, + term: str, + tty_name: str, + client_pid: int, + environ: dict[str, str], + flags: int = 0, + features: int = 0, + stdin_fd: int | None = None, + stdout_fd: int | None = None, + ) -> list[ImsgFrame]: + """Build the identify handshake messages for a tmux client.""" + peer_id = int(self.version) + messages = [ + self.frame_message( + MessageType.MSG_IDENTIFY_LONGFLAGS, + _UINT64.pack(flags), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_LONGFLAGS, + _UINT64.pack(flags), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_TERM, + _c_string(term), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_FEATURES, + _INT32.pack(features), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_TTYNAME, + _c_string(tty_name), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_CWD, + _c_string(cwd), + peer_id=peer_id, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_STDIN, + b"", + peer_id=peer_id, + fd=stdin_fd, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_STDOUT, + b"", + peer_id=peer_id, + fd=stdout_fd, + ), + self.frame_message( + MessageType.MSG_IDENTIFY_CLIENTPID, + _INT32.pack(client_pid), + peer_id=peer_id, + ), + ] + for key, value in environ.items(): + encoded = _c_string(f"{key}={value}") + if len(encoded) > MAX_IMSGSIZE - IMSG_HEADER_SIZE: + continue + messages.append( + self.frame_message( + MessageType.MSG_IDENTIFY_ENVIRON, + encoded, + peer_id=peer_id, + ), + ) + messages.append( + self.frame_message( + MessageType.MSG_IDENTIFY_DONE, + b"", + peer_id=peer_id, + ), + ) + return messages + + def command_message(self, argv: tuple[str, ...], *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_COMMAND`` frame.""" + payload = _INT32.pack(len(argv)) + b"".join(_c_string(arg) for arg in argv) + return self.frame_message( + MessageType.MSG_COMMAND, + payload, + peer_id=peer_id, + ) + + def parse_message( + self, + msg_type: int, + payload: bytes, + *, + peer_id: int, + pid: int, + ) -> ParsedMessage: + """Parse a typed tmux message payload.""" + del peer_id, pid + if msg_type == int(MessageType.MSG_WRITE_OPEN): + _require_min_size(payload, _WRITE_OPEN.size, "MSG_WRITE_OPEN") + stream, fd, flags = _WRITE_OPEN.unpack_from(payload) + path = _decode_c_string(payload[_WRITE_OPEN.size :]) + return WriteOpenMessage(stream=stream, fd=fd, flags=flags, path=path) + if msg_type == int(MessageType.MSG_WRITE): + _require_min_size(payload, _WRITE_DATA.size, "MSG_WRITE") + (stream,) = _WRITE_DATA.unpack_from(payload) + return WriteDataMessage(stream=stream, data=payload[_WRITE_DATA.size :]) + if msg_type == int(MessageType.MSG_WRITE_READY): + _require_exact_size(payload, _WRITE_READY.size, "MSG_WRITE_READY") + stream, error = _WRITE_READY.unpack(payload) + return WriteReadyMessage(stream=stream, error=error) + if msg_type == int(MessageType.MSG_WRITE_CLOSE): + _require_exact_size(payload, _WRITE_CLOSE.size, "MSG_WRITE_CLOSE") + (stream,) = _WRITE_CLOSE.unpack(payload) + return WriteCloseMessage(stream=stream) + if msg_type == int(MessageType.MSG_READ_OPEN): + _require_min_size(payload, _READ_OPEN.size, "MSG_READ_OPEN") + stream, fd = _READ_OPEN.unpack_from(payload) + path = _decode_c_string(payload[_READ_OPEN.size :]) + return ReadOpenMessage(stream=stream, fd=fd, path=path) + if msg_type == int(MessageType.MSG_READ_DONE): + _require_exact_size(payload, _READ_DONE.size, "MSG_READ_DONE") + stream, error = _READ_DONE.unpack(payload) + return ReadDoneMessage(stream=stream, error=error) + if msg_type == int(MessageType.MSG_EXIT): + return _parse_exit_message(payload) + return RawMessage(payload=payload) + + def exit_status_from_message(self, message: object) -> _ExitStatus | None: + """Return exit metadata if the parsed message encodes it.""" + if isinstance(message, ExitMessage): + return message.returncode, message.message + return None + + def write_open_stream(self, message: object) -> int | None: + """Return the stream id from a ``MSG_WRITE_OPEN`` message.""" + if isinstance(message, WriteOpenMessage): + return message.stream + return None + + def write_payload(self, message: object) -> tuple[int, bytes] | None: + """Return stream id and bytes from a ``MSG_WRITE`` message.""" + if isinstance(message, WriteDataMessage): + return message.stream, message.data + return None + + def write_close_stream(self, message: object) -> int | None: + """Return the closed stream id from a ``MSG_WRITE_CLOSE`` message.""" + if isinstance(message, WriteCloseMessage): + return message.stream + return None + + def read_open_stream(self, message: object) -> int | None: + """Return the stream id from a ``MSG_READ_OPEN`` message.""" + if isinstance(message, ReadOpenMessage): + return message.stream + return None + + def write_ready_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_WRITE_READY`` reply.""" + return self.frame_message( + MessageType.MSG_WRITE_READY, + _WRITE_READY.pack(stream, error_code), + peer_id=peer_id, + ) + + def read_done_message( + self, + stream: int, + error_code: int, + *, + peer_id: int, + ) -> ImsgFrame: + """Build a ``MSG_READ_DONE`` reply.""" + return self.frame_message( + MessageType.MSG_READ_DONE, + _READ_DONE.pack(stream, error_code), + peer_id=peer_id, + ) + + def exiting_message(self, *, peer_id: int) -> ImsgFrame: + """Build a ``MSG_EXITING`` notification.""" + return self.frame_message(MessageType.MSG_EXITING, b"", peer_id=peer_id) + + +def _c_string(value: str) -> bytes: + return value.encode("utf-8") + b"\0" + + +def _decode_c_string(data: bytes) -> str: + if not data: + return "" + if data[-1] != 0: + msg = "tmux imsg string payload is not NUL terminated" + raise ImsgProtocolError(msg) + return data[:-1].decode("utf-8", errors="backslashreplace") + + +def _require_min_size(payload: bytes, min_size: int, name: str) -> None: + if len(payload) < min_size: + msg = f"bad {name} payload size: {len(payload)}" + raise ImsgProtocolError(msg) + + +def _require_exact_size(payload: bytes, expected_size: int, name: str) -> None: + if len(payload) != expected_size: + msg = f"bad {name} payload size: {len(payload)}" + raise ImsgProtocolError(msg) + + +def _parse_exit_message(payload: bytes) -> ExitMessage: + if len(payload) < _INT32.size and payload: + msg = "bad MSG_EXIT payload size" + raise ImsgProtocolError(msg) + + returncode = 0 + message: str | None = None + if len(payload) >= _INT32.size: + (returncode,) = _INT32.unpack_from(payload) + if len(payload) > _INT32.size: + message = _decode_c_string(payload[_INT32.size :]) or None + return ExitMessage(returncode=returncode, message=message) + + +__all__ = ( + "IMSG_FD_MARK", + "IMSG_HEADER_SIZE", + "MAX_IMSGSIZE", + "ExitMessage", + "MessageType", + "ParsedMessage", + "ProtocolV8Codec", + "RawMessage", + "ReadDoneMessage", + "ReadOpenMessage", + "WriteCloseMessage", + "WriteDataMessage", + "WriteOpenMessage", + "WriteReadyMessage", +) diff --git a/tests/experimental/engines/__init__.py b/tests/experimental/engines/__init__.py new file mode 100644 index 000000000..bdf24ec3b --- /dev/null +++ b/tests/experimental/engines/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.engines.""" + +from __future__ import annotations diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py new file mode 100644 index 000000000..0715c2025 --- /dev/null +++ b/tests/experimental/engines/test_imsg.py @@ -0,0 +1,93 @@ +"""Tests for the native imsg engine (codec unit tests + live tmux parity). + +The prototype this is ported from only ever tested against a fake socketpair +server; the live parity test here is the real wire-compatibility proof against a +tmux built from source, and it runs across the CI tmux matrix. +""" + +from __future__ import annotations + +import socket +import typing as t + +import pytest + +from libtmux.experimental.engines import ( + CommandRequest, + ImsgEngine, + SubprocessEngine, + available_engines, + create_engine, +) +from libtmux.experimental.engines.imsg.v8 import IMSG_HEADER_SIZE, ProtocolV8Codec + +if t.TYPE_CHECKING: + from libtmux.session import Session + +needs_af_unix = pytest.mark.skipif( + not hasattr(socket, "AF_UNIX"), + reason="imsg engine needs AF_UNIX sockets (POSIX only)", +) + + +def test_imsg_registered() -> None: + """The imsg engine is registered and constructible by name.""" + assert "imsg" in available_engines() + assert type(create_engine("imsg")).__name__ == "ImsgEngine" + + +def test_v8_codec_header_round_trip() -> None: + """A v8 frame packs to wire bytes and its header unpacks back (no tmux).""" + codec = ProtocolV8Codec() + payload = b"hello\x00" + frame = codec.frame_message(200, payload, peer_id=8) + wire = codec.pack_frame(frame) + + assert len(wire) == IMSG_HEADER_SIZE + len(payload) + header = codec.unpack_header(wire[:IMSG_HEADER_SIZE]) + assert header.msg_type == 200 + assert header.peer_id == 8 # peer_id carries PROTOCOL_VERSION + assert header.length == IMSG_HEADER_SIZE + len(payload) + assert header.has_fd is False + + +def test_v8_command_message_packs_argc_and_argv() -> None: + """A MSG_COMMAND frame encodes argc + NUL-joined argv (no tmux).""" + codec = ProtocolV8Codec() + frame = codec.command_message(("list-sessions", "-F", "#{session_id}"), peer_id=8) + # int32 argc=3 then three NUL-terminated args + assert frame.payload.startswith(b"\x03\x00\x00\x00") + assert frame.payload.endswith(b"#{session_id}\x00") + + +def _socket_prefix(server: t.Any) -> tuple[str, ...]: + """Build the -L/-S flag that targets the test server's socket.""" + if server.socket_name: + return (f"-L{server.socket_name}",) + return (f"-S{server.socket_path}",) + + +@needs_af_unix +def test_imsg_subprocess_parity(session: Session) -> None: + """Imsg and subprocess engines return identical output for read commands. + + This is the wire-compatibility proof: the same typed CommandResult from + speaking tmux's binary protocol directly and from forking the tmux CLI. + """ + server = session.server + prefix = _socket_prefix(server) + session_id = session.session_id + assert session_id is not None + imsg = ImsgEngine() + classic = SubprocessEngine() + + def parity(*cmd: str) -> None: + request = CommandRequest.from_args(*prefix, *cmd) + via_imsg = imsg.run(request) + via_subprocess = classic.run(request) + assert via_imsg.returncode == via_subprocess.returncode, cmd + assert via_imsg.stdout == via_subprocess.stdout, cmd + + parity("display-message", "-p", "-t", session_id, "#{session_id}") + parity("list-sessions", "-F", "#{session_id}") + parity("has-session", "-t", session_id) From f9013726543e3e5a43a4dc9da596c67248b0a1e1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:01:27 -0500 Subject: [PATCH 015/154] Facade(feat): Complete the facade matrix (Server/Session/Client) why: Finish the mode-in-the-type matrix so every tmux scope has eager/lazy/async facades, and add the client-scoped ops a Client facade needs. The matrix is now 5 scopes x 3 modes, all over the shared spine. what: - ops: detach-client, refresh-client, switch-client (AckResult, client scope; switch-client renders -c/-t rather than the generic target) - facade: LazyServer/AsyncServer, LazySession/AsyncSession, and the new client scope (EagerClient/LazyClient/AsyncClient); AsyncServer.for_server binds the async engine to a live Server - tests: a lazy full Server->Session->Window->pane plan, async navigation, and eager/lazy/async client methods --- src/libtmux/experimental/facade/__init__.py | 38 ++++-- src/libtmux/experimental/facade/client.py | 124 ++++++++++++++++++ src/libtmux/experimental/facade/server.py | 78 ++++++++++- src/libtmux/experimental/facade/session.py | 97 +++++++++++++- src/libtmux/experimental/ops/__init__.py | 6 + src/libtmux/experimental/ops/_ops/__init__.py | 6 + .../experimental/ops/_ops/detach_client.py | 36 +++++ .../experimental/ops/_ops/refresh_client.py | 34 +++++ .../experimental/ops/_ops/switch_client.py | 39 ++++++ src/libtmux/experimental/ops/catalog.py | 8 +- .../facade/test_matrix_complete.py | 69 ++++++++++ 11 files changed, 509 insertions(+), 26 deletions(-) create mode 100644 src/libtmux/experimental/facade/client.py create mode 100644 src/libtmux/experimental/ops/_ops/detach_client.py create mode 100644 src/libtmux/experimental/ops/_ops/refresh_client.py create mode 100644 src/libtmux/experimental/ops/_ops/switch_client.py create mode 100644 tests/experimental/facade/test_matrix_complete.py diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/facade/__init__.py index c034c34a7..5d1a62943 100644 --- a/src/libtmux/experimental/facade/__init__.py +++ b/src/libtmux/experimental/facade/__init__.py @@ -2,38 +2,50 @@ The execution mode lives in the facade *type* (eager vs lazy vs async), so each method has one statically-known return type, while the operation definitions stay -shared. The facades form a small matrix over scope x mode: +shared. The matrix over scope x mode: -========== =========== ========== =========== -scope eager lazy async -========== =========== ========== =========== -server EagerServer -- -- -session EagerSession -- -- -window EagerWindow LazyWindow AsyncWindow -pane EagerPane LazyPane AsyncPane -========== =========== ========== =========== +========== ============ ============ ============ +scope eager lazy async +========== ============ ============ ============ +server EagerServer LazyServer AsyncServer +session EagerSession LazySession AsyncSession +window EagerWindow LazyWindow AsyncWindow +pane EagerPane LazyPane AsyncPane +client EagerClient LazyClient AsyncClient +========== ============ ============ ============ Eager handles execute immediately and return live handles; lazy handles record into a :class:`~..ops.plan.LazyPlan`; async handles await an :class:`~..engines.base.AsyncTmuxEngine`. "Control mode" is not a separate family --- any eager/async facade bound to a ``ControlModeEngine`` already uses it. See -issue 689 for the full matrix. +-- any eager/async facade bound to a ``ControlModeEngine`` already uses it. """ from __future__ import annotations +from libtmux.experimental.facade.client import AsyncClient, EagerClient, LazyClient from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane -from libtmux.experimental.facade.server import EagerServer -from libtmux.experimental.facade.session import EagerSession +from libtmux.experimental.facade.server import AsyncServer, EagerServer, LazyServer +from libtmux.experimental.facade.session import ( + AsyncSession, + EagerSession, + LazySession, +) from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow __all__ = ( + "AsyncClient", "AsyncPane", + "AsyncServer", + "AsyncSession", "AsyncWindow", + "EagerClient", "EagerPane", "EagerServer", "EagerSession", "EagerWindow", + "LazyClient", "LazyPane", + "LazyServer", + "LazySession", "LazyWindow", ) diff --git a/src/libtmux/experimental/facade/client.py b/src/libtmux/experimental/facade/client.py new file mode 100644 index 000000000..6cd280239 --- /dev/null +++ b/src/libtmux/experimental/facade/client.py @@ -0,0 +1,124 @@ +"""Client-scope facades (eager / lazy / async) over the operation spine. + +A client is a *view* (a terminal attachment keyed by name/tty), not part of the +ownership chain, but tmux exposes client-scoped commands -- ``detach-client``, +``switch-client``, ``refresh-client`` -- so it gets a facade like any other scope. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops import ( + DetachClient, + RefreshClient, + SwitchClient, + arun, + run, +) +from libtmux.experimental.ops._types import ClientName + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class EagerClient: + """A live client handle; methods execute immediately. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> client = EagerClient(ConcreteEngine(), "/dev/pts/3") + >>> client.refresh().ok + True + >>> client.switch_to("$1").ok + True + """ + + engine: TmuxEngine + client_name: str + version: str | None = None + + def detach(self) -> Result: + """Detach this client.""" + return run( + DetachClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + def refresh(self) -> Result: + """Refresh this client.""" + return run( + RefreshClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + def switch_to(self, session_id: str) -> Result: + """Switch this client to a session.""" + return run( + SwitchClient(client=self.client_name, to_session=session_id), + self.engine, + version=self.version, + ) + + +@dataclass(frozen=True) +class LazyClient: + """A deferred client handle; methods record into a plan.""" + + plan: LazyPlan + client_name: str + + def detach(self) -> LazyClient: + """Record a detach; return self for chaining.""" + self.plan.add(DetachClient(target=ClientName(self.client_name))) + return self + + def refresh(self) -> LazyClient: + """Record a refresh; return self for chaining.""" + self.plan.add(RefreshClient(target=ClientName(self.client_name))) + return self + + def switch_to(self, session_id: str) -> LazyClient: + """Record a switch-client; return self for chaining.""" + self.plan.add(SwitchClient(client=self.client_name, to_session=session_id)) + return self + + +@dataclass(frozen=True) +class AsyncClient: + """An async live client handle: the eager client, awaited.""" + + engine: AsyncTmuxEngine + client_name: str + version: str | None = None + + async def detach(self) -> Result: + """Detach this client.""" + return await arun( + DetachClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + async def refresh(self) -> Result: + """Refresh this client.""" + return await arun( + RefreshClient(target=ClientName(self.client_name)), + self.engine, + version=self.version, + ) + + async def switch_to(self, session_id: str) -> Result: + """Switch this client to a session.""" + return await arun( + SwitchClient(client=self.client_name, to_session=session_id), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/facade/server.py b/src/libtmux/experimental/facade/server.py index caf8de7a9..b10f6da7d 100644 --- a/src/libtmux/experimental/facade/server.py +++ b/src/libtmux/experimental/facade/server.py @@ -1,15 +1,20 @@ -"""Server-scope eager facade -- the entry point for live navigation.""" +"""Server-scope facades -- the entry points for facade navigation.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.session import EagerSession -from libtmux.experimental.ops import NewSession, run +from libtmux.experimental.facade.session import ( + AsyncSession, + EagerSession, + LazySession, +) +from libtmux.experimental.ops import NewSession, arun, run if t.TYPE_CHECKING: - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan @dataclass(frozen=True) @@ -23,8 +28,7 @@ class EagerServer: >>> session = server.new_session(name="work") >>> session.session_id '$1' - >>> window = session.new_window() - >>> pane = window.split() + >>> pane = session.new_window().split() >>> pane.pane_id '%1' """ @@ -54,3 +58,65 @@ def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer from libtmux.experimental.engines import SubprocessEngine return cls(SubprocessEngine.for_server(server), version=version) + + +@dataclass(frozen=True) +class LazyServer: + """A deferred server handle; records session creation into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> plan = LazyPlan() + >>> server = LazyServer(plan) + >>> session = server.new_session(name="work") + >>> _ = session.new_window(name="build") + >>> plan.execute(ConcreteEngine()).ok + True + """ + + plan: LazyPlan + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> LazySession: + """Record a new session; return a deferred session handle.""" + slot = self.plan.add( + NewSession(session_name=name, start_directory=start_directory), + ) + return LazySession(self.plan, slot) + + +@dataclass(frozen=True) +class AsyncServer: + """An async live server handle: the eager server, awaited.""" + + engine: AsyncTmuxEngine + version: str | None = None + + async def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> AsyncSession: + """Create a detached session; return a live async session handle.""" + result = await arun( + NewSession(session_name=name, start_directory=start_directory), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return AsyncSession(self.engine, result.new_id, self.version) + + @classmethod + def for_server(cls, server: t.Any, *, version: str | None = None) -> AsyncServer: + """Bind an async facade to a live :class:`libtmux.Server`'s socket.""" + from libtmux.experimental.engines import AsyncSubprocessEngine + + return cls(AsyncSubprocessEngine.for_server(server), version=version) diff --git a/src/libtmux/experimental/facade/session.py b/src/libtmux/experimental/facade/session.py index c2cad8c57..83cc0d40d 100644 --- a/src/libtmux/experimental/facade/session.py +++ b/src/libtmux/experimental/facade/session.py @@ -1,21 +1,24 @@ -"""Session-scope eager facade over the operation spine.""" +"""Session-scope facades (eager / lazy / async) over the operation spine.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.window import EagerWindow +from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow from libtmux.experimental.ops import ( KillSession, NewWindow, RenameSession, + arun, run, ) from libtmux.experimental.ops._types import SessionId if t.TYPE_CHECKING: - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.plan import LazyPlan from libtmux.experimental.ops.results import Result @@ -73,3 +76,91 @@ def kill(self) -> Result: self.engine, version=self.version, ) + + +@dataclass(frozen=True) +class LazySession: + """A deferred session handle; methods record into a plan. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import LazyPlan + >>> from libtmux.experimental.ops._types import SessionId + >>> plan = LazyPlan() + >>> session = LazySession(plan, SessionId("$0")) + >>> window = session.new_window(name="build") + >>> _ = session.rename("work") + >>> plan.execute(ConcreteEngine()).ok + True + """ + + plan: LazyPlan + ref: Target + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> LazyWindow: + """Record a new window; return a deferred window handle.""" + slot = self.plan.add( + NewWindow(target=self.ref, name=name, start_directory=start_directory), + ) + return LazyWindow(self.plan, slot) + + def rename(self, name: str) -> LazySession: + """Record a rename; return self for chaining.""" + self.plan.add(RenameSession(target=self.ref, name=name)) + return self + + def kill(self) -> LazySession: + """Record a kill; return self for chaining.""" + self.plan.add(KillSession(target=self.ref)) + return self + + +@dataclass(frozen=True) +class AsyncSession: + """An async live session handle: the eager session, awaited.""" + + engine: AsyncTmuxEngine + session_id: str + version: str | None = None + + async def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + ) -> AsyncWindow: + """Create a window in this session; return a live async window handle.""" + result = await arun( + NewWindow( + target=SessionId(self.session_id), + name=name, + start_directory=start_directory, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_id is not None + return AsyncWindow(self.engine, result.new_id, self.version) + + async def rename(self, name: str) -> Result: + """Rename this session.""" + return await arun( + RenameSession(target=SessionId(self.session_id), name=name), + self.engine, + version=self.version, + ) + + async def kill(self) -> Result: + """Kill this session.""" + return await arun( + KillSession(target=SessionId(self.session_id)), + self.engine, + version=self.version, + ) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 75c986674..fd71445ba 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -22,6 +22,7 @@ from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._ops import ( CapturePane, + DetachClient, KillPane, KillSession, KillWindow, @@ -30,11 +31,13 @@ ListWindows, NewSession, NewWindow, + RefreshClient, RenameSession, RenameWindow, SelectLayout, SendKeys, SplitWindow, + SwitchClient, ) from libtmux.experimental.ops._types import ( ClientName, @@ -96,6 +99,7 @@ "CatalogEntry", "ClientName", "CreateResult", + "DetachClient", "DuplicateOperation", "Effects", "IndexRef", @@ -119,6 +123,7 @@ "OperationRegistry", "PaneId", "PlanResult", + "RefreshClient", "RenameSession", "RenameWindow", "Result", @@ -132,6 +137,7 @@ "SplitWindow", "SplitWindowResult", "Status", + "SwitchClient", "Target", "TmuxCommandError", "UnknownOperation", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 101f22aa5..61620c006 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -8,6 +8,7 @@ from __future__ import annotations from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.detach_client import DetachClient from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow @@ -16,14 +17,17 @@ from libtmux.experimental.ops._ops.list_windows import ListWindows from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.refresh_client import RefreshClient from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.split_window import SplitWindow +from libtmux.experimental.ops._ops.switch_client import SwitchClient __all__ = ( "CapturePane", + "DetachClient", "KillPane", "KillSession", "KillWindow", @@ -32,9 +36,11 @@ "ListWindows", "NewSession", "NewWindow", + "RefreshClient", "RenameSession", "RenameWindow", "SelectLayout", "SendKeys", "SplitWindow", + "SwitchClient", ) diff --git a/src/libtmux/experimental/ops/_ops/detach_client.py b/src/libtmux/experimental/ops/_ops/detach_client.py new file mode 100644 index 000000000..d12cf0897 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/detach_client.py @@ -0,0 +1,36 @@ +"""The ``detach-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class DetachClient(Operation[AckResult]): + """Detach a client. Produces no output (:class:`AckResult`). + + ``target`` is the client (a :class:`~.._types.ClientName`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> DetachClient(target=ClientName("/dev/pts/3")).render() + ('detach-client', '-t', '/dev/pts/3') + """ + + kind = "detach_client" + command = "detach-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target client.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/refresh_client.py b/src/libtmux/experimental/ops/_ops/refresh_client.py new file mode 100644 index 000000000..624d81d15 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/refresh_client.py @@ -0,0 +1,34 @@ +"""The ``refresh-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RefreshClient(Operation[AckResult]): + """Refresh a client. Produces no output (:class:`AckResult`). + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> RefreshClient(target=ClientName("/dev/pts/3")).render() + ('refresh-client', '-t', '/dev/pts/3') + """ + + kind = "refresh_client" + command = "refresh-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target client.""" + return () diff --git a/src/libtmux/experimental/ops/_ops/switch_client.py b/src/libtmux/experimental/ops/_ops/switch_client.py new file mode 100644 index 000000000..3938d9593 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/switch_client.py @@ -0,0 +1,39 @@ +"""The ``switch-client`` operation (no output -- an acknowledgement).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwitchClient(Operation[AckResult]): + """Switch a client to a session. Produces no output (:class:`AckResult`). + + Uses ``-c`` for the client and ``-t`` for the destination session, so it + does not use the generic target slot. + + Examples + -------- + >>> SwitchClient(client="/dev/pts/3", to_session="$1").render() + ('switch-client', '-c', '/dev/pts/3', '-t', '$1') + """ + + kind = "switch_client" + command = "switch-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + client: str + to_session: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-c -t ``.""" + return ("-c", self.client, "-t", self.to_session) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index b12f8d363..bd74aa2ca 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,10 +65,10 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'kill_pane', 'kill_session', 'kill_window', 'list_panes', - 'list_sessions', 'list_windows', 'new_session', 'new_window', - 'rename_session', 'rename_window', 'select_layout', 'send_keys', - 'split_window'] + ['capture_pane', 'detach_client', 'kill_pane', 'kill_session', 'kill_window', + 'list_panes', 'list_sessions', 'list_windows', 'new_session', 'new_window', + 'refresh_client', 'rename_session', 'rename_window', 'select_layout', + 'send_keys', 'split_window', 'switch_client'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/tests/experimental/facade/test_matrix_complete.py b/tests/experimental/facade/test_matrix_complete.py new file mode 100644 index 000000000..b5381a168 --- /dev/null +++ b/tests/experimental/facade/test_matrix_complete.py @@ -0,0 +1,69 @@ +"""Tests completing the facade matrix: lazy/async Server+Session and Client.""" + +from __future__ import annotations + +import asyncio + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.facade import ( + AsyncClient, + AsyncServer, + EagerClient, + LazyClient, + LazyServer, +) +from libtmux.experimental.ops import LazyPlan + + +def test_lazy_server_session_window_plan() -> None: + """LazyServer records a full Server->Session->Window creation plan.""" + plan = LazyPlan() + server = LazyServer(plan) + session = server.new_session(name="work") + window = session.new_window(name="build") + window.split() + assert len(plan) == 3 # new-session, new-window, split-window + + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert [r.created_id for r in outcome.results] == ["$1", "@1", "%1"] + + +def test_async_server_navigation() -> None: + """AsyncServer->AsyncSession->AsyncWindow navigation via await.""" + + async def main() -> str: + server = AsyncServer(AsyncConcreteEngine()) + session = await server.new_session(name="work") + window = await session.new_window() + pane = await window.split() + return pane.pane_id + + assert asyncio.run(main()) == "%1" + + +def test_eager_client_methods() -> None: + """EagerClient detach/refresh/switch_to return successful results.""" + client = EagerClient(ConcreteEngine(), "/dev/pts/3") + assert client.refresh().ok + assert client.switch_to("$1").ok + assert client.detach().ok + + +def test_lazy_client_records() -> None: + """LazyClient records client ops into a plan.""" + plan = LazyPlan() + client = LazyClient(plan, "/dev/pts/3") + client.refresh().switch_to("$1") + assert [op.kind for op in plan] == ["refresh_client", "switch_client"] + assert plan.execute(ConcreteEngine()).ok + + +def test_async_client() -> None: + """AsyncClient mirrors the eager client via await.""" + + async def main() -> bool: + client = AsyncClient(AsyncConcreteEngine(), "/dev/pts/3") + return (await client.refresh()).ok + + assert asyncio.run(main()) From 55c3d89dca352056598250ecc5c918944d4f3c66 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:12:33 -0500 Subject: [PATCH 016/154] chore(deps[dev]): Add ty type checker + config why: The pre-commit gate now runs `uv run ty check`, so ty must be a configured dev tool. Brings the ty setup from the add-ty-type-checker branch and makes the experimental tree ty-clean. what: - add `ty` to the dev dependency group (uv.lock updated) - add [tool.ty] (environment py3.10, src=src/tests) with the documented rule ignores for known ty false positives, ported verbatim - fixes ty surfaced in experimental: Target is now a real union (ty rejects an implicit two-string type alias); OperationRegistry.list -> select so the `-> list[OpSpec]` return annotation is not shadowed by the method name --- pyproject.toml | 69 +++++ src/libtmux/experimental/ops/_types.py | 3 +- src/libtmux/experimental/ops/catalog.py | 2 +- src/libtmux/experimental/ops/registry.py | 9 +- tests/experimental/ops/test_registry.py | 2 +- uv.lock | 322 ++++++++++++----------- 6 files changed, 251 insertions(+), 156 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dac1bf00f..69db3d394 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ # Lint "ruff", "mypy", + "ty", ] docs = [ @@ -140,6 +141,74 @@ files = [ ] +[tool.ty.environment] +python-version = "3.10" + +[tool.ty.src] +include = ["src", "tests"] + +[tool.ty.rules] +# Private _pytest APIs (e.g. RaisesContext) are not publicly exported; +# both mypy and ty flag them. ty categorizes these as unresolved-import +# rather than attr-defined. +# https://github.com/astral-sh/ty/issues/1276 +unresolved-import = "ignore" +# ty resolves cmd() as a union type including a (Any, Any, /) -> tmux_cmd +# variant from CmdProtocol, causing false positives on *args methods. +# ty does not yet fully support argument unpacking (*args/**kwargs). +# https://github.com/astral-sh/ty/issues/404 +too-many-positional-arguments = "ignore" +# Same root cause as too-many-positional-arguments: ty cannot verify +# required params are present when calling with **kwargs unpacking. +# https://github.com/astral-sh/ty/issues/785 +missing-argument = "ignore" +# ty falls back to object.__init__ for union return types and +# dataclass-transform decorators, flagging all kwargs as unknown. +# https://github.com/astral-sh/ty/issues/2369 +unknown-argument = "ignore" +# Tests use monkeypatch.setattr(libtmux.common, ...) without explicit +# submodule import. The submodule is always available via __init__.py +# re-exports but ty cannot detect implicit registration. +# https://github.com/astral-sh/ty/issues/133 +possibly-missing-submodule = "ignore" +# Vendored version.py uses tuple comparison with mixed-type elements +# (int, str, InfinityType) for PEP 440 version ordering. ty cannot +# resolve element-wise comparison operators across union-typed tuples. +# https://github.com/astral-sh/ty/issues/1202 +unsupported-operator = "ignore" +# ty cannot verify argument types through **kwargs unpacking and +# narrows LiteralString to str differently than mypy. 20 false +# positives, mostly from **call_kwargs and **filter_expr patterns. +# https://github.com/astral-sh/ty/issues/785 +invalid-argument-type = "ignore" +# ty doesn't narrow through dict value iteration (item.split() in +# options.py) or union access patterns (Pane | None). 5 false +# positives where mypy correctly narrows the type. +unresolved-attribute = "ignore" +# ty can't see through isinstance narrowing for TypeVars (subscript +# assignment on _V in options.py) and IO[str] | None unions +# (control_mode.py, already suppressed for mypy). 4 false positives. +invalid-assignment = "ignore" +# Pane, Session, Window inherit from Obj which defines defaulted fields; +# subclasses add required server: Server. Python dataclass inheritance +# handles this at runtime but ty's analysis doesn't account for it. +dataclass-field-order = "ignore" +# re.search(rhs, data) in query_list.py where isinstance narrows both +# to str | bytes, but ty can't match the overload (str, str) | (bytes, +# Buffer) against (str | bytes, str | bytes). 2 false positives. +no-matching-overload = "ignore" +# options.py returns dict subscript typed as object where str | int | +# None expected; test_session.py returns MockTmuxCmd where tmux_cmd +# expected (already suppressed for mypy). 2 false positives. +invalid-return-type = "ignore" +# query_list.py b[key] where b is typed as object from a narrowing +# path ty can't follow (same context as unresolved-attribute). +not-subscriptable = "ignore" +# query_list.py filter_(k) where filter_ is a union including +# T@QueryList & Top[(...) -> object] — ty's intersection type +# resolution produces an uncallable Top type. +call-top-callable = "ignore" + [tool.coverage.run] branch = true parallel = true diff --git a/src/libtmux/experimental/ops/_types.py b/src/libtmux/experimental/ops/_types.py index 6fc387ff9..897052591 100644 --- a/src/libtmux/experimental/ops/_types.py +++ b/src/libtmux/experimental/ops/_types.py @@ -295,8 +295,7 @@ def render(self) -> str: Target: t.TypeAlias = ( - "PaneId | WindowId | SessionId | ClientName | NameRef | IndexRef " - "| Special | SlotRef" + PaneId | WindowId | SessionId | ClientName | NameRef | IndexRef | Special | SlotRef ) """The closed sum of everything that can appear as an operation ``-t`` target.""" diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index bd74aa2ca..9fd67aa3c 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -90,5 +90,5 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: effects=dataclasses.asdict(spec.effects), summary=_summary(spec.operation_cls.__doc__), ) - for spec in reg.list() + for spec in reg.select() ] diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index c410c1936..b85468195 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -149,12 +149,15 @@ def operation(self, kind: str) -> type[Operation[t.Any]]: """Return the operation class registered for ``kind``.""" return self.get(kind).operation_cls - def list( + def select( self, predicate: Callable[[OpSpec], bool] | None = None, ) -> list[OpSpec]: """Return registered specs (optionally filtered), sorted by ``kind``. + Named ``select`` rather than ``list`` so the ``-> list[OpSpec]`` return + annotation is not shadowed by the method name. + Parameters ---------- predicate : callable, optional @@ -163,7 +166,7 @@ def list( Examples -------- >>> from libtmux.experimental.ops import registry - >>> [s.kind for s in registry.list(lambda s: s.safety == "readonly")] + >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'list_panes', 'list_sessions', 'list_windows'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) @@ -181,7 +184,7 @@ def __contains__(self, kind: object) -> bool: def __iter__(self) -> Iterator[OpSpec]: """Iterate specs sorted by ``kind``.""" - return iter(self.list()) + return iter(self.select()) def __len__(self) -> int: """Return the number of registered operations.""" diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 0d6d4c766..6dbe407aa 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -43,7 +43,7 @@ def test_spec_from_operation_reads_classvars() -> None: def test_list_predicate_filters() -> None: """``list`` filters by a predicate and stays sorted by kind.""" readonly = [ - spec.kind for spec in registry.list(lambda spec: spec.safety == "readonly") + spec.kind for spec in registry.select(lambda spec: spec.safety == "readonly") ] assert readonly == ["capture_pane", "list_panes", "list_sessions", "list_windows"] diff --git a/uv.lock b/uv.lock index 62bb04425..d3f238bb1 100644 --- a/uv.lock +++ b/uv.lock @@ -53,57 +53,56 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.1" +version = "4.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, ] [[package]] name = "ast-serialize" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, - { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, - { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, - { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, - { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, - { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, - { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, - { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, - { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, - { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, - { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, - { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, - { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, - { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, - { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, - { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, - { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, - { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] @@ -244,14 +243,14 @@ wheels = [ [[package]] name = "click" -version = "8.4.2" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -519,89 +518,87 @@ wheels = [ [[package]] name = "librt" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/66/c9d88366893b4b0df6b5375c27ebc9f14c43419d9e244b493be20e85bc74/librt-0.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fe3547407bbce45c09885591f90168325c5a31a6795b9a13f6b9ff3d25093d93", size = 144398, upload-time = "2026-06-30T16:12:03.947Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f2/9be1c6da204701163ec3aaedbf893d2f656b363d8fa302af536ce6471eb4/librt-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5925eca673207204a3adca040a91bdd3738fc7ba48da647ccd55732692a35736", size = 148924, upload-time = "2026-06-30T16:12:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/256824ee27649c6e0a693db25d391f97b43b52364f8efb466014a564bbc7/librt-0.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f9ef097a7711465a204454c69658bbb6b2a6be9bdef0eeeba9a042016d00688", size = 479654, upload-time = "2026-06-30T16:12:07.175Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3f/f4adbb3f293a04bd3dc2eb91d814f5b1e221e6b4522585696ba6901a0b9a/librt-0.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57abc8b65edf1a8e80e5472c81c108a7527202e5febfda9e00a684dbaeae534e", size = 472318, upload-time = "2026-06-30T16:12:08.758Z" }, - { url = "https://files.pythonhosted.org/packages/9d/b5/362c93f7b43d4ef84a3d5f156c8d4eeddb22badcf5529a1281c387abbbd7/librt-0.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e6f53732a8ae5012a3b6ae092da2933be74ec4169d16038f4af87a0019afea", size = 501555, upload-time = "2026-06-30T16:12:10.623Z" }, - { url = "https://files.pythonhosted.org/packages/24/1d/2d6abf059c3a4b88a6668e7bb81af332b14463028ac8f2b08a1212eb1ebc/librt-0.12.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edb5f06cdb38d6ef9fd7ae06d62962d65c881b5f965d5e8a6c53e59c15ae4338", size = 494118, upload-time = "2026-06-30T16:12:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/39/c1/f91f3094be2c76361d88aca613d8b7586d15b6026714d59d2e3dc0e35f44/librt-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1473ef42263dfee7553a5c460f11730a4409acf0d52629b284eb1e6b13eb460a", size = 516318, upload-time = "2026-06-30T16:12:14.192Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e2/5211af94252458cbed7a6250163dff9c5a84aec29609121c828375a3b319/librt-0.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1d6f69a06295fb6ad8dcf92b4b2d15d211842005e86eedce64d88e0633592f58", size = 522294, upload-time = "2026-06-30T16:12:15.879Z" }, - { url = "https://files.pythonhosted.org/packages/90/9b/de31f5b9fdf7fa3699c4bbbecf82ebd52013d5d6b500b70b07b0ebacbd51/librt-0.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3275d0270cd07ca9c2e140ae4da34e24a0350e98c6e3815dce96ead67cf0487d", size = 502494, upload-time = "2026-06-30T16:12:17.394Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/22c18dff89f3900dddb3e470e6f7febcda37ff3667b73097a848c9a608b2/librt-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a4834462ec68613024d063c7efe9b188e350d40fda9ba937372039883d2a8051", size = 543422, upload-time = "2026-06-30T16:12:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/74691b4b55944227245fffef063714e3ab9707ab1111eb0068512b428c7c/librt-0.12.0-cp310-cp310-win32.whl", hash = "sha256:bcf9b55ac089e8cf201d2146833e1097812c15dcea61911e84d6a2904cf78893", size = 97642, upload-time = "2026-06-30T16:12:20.386Z" }, - { url = "https://files.pythonhosted.org/packages/c5/dc/7f8fa369a1f7cc9b090fecd373659ada0e9bab1ae4a3ac9f163eabd04977/librt-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0a122002f7e0d5c93e84465c4b3fe86621402b7b92f1e2bc0784ebe67793112", size = 117583, upload-time = "2026-06-30T16:12:21.829Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, - { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, - { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, - { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, - { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, - { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, - { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, - { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, - { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, - { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, - { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, - { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, - { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, - { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, - { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, - { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, - { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, - { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, - { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, - { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, - { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, - { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, - { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, - { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, - { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, - { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, - { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, - { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, - { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, - { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, - { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, - { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, - { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, - { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, - { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, - { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, - { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, - { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, - { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, - { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, - { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, - { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, - { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] @@ -632,6 +629,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, + { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -678,8 +676,9 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a33" }, - { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a33" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a32" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a32" }, + { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -1183,27 +1182,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +version = "0.15.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" }, + { url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" }, + { url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" }, + { url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" }, + { url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" }, + { url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" }, + { url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" }, ] [[package]] @@ -1669,6 +1668,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "ty" +version = "0.0.53" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/87/d5a1d099a41ed22f939b9eec5af3c40bd907409e673cc0b8fcfd1e354ab2/ty-0.0.53.tar.gz", hash = "sha256:86e8c522b1a1ae267cd6442cc93c0c954a2a59b89565e4fb493c1133bd5a056e", size = 5998692, upload-time = "2026-06-24T06:53:57.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/4a/1364826b7747a6ca6de4c3b1a47b6ed6e4d27d76236c704f02ccc2624686/ty-0.0.53-py3-none-linux_armv6l.whl", hash = "sha256:637f3c8e4837973c530d659cbd9a6697c12141b61b458214570a95048064acf5", size = 12034508, upload-time = "2026-06-24T06:53:18.729Z" }, + { url = "https://files.pythonhosted.org/packages/9d/55/9edc86267086c1d0be32b0625f2cae4bd269f62e9f0084f5469d5a7ada9e/ty-0.0.53-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b1079f5f18667362e6a2df0c08eef83b99cc7ec1f1e6bd6a43eb17087a62da2", size = 11793873, upload-time = "2026-06-24T06:53:21.188Z" }, + { url = "https://files.pythonhosted.org/packages/17/9b/92f080253ca27ad60f4a982f1d6e67fce1ab117514b8b51a25ed3e64a6b2/ty-0.0.53-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0699807f3333c052120998d0f652f9e13d534b16cbb4f04397885569e2889e5", size = 11190702, upload-time = "2026-06-24T06:53:23.216Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5b/fb3727c810bc9d131e74f21ed23ab04b2f6027a34f6399528c1adf1fc48c/ty-0.0.53-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fded06aeb8fbb0eb30d3164bba6d42446ca0f92268bd13668e636d984ff5ae32", size = 11711062, upload-time = "2026-06-24T06:53:25.312Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0b/e542864d10d84f6bd7e19b76578cb1f80ef83aac208f3dc0cafdf22ce924/ty-0.0.53-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4c890c34364d020f9aead3737fd00b06bb57981d5689825dd7face9caf89b92", size = 11801237, upload-time = "2026-06-24T06:53:27.437Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/f00b26d3618b8e4399d7b86a380867ff62ade8347650234fd50f74585863/ty-0.0.53-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2d2be32efc1c937551b5bdb6f94c96b330baac9cc12e69a91c82d2fdad5d9", size = 12324525, upload-time = "2026-06-24T06:53:29.537Z" }, + { url = "https://files.pythonhosted.org/packages/8c/01/63ce174e9a330e2c90f829fe523c737c2ecc7b5504a7a43f44866069e0fd/ty-0.0.53-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b175ce1a92e382991341a9ad2760de5d87b7b88c509a6ac26cbfcd5c723a3179", size = 12925679, upload-time = "2026-06-24T06:53:31.912Z" }, + { url = "https://files.pythonhosted.org/packages/14/98/129a2cd39b0e5ba72b748b46134fd278c749802389899eefc9320689e39c/ty-0.0.53-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c9a145dfe24bb0be4a99d10480ccc0c7e5a7e36cf371d0a57bd7001cfd9bef5", size = 12551302, upload-time = "2026-06-24T06:53:33.993Z" }, + { url = "https://files.pythonhosted.org/packages/23/0a/778deab4b31e3f6879073c668a4851de41f96174b5ff6bc8971c7d01d7b3/ty-0.0.53-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0664e9f707a4937e292ae83403e0486f610107b76f25e0987fec1bcda04121", size = 12353792, upload-time = "2026-06-24T06:53:36.347Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d2/f0823fd73233b9fdec9f90af2069b4a05f86b57da3bf274bd18744e8a1be/ty-0.0.53-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9eef1fc8e273f946103b264431ef314b72d2cbf50f0ef790f09fd308ec061259", size = 12600311, upload-time = "2026-06-24T06:53:38.615Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/ac64b9253223379b2e8e34900d852b3f561d5bb1b818caafda64f561fe79/ty-0.0.53-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d0afb96763442dc3dead88573e24d991bee9d4c52dbb57dee4c900c401dc269b", size = 11671972, upload-time = "2026-06-24T06:53:40.893Z" }, + { url = "https://files.pythonhosted.org/packages/55/1a/dc83c6b89683cc18a060611f7e7836abb3211744ace1ab0174cb96533a7b/ty-0.0.53-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f56edd323fcf95801c9487dd85e3b7bb329313adcd58d8d057b0823e30b579c4", size = 11832266, upload-time = "2026-06-24T06:53:43.048Z" }, + { url = "https://files.pythonhosted.org/packages/45/e3/7c11d218da1b5ccadf4b6ddab83b54333703e0bcdadb1d912093c16324fb/ty-0.0.53-py3-none-musllinux_1_2_i686.whl", hash = "sha256:93f13cc1616fcf38df280263631121770e915d823b66b82a13a12d4e43bafb37", size = 11974033, upload-time = "2026-06-24T06:53:45.387Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bb/af534430d07cf26b30f7ca76ac0f34df11bc3957d92eb29597ffa803626b/ty-0.0.53-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:de583808d93f7b6c93e1841deb4db5b181d8a7a1503c674f8143d6e06f2c67a2", size = 12462111, upload-time = "2026-06-24T06:53:47.76Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/0ba395d495669d2c022f8c0df4451ec0fc8f0d81fbc5d4fcf6f4f25ab98d/ty-0.0.53-py3-none-win32.whl", hash = "sha256:c58318cd7835aab2d1cd26d6995d3b776bf40800aa45eeb32e8e60e912863fc8", size = 11297877, upload-time = "2026-06-24T06:53:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/49/7fe9cec8f0e35b71e6935d753c8c7acf7731f787355b5f6a85f10f9f07bb/ty-0.0.53-py3-none-win_amd64.whl", hash = "sha256:4e3e24605c960dc6ce8ee8cd2ccb6dee2a1acbbded96ac62329ee4f200605235", size = 12414620, upload-time = "2026-06-24T06:53:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f8/355b115b42f74bce0be582232a68b7e21d9cac43c77e005dfaa486e79873/ty-0.0.53-py3-none-win_arm64.whl", hash = "sha256:ea0e13311c6f386b36f1c76be114121a1ad7da38f025b143ae193899816ca791", size = 11772715, upload-time = "2026-06-24T06:53:55.132Z" }, +] + [[package]] name = "types-docutils" version = "0.22.3.20260518" From 5abbfc7b835d4aba7292797ba68c77988761a423 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:22:39 -0500 Subject: [PATCH 017/154] Ops(feat): Add pluggable planners + {marked} fold why: Make lazy-plan dispatch strategy pluggable and A/B-testable, and add the chainable-commands {marked} lone-pane single-dispatch optimization the plain ;-fold lacked. what: - ops.planner: Planner Protocol + PlanStep; SequentialPlanner (one dispatch per op), FoldingPlanner (;-fold maximal chainable runs), MarkedPlanner (fold a pane creation + the chainable ops decorating its slot into one "split -P -F ; select-pane -m ; ... -t {marked} ; select-pane -M" dispatch) - _chain: render_marked / attribute_marked - LazyPlan.execute/aexecute take planner= (default SequentialPlanner), replacing fold=bool; _drive consumes the planner's PlanStep units and stays sans-I/O so sync and async share it - tests (NamedTuple + test_id): planner dispatch counts 3/2/1 with an identical PlanResult, marked single-dispatch rendering + fallback, and a live {marked} fold against a real tmux server --- src/libtmux/experimental/ops/__init__.py | 12 ++ src/libtmux/experimental/ops/_chain.py | 58 ++++++++- src/libtmux/experimental/ops/plan.py | 91 +++++++------- src/libtmux/experimental/ops/planner.py | 133 +++++++++++++++++++++ tests/experimental/ops/test_chain.py | 7 +- tests/experimental/ops/test_planner.py | 146 +++++++++++++++++++++++ 6 files changed, 401 insertions(+), 46 deletions(-) create mode 100644 src/libtmux/experimental/ops/planner.py create mode 100644 tests/experimental/ops/test_planner.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index fd71445ba..13a224b18 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -66,6 +66,13 @@ from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.plan import LazyPlan, PlanResult +from libtmux.experimental.ops.planner import ( + FoldingPlanner, + MarkedPlanner, + Planner, + PlanStep, + SequentialPlanner, +) from libtmux.experimental.ops.registry import ( OperationRegistry, OpSpec, @@ -102,6 +109,7 @@ "DetachClient", "DuplicateOperation", "Effects", + "FoldingPlanner", "IndexRef", "KillPane", "KillSession", @@ -113,6 +121,7 @@ "ListSessionsResult", "ListWindows", "ListWindowsResult", + "MarkedPlanner", "NameRef", "NewSession", "NewWindow", @@ -123,6 +132,8 @@ "OperationRegistry", "PaneId", "PlanResult", + "PlanStep", + "Planner", "RefreshClient", "RenameSession", "RenameWindow", @@ -131,6 +142,7 @@ "Scope", "SelectLayout", "SendKeys", + "SequentialPlanner", "SessionId", "SlotRef", "Special", diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 0d62b12cb..7561047ba 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -16,9 +16,11 @@ from __future__ import annotations +import dataclasses import typing as t from dataclasses import dataclass +from libtmux.experimental.ops._types import Special from libtmux.experimental.ops.exc import OperationError if t.TYPE_CHECKING: @@ -92,13 +94,65 @@ def attribute( return results +def render_marked( + create: Operation[t.Any], + decorates: Sequence[Operation[t.Any]], + version: str | None = None, +) -> tuple[str, ...]: + r"""Render a pane creation + its decorates as one ``{marked}`` invocation. + + Emits `` ; select-pane -m ; + ... ; select-pane -M``: the new pane is marked, every decorate addresses it + through tmux's ``{marked}`` register, and the mark is cleared at the end. + """ + parts: list[tuple[str, ...]] = [ + create.render(version=version), + ("select-pane", "-m"), + ] + parts.extend( + dataclasses.replace(op, target=Special("{marked}")).render(version=version) + for op in decorates + ) + parts.append(("select-pane", "-M")) + out: list[str] = [] + for index, part in enumerate(parts): + if index: + out.append(";") + out.extend(_escape_arg(token) for token in part) + return tuple(out) + + +def attribute_marked( + create: Operation[t.Any], + decorates: Sequence[Operation[t.Any]], + merged: CommandResult, + version: str | None = None, +) -> tuple[Result, list[Result], str | None]: + """Split a ``{marked}`` dispatch result into the create's + decorates' results.""" + new_id = merged.stdout[0].strip() if merged.stdout else None + if new_id is not None: + create_result = create.build_result( + returncode=0, stdout=(new_id,), version=version + ) + else: + create_result = create.build_result( + returncode=merged.returncode or 1, + stderr=tuple(merged.stderr), + version=version, + ) + # Attribute over the {marked}-retargeted decorates -- their original SlotRef + # target is unresolved and cannot render. + marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] + return create_result, attribute(marked, merged, version), new_id + + @dataclass(frozen=True) class OpChain: """An ordered group of operations composed with :meth:`~.Operation.then`. A power-user, inspectable handle for explicit chaining. Add it to a - :class:`~.plan.LazyPlan` with :meth:`~.plan.LazyPlan.add_chain`; the plan's - folded execution (``execute(fold=True)``) batches chainable runs anyway. + :class:`~.plan.LazyPlan` with :meth:`~.plan.LazyPlan.add_chain`; a folding + planner (``execute(planner=FoldingPlanner())``) batches chainable runs anyway. Examples -------- diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index d408dbe13..74bec9639 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -19,7 +19,12 @@ from dataclasses import dataclass, field from libtmux.experimental.engines.base import CommandRequest -from libtmux.experimental.ops._chain import attribute, render_chain +from libtmux.experimental.ops._chain import ( + attribute, + attribute_marked, + render_chain, + render_marked, +) from libtmux.experimental.ops._types import ( PaneId, SessionId, @@ -29,6 +34,7 @@ ) from libtmux.experimental.ops.exc import OperationError from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.planner import Planner, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict if t.TYPE_CHECKING: @@ -182,46 +188,49 @@ def add_chain(self, chain: OpChain) -> None: def _drive( self, version: str | None, - fold: bool, + planner: Planner, ) -> Generator[_Single | _Chain, t.Any, PlanResult]: - """Sans-I/O resolution core. + """Sans-I/O resolution core driven by a :class:`~.planner.Planner`. - Yields a :class:`_Single` (driver runs one op and returns its - :class:`~.results.Result`) or, when ``fold`` is set, a :class:`_Chain` - for a maximal run of chainable ops (driver returns the merged - :class:`~..engines.base.CommandResult`, attributed per op here). The - sync and async drivers differ only in ``run`` vs ``await arun`` and + Yields a :class:`_Single` (driver runs one op, returns its + :class:`~.results.Result`) or a :class:`_Chain` (driver returns the + merged :class:`~..engines.base.CommandResult`, attributed per op here). + The sync and async drivers differ only in ``run`` vs ``await arun`` and ``engine.run`` vs ``await engine.run``. """ bindings: dict[int, str] = {} results: dict[int, Result] = {} - index = 0 - total = len(self._operations) - while index < total: - operation = self._operations[index] - if fold and operation.chainable: - indices: list[int] = [] - group: list[Operation[t.Any]] = [] - cursor = index - while cursor < total and self._operations[cursor].chainable: - indices.append(cursor) - group.append(_resolve(self._operations[cursor], bindings)) - cursor += 1 - if len(group) == 1: - results[indices[0]] = yield _Single(group[0]) - else: - merged: CommandResult = yield _Chain(render_chain(group, version)) - results.update( - zip(indices, attribute(group, merged, version), strict=True), - ) - index = cursor - else: - result = yield _Single(_resolve(operation, bindings)) + for step in planner.plan(self._operations): + if step.marked: + create_idx, *decorate_idx = step.indices + create = _resolve(self._operations[create_idx], bindings) + decorates = [self._operations[i] for i in decorate_idx] + merged: CommandResult = yield _Chain( + render_marked(create, decorates, version), + ) + created, decorated, new_id = attribute_marked( + create, + decorates, + merged, + version, + ) + results[create_idx] = created + results.update(zip(decorate_idx, decorated, strict=True)) + if new_id is not None: + bindings[create_idx] = new_id + elif len(step.indices) == 1: + index = step.indices[0] + result = yield _Single(_resolve(self._operations[index], bindings)) results[index] = result if result.created_id is not None: bindings[index] = result.created_id - index += 1 - ordered = tuple(results[slot] for slot in range(total)) + else: + group = [_resolve(self._operations[i], bindings) for i in step.indices] + merged = yield _Chain(render_chain(group, version)) + results.update( + zip(step.indices, attribute(group, merged, version), strict=True), + ) + ordered = tuple(results[slot] for slot in range(len(self._operations))) return PlanResult(ordered, bindings) def execute( @@ -229,17 +238,17 @@ def execute( engine: TmuxEngine, *, version: str | None = None, - fold: bool = False, + planner: Planner | None = None, ) -> PlanResult: """Resolve and execute the plan synchronously. - With ``fold=True``, maximal runs of chainable operations dispatch as one - ``tmux a ; b`` invocation (fewer forks / one control-mode command), - attributing a typed result per operation. ``fold`` defaults off because - folding changes observable semantics (fewer dispatches; a folded - failure marks the first member failed and the rest skipped). + The *planner* decides dispatch grouping; it defaults to + :class:`~.planner.SequentialPlanner` (one tmux call per op). Pass a + :class:`~.planner.FoldingPlanner` or :class:`~.planner.MarkedPlanner` to + fold dispatches -- the :class:`PlanResult` is identical, only the + dispatch count changes. """ - gen = self._drive(version, fold) + gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: @@ -263,10 +272,10 @@ async def aexecute( engine: AsyncTmuxEngine, *, version: str | None = None, - fold: bool = False, + planner: Planner | None = None, ) -> PlanResult: """Resolve and execute the plan asynchronously (same resolution core).""" - gen = self._drive(version, fold) + gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py new file mode 100644 index 000000000..928dfabe8 --- /dev/null +++ b/src/libtmux/experimental/ops/planner.py @@ -0,0 +1,133 @@ +"""Pluggable planners that decide how a lazy plan dispatches. + +A planner is pure policy: given the recorded operations it returns a list of +:class:`PlanStep` units, and :meth:`~.plan.LazyPlan.execute` runs them. Swapping +planners changes *how many tmux dispatches* a plan costs without changing its +result, so strategies can be A/B-tested (same :class:`~.plan.PlanResult`, +differing dispatch count). + +- :class:`SequentialPlanner` -- one dispatch per operation (the safe default). +- :class:`FoldingPlanner` -- fold maximal runs of chainable ops into one + ``tmux a ; b`` dispatch. +- :class:`MarkedPlanner` -- additionally fold a pane creation plus the chainable + ops that decorate it into a *single* dispatch via tmux's ``{marked}`` register + (the chainable-commands lone-pane optimization). +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import SlotRef + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.ops.operation import Operation + + +@dataclass(frozen=True) +class PlanStep: + """One dispatch unit. + + A single op (``len(indices) == 1``), a ``;``-folded chain (more, ``marked`` + false), or a ``{marked}`` fold (``marked`` true: ``indices[0]`` is the pane + creation, the rest decorate it through ``{marked}``). + """ + + indices: tuple[int, ...] + marked: bool = False + + +@t.runtime_checkable +class Planner(t.Protocol): + """Decides the dispatch units for a plan's operations.""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Return the ordered dispatch units for *operations*.""" + ... + + +class SequentialPlanner: + """Dispatch each operation on its own (one tmux call per op).""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """One single-op step per operation.""" + return [PlanStep((index,)) for index in range(len(operations))] + + +def _fold_runs(operations: Sequence[Operation[t.Any]], start: int) -> list[PlanStep]: + """Group maximal runs of chainable ops from *start* into chain/single steps.""" + steps: list[PlanStep] = [] + index = start + total = len(operations) + while index < total: + if operations[index].chainable: + cursor = index + while cursor < total and operations[cursor].chainable: + cursor += 1 + steps.append(PlanStep(tuple(range(index, cursor)))) + index = cursor + else: + steps.append(PlanStep((index,))) + index += 1 + return steps + + +class FoldingPlanner: + """Fold maximal runs of chainable ops into one ``;`` dispatch each.""" + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Chain consecutive chainable ops; dispatch the rest alone.""" + return _fold_runs(operations, 0) + + +class MarkedPlanner: + """Fold a pane creation + the chainable ops that decorate it into one call. + + When a pane-creating op (``effects.creates == "pane"``) is immediately + followed by chainable ops that target *its* slot, they collapse into a single + ``split-window … ; select-pane -m ; … -t {marked} … ; select-pane -M`` + dispatch. Anything else folds like :class:`FoldingPlanner`. + """ + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Emit ``{marked}`` folds where possible, else fold normally.""" + steps: list[PlanStep] = [] + index = 0 + total = len(operations) + while index < total: + decorates = _marked_decorates(operations, index) + if decorates: + steps.append(PlanStep((index, *decorates), marked=True)) + index = decorates[-1] + 1 + else: + run = _fold_runs(operations, index)[0] + steps.append(run) + index = run.indices[-1] + 1 + return steps + + +def _marked_decorates( + operations: Sequence[Operation[t.Any]], + index: int, +) -> tuple[int, ...]: + """Return the indices of chainable ops decorating a pane created at *index*. + + Empty unless *index* is a pane creation followed by at least one chainable op + whose target is that creation's :class:`SlotRef`. + """ + creator = operations[index] + if creator.effects.creates != "pane" or creator.chainable: + return () + decorates: list[int] = [] + cursor = index + 1 + while cursor < len(operations): + op = operations[cursor] + if op.chainable and op.target == SlotRef(index): + decorates.append(cursor) + cursor += 1 + else: + break + return tuple(decorates) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 635713da2..66501299a 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -9,6 +9,7 @@ from libtmux.experimental.engines import CommandResult from libtmux.experimental.ops import ( CapturePane, + FoldingPlanner, KillWindow, LazyPlan, OpChain, @@ -97,7 +98,7 @@ def test_fold_dispatches_once() -> None: plan.add(KillWindow(target=WindowId("@2"))) engine = _CountingEngine() - outcome = plan.execute(engine, fold=True) + outcome = plan.execute(engine, planner=FoldingPlanner()) assert len(engine.calls) == 1 # all three folded into one ';' dispatch assert ";" in engine.calls[0] @@ -125,7 +126,7 @@ def test_fold_failure_attributes_first_failed_rest_skipped() -> None: plan.add(KillWindow(target=WindowId("@2"))) engine = _CountingEngine(returncode=1, stderr=("boom",)) - outcome = plan.execute(engine, fold=True) + outcome = plan.execute(engine, planner=FoldingPlanner()) assert [r.status for r in outcome.results] == ["failed", "skipped", "skipped"] assert not outcome.ok @@ -139,7 +140,7 @@ def test_fold_keeps_creation_ops_unfolded() -> None: plan.add(RenameWindow(target=WindowId("@1"), name="x")) # chainable from libtmux.experimental.engines import ConcreteEngine - outcome = plan.execute(ConcreteEngine(), fold=True) + outcome = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) # split resolved the pane id; the send-keys folded with rename, retargeted assert outcome.results[1].argv[:3] == ("send-keys", "-t", "%1") diff --git a/tests/experimental/ops/test_planner.py b/tests/experimental/ops/test_planner.py new file mode 100644 index 000000000..8595982a2 --- /dev/null +++ b/tests/experimental/ops/test_planner.py @@ -0,0 +1,146 @@ +"""Tests for pluggable planners and the {marked} fold. + +Planners must produce the same PlanResult while differing only in dispatch +count -- the property that makes them A/B-testable. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + MarkedPlanner, + SendKeys, + SequentialPlanner, + SplitWindow, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.ops.planner import Planner + from libtmux.session import Session + + +class _CountingEngine: + """Engine that counts dispatches and echoes a fabricated pane id.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + self._pane = 0 + + def run(self, request: CommandRequest) -> CommandResult: + """Record argv; fabricate a pane id when an id is captured.""" + from libtmux.experimental.engines.base import CommandResult + + self.calls.append(request.args) + stdout: tuple[str, ...] = () + if "-F" in request.args and "#{pane_id}" in request.args: + self._pane += 1 + stdout = (f"%{self._pane}",) + return CommandResult(cmd=("tmux", *request.args), stdout=stdout, returncode=0) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def _build_plan() -> LazyPlan: + """Split a window, then decorate the new pane (the {marked}-foldable shape).""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + plan.add(SendKeys(target=pane, keys=":w", enter=True)) + return plan + + +class PlannerCase(t.NamedTuple): + """One planner and the dispatch count it should produce for the plan.""" + + test_id: str + planner: Planner + dispatches: int + + +PLANNER_CASES = ( + PlannerCase(test_id="sequential", planner=SequentialPlanner(), dispatches=3), + PlannerCase(test_id="folding", planner=FoldingPlanner(), dispatches=2), + PlannerCase(test_id="marked", planner=MarkedPlanner(), dispatches=1), +) + + +@pytest.mark.parametrize( + list(PlannerCase._fields), + PLANNER_CASES, + ids=[c.test_id for c in PLANNER_CASES], +) +def test_planner_dispatch_count( + test_id: str, + planner: Planner, + dispatches: int, +) -> None: + """Each planner produces the expected number of tmux dispatches.""" + engine = _CountingEngine() + _build_plan().execute(engine, planner=planner) + assert len(engine.calls) == dispatches + + +def test_planners_agree_on_result() -> None: + """Different planners yield the same per-op result (status + new pane id).""" + + def outcome(planner: Planner) -> tuple[list[str], str | None]: + result = _build_plan().execute(_CountingEngine(), planner=planner) + first = result.results[0] + return [r.status for r in result.results], first.created_id + + sequential = outcome(SequentialPlanner()) + assert outcome(FoldingPlanner()) == sequential + assert outcome(MarkedPlanner()) == sequential + assert sequential == (["complete", "complete", "complete"], "%1") + + +def test_marked_renders_single_dispatch() -> None: + """The {marked} fold issues split + mark + decorates + unmark in one call.""" + engine = _CountingEngine() + _build_plan().execute(engine, planner=MarkedPlanner()) + (argv,) = engine.calls + assert "#{pane_id}" in argv # split captures the new pane id + assert "-m" in argv and "-M" in argv # mark set then cleared + assert "{marked}" in argv # decorates target the marked register + + +def test_marked_falls_back_without_pattern() -> None: + """A non-creator chainable run still folds (no {marked} shape required).""" + plan = LazyPlan() + plan.add(SendKeys(target=PaneId("%1"), keys="a")) + plan.add(SendKeys(target=PaneId("%1"), keys="b")) + engine = _CountingEngine() + plan.execute(engine, planner=MarkedPlanner()) + assert len(engine.calls) == 1 # folded as a plain ; chain + + +def test_marked_fold_live(session: Session) -> None: + """The {marked} fold creates and decorates a real pane in one dispatch.""" + from libtmux.experimental.engines import SubprocessEngine + + server = session.server + window = session.active_window + assert window.window_id is not None + engine = SubprocessEngine.for_server(server) + + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId(window.window_id))) + plan.add(SendKeys(target=pane, keys="echo marked", enter=True)) + + outcome = plan.execute(engine, planner=MarkedPlanner()) + + assert outcome.ok + new_id = outcome.results[0].created_id + assert new_id is not None + assert server.panes.get(pane_id=new_id) is not None From f03747a0d5ea97a17fb4be42752a2503ed0c5a04 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:35:34 -0500 Subject: [PATCH 018/154] Ops(feat): Add non-list read operations why: The read seam only covered the list-* family, leaving common queries (existence, format evaluation, option dumps, attached clients) outside the typed operation/result model. what: - Add has-session, display-message, show-options, list-clients ops, each rendering inert argv and parsing tmux output into a typed result - Add HasSessionResult.exists, DisplayMessageResult.text, ShowOptionsResult.options, ListClientsResult.clients result types - Add ClientSnapshot model (a leaf view, not part of the tree) - has-session maps rc != 0 to exists=False (a valid answer, not failure) - Wire ops/results/snapshot exports; update enumerating doctests/tests - Add test_read_breadth.py (NamedTuple + test_id render/parse/round-trip cases plus live tmux coverage) --- src/libtmux/experimental/models/__init__.py | 2 + src/libtmux/experimental/models/snapshots.py | 39 ++++ src/libtmux/experimental/ops/__init__.py | 16 ++ src/libtmux/experimental/ops/_ops/__init__.py | 8 + .../experimental/ops/_ops/display_message.py | 65 ++++++ .../experimental/ops/_ops/has_session.py | 65 ++++++ .../experimental/ops/_ops/list_clients.py | 70 ++++++ .../experimental/ops/_ops/show_options.py | 87 +++++++ src/libtmux/experimental/ops/catalog.py | 7 +- src/libtmux/experimental/ops/registry.py | 3 +- src/libtmux/experimental/ops/results.py | 39 ++++ tests/experimental/ops/test_read_breadth.py | 220 ++++++++++++++++++ tests/experimental/ops/test_registry.py | 11 +- 13 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/display_message.py create mode 100644 src/libtmux/experimental/ops/_ops/has_session.py create mode 100644 src/libtmux/experimental/ops/_ops/list_clients.py create mode 100644 src/libtmux/experimental/ops/_ops/show_options.py create mode 100644 tests/experimental/ops/test_read_breadth.py diff --git a/src/libtmux/experimental/models/__init__.py b/src/libtmux/experimental/models/__init__.py index 82866f450..9f07540f9 100644 --- a/src/libtmux/experimental/models/__init__.py +++ b/src/libtmux/experimental/models/__init__.py @@ -11,6 +11,7 @@ from __future__ import annotations from libtmux.experimental.models.snapshots import ( + ClientSnapshot, PaneSnapshot, ServerSnapshot, SessionSnapshot, @@ -18,6 +19,7 @@ ) __all__ = ( + "ClientSnapshot", "PaneSnapshot", "ServerSnapshot", "SessionSnapshot", diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py index 9a8e4e808..7e2f1b5e3 100644 --- a/src/libtmux/experimental/models/snapshots.py +++ b/src/libtmux/experimental/models/snapshots.py @@ -118,6 +118,45 @@ def from_dict(cls, data: Mapping[str, t.Any]) -> PaneSnapshot: return cls.from_format(data["fields"]) +@dataclass(frozen=True) +class ClientSnapshot: + """An immutable snapshot of one attached tmux client. + + A client is a view (a terminal attachment), not part of the ownership tree, + so it is a leaf snapshot. + + Examples + -------- + >>> client = ClientSnapshot.from_format({ + ... "client_name": "/dev/pts/3", "client_tty": "/dev/pts/3", + ... "client_session": "$0", "client_pid": "4242", + ... }) + >>> client.name, client.session, client.pid + ('/dev/pts/3', '$0', 4242) + """ + + name: str = "" + tty: str | None = None + session: str = "" + pid: int | None = None + width: int | None = None + height: int | None = None + fields: Mapping[str, str] = field(default_factory=dict) + + @classmethod + def from_format(cls, raw: Mapping[str, str]) -> ClientSnapshot: + """Build a client snapshot from a raw tmux format mapping.""" + return cls( + name=raw.get("client_name", ""), + tty=raw.get("client_tty"), + session=raw.get("client_session", ""), + pid=_as_int(raw.get("client_pid")), + width=_as_int(raw.get("client_width")), + height=_as_int(raw.get("client_height")), + fields=dict(raw), + ) + + @dataclass(frozen=True) class WindowSnapshot: """An immutable snapshot of one tmux window and its panes. diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 13a224b18..273799e7e 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -23,9 +23,12 @@ from libtmux.experimental.ops._ops import ( CapturePane, DetachClient, + DisplayMessage, + HasSession, KillPane, KillSession, KillWindow, + ListClients, ListPanes, ListSessions, ListWindows, @@ -36,6 +39,7 @@ RenameWindow, SelectLayout, SendKeys, + ShowOptions, SplitWindow, SwitchClient, ) @@ -83,10 +87,14 @@ AckResult, CapturePaneResult, CreateResult, + DisplayMessageResult, + HasSessionResult, + ListClientsResult, ListPanesResult, ListSessionsResult, ListWindowsResult, Result, + ShowOptionsResult, SplitWindowResult, status_for, ) @@ -107,14 +115,20 @@ "ClientName", "CreateResult", "DetachClient", + "DisplayMessage", + "DisplayMessageResult", "DuplicateOperation", "Effects", "FoldingPlanner", + "HasSession", + "HasSessionResult", "IndexRef", "KillPane", "KillSession", "KillWindow", "LazyPlan", + "ListClients", + "ListClientsResult", "ListPanes", "ListPanesResult", "ListSessions", @@ -144,6 +158,8 @@ "SendKeys", "SequentialPlanner", "SessionId", + "ShowOptions", + "ShowOptionsResult", "SlotRef", "Special", "SplitWindow", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 61620c006..9bbc8a1f5 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -9,9 +9,12 @@ from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.detach_client import DetachClient +from libtmux.experimental.ops._ops.display_message import DisplayMessage +from libtmux.experimental.ops._ops.has_session import HasSession from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.list_clients import ListClients from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows @@ -22,15 +25,19 @@ from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.split_window import SplitWindow from libtmux.experimental.ops._ops.switch_client import SwitchClient __all__ = ( "CapturePane", "DetachClient", + "DisplayMessage", + "HasSession", "KillPane", "KillSession", "KillWindow", + "ListClients", "ListPanes", "ListSessions", "ListWindows", @@ -41,6 +48,7 @@ "RenameWindow", "SelectLayout", "SendKeys", + "ShowOptions", "SplitWindow", "SwitchClient", ) diff --git a/src/libtmux/experimental/ops/_ops/display_message.py b/src/libtmux/experimental/ops/_ops/display_message.py new file mode 100644 index 000000000..68821960a --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/display_message.py @@ -0,0 +1,65 @@ +"""The ``display-message -p`` operation -- a typed format query.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import DisplayMessageResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class DisplayMessage(Operation[DisplayMessageResult]): + """Evaluate a tmux format and print it (``display-message -p``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> DisplayMessage(target=PaneId("%1"), message="#{pane_width}").render() + ('display-message', '-t', '%1', '-p', '#{pane_width}') + >>> DisplayMessage(message="#{pane_id}").build_result( + ... returncode=0, stdout=("%1",) + ... ).text + '%1' + """ + + kind = "display_message" + command = "display-message" + scope = "pane" + result_cls = DisplayMessageResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, reads_output=True, idempotent=True) + + message: str + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``-p ``.""" + return ("-p", self.message) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> DisplayMessageResult: + """Expose the printed line as :attr:`~.DisplayMessageResult.text`.""" + return DisplayMessageResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + text=stdout[0] if stdout else "", + ) diff --git a/src/libtmux/experimental/ops/_ops/has_session.py b/src/libtmux/experimental/ops/_ops/has_session.py new file mode 100644 index 000000000..0c236763e --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/has_session.py @@ -0,0 +1,65 @@ +"""The ``has-session`` operation -- a typed existence query.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import HasSessionResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class HasSession(Operation[HasSessionResult]): + """Check whether a session exists (``has-session``). + + ``target`` is the session. A missing session is a valid answer (rc 1), not + an error, so the result is always ``complete`` and carries the answer in + :attr:`~.HasSessionResult.exists`. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> HasSession(target=SessionId("$0")).render() + ('has-session', '-t', '$0') + >>> HasSession(target=SessionId("$0")).build_result(returncode=1).exists + False + """ + + kind = "has_session" + command = "has-session" + scope = "session" + result_cls = HasSessionResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """No positional arguments beyond the target.""" + return () + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> HasSessionResult: + """Map the exit code to existence; the query itself always completes.""" + return HasSessionResult( + operation=self, + argv=argv, + status="complete", + returncode=returncode, + stdout=stdout, + stderr=stderr, + exists=returncode == 0, + ) diff --git a/src/libtmux/experimental/ops/_ops/list_clients.py b/src/libtmux/experimental/ops/_ops/list_clients.py new file mode 100644 index 000000000..dfc1023f1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/list_clients.py @@ -0,0 +1,70 @@ +"""The ``list-clients`` operation -- typed client snapshots.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._read import ( + DEFAULT_LIST_VERSION, + get_output_format, + parse_output, +) +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ListClientsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ListClients(Operation[ListClientsResult]): + """List attached clients and return typed snapshots. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.ops import run + >>> run(ListClients(), ConcreteEngine(), version="3.6a").clients + () + """ + + kind = "list_clients" + command = "list-clients" + scope = "server" + result_cls = ListClientsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the ``-F`` format template.""" + _fields, fmt = get_output_format( + "list-clients", version or DEFAULT_LIST_VERSION + ) + return ("-F", fmt) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ListClientsResult: + """Parse each output row into a client format mapping.""" + ver = version or DEFAULT_LIST_VERSION + rows = tuple(parse_output(line, "list-clients", ver) for line in stdout if line) + return ListClientsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + rows=rows, + ) diff --git a/src/libtmux/experimental/ops/_ops/show_options.py b/src/libtmux/experimental/ops/_ops/show_options.py new file mode 100644 index 000000000..fae7f71d6 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/show_options.py @@ -0,0 +1,87 @@ +"""The ``show-options`` operation -- typed option pairs.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ShowOptionsResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ShowOptions(Operation[ShowOptionsResult]): + """Show options as ``name value`` pairs (``show-options``). + + Parameters + ---------- + global_, server, window : bool + Scope flags (``-g`` / ``-s`` / ``-w``). + include_inherited : bool + Include inherited options (``-A``). + + Examples + -------- + >>> ShowOptions(global_=True).render() + ('show-options', '-g') + >>> ShowOptions().build_result( + ... returncode=0, stdout=("status on", "history-limit 2000") + ... ).options["history-limit"] + '2000' + """ + + kind = "show_options" + command = "show-options" + scope = "session" + result_cls = ShowOptionsResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + global_: bool = False + server: bool = False + window: bool = False + include_inherited: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the scope/inheritance flags.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.server: + out.append("-s") + if self.window: + out.append("-w") + if self.include_inherited: + out.append("-A") + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ShowOptionsResult: + """Parse ``name value`` lines into a mapping.""" + options: dict[str, str] = {} + for line in stdout: + name, _, value = line.partition(" ") + options[name] = value + return ShowOptionsResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + options=options, + ) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 9fd67aa3c..35484094a 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,10 +65,11 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'detach_client', 'kill_pane', 'kill_session', 'kill_window', - 'list_panes', 'list_sessions', 'list_windows', 'new_session', 'new_window', + ['capture_pane', 'detach_client', 'display_message', 'has_session', + 'kill_pane', 'kill_session', 'kill_window', 'list_clients', 'list_panes', + 'list_sessions', 'list_windows', 'new_session', 'new_window', 'refresh_client', 'rename_session', 'rename_window', 'select_layout', - 'send_keys', 'split_window', 'switch_client'] + 'send_keys', 'show_options', 'split_window', 'switch_client'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index b85468195..f610606b6 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -167,7 +167,8 @@ def select( -------- >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] - ['capture_pane', 'list_panes', 'list_sessions', 'list_windows'] + ['capture_pane', 'display_message', 'has_session', 'list_clients', + 'list_panes', 'list_sessions', 'list_windows', 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index adddb3a18..3f4073ba8 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -20,6 +20,7 @@ from dataclasses import dataclass, field from libtmux.experimental.models.snapshots import ( + ClientSnapshot, PaneSnapshot, ServerSnapshot, SessionSnapshot, @@ -280,3 +281,41 @@ class ListSessionsResult(Result): def sessions(self) -> tuple[SessionSnapshot, ...]: """One typed session snapshot per row.""" return tuple(SessionSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class ListClientsResult(Result): + """Result of a ``list-clients`` operation (typed :attr:`clients`).""" + + rows: tuple[Mapping[str, str], ...] = () + + @property + def clients(self) -> tuple[ClientSnapshot, ...]: + """One typed client snapshot per row.""" + return tuple(ClientSnapshot.from_format(row) for row in self.rows) + + +@dataclass(frozen=True) +class HasSessionResult(Result): + """Result of a ``has-session`` existence query. + + ``has-session`` exits ``0`` when the session exists and nonzero otherwise -- + a valid answer, not a failure -- so this result is always ``complete`` and + carries the answer in :attr:`exists`. + """ + + exists: bool = False + + +@dataclass(frozen=True) +class DisplayMessageResult(Result): + """Result of ``display-message -p``: the formatted :attr:`text`.""" + + text: str = "" + + +@dataclass(frozen=True) +class ShowOptionsResult(Result): + """Result of ``show-options``: parsed ``name value`` pairs in :attr:`options`.""" + + options: Mapping[str, str] = field(default_factory=dict) diff --git a/tests/experimental/ops/test_read_breadth.py b/tests/experimental/ops/test_read_breadth.py new file mode 100644 index 000000000..276ff9052 --- /dev/null +++ b/tests/experimental/ops/test_read_breadth.py @@ -0,0 +1,220 @@ +"""Tests for the non-list read ops (has-session/display-message/show-options). + +These cover the read seam beyond the ``list-*`` family: a typed existence +query, a format evaluation, an option dump, and the client listing. Each op +renders an inert argv and parses tmux output into a typed result without a live +server; live tests then exercise them against real tmux. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + DisplayMessage, + HasSession, + ListClients, + ShowOptions, + result_from_dict, + result_to_dict, +) +from libtmux.experimental.ops._types import NameRef, PaneId, SessionId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the argv fragments its render must contain.""" + + test_id: str + op: Operation[t.Any] + fragments: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="has_session", + op=HasSession(target=SessionId("$0")), + fragments=("has-session", "-t", "$0"), + ), + RenderCase( + test_id="display_message", + op=DisplayMessage(target=PaneId("%1"), message="#{pane_id}"), + fragments=("display-message", "-t", "%1", "-p", "#{pane_id}"), + ), + RenderCase( + test_id="show_options_global", + op=ShowOptions(global_=True), + fragments=("show-options", "-g"), + ), + RenderCase( + test_id="show_options_server_inherited", + op=ShowOptions(server=True, include_inherited=True), + fragments=("show-options", "-s", "-A"), + ), + RenderCase( + test_id="list_clients", + op=ListClients(), + fragments=("list-clients", "-F"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_read_op_render( + test_id: str, + op: Operation[t.Any], + fragments: tuple[str, ...], +) -> None: + """Each read op renders the expected argv fragments.""" + argv = op.render(version="3.2a") + for fragment in fragments: + assert fragment in argv + + +class ParseCase(t.NamedTuple): + """An op plus a synthesized tmux outcome and the result fields it yields.""" + + test_id: str + op: Operation[t.Any] + returncode: int + stdout: tuple[str, ...] + expected: dict[str, t.Any] + + +PARSE_CASES = ( + ParseCase( + test_id="has_session_exists", + op=HasSession(target=SessionId("$0")), + returncode=0, + stdout=(), + expected={"exists": True, "status": "complete"}, + ), + ParseCase( + test_id="has_session_missing", + op=HasSession(target=SessionId("$9")), + returncode=1, + stdout=(), + expected={"exists": False, "status": "complete"}, + ), + ParseCase( + test_id="display_message_text", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=("%1",), + expected={"text": "%1"}, + ), + ParseCase( + test_id="display_message_empty", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=(), + expected={"text": ""}, + ), + ParseCase( + test_id="show_options_pairs", + op=ShowOptions(), + returncode=0, + stdout=("status on", "history-limit 2000"), + expected={"options": {"status": "on", "history-limit": "2000"}}, + ), +) + + +@pytest.mark.parametrize( + list(ParseCase._fields), + PARSE_CASES, + ids=[c.test_id for c in PARSE_CASES], +) +def test_read_op_parse( + test_id: str, + op: Operation[t.Any], + returncode: int, + stdout: tuple[str, ...], + expected: dict[str, t.Any], +) -> None: + """Each read op parses its tmux output into the expected result fields.""" + result = op.build_result(returncode=returncode, stdout=stdout) + for attr, value in expected.items(): + assert getattr(result, attr) == value + + +@pytest.mark.parametrize( + list(ParseCase._fields), + PARSE_CASES, + ids=[c.test_id for c in PARSE_CASES], +) +def test_read_result_round_trip( + test_id: str, + op: Operation[t.Any], + returncode: int, + stdout: tuple[str, ...], + expected: dict[str, t.Any], +) -> None: + """Every read result round-trips through its JSON-friendly dict form.""" + result = op.build_result(returncode=returncode, stdout=stdout) + assert result_from_dict(result_to_dict(result)) == result + + +def test_has_session_live(session: Session) -> None: + """has-session answers True for the fixture session, False for a fake one.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + assert session.session_id is not None + + present = run(HasSession(target=SessionId(session.session_id)), engine) + assert present.status == "complete" + assert present.exists is True + + absent = run(HasSession(target=NameRef("no-such-session-xyz")), engine) + assert absent.status == "complete" + assert absent.exists is False + + +def test_display_message_live(session: Session) -> None: + """display-message -p evaluates a format against a real pane.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + result = run( + DisplayMessage(target=PaneId(pane.pane_id), message="#{session_id}"), + engine, + ) + assert result.ok + assert result.text == session.session_id + + +def test_show_options_live(session: Session) -> None: + """show-options -g returns a non-empty option mapping.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + result = run(ShowOptions(global_=True), engine) + assert result.ok + assert result.options # global options are always present + + +def test_list_clients_live(session: Session) -> None: + """list-clients returns typed client snapshots (possibly none).""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import run + + engine = SubprocessEngine.for_server(session.server) + result = run(ListClients(), engine) + assert result.ok + assert all(c.name for c in result.clients) diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 6dbe407aa..667656f25 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -45,7 +45,16 @@ def test_list_predicate_filters() -> None: readonly = [ spec.kind for spec in registry.select(lambda spec: spec.safety == "readonly") ] - assert readonly == ["capture_pane", "list_panes", "list_sessions", "list_windows"] + assert readonly == [ + "capture_pane", + "display_message", + "has_session", + "list_clients", + "list_panes", + "list_sessions", + "list_windows", + "show_options", + ] def test_register_duplicate_fails_closed() -> None: From 37ce66e9594d446d3d679522b020c6e923ca8a16 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:47:47 -0500 Subject: [PATCH 019/154] Ops(feat): Add pane mutation/creation operations why: The operation surface lacked the pane verbs the ORM relies on (select/resize/swap/break/join/move/respawn/pipe/clear-history), blocking pane-level parity for engine-driven callers. what: - Add select-pane, last-pane, resize-pane, respawn-pane, pipe-pane, clear-history (single-target) ops - Add swap-pane, join-pane, move-pane (dual-target) and break-pane (creates a window, captures #{window_id} into CreateResult) - Add src_target field + src_args() helper on Operation for the -s source of dual-target commands; serialize handles src_target like target - Wire ops/exports; extend the catalog kind-enumeration doctest - Add test_pane_ops.py (NamedTuple + test_id render/round-trip cases plus live tmux coverage) --- src/libtmux/experimental/ops/__init__.py | 20 ++ src/libtmux/experimental/ops/_ops/__init__.py | 20 ++ .../experimental/ops/_ops/break_pane.py | 89 ++++++++ .../experimental/ops/_ops/clear_history.py | 30 +++ .../experimental/ops/_ops/join_pane.py | 66 ++++++ .../experimental/ops/_ops/last_pane.py | 32 +++ .../experimental/ops/_ops/move_pane.py | 27 +++ .../experimental/ops/_ops/pipe_pane.py | 61 ++++++ .../experimental/ops/_ops/resize_pane.py | 65 ++++++ .../experimental/ops/_ops/respawn_pane.py | 64 ++++++ .../experimental/ops/_ops/select_pane.py | 70 ++++++ .../experimental/ops/_ops/swap_pane.py | 63 ++++++ src/libtmux/experimental/ops/catalog.py | 12 +- src/libtmux/experimental/ops/operation.py | 19 ++ src/libtmux/experimental/ops/serialize.py | 8 +- tests/experimental/ops/test_pane_ops.py | 207 ++++++++++++++++++ 16 files changed, 844 insertions(+), 9 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/break_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/clear_history.py create mode 100644 src/libtmux/experimental/ops/_ops/join_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/last_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/move_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/pipe_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/resize_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/respawn_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/select_pane.py create mode 100644 src/libtmux/experimental/ops/_ops/swap_pane.py create mode 100644 tests/experimental/ops/test_pane_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 273799e7e..fa229bef8 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -21,26 +21,36 @@ from libtmux.experimental.ops._chain import OpChain from libtmux.experimental.ops._ops import ( + BreakPane, CapturePane, + ClearHistory, DetachClient, DisplayMessage, HasSession, + JoinPane, KillPane, KillSession, KillWindow, + LastPane, ListClients, ListPanes, ListSessions, ListWindows, + MovePane, NewSession, NewWindow, + PipePane, RefreshClient, RenameSession, RenameWindow, + ResizePane, + RespawnPane, SelectLayout, + SelectPane, SendKeys, ShowOptions, SplitWindow, + SwapPane, SwitchClient, ) from libtmux.experimental.ops._types import ( @@ -109,9 +119,11 @@ __all__ = ( "AckResult", + "BreakPane", "CapturePane", "CapturePaneResult", "CatalogEntry", + "ClearHistory", "ClientName", "CreateResult", "DetachClient", @@ -123,9 +135,11 @@ "HasSession", "HasSessionResult", "IndexRef", + "JoinPane", "KillPane", "KillSession", "KillWindow", + "LastPane", "LazyPlan", "ListClients", "ListClientsResult", @@ -136,6 +150,7 @@ "ListWindows", "ListWindowsResult", "MarkedPlanner", + "MovePane", "NameRef", "NewSession", "NewWindow", @@ -145,16 +160,20 @@ "OperationError", "OperationRegistry", "PaneId", + "PipePane", "PlanResult", "PlanStep", "Planner", "RefreshClient", "RenameSession", "RenameWindow", + "ResizePane", + "RespawnPane", "Result", "Safety", "Scope", "SelectLayout", + "SelectPane", "SendKeys", "SequentialPlanner", "SessionId", @@ -165,6 +184,7 @@ "SplitWindow", "SplitWindowResult", "Status", + "SwapPane", "SwitchClient", "Target", "TmuxCommandError", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 9bbc8a1f5..4b54007fd 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -7,48 +7,68 @@ from __future__ import annotations +from libtmux.experimental.ops._ops.break_pane import BreakPane from libtmux.experimental.ops._ops.capture_pane import CapturePane +from libtmux.experimental.ops._ops.clear_history import ClearHistory from libtmux.experimental.ops._ops.detach_client import DetachClient from libtmux.experimental.ops._ops.display_message import DisplayMessage from libtmux.experimental.ops._ops.has_session import HasSession +from libtmux.experimental.ops._ops.join_pane import JoinPane from libtmux.experimental.ops._ops.kill_pane import KillPane from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow +from libtmux.experimental.ops._ops.last_pane import LastPane from libtmux.experimental.ops._ops.list_clients import ListClients from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows +from libtmux.experimental.ops._ops.move_pane import MovePane from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.pipe_pane import PipePane from libtmux.experimental.ops._ops.refresh_client import RefreshClient from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow +from libtmux.experimental.ops._ops.resize_pane import ResizePane +from libtmux.experimental.ops._ops.respawn_pane import RespawnPane from libtmux.experimental.ops._ops.select_layout import SelectLayout +from libtmux.experimental.ops._ops.select_pane import SelectPane from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.split_window import SplitWindow +from libtmux.experimental.ops._ops.swap_pane import SwapPane from libtmux.experimental.ops._ops.switch_client import SwitchClient __all__ = ( + "BreakPane", "CapturePane", + "ClearHistory", "DetachClient", "DisplayMessage", "HasSession", + "JoinPane", "KillPane", "KillSession", "KillWindow", + "LastPane", "ListClients", "ListPanes", "ListSessions", "ListWindows", + "MovePane", "NewSession", "NewWindow", + "PipePane", "RefreshClient", "RenameSession", "RenameWindow", + "ResizePane", + "RespawnPane", "SelectLayout", + "SelectPane", "SendKeys", "ShowOptions", "SplitWindow", + "SwapPane", "SwitchClient", ) diff --git a/src/libtmux/experimental/ops/_ops/break_pane.py b/src/libtmux/experimental/ops/_ops/break_pane.py new file mode 100644 index 000000000..1034def13 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/break_pane.py @@ -0,0 +1,89 @@ +"""The ``break-pane`` operation (creates a window, captures its id).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import CreateResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class BreakPane(Operation[CreateResult]): + """Break a pane out into a new window (``break-pane``). + + The pane to break is the ``-s`` source (``src_target``); there is no ``-t``. + By default it appends ``-P -F '#{window_id}'`` so the new window's id is + captured into :attr:`~.results.CreateResult.new_id`. + + Parameters + ---------- + detach : bool + Do not switch to the new window (``-d``). + name : str or None + Name for the new window (``-n``). + capture : bool + Append ``-P -F '#{window_id}'`` to capture the new window id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> BreakPane(src_target=PaneId("%2"), name="logs").render() + ('break-pane', '-d', '-n', 'logs', '-P', '-F', '#{window_id}', '-s', '%2') + >>> BreakPane(src_target=PaneId("%2")).build_result( + ... returncode=0, stdout=("@7",) + ... ).new_id + '@7' + """ + + kind = "break_pane" + command = "break-pane" + scope = "window" + result_cls = CreateResult + safety = "mutating" + chainable = False + effects = Effects(creates="window") + + detach: bool = True + name: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the break flags, capture template, and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.name is not None: + out.extend(("-n", self.name)) + if self.capture: + out.extend(("-P", "-F", "#{window_id}")) + out.extend(self.src_args()) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> CreateResult: + """Parse the captured new-window id.""" + new_id = stdout[0].strip() if status == "complete" and stdout else None + return CreateResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_id=new_id, + ) diff --git a/src/libtmux/experimental/ops/_ops/clear_history.py b/src/libtmux/experimental/ops/_ops/clear_history.py new file mode 100644 index 000000000..2be1b405d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/clear_history.py @@ -0,0 +1,30 @@ +"""The ``clear-history`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ClearHistory(Operation[AckResult]): + """Clear a pane's scrollback history (``clear-history``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> ClearHistory(target=PaneId("%1")).render() + ('clear-history', '-t', '%1') + """ + + kind = "clear_history" + command = "clear-history" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/join_pane.py b/src/libtmux/experimental/ops/_ops/join_pane.py new file mode 100644 index 000000000..7924517f5 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/join_pane.py @@ -0,0 +1,66 @@ +"""The ``join-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class JoinPane(Operation[AckResult]): + """Join a source pane into a destination window/pane (``join-pane``). + + ``target`` is the destination (``-t``); ``src_target`` is the pane to move + (``-s``). The inverse of :class:`BreakPane`. + + Parameters + ---------- + horizontal : bool + Split the destination left/right (``-h``) instead of top/bottom (``-v``). + detach : bool + Do not switch to the destination window (``-d``). + full_size : bool + Span the full window width/height (``-f``). + size : int or None + Size of the joined pane (``-l``). + before : bool + Place the pane before the destination (``-b``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> JoinPane(target=WindowId("@1"), src_target=PaneId("%2")).render() + ('join-pane', '-t', '@1', '-v', '-d', '-s', '%2') + """ + + kind = "join_pane" + command = "join-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + horizontal: bool = False + detach: bool = True + full_size: bool = False + size: int | None = None + before: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the join flags and ``-s`` source.""" + out: list[str] = ["-h" if self.horizontal else "-v"] + if self.detach: + out.append("-d") + if self.full_size: + out.append("-f") + if self.size is not None: + out.append(f"-l{self.size}") + if self.before: + out.append("-b") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/last_pane.py b/src/libtmux/experimental/ops/_ops/last_pane.py new file mode 100644 index 000000000..21fadb606 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/last_pane.py @@ -0,0 +1,32 @@ +"""The ``last-pane`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LastPane(Operation[AckResult]): + """Select the previously active pane in a window (``last-pane``). + + ``target`` is the window whose last pane to select. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> LastPane(target=WindowId("@1")).render() + ('last-pane', '-t', '@1') + """ + + kind = "last_pane" + command = "last-pane" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/move_pane.py b/src/libtmux/experimental/ops/_ops/move_pane.py new file mode 100644 index 000000000..75fe6feb4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/move_pane.py @@ -0,0 +1,27 @@ +"""The ``move-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._ops.join_pane import JoinPane +from libtmux.experimental.ops.registry import register + + +@register +@dataclass(frozen=True, kw_only=True) +class MovePane(JoinPane): + """Move a source pane into a destination window (``move-pane``). + + Identical in shape to :class:`JoinPane`; tmux exposes ``move-pane`` as the + same command under a different name. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId, WindowId + >>> MovePane(target=WindowId("@1"), src_target=PaneId("%2")).render() + ('move-pane', '-t', '@1', '-v', '-d', '-s', '%2') + """ + + kind = "move_pane" + command = "move-pane" diff --git a/src/libtmux/experimental/ops/_ops/pipe_pane.py b/src/libtmux/experimental/ops/_ops/pipe_pane.py new file mode 100644 index 000000000..c30c45c0d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/pipe_pane.py @@ -0,0 +1,61 @@ +"""The ``pipe-pane`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PipePane(Operation[AckResult]): + """Pipe a pane's output to a shell command (``pipe-pane``). + + Parameters + ---------- + command : str or None + Shell command to pipe to. Omit to stop an existing pipe. + stdin : bool + Connect the pane's input to the command (``-I``). + stdout : bool + Connect the pane's output to the command (``-O``). + toggle : bool + Only toggle: stop if already piping the same command (``-o``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> PipePane(target=PaneId("%1"), command_line="cat >>/tmp/log").render() + ('pipe-pane', '-t', '%1', 'cat >>/tmp/log') + >>> PipePane(target=PaneId("%1")).render() + ('pipe-pane', '-t', '%1') + """ + + kind = "pipe_pane" + command = "pipe-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(reads_output=True) + + command_line: str | None = None + stdin: bool = False + stdout: bool = False + toggle: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the pipe flags and command.""" + out: list[str] = [] + if self.stdin: + out.append("-I") + if self.stdout: + out.append("-O") + if self.toggle: + out.append("-o") + if self.command_line is not None: + out.append(self.command_line) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/resize_pane.py b/src/libtmux/experimental/ops/_ops/resize_pane.py new file mode 100644 index 000000000..72b6624b8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/resize_pane.py @@ -0,0 +1,65 @@ +"""The ``resize-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ResizePane(Operation[AckResult]): + """Resize a pane, optionally zooming it. + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Resize toward a side (``-L``/``-R``/``-U``/``-D``). + adjustment : int or None + Cells to adjust by when *direction* is set. + width, height : int or None + Absolute width (``-x``) / height (``-y``) in cells. + zoom : bool + Toggle pane zoom (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> ResizePane(target=PaneId("%1"), height=20).render() + ('resize-pane', '-t', '%1', '-y20') + >>> ResizePane(target=PaneId("%1"), direction="D", adjustment=5).render() + ('resize-pane', '-t', '%1', '-D', '5') + """ + + kind = "resize_pane" + command = "resize-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + adjustment: int | None = None + width: int | None = None + height: int | None = None + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the resize flags.""" + out: list[str] = [] + if self.zoom: + out.append("-Z") + if self.direction is not None: + out.append(f"-{self.direction}") + if self.adjustment is not None: + out.append(str(self.adjustment)) + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/respawn_pane.py b/src/libtmux/experimental/ops/_ops/respawn_pane.py new file mode 100644 index 000000000..4b09f50c5 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/respawn_pane.py @@ -0,0 +1,64 @@ +"""The ``respawn-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + +@register +@dataclass(frozen=True, kw_only=True) +class RespawnPane(Operation[AckResult]): + """Restart the command in a (usually dead) pane (``respawn-pane``). + + Parameters + ---------- + kill : bool + Kill the existing process first (``-k``). + start_directory : str or None + Working directory for the new process (``-c``). + environment : Mapping[str, str] or None + Environment variables (``-e``; tmux 3.0+). + shell : str or None + Command to run instead of the default shell. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> RespawnPane(target=PaneId("%1"), kill=True).render() + ('respawn-pane', '-t', '%1', '-k') + """ + + kind = "respawn_pane" + command = "respawn-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + kill: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the respawn flags.""" + out: list[str] = [] + if self.kill: + out.append("-k") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.shell is not None: + out.append(self.shell) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/select_pane.py b/src/libtmux/experimental/ops/_ops/select_pane.py new file mode 100644 index 000000000..0d11562eb --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_pane.py @@ -0,0 +1,70 @@ +"""The ``select-pane`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectPane(Operation[AckResult]): + """Make a pane active, or move/mark the selection. + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Move to the pane left/right/above/below the target. + last : bool + Select the last (previously active) pane (``-l``). + mark, unmark : bool + Set (``-m``) or clear (``-M``) the marked pane. + zoom : bool + Keep the window zoomed (``-Z``). + title : str or None + Set the pane title (``-T``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SelectPane(target=PaneId("%1")).render() + ('select-pane', '-t', '%1') + >>> SelectPane(target=PaneId("%2"), direction="L", zoom=True).render() + ('select-pane', '-t', '%2', '-L', '-Z') + """ + + kind = "select_pane" + command = "select-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + last: bool = False + mark: bool = False + unmark: bool = False + zoom: bool = False + title: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the selection flags.""" + out: list[str] = [] + if self.last: + out.append("-l") + if self.direction is not None: + out.append(f"-{self.direction}") + if self.mark: + out.append("-m") + if self.unmark: + out.append("-M") + if self.zoom: + out.append("-Z") + if self.title is not None: + out.extend(("-T", self.title)) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/swap_pane.py b/src/libtmux/experimental/ops/_ops/swap_pane.py new file mode 100644 index 000000000..bb8cce9b0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/swap_pane.py @@ -0,0 +1,63 @@ +"""The ``swap-pane`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwapPane(Operation[AckResult]): + """Swap two panes (``swap-pane``). + + ``target`` is the destination pane (``-t``); ``src_target`` is the source + pane (``-s``). With *up*/*down* and no source, swap with the adjacent pane. + + Parameters + ---------- + detach : bool + Do not change the active pane (``-d``). + up, down : bool + Swap with the pane above (``-U``) / below (``-D``). + zoom : bool + Keep the window zoomed (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> SwapPane(target=PaneId("%1"), src_target=PaneId("%2")).render() + ('swap-pane', '-t', '%1', '-s', '%2') + >>> SwapPane(target=PaneId("%1"), down=True, detach=True).render() + ('swap-pane', '-t', '%1', '-d', '-D') + """ + + kind = "swap_pane" + command = "swap-pane" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + up: bool = False + down: bool = False + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the swap flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.up: + out.append("-U") + if self.down: + out.append("-D") + if self.zoom: + out.append("-Z") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 35484094a..1841a9069 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,11 +65,13 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['capture_pane', 'detach_client', 'display_message', 'has_session', - 'kill_pane', 'kill_session', 'kill_window', 'list_clients', 'list_panes', - 'list_sessions', 'list_windows', 'new_session', 'new_window', - 'refresh_client', 'rename_session', 'rename_window', 'select_layout', - 'send_keys', 'show_options', 'split_window', 'switch_client'] + ['break_pane', 'capture_pane', 'clear_history', 'detach_client', + 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_session', + 'kill_window', 'last_pane', 'list_clients', 'list_panes', 'list_sessions', + 'list_windows', 'move_pane', 'new_session', 'new_window', 'pipe_pane', + 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', + 'respawn_pane', 'select_layout', 'select_pane', 'send_keys', 'show_options', + 'split_window', 'swap_pane', 'switch_client'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index 57479717f..84c2bdd30 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -51,6 +51,9 @@ class Operation(t.Generic[ResultT]): ---------- target : Target or None The ``-t`` target, or ``None`` for "no explicit target". + src_target : Target or None + The ``-s`` source target for dual-target commands (``swap-pane``, + ``join-pane``, ``link-window``, ...), or ``None`` when unused. Notes ----- @@ -81,6 +84,7 @@ class Operation(t.Generic[ResultT]): """ target: Target | None = None + src_target: Target | None = None kind: t.ClassVar[str] command: t.ClassVar[str] @@ -195,6 +199,21 @@ def flag_available(self, label: str, version: str | None) -> bool: return True return LooseVersion(version) >= LooseVersion(need) + def src_args(self) -> tuple[str, ...]: + """Render the ``-s`` source target, or ``()`` when there is none. + + Dual-target commands call this from :meth:`args` to emit their source. + + Examples + -------- + >>> from libtmux.experimental.ops import SwapPane + >>> from libtmux.experimental.ops._types import PaneId + >>> SwapPane(target=PaneId("%1"), src_target=PaneId("%2")).src_args() + ('-s', '%2') + """ + token = render_target(self.src_target) + return ("-s", token) if token is not None else () + def build_result( self, *, diff --git a/src/libtmux/experimental/ops/serialize.py b/src/libtmux/experimental/ops/serialize.py index d65dff506..e96de5ff2 100644 --- a/src/libtmux/experimental/ops/serialize.py +++ b/src/libtmux/experimental/ops/serialize.py @@ -103,8 +103,8 @@ def operation_to_dict(operation: Operation[t.Any]) -> dict[str, t.Any]: data: dict[str, t.Any] = {"kind": operation.kind} for field in dataclasses.fields(operation): value = getattr(operation, field.name) - if field.name == "target": - data["target"] = target_to_dict(value) + if field.name in {"target", "src_target"}: + data[field.name] = target_to_dict(value) else: data[field.name] = _jsonify(value) return data @@ -126,8 +126,8 @@ def operation_from_dict(data: Mapping[str, t.Any]) -> Operation[t.Any]: for field in dataclasses.fields(operation_cls): if field.name not in data: continue - if field.name == "target": - kwargs["target"] = target_from_dict(data["target"]) + if field.name in {"target", "src_target"}: + kwargs[field.name] = target_from_dict(data[field.name]) else: kwargs[field.name] = data[field.name] return operation_cls(**kwargs) diff --git a/tests/experimental/ops/test_pane_ops.py b/tests/experimental/ops/test_pane_ops.py new file mode 100644 index 000000000..eecad6c72 --- /dev/null +++ b/tests/experimental/ops/test_pane_ops.py @@ -0,0 +1,207 @@ +"""Tests for the pane mutation/creation operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + BreakPane, + ClearHistory, + JoinPane, + LastPane, + MovePane, + PipePane, + ResizePane, + RespawnPane, + SelectPane, + SplitWindow, + SwapPane, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="select_pane", + op=SelectPane(target=PaneId("%1")), + expected=("select-pane", "-t", "%1"), + ), + RenderCase( + test_id="select_pane_direction_zoom", + op=SelectPane(target=PaneId("%2"), direction="L", zoom=True), + expected=("select-pane", "-t", "%2", "-L", "-Z"), + ), + RenderCase( + test_id="last_pane", + op=LastPane(target=WindowId("@1")), + expected=("last-pane", "-t", "@1"), + ), + RenderCase( + test_id="resize_pane_height", + op=ResizePane(target=PaneId("%1"), height=20), + expected=("resize-pane", "-t", "%1", "-y20"), + ), + RenderCase( + test_id="resize_pane_direction", + op=ResizePane(target=PaneId("%1"), direction="D", adjustment=5), + expected=("resize-pane", "-t", "%1", "-D", "5"), + ), + RenderCase( + test_id="respawn_pane_kill", + op=RespawnPane(target=PaneId("%1"), kill=True), + expected=("respawn-pane", "-t", "%1", "-k"), + ), + RenderCase( + test_id="pipe_pane", + op=PipePane(target=PaneId("%1"), command_line="cat"), + expected=("pipe-pane", "-t", "%1", "cat"), + ), + RenderCase( + test_id="clear_history", + op=ClearHistory(target=PaneId("%1")), + expected=("clear-history", "-t", "%1"), + ), + RenderCase( + test_id="swap_pane", + op=SwapPane(target=PaneId("%1"), src_target=PaneId("%2")), + expected=("swap-pane", "-t", "%1", "-s", "%2"), + ), + RenderCase( + test_id="join_pane", + op=JoinPane(target=WindowId("@1"), src_target=PaneId("%2")), + expected=("join-pane", "-t", "@1", "-v", "-d", "-s", "%2"), + ), + RenderCase( + test_id="move_pane", + op=MovePane(target=WindowId("@1"), src_target=PaneId("%2")), + expected=("move-pane", "-t", "@1", "-v", "-d", "-s", "%2"), + ), + RenderCase( + test_id="break_pane", + op=BreakPane(src_target=PaneId("%2"), name="logs"), + expected=( + "break-pane", + "-d", + "-n", + "logs", + "-P", + "-F", + "#{window_id}", + "-s", + "%2", + ), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_pane_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each pane op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_pane_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op (incl. its src_target) and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0, stdout=("@7",)) + assert result_from_dict(result_to_dict(result)) == result + + +def test_break_pane_captures_new_window_id() -> None: + """break-pane parses the captured window id into the typed result.""" + result = BreakPane(src_target=PaneId("%2")).build_result( + returncode=0, + stdout=("@9",), + ) + assert result.new_id == "@9" + assert result.created_id == "@9" + + +def test_select_pane_live(session: Session) -> None: + """select-pane makes the requested pane active.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + original = session.active_pane + assert original is not None and original.pane_id is not None + + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + run(SelectPane(target=PaneId(original.pane_id)), engine).raise_for_status() + + window.refresh() + active = window.active_pane + assert active is not None + assert active.pane_id == original.pane_id + + +def test_resize_and_clear_live(session: Session) -> None: + """resize-pane and clear-history succeed against a real pane.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + assert run(ResizePane(target=PaneId(pane.pane_id), height=10), engine).ok + assert run(ClearHistory(target=PaneId(pane.pane_id)), engine).ok + + +def test_break_and_swap_live(session: Session) -> None: + """break-pane creates a window; swap-pane swaps two real panes.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + + split = run(SplitWindow(target=WindowId(window.window_id)), engine) + new_pane = split.new_pane_id + assert new_pane is not None + + window.refresh() + first = window.panes[0].pane_id + assert first is not None + assert run(SwapPane(target=PaneId(first), src_target=PaneId(new_pane)), engine).ok + + broken = run(BreakPane(src_target=PaneId(new_pane)), engine) + assert broken.ok + assert broken.new_id is not None + assert session.server.windows.get(window_id=broken.new_id) is not None From eb3a4002700b3d6defe9616d1626597cc6f9e9bc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 12:54:13 -0500 Subject: [PATCH 020/154] Ops(feat): Add window mutation/navigation operations why: Window-level parity was missing the verbs the ORM uses to navigate and rearrange windows, so engine-driven callers could not select, move, or relink windows. what: - Add select-window, last-window, next-window, previous-window, resize-window, rotate-window, respawn-window, unlink-window - Add swap-window, move-window, link-window (dual-target, via -s src_target) - Wire ops/exports; extend the catalog kind-enumeration doctest - Add test_window_ops.py (NamedTuple + test_id render/round-trip cases plus live navigation/swap/move/unlink coverage) --- src/libtmux/experimental/ops/__init__.py | 22 ++ src/libtmux/experimental/ops/_ops/__init__.py | 22 ++ .../experimental/ops/_ops/last_window.py | 32 +++ .../experimental/ops/_ops/link_window.py | 61 +++++ .../experimental/ops/_ops/move_window.py | 61 +++++ .../experimental/ops/_ops/next_window.py | 41 ++++ .../experimental/ops/_ops/previous_window.py | 41 ++++ .../experimental/ops/_ops/resize_window.py | 58 +++++ .../experimental/ops/_ops/respawn_window.py | 64 ++++++ .../experimental/ops/_ops/rotate_window.py | 54 +++++ .../experimental/ops/_ops/select_window.py | 30 +++ .../experimental/ops/_ops/swap_window.py | 48 ++++ .../experimental/ops/_ops/unlink_window.py | 41 ++++ src/libtmux/experimental/ops/catalog.py | 10 +- tests/experimental/ops/test_window_ops.py | 216 ++++++++++++++++++ 15 files changed, 797 insertions(+), 4 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/last_window.py create mode 100644 src/libtmux/experimental/ops/_ops/link_window.py create mode 100644 src/libtmux/experimental/ops/_ops/move_window.py create mode 100644 src/libtmux/experimental/ops/_ops/next_window.py create mode 100644 src/libtmux/experimental/ops/_ops/previous_window.py create mode 100644 src/libtmux/experimental/ops/_ops/resize_window.py create mode 100644 src/libtmux/experimental/ops/_ops/respawn_window.py create mode 100644 src/libtmux/experimental/ops/_ops/rotate_window.py create mode 100644 src/libtmux/experimental/ops/_ops/select_window.py create mode 100644 src/libtmux/experimental/ops/_ops/swap_window.py create mode 100644 src/libtmux/experimental/ops/_ops/unlink_window.py create mode 100644 tests/experimental/ops/test_window_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index fa229bef8..402122afb 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -32,26 +32,37 @@ KillSession, KillWindow, LastPane, + LastWindow, + LinkWindow, ListClients, ListPanes, ListSessions, ListWindows, MovePane, + MoveWindow, NewSession, NewWindow, + NextWindow, PipePane, + PreviousWindow, RefreshClient, RenameSession, RenameWindow, ResizePane, + ResizeWindow, RespawnPane, + RespawnWindow, + RotateWindow, SelectLayout, SelectPane, + SelectWindow, SendKeys, ShowOptions, SplitWindow, SwapPane, + SwapWindow, SwitchClient, + UnlinkWindow, ) from libtmux.experimental.ops._types import ( ClientName, @@ -140,7 +151,9 @@ "KillSession", "KillWindow", "LastPane", + "LastWindow", "LazyPlan", + "LinkWindow", "ListClients", "ListClientsResult", "ListPanes", @@ -151,9 +164,11 @@ "ListWindowsResult", "MarkedPlanner", "MovePane", + "MoveWindow", "NameRef", "NewSession", "NewWindow", + "NextWindow", "OpChain", "OpSpec", "Operation", @@ -164,16 +179,21 @@ "PlanResult", "PlanStep", "Planner", + "PreviousWindow", "RefreshClient", "RenameSession", "RenameWindow", "ResizePane", + "ResizeWindow", "RespawnPane", + "RespawnWindow", "Result", + "RotateWindow", "Safety", "Scope", "SelectLayout", "SelectPane", + "SelectWindow", "SendKeys", "SequentialPlanner", "SessionId", @@ -185,10 +205,12 @@ "SplitWindowResult", "Status", "SwapPane", + "SwapWindow", "SwitchClient", "Target", "TmuxCommandError", "UnknownOperation", + "UnlinkWindow", "VersionUnsupported", "WindowId", "arun", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 4b54007fd..0b6593907 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -18,26 +18,37 @@ from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow from libtmux.experimental.ops._ops.last_pane import LastPane +from libtmux.experimental.ops._ops.last_window import LastWindow +from libtmux.experimental.ops._ops.link_window import LinkWindow from libtmux.experimental.ops._ops.list_clients import ListClients from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows from libtmux.experimental.ops._ops.move_pane import MovePane +from libtmux.experimental.ops._ops.move_window import MoveWindow from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow +from libtmux.experimental.ops._ops.next_window import NextWindow from libtmux.experimental.ops._ops.pipe_pane import PipePane +from libtmux.experimental.ops._ops.previous_window import PreviousWindow from libtmux.experimental.ops._ops.refresh_client import RefreshClient from libtmux.experimental.ops._ops.rename_session import RenameSession from libtmux.experimental.ops._ops.rename_window import RenameWindow from libtmux.experimental.ops._ops.resize_pane import ResizePane +from libtmux.experimental.ops._ops.resize_window import ResizeWindow from libtmux.experimental.ops._ops.respawn_pane import RespawnPane +from libtmux.experimental.ops._ops.respawn_window import RespawnWindow +from libtmux.experimental.ops._ops.rotate_window import RotateWindow from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.select_pane import SelectPane +from libtmux.experimental.ops._ops.select_window import SelectWindow from libtmux.experimental.ops._ops.send_keys import SendKeys from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.split_window import SplitWindow from libtmux.experimental.ops._ops.swap_pane import SwapPane +from libtmux.experimental.ops._ops.swap_window import SwapWindow from libtmux.experimental.ops._ops.switch_client import SwitchClient +from libtmux.experimental.ops._ops.unlink_window import UnlinkWindow __all__ = ( "BreakPane", @@ -51,24 +62,35 @@ "KillSession", "KillWindow", "LastPane", + "LastWindow", + "LinkWindow", "ListClients", "ListPanes", "ListSessions", "ListWindows", "MovePane", + "MoveWindow", "NewSession", "NewWindow", + "NextWindow", "PipePane", + "PreviousWindow", "RefreshClient", "RenameSession", "RenameWindow", "ResizePane", + "ResizeWindow", "RespawnPane", + "RespawnWindow", + "RotateWindow", "SelectLayout", "SelectPane", + "SelectWindow", "SendKeys", "ShowOptions", "SplitWindow", "SwapPane", + "SwapWindow", "SwitchClient", + "UnlinkWindow", ) diff --git a/src/libtmux/experimental/ops/_ops/last_window.py b/src/libtmux/experimental/ops/_ops/last_window.py new file mode 100644 index 000000000..b62fa2732 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/last_window.py @@ -0,0 +1,32 @@ +"""The ``last-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LastWindow(Operation[AckResult]): + """Select the previously active window (``last-window``). + + ``target`` is the session. + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> LastWindow(target=SessionId("$0")).render() + ('last-window', '-t', '$0') + """ + + kind = "last_window" + command = "last-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/link_window.py b/src/libtmux/experimental/ops/_ops/link_window.py new file mode 100644 index 000000000..2eb640498 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/link_window.py @@ -0,0 +1,61 @@ +"""The ``link-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LinkWindow(Operation[AckResult]): + """Link a window into another session (``link-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the window to + link (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + before, after : bool + Insert before (``-b``) or after (``-a``) the destination index. + kill : bool + Replace (kill) any window already at the destination (``-k``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId, WindowId + >>> LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")).render() + ('link-window', '-t', '$0', '-s', '@2') + """ + + kind = "link_window" + command = "link-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + before: bool = False + after: bool = False + kill: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the link flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.before: + out.append("-b") + if self.after: + out.append("-a") + if self.kill: + out.append("-k") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/move_window.py b/src/libtmux/experimental/ops/_ops/move_window.py new file mode 100644 index 000000000..525f2944d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/move_window.py @@ -0,0 +1,61 @@ +"""The ``move-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class MoveWindow(Operation[AckResult]): + """Move a window to a new index/session (``move-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the window to + move (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + before, after : bool + Insert before (``-b``) or after (``-a``) the destination index. + renumber : bool + Renumber windows to close gaps (``-r``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId, WindowId + >>> MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")).render() + ('move-window', '-t', '$0', '-s', '@2') + """ + + kind = "move_window" + command = "move-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + before: bool = False + after: bool = False + renumber: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the move flags and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + if self.before: + out.append("-b") + if self.after: + out.append("-a") + if self.renumber: + out.append("-r") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/next_window.py b/src/libtmux/experimental/ops/_ops/next_window.py new file mode 100644 index 000000000..0087aba16 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/next_window.py @@ -0,0 +1,41 @@ +"""The ``next-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class NextWindow(Operation[AckResult]): + """Select the next window in a session (``next-window``). + + Parameters + ---------- + alert : bool + Move to the next window with an alert (``-a``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> NextWindow(target=SessionId("$0")).render() + ('next-window', '-t', '$0') + """ + + kind = "next_window" + command = "next-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + alert: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.alert else () diff --git a/src/libtmux/experimental/ops/_ops/previous_window.py b/src/libtmux/experimental/ops/_ops/previous_window.py new file mode 100644 index 000000000..25cf1e2d0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/previous_window.py @@ -0,0 +1,41 @@ +"""The ``previous-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PreviousWindow(Operation[AckResult]): + """Select the previous window in a session (``previous-window``). + + Parameters + ---------- + alert : bool + Move to the previous window with an alert (``-a``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import SessionId + >>> PreviousWindow(target=SessionId("$0")).render() + ('previous-window', '-t', '$0') + """ + + kind = "previous_window" + command = "previous-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + alert: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-a`` flag.""" + return ("-a",) if self.alert else () diff --git a/src/libtmux/experimental/ops/_ops/resize_window.py b/src/libtmux/experimental/ops/_ops/resize_window.py new file mode 100644 index 000000000..952753340 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/resize_window.py @@ -0,0 +1,58 @@ +"""The ``resize-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class ResizeWindow(Operation[AckResult]): + """Resize a window (``resize-window``). + + Parameters + ---------- + direction : {"L", "R", "U", "D"} or None + Resize toward a side (``-L``/``-R``/``-U``/``-D``). + adjustment : int or None + Cells to adjust by when *direction* is set. + width, height : int or None + Absolute width (``-x``) / height (``-y``) in cells. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> ResizeWindow(target=WindowId("@1"), width=100).render() + ('resize-window', '-t', '@1', '-x100') + """ + + kind = "resize_window" + command = "resize-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + direction: t.Literal["L", "R", "U", "D"] | None = None + adjustment: int | None = None + width: int | None = None + height: int | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the resize flags.""" + out: list[str] = [] + if self.direction is not None: + out.append(f"-{self.direction}") + if self.adjustment is not None: + out.append(str(self.adjustment)) + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/respawn_window.py b/src/libtmux/experimental/ops/_ops/respawn_window.py new file mode 100644 index 000000000..93926cf68 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/respawn_window.py @@ -0,0 +1,64 @@ +"""The ``respawn-window`` operation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + +@register +@dataclass(frozen=True, kw_only=True) +class RespawnWindow(Operation[AckResult]): + """Restart the command in a (usually dead) window (``respawn-window``). + + Parameters + ---------- + kill : bool + Kill the existing process first (``-k``). + start_directory : str or None + Working directory for the new process (``-c``). + environment : Mapping[str, str] or None + Environment variables (``-e``; tmux 3.0+). + shell : str or None + Command to run instead of the default shell. + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RespawnWindow(target=WindowId("@1"), kill=True).render() + ('respawn-window', '-t', '@1', '-k') + """ + + kind = "respawn_window" + command = "respawn-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + flag_version_map: t.ClassVar[Mapping[str, str]] = {"environment": "3.0"} + + kill: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + shell: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the respawn flags.""" + out: list[str] = [] + if self.kill: + out.append("-k") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment and self.flag_available("environment", version): + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.shell is not None: + out.append(self.shell) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/rotate_window.py b/src/libtmux/experimental/ops/_ops/rotate_window.py new file mode 100644 index 000000000..1ceff7b4c --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/rotate_window.py @@ -0,0 +1,54 @@ +"""The ``rotate-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RotateWindow(Operation[AckResult]): + """Rotate the panes in a window (``rotate-window``). + + Parameters + ---------- + up, down : bool + Rotate upward (``-U``) or downward (``-D``). + zoom : bool + Keep the window zoomed (``-Z``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> RotateWindow(target=WindowId("@1")).render() + ('rotate-window', '-t', '@1') + >>> RotateWindow(target=WindowId("@1"), up=True).render() + ('rotate-window', '-t', '@1', '-U') + """ + + kind = "rotate_window" + command = "rotate-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(mutates_layout=True) + + up: bool = False + down: bool = False + zoom: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the rotate flags.""" + out: list[str] = [] + if self.up: + out.append("-U") + if self.down: + out.append("-D") + if self.zoom: + out.append("-Z") + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/select_window.py b/src/libtmux/experimental/ops/_ops/select_window.py new file mode 100644 index 000000000..0ce5412d8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/select_window.py @@ -0,0 +1,30 @@ +"""The ``select-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SelectWindow(Operation[AckResult]): + """Make a window active (``select-window``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SelectWindow(target=WindowId("@1")).render() + ('select-window', '-t', '@1') + """ + + kind = "select_window" + command = "select-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/swap_window.py b/src/libtmux/experimental/ops/_ops/swap_window.py new file mode 100644 index 000000000..b3237a726 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/swap_window.py @@ -0,0 +1,48 @@ +"""The ``swap-window`` operation (dual-target).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SwapWindow(Operation[AckResult]): + """Swap two windows (``swap-window``). + + ``target`` is the destination (``-t``); ``src_target`` is the source + window (``-s``). + + Parameters + ---------- + detach : bool + Do not change the active window (``-d``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> SwapWindow(target=WindowId("@1"), src_target=WindowId("@2")).render() + ('swap-window', '-t', '@1', '-s', '@2') + """ + + kind = "swap_window" + command = "swap-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + detach: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-d`` flag and ``-s`` source.""" + out: list[str] = [] + if self.detach: + out.append("-d") + out.extend(self.src_args()) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/unlink_window.py b/src/libtmux/experimental/ops/_ops/unlink_window.py new file mode 100644 index 000000000..08c7805de --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/unlink_window.py @@ -0,0 +1,41 @@ +"""The ``unlink-window`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class UnlinkWindow(Operation[AckResult]): + """Unlink a window from a session (``unlink-window``). + + Parameters + ---------- + kill : bool + Also destroy the window if it is no longer linked anywhere (``-k``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import WindowId + >>> UnlinkWindow(target=WindowId("@1")).render() + ('unlink-window', '-t', '@1') + """ + + kind = "unlink_window" + command = "unlink-window" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + kill: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-k`` flag.""" + return ("-k",) if self.kill else () diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 1841a9069..7eae45b61 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -67,11 +67,13 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> [entry.kind for entry in entries] ['break_pane', 'capture_pane', 'clear_history', 'detach_client', 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_session', - 'kill_window', 'last_pane', 'list_clients', 'list_panes', 'list_sessions', - 'list_windows', 'move_pane', 'new_session', 'new_window', 'pipe_pane', + 'kill_window', 'last_pane', 'last_window', 'link_window', 'list_clients', + 'list_panes', 'list_sessions', 'list_windows', 'move_pane', 'move_window', + 'new_session', 'new_window', 'next_window', 'pipe_pane', 'previous_window', 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', - 'respawn_pane', 'select_layout', 'select_pane', 'send_keys', 'show_options', - 'split_window', 'swap_pane', 'switch_client'] + 'resize_window', 'respawn_pane', 'respawn_window', 'rotate_window', + 'select_layout', 'select_pane', 'select_window', 'send_keys', 'show_options', + 'split_window', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/tests/experimental/ops/test_window_ops.py b/tests/experimental/ops/test_window_ops.py new file mode 100644 index 000000000..b428998a5 --- /dev/null +++ b/tests/experimental/ops/test_window_ops.py @@ -0,0 +1,216 @@ +"""Tests for the window mutation/navigation operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + LastWindow, + LinkWindow, + MoveWindow, + NewWindow, + NextWindow, + PreviousWindow, + ResizeWindow, + RespawnWindow, + RotateWindow, + SelectWindow, + SplitWindow, + SwapWindow, + UnlinkWindow, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import IndexRef, SessionId, WindowId + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="select_window", + op=SelectWindow(target=WindowId("@1")), + expected=("select-window", "-t", "@1"), + ), + RenderCase( + test_id="last_window", + op=LastWindow(target=SessionId("$0")), + expected=("last-window", "-t", "$0"), + ), + RenderCase( + test_id="next_window", + op=NextWindow(target=SessionId("$0")), + expected=("next-window", "-t", "$0"), + ), + RenderCase( + test_id="next_window_alert", + op=NextWindow(target=SessionId("$0"), alert=True), + expected=("next-window", "-t", "$0", "-a"), + ), + RenderCase( + test_id="previous_window", + op=PreviousWindow(target=SessionId("$0")), + expected=("previous-window", "-t", "$0"), + ), + RenderCase( + test_id="resize_window_width", + op=ResizeWindow(target=WindowId("@1"), width=100), + expected=("resize-window", "-t", "@1", "-x100"), + ), + RenderCase( + test_id="rotate_window_up", + op=RotateWindow(target=WindowId("@1"), up=True), + expected=("rotate-window", "-t", "@1", "-U"), + ), + RenderCase( + test_id="respawn_window_kill", + op=RespawnWindow(target=WindowId("@1"), kill=True), + expected=("respawn-window", "-t", "@1", "-k"), + ), + RenderCase( + test_id="unlink_window_kill", + op=UnlinkWindow(target=WindowId("@1"), kill=True), + expected=("unlink-window", "-t", "@1", "-k"), + ), + RenderCase( + test_id="swap_window", + op=SwapWindow(target=WindowId("@1"), src_target=WindowId("@2")), + expected=("swap-window", "-t", "@1", "-s", "@2"), + ), + RenderCase( + test_id="move_window", + op=MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")), + expected=("move-window", "-t", "$0", "-s", "@2"), + ), + RenderCase( + test_id="link_window", + op=LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")), + expected=("link-window", "-t", "$0", "-s", "@2"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_window_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each window op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_window_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op (incl. its src_target) and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_window_navigation_live(session: Session) -> None: + """select/next/previous/last-window move the active window.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + + session.refresh() + first = session.windows[0].window_id + assert first is not None + run(SelectWindow(target=WindowId(first)), engine).raise_for_status() + session.refresh() + assert session.active_window.window_id == first + + run(NextWindow(target=SessionId(sid)), engine).raise_for_status() + session.refresh() + assert session.active_window.window_id != first + + assert run(LastWindow(target=SessionId(sid)), engine).ok + assert run(PreviousWindow(target=SessionId(sid)), engine).ok + + +def test_resize_and_rotate_live(session: Session) -> None: + """resize-window and rotate-window succeed against a real window.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + assert run(ResizeWindow(target=WindowId(window.window_id), width=90), engine).ok + assert run(RotateWindow(target=WindowId(window.window_id)), engine).ok + + +def test_swap_and_move_live(session: Session) -> None: + """swap-window swaps two windows; move-window relocates one by index.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run(NewWindow(target=SessionId(sid)), engine).raise_for_status() + session.refresh() + first = session.windows[0].window_id + second = session.windows[1].window_id + assert first is not None and second is not None + + assert run( + SwapWindow(target=WindowId(first), src_target=WindowId(second)), + engine, + ).ok + assert run( + MoveWindow(target=IndexRef(9, parent=sid), src_target=WindowId(first)), + engine, + ).ok + + +def test_unlink_window_live(session: Session) -> None: + """unlink-window -k removes a window from its session.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + created = run(NewWindow(target=SessionId(sid)), engine) + created.raise_for_status() + new_id = created.new_id + assert new_id is not None + + assert run(UnlinkWindow(target=WindowId(new_id), kill=True), engine).ok + session.refresh() + assert session.windows.get(window_id=new_id, default=None) is None From 27ac248503caa850e772a6c78aa73a242e137e59 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:00:31 -0500 Subject: [PATCH 021/154] Ops(feat): Add server/option/environment operations why: Engine-driven callers had no typed way to drive the tmux server lifecycle or write options, environment, and hooks -- the write side of the options surface that show-options already read. what: - Add start-server, kill-server, run-shell, source-file, suspend-client lifecycle ops - Add set-option, set-window-option (the write counterpart to show-options), set-environment, set-hook - Wire ops/exports; extend the catalog kind-enumeration doctest - Add test_lifecycle_ops.py (NamedTuple + test_id render/round-trip cases plus live option/env/hook/run-shell/source-file coverage) --- src/libtmux/experimental/ops/__init__.py | 18 ++ src/libtmux/experimental/ops/_ops/__init__.py | 18 ++ .../experimental/ops/_ops/kill_server.py | 29 +++ .../experimental/ops/_ops/run_shell.py | 53 +++++ .../experimental/ops/_ops/set_environment.py | 64 ++++++ src/libtmux/experimental/ops/_ops/set_hook.py | 57 +++++ .../experimental/ops/_ops/set_option.py | 80 +++++++ .../ops/_ops/set_window_option.py | 62 ++++++ .../experimental/ops/_ops/source_file.py | 57 +++++ .../experimental/ops/_ops/start_server.py | 29 +++ .../experimental/ops/_ops/suspend_client.py | 32 +++ src/libtmux/experimental/ops/catalog.py | 18 +- tests/experimental/ops/test_lifecycle_ops.py | 200 ++++++++++++++++++ 13 files changed, 709 insertions(+), 8 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/kill_server.py create mode 100644 src/libtmux/experimental/ops/_ops/run_shell.py create mode 100644 src/libtmux/experimental/ops/_ops/set_environment.py create mode 100644 src/libtmux/experimental/ops/_ops/set_hook.py create mode 100644 src/libtmux/experimental/ops/_ops/set_option.py create mode 100644 src/libtmux/experimental/ops/_ops/set_window_option.py create mode 100644 src/libtmux/experimental/ops/_ops/source_file.py create mode 100644 src/libtmux/experimental/ops/_ops/start_server.py create mode 100644 src/libtmux/experimental/ops/_ops/suspend_client.py create mode 100644 tests/experimental/ops/test_lifecycle_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 402122afb..d21b099e5 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -29,6 +29,7 @@ HasSession, JoinPane, KillPane, + KillServer, KillSession, KillWindow, LastPane, @@ -53,12 +54,20 @@ RespawnPane, RespawnWindow, RotateWindow, + RunShell, SelectLayout, SelectPane, SelectWindow, SendKeys, + SetEnvironment, + SetHook, + SetOption, + SetWindowOption, ShowOptions, + SourceFile, SplitWindow, + StartServer, + SuspendClient, SwapPane, SwapWindow, SwitchClient, @@ -148,6 +157,7 @@ "IndexRef", "JoinPane", "KillPane", + "KillServer", "KillSession", "KillWindow", "LastPane", @@ -189,6 +199,7 @@ "RespawnWindow", "Result", "RotateWindow", + "RunShell", "Safety", "Scope", "SelectLayout", @@ -197,13 +208,20 @@ "SendKeys", "SequentialPlanner", "SessionId", + "SetEnvironment", + "SetHook", + "SetOption", + "SetWindowOption", "ShowOptions", "ShowOptionsResult", "SlotRef", + "SourceFile", "Special", "SplitWindow", "SplitWindowResult", + "StartServer", "Status", + "SuspendClient", "SwapPane", "SwapWindow", "SwitchClient", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 0b6593907..66e285049 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -15,6 +15,7 @@ from libtmux.experimental.ops._ops.has_session import HasSession from libtmux.experimental.ops._ops.join_pane import JoinPane from libtmux.experimental.ops._ops.kill_pane import KillPane +from libtmux.experimental.ops._ops.kill_server import KillServer from libtmux.experimental.ops._ops.kill_session import KillSession from libtmux.experimental.ops._ops.kill_window import KillWindow from libtmux.experimental.ops._ops.last_pane import LastPane @@ -39,12 +40,20 @@ from libtmux.experimental.ops._ops.respawn_pane import RespawnPane from libtmux.experimental.ops._ops.respawn_window import RespawnWindow from libtmux.experimental.ops._ops.rotate_window import RotateWindow +from libtmux.experimental.ops._ops.run_shell import RunShell from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.select_pane import SelectPane from libtmux.experimental.ops._ops.select_window import SelectWindow from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.set_environment import SetEnvironment +from libtmux.experimental.ops._ops.set_hook import SetHook +from libtmux.experimental.ops._ops.set_option import SetOption +from libtmux.experimental.ops._ops.set_window_option import SetWindowOption from libtmux.experimental.ops._ops.show_options import ShowOptions +from libtmux.experimental.ops._ops.source_file import SourceFile from libtmux.experimental.ops._ops.split_window import SplitWindow +from libtmux.experimental.ops._ops.start_server import StartServer +from libtmux.experimental.ops._ops.suspend_client import SuspendClient from libtmux.experimental.ops._ops.swap_pane import SwapPane from libtmux.experimental.ops._ops.swap_window import SwapWindow from libtmux.experimental.ops._ops.switch_client import SwitchClient @@ -59,6 +68,7 @@ "HasSession", "JoinPane", "KillPane", + "KillServer", "KillSession", "KillWindow", "LastPane", @@ -83,12 +93,20 @@ "RespawnPane", "RespawnWindow", "RotateWindow", + "RunShell", "SelectLayout", "SelectPane", "SelectWindow", "SendKeys", + "SetEnvironment", + "SetHook", + "SetOption", + "SetWindowOption", "ShowOptions", + "SourceFile", "SplitWindow", + "StartServer", + "SuspendClient", "SwapPane", "SwapWindow", "SwitchClient", diff --git a/src/libtmux/experimental/ops/_ops/kill_server.py b/src/libtmux/experimental/ops/_ops/kill_server.py new file mode 100644 index 000000000..a5754d648 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/kill_server.py @@ -0,0 +1,29 @@ +"""The ``kill-server`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class KillServer(Operation[AckResult]): + """Kill the tmux server and all its sessions (``kill-server``). + + Examples + -------- + >>> KillServer().render() + ('kill-server',) + """ + + kind = "kill_server" + command = "kill-server" + scope = "server" + result_cls = AckResult + safety = "destructive" + effects = Effects(destructive=True) diff --git a/src/libtmux/experimental/ops/_ops/run_shell.py b/src/libtmux/experimental/ops/_ops/run_shell.py new file mode 100644 index 000000000..9fab49ece --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/run_shell.py @@ -0,0 +1,53 @@ +"""The ``run-shell`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class RunShell(Operation[AckResult]): + """Run a shell command via tmux (``run-shell``). + + Parameters + ---------- + command_line : str or None + The shell command to run. + background : bool + Run in the background (``-b``). + delay : int or None + Delay in seconds before running (``-d``). + + Examples + -------- + >>> RunShell(command_line="echo hi").render() + ('run-shell', 'echo hi') + """ + + kind = "run_shell" + command = "run-shell" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + command_line: str | None = None + background: bool = False + delay: int | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the run-shell flags and command.""" + out: list[str] = [] + if self.background: + out.append("-b") + if self.delay is not None: + out.extend(("-d", str(self.delay))) + if self.command_line is not None: + out.append(self.command_line) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_environment.py b/src/libtmux/experimental/ops/_ops/set_environment.py new file mode 100644 index 000000000..dac3bce0a --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_environment.py @@ -0,0 +1,64 @@ +"""The ``set-environment`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetEnvironment(Operation[AckResult]): + """Set or unset a session environment variable (``set-environment``). + + Parameters + ---------- + name : str + The variable name. + value : str or None + The value to set (omit when *remove*/*unset*). + global_ : bool + Apply to the global environment (``-g``). + remove : bool + Remove the variable from the environment (``-r``). + unset : bool + Unset the variable (``-u``). + + Examples + -------- + >>> SetEnvironment(name="FOO", value="bar").render() + ('set-environment', 'FOO', 'bar') + >>> SetEnvironment(global_=True, name="FOO", unset=True).render() + ('set-environment', '-g', '-u', 'FOO') + """ + + kind = "set_environment" + command = "set-environment" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + name: str + value: str | None = None + global_: bool = False + remove: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, name, and value.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.remove: + out.append("-r") + if self.unset: + out.append("-u") + out.append(self.name) + if self.value is not None and not (self.unset or self.remove): + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_hook.py b/src/libtmux/experimental/ops/_ops/set_hook.py new file mode 100644 index 000000000..42d1a567d --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_hook.py @@ -0,0 +1,57 @@ +"""The ``set-hook`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetHook(Operation[AckResult]): + """Set or unset a tmux hook (``set-hook``). + + Parameters + ---------- + name : str + The hook name (e.g. ``after-new-window``). + hook_command : str or None + The tmux command to run (omit when *unset*). + global_ : bool + Apply globally (``-g``). + unset : bool + Unset the hook (``-u``). + + Examples + -------- + >>> SetHook(name="after-new-window", hook_command="display hi").render() + ('set-hook', 'after-new-window', 'display hi') + """ + + kind = "set_hook" + command = "set-hook" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + name: str + hook_command: str | None = None + global_: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, hook name, and command.""" + out: list[str] = [] + if self.global_: + out.append("-g") + if self.unset: + out.append("-u") + out.append(self.name) + if self.hook_command is not None and not self.unset: + out.append(self.hook_command) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_option.py b/src/libtmux/experimental/ops/_ops/set_option.py new file mode 100644 index 000000000..acbb0e4d2 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_option.py @@ -0,0 +1,80 @@ +"""The ``set-option`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetOption(Operation[AckResult]): + """Set a tmux option (``set-option``); the write counterpart to show-options. + + Parameters + ---------- + option : str + The option name. + value : str or None + The value to set (omit when *unset* is true). + global_, server, window, pane : bool + Scope flags (``-g`` / ``-s`` / ``-w`` / ``-p``). + append : bool + Append to a string/array option (``-a``). + unset : bool + Unset the option (``-u``). + quiet : bool + Suppress errors (``-q``). + + Examples + -------- + >>> SetOption(option="status", value="on").render() + ('set-option', 'status', 'on') + >>> SetOption(global_=True, option="status", value="on").render() + ('set-option', '-g', 'status', 'on') + >>> SetOption(option="status", unset=True).render() + ('set-option', '-u', 'status') + """ + + kind = "set_option" + command = "set-option" + scope = "session" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + option: str + value: str | None = None + global_: bool = False + server: bool = False + window: bool = False + pane: bool = False + append: bool = False + unset: bool = False + quiet: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the option flags, name, and value.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.global_: + out.append("-g") + if self.server: + out.append("-s") + if self.window: + out.append("-w") + if self.pane: + out.append("-p") + if self.quiet: + out.append("-q") + if self.unset: + out.append("-u") + out.append(self.option) + if self.value is not None and not self.unset: + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_window_option.py b/src/libtmux/experimental/ops/_ops/set_window_option.py new file mode 100644 index 000000000..2fb1cb2b2 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_window_option.py @@ -0,0 +1,62 @@ +"""The ``set-window-option`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetWindowOption(Operation[AckResult]): + """Set a window option (``set-window-option``). + + Parameters + ---------- + option : str + The option name. + value : str or None + The value to set (omit when *unset* is true). + global_ : bool + Apply to all windows (``-g``). + append : bool + Append to a string/array option (``-a``). + unset : bool + Unset the option (``-u``). + + Examples + -------- + >>> SetWindowOption(option="mode-keys", value="vi").render() + ('set-window-option', 'mode-keys', 'vi') + """ + + kind = "set_window_option" + command = "set-window-option" + scope = "window" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + option: str + value: str | None = None + global_: bool = False + append: bool = False + unset: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the option flags, name, and value.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.global_: + out.append("-g") + if self.unset: + out.append("-u") + out.append(self.option) + if self.value is not None and not self.unset: + out.append(self.value) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/source_file.py b/src/libtmux/experimental/ops/_ops/source_file.py new file mode 100644 index 000000000..b288c89f4 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/source_file.py @@ -0,0 +1,57 @@ +"""The ``source-file`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SourceFile(Operation[AckResult]): + """Execute tmux commands from a file (``source-file``). + + Parameters + ---------- + path : str + Path to the file to source. + quiet : bool + Suppress errors for missing files (``-q``). + verbose : bool + Show the parsed commands (``-v``). + no_exec : bool + Parse but do not execute (``-n``). + + Examples + -------- + >>> SourceFile(path="~/.tmux.conf").render() + ('source-file', '~/.tmux.conf') + """ + + kind = "source_file" + command = "source-file" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + path: str + quiet: bool = False + verbose: bool = False + no_exec: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the source-file flags and path.""" + out: list[str] = [] + if self.no_exec: + out.append("-n") + if self.quiet: + out.append("-q") + if self.verbose: + out.append("-v") + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/start_server.py b/src/libtmux/experimental/ops/_ops/start_server.py new file mode 100644 index 000000000..36a03c624 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/start_server.py @@ -0,0 +1,29 @@ +"""The ``start-server`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class StartServer(Operation[AckResult]): + """Start the tmux server if it is not already running (``start-server``). + + Examples + -------- + >>> StartServer().render() + ('start-server',) + """ + + kind = "start_server" + command = "start-server" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects(idempotent=True) diff --git a/src/libtmux/experimental/ops/_ops/suspend_client.py b/src/libtmux/experimental/ops/_ops/suspend_client.py new file mode 100644 index 000000000..0aac0b018 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/suspend_client.py @@ -0,0 +1,32 @@ +"""The ``suspend-client`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SuspendClient(Operation[AckResult]): + """Suspend a client (``suspend-client``). + + ``target`` is the client to suspend. + + Examples + -------- + >>> from libtmux.experimental.ops._types import ClientName + >>> SuspendClient(target=ClientName("/dev/pts/1")).render() + ('suspend-client', '-t', '/dev/pts/1') + """ + + kind = "suspend_client" + command = "suspend-client" + scope = "client" + result_cls = AckResult + safety = "mutating" + effects = Effects() diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 7eae45b61..aa59063d9 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -66,14 +66,16 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> entries = catalog() >>> [entry.kind for entry in entries] ['break_pane', 'capture_pane', 'clear_history', 'detach_client', - 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_session', - 'kill_window', 'last_pane', 'last_window', 'link_window', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'move_pane', 'move_window', - 'new_session', 'new_window', 'next_window', 'pipe_pane', 'previous_window', - 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', - 'resize_window', 'respawn_pane', 'respawn_window', 'rotate_window', - 'select_layout', 'select_pane', 'select_window', 'send_keys', 'show_options', - 'split_window', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] + 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_server', + 'kill_session', 'kill_window', 'last_pane', 'last_window', 'link_window', + 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'move_pane', + 'move_window', 'new_session', 'new_window', 'next_window', 'pipe_pane', + 'previous_window', 'refresh_client', 'rename_session', 'rename_window', + 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', + 'rotate_window', 'run_shell', 'select_layout', 'select_pane', 'select_window', + 'send_keys', 'set_environment', 'set_hook', 'set_option', 'set_window_option', + 'show_options', 'source_file', 'split_window', 'start_server', + 'suspend_client', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/tests/experimental/ops/test_lifecycle_ops.py b/tests/experimental/ops/test_lifecycle_ops.py new file mode 100644 index 000000000..f8a8d4083 --- /dev/null +++ b/tests/experimental/ops/test_lifecycle_ops.py @@ -0,0 +1,200 @@ +"""Tests for server/session lifecycle, option, and environment operations.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + KillServer, + RunShell, + SetEnvironment, + SetHook, + SetOption, + SetWindowOption, + ShowOptions, + SourceFile, + StartServer, + SuspendClient, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import ClientName, SessionId, WindowId + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="start_server", + op=StartServer(), + expected=("start-server",), + ), + RenderCase( + test_id="kill_server", + op=KillServer(), + expected=("kill-server",), + ), + RenderCase( + test_id="run_shell", + op=RunShell(command_line="echo hi"), + expected=("run-shell", "echo hi"), + ), + RenderCase( + test_id="run_shell_background", + op=RunShell(command_line="x", background=True), + expected=("run-shell", "-b", "x"), + ), + RenderCase( + test_id="source_file", + op=SourceFile(path="~/.tmux.conf"), + expected=("source-file", "~/.tmux.conf"), + ), + RenderCase( + test_id="suspend_client", + op=SuspendClient(target=ClientName("/dev/pts/1")), + expected=("suspend-client", "-t", "/dev/pts/1"), + ), + RenderCase( + test_id="set_option", + op=SetOption(option="status", value="on"), + expected=("set-option", "status", "on"), + ), + RenderCase( + test_id="set_option_global", + op=SetOption(global_=True, option="status", value="on"), + expected=("set-option", "-g", "status", "on"), + ), + RenderCase( + test_id="set_option_unset", + op=SetOption(option="status", unset=True), + expected=("set-option", "-u", "status"), + ), + RenderCase( + test_id="set_window_option", + op=SetWindowOption(option="mode-keys", value="vi"), + expected=("set-window-option", "mode-keys", "vi"), + ), + RenderCase( + test_id="set_environment", + op=SetEnvironment(name="FOO", value="bar"), + expected=("set-environment", "FOO", "bar"), + ), + RenderCase( + test_id="set_environment_unset", + op=SetEnvironment(global_=True, name="FOO", unset=True), + expected=("set-environment", "-g", "-u", "FOO"), + ), + RenderCase( + test_id="set_hook", + op=SetHook(name="after-new-window", hook_command="display hi"), + expected=("set-hook", "after-new-window", "display hi"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_lifecycle_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_lifecycle_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_set_and_show_option_live(session: Session) -> None: + """set-option writes a session option that show-options reads back.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + assert sid is not None + + run( + SetOption(target=SessionId(sid), option="@ops_var", value="hello"), + engine, + ).raise_for_status() + shown = run(ShowOptions(target=SessionId(sid)), engine) + assert shown.ok + assert shown.options.get("@ops_var") == "hello" + + +def test_set_window_option_and_environment_live(session: Session) -> None: + """set-window-option and set-environment succeed against real objects.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + sid = session.session_id + window = session.active_window + assert sid is not None and window.window_id is not None + + assert run( + SetWindowOption(target=WindowId(window.window_id), option="@w", value="x"), + engine, + ).ok + assert run( + SetEnvironment(target=SessionId(sid), name="OPS_ENV", value="1"), + engine, + ).ok + assert run( + SetHook( + target=SessionId(sid), + name="after-new-window", + hook_command="display-message ok", + ), + engine, + ).ok + + +def test_run_shell_source_file_start_server_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """run-shell, source-file, and start-server all succeed.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + assert run(RunShell(command_line="true"), engine).ok + assert run(StartServer(), engine).ok + + conf = tmp_path / "snippet.conf" + conf.write_text("set-option -g @sourced yes\n") + assert run(SourceFile(path=str(conf)), engine).ok From a22ee7f6050f575950ba4b14a9c9a595881ddd79 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:09:20 -0500 Subject: [PATCH 022/154] Ops(feat): Add paste-buffer operations why: The paste-buffer family the ORM uses for clipboard interchange had no typed operations, leaving buffer set/load/save/paste outside the engine-driven surface. what: - Add set-buffer, delete-buffer, load-buffer, save-buffer, paste-buffer ops - Add show-buffer read op + ShowBufferResult.text (buffer contents) - Wire ops/results/exports; extend the catalog kind-enumeration and registry readonly doctests - Add test_buffer_ops.py (NamedTuple + test_id render/round-trip cases plus a live set/show/save/delete and load/paste round-trip) --- src/libtmux/experimental/ops/__init__.py | 14 ++ src/libtmux/experimental/ops/_ops/__init__.py | 12 ++ .../experimental/ops/_ops/delete_buffer.py | 44 +++++ .../experimental/ops/_ops/load_buffer.py | 49 +++++ .../experimental/ops/_ops/paste_buffer.py | 68 +++++++ .../experimental/ops/_ops/save_buffer.py | 54 ++++++ .../experimental/ops/_ops/set_buffer.py | 54 ++++++ .../experimental/ops/_ops/show_buffer.py | 69 +++++++ src/libtmux/experimental/ops/catalog.py | 19 +- src/libtmux/experimental/ops/registry.py | 3 +- src/libtmux/experimental/ops/results.py | 7 + tests/experimental/ops/test_buffer_ops.py | 169 ++++++++++++++++++ tests/experimental/ops/test_registry.py | 1 + 13 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 src/libtmux/experimental/ops/_ops/delete_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/load_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/paste_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/save_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/set_buffer.py create mode 100644 src/libtmux/experimental/ops/_ops/show_buffer.py create mode 100644 tests/experimental/ops/test_buffer_ops.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index d21b099e5..e0d1a3e37 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -24,6 +24,7 @@ BreakPane, CapturePane, ClearHistory, + DeleteBuffer, DetachClient, DisplayMessage, HasSession, @@ -39,11 +40,13 @@ ListPanes, ListSessions, ListWindows, + LoadBuffer, MovePane, MoveWindow, NewSession, NewWindow, NextWindow, + PasteBuffer, PipePane, PreviousWindow, RefreshClient, @@ -55,14 +58,17 @@ RespawnWindow, RotateWindow, RunShell, + SaveBuffer, SelectLayout, SelectPane, SelectWindow, SendKeys, + SetBuffer, SetEnvironment, SetHook, SetOption, SetWindowOption, + ShowBuffer, ShowOptions, SourceFile, SplitWindow, @@ -124,6 +130,7 @@ ListSessionsResult, ListWindowsResult, Result, + ShowBufferResult, ShowOptionsResult, SplitWindowResult, status_for, @@ -146,6 +153,7 @@ "ClearHistory", "ClientName", "CreateResult", + "DeleteBuffer", "DetachClient", "DisplayMessage", "DisplayMessageResult", @@ -172,6 +180,7 @@ "ListSessionsResult", "ListWindows", "ListWindowsResult", + "LoadBuffer", "MarkedPlanner", "MovePane", "MoveWindow", @@ -185,6 +194,7 @@ "OperationError", "OperationRegistry", "PaneId", + "PasteBuffer", "PipePane", "PlanResult", "PlanStep", @@ -201,6 +211,7 @@ "RotateWindow", "RunShell", "Safety", + "SaveBuffer", "Scope", "SelectLayout", "SelectPane", @@ -208,10 +219,13 @@ "SendKeys", "SequentialPlanner", "SessionId", + "SetBuffer", "SetEnvironment", "SetHook", "SetOption", "SetWindowOption", + "ShowBuffer", + "ShowBufferResult", "ShowOptions", "ShowOptionsResult", "SlotRef", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 66e285049..17d76abab 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -10,6 +10,7 @@ from libtmux.experimental.ops._ops.break_pane import BreakPane from libtmux.experimental.ops._ops.capture_pane import CapturePane from libtmux.experimental.ops._ops.clear_history import ClearHistory +from libtmux.experimental.ops._ops.delete_buffer import DeleteBuffer from libtmux.experimental.ops._ops.detach_client import DetachClient from libtmux.experimental.ops._ops.display_message import DisplayMessage from libtmux.experimental.ops._ops.has_session import HasSession @@ -25,11 +26,13 @@ from libtmux.experimental.ops._ops.list_panes import ListPanes from libtmux.experimental.ops._ops.list_sessions import ListSessions from libtmux.experimental.ops._ops.list_windows import ListWindows +from libtmux.experimental.ops._ops.load_buffer import LoadBuffer from libtmux.experimental.ops._ops.move_pane import MovePane from libtmux.experimental.ops._ops.move_window import MoveWindow from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow from libtmux.experimental.ops._ops.next_window import NextWindow +from libtmux.experimental.ops._ops.paste_buffer import PasteBuffer from libtmux.experimental.ops._ops.pipe_pane import PipePane from libtmux.experimental.ops._ops.previous_window import PreviousWindow from libtmux.experimental.ops._ops.refresh_client import RefreshClient @@ -41,14 +44,17 @@ from libtmux.experimental.ops._ops.respawn_window import RespawnWindow from libtmux.experimental.ops._ops.rotate_window import RotateWindow from libtmux.experimental.ops._ops.run_shell import RunShell +from libtmux.experimental.ops._ops.save_buffer import SaveBuffer from libtmux.experimental.ops._ops.select_layout import SelectLayout from libtmux.experimental.ops._ops.select_pane import SelectPane from libtmux.experimental.ops._ops.select_window import SelectWindow from libtmux.experimental.ops._ops.send_keys import SendKeys +from libtmux.experimental.ops._ops.set_buffer import SetBuffer from libtmux.experimental.ops._ops.set_environment import SetEnvironment from libtmux.experimental.ops._ops.set_hook import SetHook from libtmux.experimental.ops._ops.set_option import SetOption from libtmux.experimental.ops._ops.set_window_option import SetWindowOption +from libtmux.experimental.ops._ops.show_buffer import ShowBuffer from libtmux.experimental.ops._ops.show_options import ShowOptions from libtmux.experimental.ops._ops.source_file import SourceFile from libtmux.experimental.ops._ops.split_window import SplitWindow @@ -63,6 +69,7 @@ "BreakPane", "CapturePane", "ClearHistory", + "DeleteBuffer", "DetachClient", "DisplayMessage", "HasSession", @@ -78,11 +85,13 @@ "ListPanes", "ListSessions", "ListWindows", + "LoadBuffer", "MovePane", "MoveWindow", "NewSession", "NewWindow", "NextWindow", + "PasteBuffer", "PipePane", "PreviousWindow", "RefreshClient", @@ -94,14 +103,17 @@ "RespawnWindow", "RotateWindow", "RunShell", + "SaveBuffer", "SelectLayout", "SelectPane", "SelectWindow", "SendKeys", + "SetBuffer", "SetEnvironment", "SetHook", "SetOption", "SetWindowOption", + "ShowBuffer", "ShowOptions", "SourceFile", "SplitWindow", diff --git a/src/libtmux/experimental/ops/_ops/delete_buffer.py b/src/libtmux/experimental/ops/_ops/delete_buffer.py new file mode 100644 index 000000000..03ef19784 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/delete_buffer.py @@ -0,0 +1,44 @@ +"""The ``delete-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class DeleteBuffer(Operation[AckResult]): + """Delete a paste buffer (``delete-buffer``). + + Parameters + ---------- + buffer_name : str or None + The buffer to delete (``-b``); the most recent when omitted. + + Examples + -------- + >>> DeleteBuffer(buffer_name="b0").render() + ('delete-buffer', '-b', 'b0') + >>> DeleteBuffer().render() + ('delete-buffer',) + """ + + kind = "delete_buffer" + command = "delete-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-b`` buffer name.""" + if self.buffer_name is not None: + return ("-b", self.buffer_name) + return () diff --git a/src/libtmux/experimental/ops/_ops/load_buffer.py b/src/libtmux/experimental/ops/_ops/load_buffer.py new file mode 100644 index 000000000..f868b89d1 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/load_buffer.py @@ -0,0 +1,49 @@ +"""The ``load-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class LoadBuffer(Operation[AckResult]): + """Load a paste buffer from a file (``load-buffer``). + + Parameters + ---------- + path : str + The file to load (``-`` for stdin). + buffer_name : str or None + The buffer to load into (``-b``). + + Examples + -------- + >>> LoadBuffer(path="/tmp/x").render() + ('load-buffer', '/tmp/x') + >>> LoadBuffer(buffer_name="b0", path="/tmp/x").render() + ('load-buffer', '-b', 'b0', '/tmp/x') + """ + + kind = "load_buffer" + command = "load-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + path: str + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional buffer name and path.""" + out: list[str] = [] + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/paste_buffer.py b/src/libtmux/experimental/ops/_ops/paste_buffer.py new file mode 100644 index 000000000..12281b841 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/paste_buffer.py @@ -0,0 +1,68 @@ +"""The ``paste-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class PasteBuffer(Operation[AckResult]): + """Paste a buffer into a pane (``paste-buffer``). + + ``target`` is the destination pane. + + Parameters + ---------- + buffer_name : str or None + The buffer to paste (``-b``); the most recent when omitted. + delete : bool + Delete the buffer after pasting (``-d``). + bracket : bool + Use bracketed paste mode (``-p``). + no_format : bool + Do not replace separators with spaces (``-r``). + separator : str or None + Separator inserted between lines (``-s``). + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> PasteBuffer(target=PaneId("%1")).render() + ('paste-buffer', '-t', '%1') + >>> PasteBuffer(target=PaneId("%1"), delete=True).render() + ('paste-buffer', '-t', '%1', '-d') + """ + + kind = "paste_buffer" + command = "paste-buffer" + scope = "pane" + result_cls = AckResult + safety = "mutating" + effects = Effects(writes_input=True) + + buffer_name: str | None = None + delete: bool = False + bracket: bool = False + no_format: bool = False + separator: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the paste flags and buffer name.""" + out: list[str] = [] + if self.delete: + out.append("-d") + if self.bracket: + out.append("-p") + if self.no_format: + out.append("-r") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + if self.separator is not None: + out.extend(("-s", self.separator)) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/save_buffer.py b/src/libtmux/experimental/ops/_ops/save_buffer.py new file mode 100644 index 000000000..19176b8c0 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/save_buffer.py @@ -0,0 +1,54 @@ +"""The ``save-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SaveBuffer(Operation[AckResult]): + """Save a paste buffer to a file (``save-buffer``). + + Parameters + ---------- + path : str + The file to write (``-`` for stdout). + buffer_name : str or None + The buffer to save (``-b``). + append : bool + Append to the file instead of overwriting it (``-a``). + + Examples + -------- + >>> SaveBuffer(path="/tmp/x").render() + ('save-buffer', '/tmp/x') + >>> SaveBuffer(buffer_name="b0", path="/tmp/x", append=True).render() + ('save-buffer', '-a', '-b', 'b0', '/tmp/x') + """ + + kind = "save_buffer" + command = "save-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects(read_only=True) + + path: str + buffer_name: str | None = None + append: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the flags, optional buffer name, and path.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.path) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/set_buffer.py b/src/libtmux/experimental/ops/_ops/set_buffer.py new file mode 100644 index 000000000..e884c97a8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/set_buffer.py @@ -0,0 +1,54 @@ +"""The ``set-buffer`` operation.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import AckResult + + +@register +@dataclass(frozen=True, kw_only=True) +class SetBuffer(Operation[AckResult]): + """Set the contents of a paste buffer (``set-buffer``). + + Parameters + ---------- + data : str + The buffer contents. + buffer_name : str or None + The buffer to set (``-b``); tmux picks a name when omitted. + append : bool + Append to the buffer instead of replacing it (``-a``). + + Examples + -------- + >>> SetBuffer(data="hello").render() + ('set-buffer', 'hello') + >>> SetBuffer(buffer_name="b0", data="hi").render() + ('set-buffer', '-b', 'b0', 'hi') + """ + + kind = "set_buffer" + command = "set-buffer" + scope = "server" + result_cls = AckResult + safety = "mutating" + effects = Effects() + + data: str + buffer_name: str | None = None + append: bool = False + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the buffer flags and data.""" + out: list[str] = [] + if self.append: + out.append("-a") + if self.buffer_name is not None: + out.extend(("-b", self.buffer_name)) + out.append(self.data) + return tuple(out) diff --git a/src/libtmux/experimental/ops/_ops/show_buffer.py b/src/libtmux/experimental/ops/_ops/show_buffer.py new file mode 100644 index 000000000..4faa33e66 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/show_buffer.py @@ -0,0 +1,69 @@ +"""The ``show-buffer`` operation (a read returning buffer contents).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import ShowBufferResult + +if t.TYPE_CHECKING: + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class ShowBuffer(Operation[ShowBufferResult]): + r"""Show the contents of a paste buffer (``show-buffer``). + + Parameters + ---------- + buffer_name : str or None + The buffer to show (``-b``); the most recent when omitted. + + Examples + -------- + >>> ShowBuffer(buffer_name="b0").render() + ('show-buffer', '-b', 'b0') + >>> ShowBuffer().build_result(returncode=0, stdout=("line1", "line2")).text + 'line1\nline2' + """ + + kind = "show_buffer" + command = "show-buffer" + scope = "server" + result_cls = ShowBufferResult + safety = "readonly" + chainable = False + effects = Effects(read_only=True, idempotent=True) + + buffer_name: str | None = None + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render the optional ``-b`` buffer name.""" + if self.buffer_name is not None: + return ("-b", self.buffer_name) + return () + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> ShowBufferResult: + """Join the captured lines into the buffer text.""" + return ShowBufferResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + text="\n".join(stdout), + ) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index aa59063d9..55ed6118a 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -65,17 +65,18 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: >>> from libtmux.experimental.ops import catalog >>> entries = catalog() >>> [entry.kind for entry in entries] - ['break_pane', 'capture_pane', 'clear_history', 'detach_client', + ['break_pane', 'capture_pane', 'clear_history', 'delete_buffer', 'detach_client', 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_server', 'kill_session', 'kill_window', 'last_pane', 'last_window', 'link_window', - 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'move_pane', - 'move_window', 'new_session', 'new_window', 'next_window', 'pipe_pane', - 'previous_window', 'refresh_client', 'rename_session', 'rename_window', - 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', - 'rotate_window', 'run_shell', 'select_layout', 'select_pane', 'select_window', - 'send_keys', 'set_environment', 'set_hook', 'set_option', 'set_window_option', - 'show_options', 'source_file', 'split_window', 'start_server', - 'suspend_client', 'swap_pane', 'swap_window', 'switch_client', 'unlink_window'] + 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'load_buffer', + 'move_pane', 'move_window', 'new_session', 'new_window', 'next_window', + 'paste_buffer', 'pipe_pane', 'previous_window', 'refresh_client', 'rename_session', + 'rename_window', 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', + 'rotate_window', 'run_shell', 'save_buffer', 'select_layout', 'select_pane', + 'select_window', 'send_keys', 'set_buffer', 'set_environment', 'set_hook', + 'set_option', 'set_window_option', 'show_buffer', 'show_options', 'source_file', + 'split_window', 'start_server', 'suspend_client', 'swap_pane', 'swap_window', + 'switch_client', 'unlink_window'] >>> capture = next(entry for entry in entries if entry.kind == "capture_pane") >>> capture.scope, capture.safety, capture.result_type ('pane', 'readonly', 'CapturePaneResult') diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index f610606b6..e0538ac6a 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -168,7 +168,8 @@ def select( >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'display_message', 'has_session', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'show_options'] + 'list_panes', 'list_sessions', 'list_windows', 'show_buffer', + 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 3f4073ba8..1e0dfad70 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -319,3 +319,10 @@ class ShowOptionsResult(Result): """Result of ``show-options``: parsed ``name value`` pairs in :attr:`options`.""" options: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ShowBufferResult(Result): + """Result of ``show-buffer``: the buffer contents as :attr:`text`.""" + + text: str = "" diff --git a/tests/experimental/ops/test_buffer_ops.py b/tests/experimental/ops/test_buffer_ops.py new file mode 100644 index 000000000..147b8abe8 --- /dev/null +++ b/tests/experimental/ops/test_buffer_ops.py @@ -0,0 +1,169 @@ +"""Tests for the paste-buffer operations (bucket A).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import ( + DeleteBuffer, + LoadBuffer, + PasteBuffer, + SaveBuffer, + SetBuffer, + ShowBuffer, + operation_from_dict, + operation_to_dict, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.experimental.ops.operation import Operation + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """An op and the exact argv it renders.""" + + test_id: str + op: Operation[t.Any] + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="set_buffer", + op=SetBuffer(data="hello"), + expected=("set-buffer", "hello"), + ), + RenderCase( + test_id="set_buffer_named", + op=SetBuffer(buffer_name="b0", data="hi"), + expected=("set-buffer", "-b", "b0", "hi"), + ), + RenderCase( + test_id="delete_buffer_named", + op=DeleteBuffer(buffer_name="b0"), + expected=("delete-buffer", "-b", "b0"), + ), + RenderCase( + test_id="delete_buffer_default", + op=DeleteBuffer(), + expected=("delete-buffer",), + ), + RenderCase( + test_id="load_buffer", + op=LoadBuffer(path="/tmp/x"), + expected=("load-buffer", "/tmp/x"), + ), + RenderCase( + test_id="save_buffer", + op=SaveBuffer(path="/tmp/x"), + expected=("save-buffer", "/tmp/x"), + ), + RenderCase( + test_id="save_buffer_append_named", + op=SaveBuffer(buffer_name="b0", path="/tmp/x", append=True), + expected=("save-buffer", "-a", "-b", "b0", "/tmp/x"), + ), + RenderCase( + test_id="paste_buffer", + op=PasteBuffer(target=PaneId("%1")), + expected=("paste-buffer", "-t", "%1"), + ), + RenderCase( + test_id="paste_buffer_delete", + op=PasteBuffer(target=PaneId("%1"), delete=True), + expected=("paste-buffer", "-t", "%1", "-d"), + ), + RenderCase( + test_id="show_buffer", + op=ShowBuffer(buffer_name="b0"), + expected=("show-buffer", "-b", "b0"), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_buffer_op_render( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each buffer op renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_buffer_op_round_trips( + test_id: str, + op: Operation[t.Any], + expected: tuple[str, ...], +) -> None: + """Each op and its result round-trip via dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0) + assert result_from_dict(result_to_dict(result)) == result + + +def test_show_buffer_joins_lines() -> None: + """show-buffer joins captured lines into the buffer text.""" + result = ShowBuffer().build_result(returncode=0, stdout=("line1", "line2")) + assert result.text == "line1\nline2" + + +def test_set_show_save_delete_buffer_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """set-buffer/show-buffer/save-buffer/delete-buffer round-trip a buffer.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + run(SetBuffer(buffer_name="ops_b", data="hello world"), engine).raise_for_status() + shown = run(ShowBuffer(buffer_name="ops_b"), engine) + assert shown.ok + assert shown.text == "hello world" + + out = tmp_path / "buf.txt" + run(SaveBuffer(buffer_name="ops_b", path=str(out)), engine).raise_for_status() + assert out.read_text() == "hello world" + + assert run(DeleteBuffer(buffer_name="ops_b"), engine).ok + + +def test_load_and_paste_buffer_live( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """load-buffer reads a file into a buffer; paste-buffer targets a pane.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + src = tmp_path / "in.txt" + src.write_text("pasted-content") + run(LoadBuffer(buffer_name="ops_lb", path=str(src)), engine).raise_for_status() + assert run(ShowBuffer(buffer_name="ops_lb"), engine).text == "pasted-content" + + assert run( + PasteBuffer(target=PaneId(pane.pane_id), buffer_name="ops_lb"), + engine, + ).ok diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 667656f25..6679bae71 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -53,6 +53,7 @@ def test_list_predicate_filters() -> None: "list_panes", "list_sessions", "list_windows", + "show_buffer", "show_options", ] From da3ecbd72b74ad1cded4c603838b523d0bef51ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:17:53 -0500 Subject: [PATCH 023/154] docs(experimental): Document engines and lazy plans why: The experimental page described operations and the catalog but not how to run them or compose multi-step plans, leaving the engine choice and planner A/B story undocumented. what: - Add "Running an operation" (run/arun, raise_for_status policy) - Add "Choosing an engine" (engine table, create_engine, async peers) - Add "Lazy plans and planners" (LazyPlan slot refs, >> chaining, Sequential/Folding/Marked planners) - All examples are executable doctests via the in-memory ConcreteEngine --- docs/experimental.md | 99 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/experimental.md b/docs/experimental.md index 8cebf6145..0cc6fbe65 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -16,6 +16,105 @@ a persistent `tmux -C` control connection, or an async transport. See ``tmux-python/libtmux`` issue 689 for the operationalization plan. +## Running an operation + +An operation is a value; ``run`` (or ``arun`` for async) hands it to an engine +and returns the engine's typed result. Results never raise on construction -- +inspect ``ok``/``status``, or opt into raising with ``raise_for_status()``: + +```python +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> from libtmux.experimental.engines import ConcreteEngine +>>> result = run(HasSession(target=SessionId("$0")), ConcreteEngine()) +>>> result.ok +True +>>> result.raise_for_status() is result +True +``` + +How a *failed* result is treated is the engine's policy: the classic subprocess +path raises in its facade to match today's libtmux behavior, while the newer +engines hand the result back and let the caller decide. + +## Choosing an engine + +Every engine satisfies the same ``TmuxEngine`` (or ``AsyncTmuxEngine``) +protocol, so swapping engines never changes an operation or its result type -- +only *how* and *where* the command runs. + +| Engine | Transport | Use it for | +| --- | --- | --- | +| ``SubprocessEngine`` | one ``tmux`` process per command | the classic path; reproduces today's libtmux behavior | +| ``ConcreteEngine`` | in-memory, no tmux | tests and dry runs (deterministic, fabricated output) | +| ``ControlModeEngine`` | a persistent ``tmux -C`` connection | many commands over one long-lived session | +| ``ImsgEngine`` | tmux's native binary peer protocol | an opt-in easter egg | + +Each has an ``Async*`` counterpart (``AsyncSubprocessEngine``, +``AsyncConcreteEngine``, ``AsyncControlModeEngine``) behind ``AsyncTmuxEngine``. +Construct one directly, bind it to a live server with +``SubprocessEngine.for_server(server)``, or select one by name from the engine +registry: + +```python +>>> from libtmux.experimental.engines import available_engines, create_engine +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> available_engines() +('concrete', 'control_mode', 'imsg', 'subprocess') +>>> engine = create_engine("concrete") +>>> run(HasSession(target=SessionId("$0")), engine).status +'complete' +``` + +## Lazy plans and planners + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations without +running them, returning a forward *slot reference* for each created object so a +later operation can target something that does not exist yet. ``execute`` +(or ``aexecute``) resolves those references against captured ids as it goes: + +```python +>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys +>>> from libtmux.experimental.ops._types import WindowId +>>> from libtmux.experimental.engines import ConcreteEngine +>>> plan = LazyPlan() +>>> pane = plan.add(SplitWindow(target=WindowId("@1"))) +>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) +>>> outcome = plan.execute(ConcreteEngine()) +>>> outcome.ok +True +>>> [r.status for r in outcome.results] +['complete', 'complete'] +``` + +Operations also compose with ``>>`` into a chain, which a plan can run as one +dispatch when the members are chainable. + +*How* a plan turns into dispatches is a pluggable +{class}`~libtmux.experimental.ops.planner.Planner`, so strategies can be A/B +tested against the same plan: + +- ``SequentialPlanner`` -- one dispatch per operation (the default). +- ``FoldingPlanner`` -- folds adjacent chainable operations into a single + ``;``-separated dispatch. +- ``MarkedPlanner`` -- folds a "create then decorate the new pane" run into one + dispatch using tmux's ``{marked}`` register. + +Every planner produces the same per-operation result; they differ only in how +many times tmux is invoked: + +```python +>>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys, FoldingPlanner +>>> from libtmux.experimental.ops._types import WindowId +>>> from libtmux.experimental.engines import ConcreteEngine +>>> plan = LazyPlan() +>>> pane = plan.add(SplitWindow(target=WindowId("@1"))) +>>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) +>>> plan.execute(ConcreteEngine(), planner=FoldingPlanner()).ok +True +``` + ## Operation catalog The catalog below is generated from the operation registry, so it always matches From 2472522047616e5ecbac9970cd91170c6a12407c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:18:01 -0500 Subject: [PATCH 024/154] docs(CHANGES): Note experimental operations and engines why: Record the experimental operations/engines layer for the upcoming release so the unreleased section tracks what landed. what: - Add a "What's new" deliverable under the unreleased 0.59.x section for the experimental operations and engines layer (#690) - Defer the release lead paragraph until the version is cut --- CHANGES | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGES b/CHANGES index f7ba0efba..66285b197 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,34 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### Experimental operations and engines (#690) + +Operations describe tmux commands as data. Each renders its argv against a tmux +version (dropping flags an older tmux cannot accept), adapts raw output into a +typed result, and serializes to and from plain dicts -- all without a running +tmux server. The set spans the read seam (``list-*``, ``has-session``, +``display-message``, ``show-options``, ``show-buffer``) and the +mutating/creating surface for panes, windows, the server, options, environment, +hooks, and paste buffers. A registry-generated catalog on the {ref}`experimental` +page always matches the code. + +Engines run those operations behind one protocol, so the same operation returns +the same typed result whether it goes through a subprocess (the classic path +that reproduces today's libtmux behavior), an in-memory simulator for tests and +dry runs, a persistent ``tmux -C`` control connection, an async transport, or +tmux's native binary peer protocol. Results never raise on construction; +raising is opt-in via ``raise_for_status()``, and how a failed result is handled +is each engine's policy. + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations and yields +forward references so a later operation can target an object that does not exist +yet, resolved against captured ids at execution time. How a plan becomes tmux +dispatches is a pluggable {class}`~libtmux.experimental.ops.planner.Planner` +(sequential, ``;``-folding, or ``{marked}``-folding), so dispatch strategies can +be A/B tested against the same plan with identical results. + ## libtmux 0.61.0 (2026-07-04) libtmux 0.61.0 hardens support for the tmux 3.7 patch line. It fixes From e0b7c48e57fa84db8084972149fa298e78776e2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 13:28:12 -0500 Subject: [PATCH 025/154] Ops(fix): Correct move-window -k and paste-buffer -r why: An adversarial review of the new ops against tmux's command grammar found two defects: move-window could not request its kill-on-collision behavior, and paste-buffer's -r flag was documented as a space replacement it never performs. what: - MoveWindow: add kill (-k) field; tmux move-window's option string is "abdkrs:t:" and -k replaces any window already at the destination index - PasteBuffer: rename no_format to no_replace and fix the docstring; -r keeps linefeeds instead of converting them to the default carriage-return separator (it has nothing to do with spaces) - Add render cases for move-window -k/-r and paste-buffer -r --- src/libtmux/experimental/ops/_ops/move_window.py | 5 +++++ src/libtmux/experimental/ops/_ops/paste_buffer.py | 9 +++++---- tests/experimental/ops/test_buffer_ops.py | 5 +++++ tests/experimental/ops/test_window_ops.py | 10 ++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/move_window.py b/src/libtmux/experimental/ops/_ops/move_window.py index 525f2944d..99092c6a0 100644 --- a/src/libtmux/experimental/ops/_ops/move_window.py +++ b/src/libtmux/experimental/ops/_ops/move_window.py @@ -24,6 +24,8 @@ class MoveWindow(Operation[AckResult]): Do not change the active window (``-d``). before, after : bool Insert before (``-b``) or after (``-a``) the destination index. + kill : bool + Replace (kill) any window already at the destination (``-k``). renumber : bool Renumber windows to close gaps (``-r``). @@ -44,6 +46,7 @@ class MoveWindow(Operation[AckResult]): detach: bool = False before: bool = False after: bool = False + kill: bool = False renumber: bool = False def args(self, *, version: str | None = None) -> tuple[str, ...]: @@ -55,6 +58,8 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out.append("-b") if self.after: out.append("-a") + if self.kill: + out.append("-k") if self.renumber: out.append("-r") out.extend(self.src_args()) diff --git a/src/libtmux/experimental/ops/_ops/paste_buffer.py b/src/libtmux/experimental/ops/_ops/paste_buffer.py index 12281b841..a4219058e 100644 --- a/src/libtmux/experimental/ops/_ops/paste_buffer.py +++ b/src/libtmux/experimental/ops/_ops/paste_buffer.py @@ -25,8 +25,9 @@ class PasteBuffer(Operation[AckResult]): Delete the buffer after pasting (``-d``). bracket : bool Use bracketed paste mode (``-p``). - no_format : bool - Do not replace separators with spaces (``-r``). + no_replace : bool + Do no separator replacement: keep linefeeds (LF) instead of + converting them to the default carriage-return separator (``-r``). separator : str or None Separator inserted between lines (``-s``). @@ -49,7 +50,7 @@ class PasteBuffer(Operation[AckResult]): buffer_name: str | None = None delete: bool = False bracket: bool = False - no_format: bool = False + no_replace: bool = False separator: str | None = None def args(self, *, version: str | None = None) -> tuple[str, ...]: @@ -59,7 +60,7 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out.append("-d") if self.bracket: out.append("-p") - if self.no_format: + if self.no_replace: out.append("-r") if self.buffer_name is not None: out.extend(("-b", self.buffer_name)) diff --git a/tests/experimental/ops/test_buffer_ops.py b/tests/experimental/ops/test_buffer_ops.py index 147b8abe8..d4b03e93a 100644 --- a/tests/experimental/ops/test_buffer_ops.py +++ b/tests/experimental/ops/test_buffer_ops.py @@ -82,6 +82,11 @@ class RenderCase(t.NamedTuple): op=PasteBuffer(target=PaneId("%1"), delete=True), expected=("paste-buffer", "-t", "%1", "-d"), ), + RenderCase( + test_id="paste_buffer_no_replace", + op=PasteBuffer(target=PaneId("%1"), no_replace=True), + expected=("paste-buffer", "-t", "%1", "-r"), + ), RenderCase( test_id="show_buffer", op=ShowBuffer(buffer_name="b0"), diff --git a/tests/experimental/ops/test_window_ops.py b/tests/experimental/ops/test_window_ops.py index b428998a5..cd88e732c 100644 --- a/tests/experimental/ops/test_window_ops.py +++ b/tests/experimental/ops/test_window_ops.py @@ -97,6 +97,16 @@ class RenderCase(t.NamedTuple): op=MoveWindow(target=SessionId("$0"), src_target=WindowId("@2")), expected=("move-window", "-t", "$0", "-s", "@2"), ), + RenderCase( + test_id="move_window_kill_renumber", + op=MoveWindow( + target=SessionId("$0"), + src_target=WindowId("@2"), + kill=True, + renumber=True, + ), + expected=("move-window", "-t", "$0", "-k", "-r", "-s", "@2"), + ), RenderCase( test_id="link_window", op=LinkWindow(target=SessionId("$0"), src_target=WindowId("@2")), From 93c8991fd7218fd2ee99154e708d7ce4950b0834 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:03:42 -0500 Subject: [PATCH 026/154] Ops(fix): Resolve SlotRef src_target in lazy plans why: A LazyPlan resolved a forward SlotRef only for an op's target, so a dual-target op (swap/join/move/break/link) whose src_target came from an earlier plan.add(...) reached render() with the slot unresolved and raised TypeError. serialize already handled both fields; resolution did not. what: - Factor _resolve_slot() and resolve both target and src_target in _resolve() - Add parametrized test_plan_resolves_src_target covering swap/join/ move/break panes --- src/libtmux/experimental/ops/plan.py | 33 ++++++++++++++--------- tests/experimental/ops/test_plan.py | 40 +++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 74bec9639..38c292a11 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -78,23 +78,32 @@ def _target_from_id(value: str) -> Target: return Special(value) -def _resolve( - operation: Operation[t.Any], - bindings: dict[int, str], -) -> Operation[t.Any]: - """Substitute a :class:`SlotRef` target with a captured concrete id.""" - target = operation.target - if not isinstance(target, SlotRef): - return operation +def _resolve_slot(ref: SlotRef, bindings: dict[int, str]) -> Target: + """Map a :class:`SlotRef` to the captured concrete target it points at.""" try: - concrete = bindings[target.slot] + target.suffix + concrete = bindings[ref.slot] + ref.suffix except KeyError as error: msg = ( - f"slot {target.slot} has no captured id yet; a plan step can only " - f"target an earlier step that creates an object" + f"slot {ref.slot} has no captured id yet; a plan step can only " + f"reference an earlier step that creates an object" ) raise OperationError(msg) from error - return dataclasses.replace(operation, target=_target_from_id(concrete)) + return _target_from_id(concrete) + + +def _resolve( + operation: Operation[t.Any], + bindings: dict[int, str], +) -> Operation[t.Any]: + """Substitute any :class:`SlotRef` ``target``/``src_target`` with its id.""" + changes: dict[str, Target] = {} + if isinstance(operation.target, SlotRef): + changes["target"] = _resolve_slot(operation.target, bindings) + if isinstance(operation.src_target, SlotRef): + changes["src_target"] = _resolve_slot(operation.src_target, bindings) + if not changes: + return operation + return dataclasses.replace(operation, **changes) @dataclass(frozen=True) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 317a953cb..c48ecea17 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -3,18 +3,26 @@ from __future__ import annotations import asyncio +import typing as t import pytest from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.ops import ( + BreakPane, + JoinPane, LazyPlan, + MovePane, SendKeys, SplitWindow, + SwapPane, ) -from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId from libtmux.experimental.ops.exc import OperationError +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + def test_plan_records_without_executing() -> None: """Building a plan touches no engine; it just records operations.""" @@ -38,6 +46,36 @@ def test_plan_resolves_forward_ref() -> None: assert outcome.ok +class SrcResolveCase(t.NamedTuple): + """A dual-target op whose ``src_target`` is a forward :class:`SlotRef`.""" + + test_id: str + op: Operation[t.Any] + + +SRC_RESOLVE_CASES = ( + SrcResolveCase("swap_pane", SwapPane(target=PaneId("%0"), src_target=SlotRef(0))), + SrcResolveCase("join_pane", JoinPane(target=WindowId("@0"), src_target=SlotRef(0))), + SrcResolveCase("move_pane", MovePane(target=WindowId("@0"), src_target=SlotRef(0))), + SrcResolveCase("break_pane", BreakPane(src_target=SlotRef(0))), +) + + +@pytest.mark.parametrize( + list(SrcResolveCase._fields), + SRC_RESOLVE_CASES, + ids=[c.test_id for c in SRC_RESOLVE_CASES], +) +def test_plan_resolves_src_target(test_id: str, op: Operation[t.Any]) -> None: + """A SlotRef used as ``src_target`` resolves to the captured id.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 + plan.add(op) + outcome = plan.execute(ConcreteEngine()) + assert outcome.ok + assert outcome.results[1].argv[-2:] == ("-s", "%1") + + def test_plan_aexecute_matches_execute() -> None: """The async driver resolves refs identically to the sync driver.""" plan = LazyPlan() From 5865c7a9e22a5ac113fe322c91da17fb134f1e78 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:07:29 -0500 Subject: [PATCH 027/154] Ops(fix): Skip all decorates when a marked-fold create fails why: In a {marked} fold, when the create step failed (no captured id) attribute_marked still ran the chain attributor, which blamed the first decorate as "failed" -- but tmux stopped at the create, so no decorate ran. The first decorate was wrongly reported as the failure. what: - When new_id is None, mark every decorate "skipped" and return the create's failure (the failed-decorate path is unchanged: first blamed, rest skipped) - Add parametrized test_attribute_marked for success/create-fails/ decorate-fails --- src/libtmux/experimental/ops/_chain.py | 17 ++++--- tests/experimental/ops/test_chain.py | 68 +++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 7561047ba..36ccdc389 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -130,19 +130,20 @@ def attribute_marked( ) -> tuple[Result, list[Result], str | None]: """Split a ``{marked}`` dispatch result into the create's + decorates' results.""" new_id = merged.stdout[0].strip() if merged.stdout else None - if new_id is not None: - create_result = create.build_result( - returncode=0, stdout=(new_id,), version=version - ) - else: + # Attribute over the {marked}-retargeted decorates -- their original SlotRef + # target is unresolved and cannot render. + marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] + if new_id is None: + # The create step failed: tmux stopped, so no decorate ran -- skip them + # all rather than blaming the first. create_result = create.build_result( returncode=merged.returncode or 1, stderr=tuple(merged.stderr), version=version, ) - # Attribute over the {marked}-retargeted decorates -- their original SlotRef - # target is unresolved and cannot render. - marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] + decorated = [op.result_with_status("skipped", version=version) for op in marked] + return create_result, decorated, None + create_result = create.build_result(returncode=0, stdout=(new_id,), version=version) return create_result, attribute(marked, merged, version), new_id diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 66501299a..35a7dfe90 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -17,7 +17,11 @@ SendKeys, SplitWindow, ) -from libtmux.experimental.ops._chain import ensure_chainable, render_chain +from libtmux.experimental.ops._chain import ( + attribute_marked, + ensure_chainable, + render_chain, +) from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.exc import OperationError @@ -147,6 +151,68 @@ def test_fold_keeps_creation_ops_unfolded() -> None: assert outcome.ok +class MarkedAttrCase(t.NamedTuple): + """A merged {marked} dispatch result and the per-op statuses it yields.""" + + test_id: str + merged: CommandResult + new_id: str | None + create_status: str + decorate_statuses: list[str] + + +_MARK_CREATE = SplitWindow(target=WindowId("@1")) +_MARK_DECORATES = ( + SendKeys(target=PaneId("%9"), keys="a"), + SendKeys(target=PaneId("%9"), keys="b"), +) + +MARKED_ATTR_CASES = ( + MarkedAttrCase( + test_id="all_succeed", + merged=CommandResult(cmd=("tmux",), stdout=("%2",), returncode=0), + new_id="%2", + create_status="complete", + decorate_statuses=["complete", "complete"], + ), + MarkedAttrCase( + test_id="create_fails", + merged=CommandResult(cmd=("tmux",), returncode=1, stderr=("boom",)), + new_id=None, + create_status="failed", + decorate_statuses=["skipped", "skipped"], + ), + MarkedAttrCase( + test_id="decorate_fails", + merged=CommandResult( + cmd=("tmux",), stdout=("%2",), returncode=1, stderr=("x",) + ), + new_id="%2", + create_status="complete", + decorate_statuses=["failed", "skipped"], + ), +) + + +@pytest.mark.parametrize( + list(MarkedAttrCase._fields), + MARKED_ATTR_CASES, + ids=[c.test_id for c in MARKED_ATTR_CASES], +) +def test_attribute_marked( + test_id: str, + merged: CommandResult, + new_id: str | None, + create_status: str, + decorate_statuses: list[str], +) -> None: + """A failed create skips all decorates; a failed decorate blames the first.""" + created, decorated, got_id = attribute_marked(_MARK_CREATE, _MARK_DECORATES, merged) + assert got_id == new_id + assert created.status == create_status + assert [r.status for r in decorated] == decorate_statuses + + def test_add_chain() -> None: """A composed OpChain can be added to a plan in order.""" plan = LazyPlan() From 2a3130efdc06b0d45bdee17d0d1ff067412eba55 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:10:48 -0500 Subject: [PATCH 028/154] Ops(fix): Mark save-buffer readonly to match its effects why: SaveBuffer declared safety="mutating" alongside effects=Effects(read_only=True) -- contradictory. save-buffer reads a tmux buffer and writes a file; it changes no tmux state, so it is a read like its peer show-buffer. A consumer filtering on safety=="readonly" wrongly omitted it. what: - Set SaveBuffer safety="readonly" and effects idempotent=True (matches ShowBuffer) - Update the registry readonly doctest + test list - Add a parametrized invariant test: safety=="readonly" agrees with effects.read_only for every registered op --- src/libtmux/experimental/ops/_ops/save_buffer.py | 4 ++-- src/libtmux/experimental/ops/registry.py | 4 ++-- tests/experimental/ops/test_registry.py | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/save_buffer.py b/src/libtmux/experimental/ops/_ops/save_buffer.py index 19176b8c0..215a078a4 100644 --- a/src/libtmux/experimental/ops/_ops/save_buffer.py +++ b/src/libtmux/experimental/ops/_ops/save_buffer.py @@ -36,8 +36,8 @@ class SaveBuffer(Operation[AckResult]): command = "save-buffer" scope = "server" result_cls = AckResult - safety = "mutating" - effects = Effects(read_only=True) + safety = "readonly" + effects = Effects(read_only=True, idempotent=True) path: str buffer_name: str | None = None diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index e0538ac6a..c986e5cdc 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -168,8 +168,8 @@ def select( >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'display_message', 'has_session', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'show_buffer', - 'show_options'] + 'list_panes', 'list_sessions', 'list_windows', 'save_buffer', + 'show_buffer', 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index 6679bae71..f7fac59cc 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -53,11 +53,22 @@ def test_list_predicate_filters() -> None: "list_panes", "list_sessions", "list_windows", + "save_buffer", "show_buffer", "show_options", ] +@pytest.mark.parametrize( + "spec", + list(registry.select()), + ids=[spec.kind for spec in registry.select()], +) +def test_readonly_safety_matches_read_only_effect(spec: OpSpec) -> None: + """Every op's ``safety == "readonly"`` agrees with ``effects.read_only``.""" + assert spec.effects.read_only == (spec.safety == "readonly") + + def test_register_duplicate_fails_closed() -> None: """Registering an existing kind raises unless ``replace=True``.""" local = OperationRegistry() From fa5749a2f9f20911740d56f8718d4f76929b5552 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:12:55 -0500 Subject: [PATCH 029/154] Ops(docs): Fix PipePane parameter name in docstring why: The PipePane docstring documented a `command` parameter, but the field is `command_line` (renamed to avoid the `command` classvar). A reader following the docstring would hit a TypeError. what: - Rename the docstring parameter to `command_line` (the doctest already used the correct name) --- src/libtmux/experimental/ops/_ops/pipe_pane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/pipe_pane.py b/src/libtmux/experimental/ops/_ops/pipe_pane.py index c30c45c0d..0e9b6ddec 100644 --- a/src/libtmux/experimental/ops/_ops/pipe_pane.py +++ b/src/libtmux/experimental/ops/_ops/pipe_pane.py @@ -17,7 +17,7 @@ class PipePane(Operation[AckResult]): Parameters ---------- - command : str or None + command_line : str or None Shell command to pipe to. Omit to stop an existing pipe. stdin : bool Connect the pane's input to the command (``-I``). From dadfad852681debfb00c2eaf0f6efea8451ebb7f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:15:37 -0500 Subject: [PATCH 030/154] Ops(fix): Log imsg argv as a scalar tmux_cmd field why: The imsg engine logged extra={"tmux_command_argv": list(...)}, a non-scalar value that violates the logging schema (avoid ad-hoc objects; prefer stable scalars). what: - Replace the list value with the documented scalar core key tmux_cmd holding the joined command line, in both imsg debug log calls --- src/libtmux/experimental/engines/imsg/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 00e56a9a8..34c8d9da2 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -485,7 +485,7 @@ def _run_socket_command( extra={ "tmux_protocol_version": codec.version, "tmux_identify_frames": len(identify_frames), - "tmux_command_argv": list(command_argv), + "tmux_cmd": " ".join(command_argv), }, ) @@ -518,7 +518,7 @@ def _run_socket_command( "tmux_message_pid": pid, "tmux_message_len": len(payload), "tmux_message_has_fd": frame.header.has_fd, - "tmux_command_argv": list(command_argv), + "tmux_cmd": " ".join(command_argv), }, ) if msg_type == codec.msg_version: From 0b16a5a03339bf1be54adf8ccf976d48101086b0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 14:21:12 -0500 Subject: [PATCH 031/154] Ops(docs): Add doctests to the planner plan() methods why: The three pluggable dispatch strategies (Sequential/Folding/ Marked) had docstrings but no examples, the clearest gap among the methods a review flagged for missing doctests. what: - Add concise, engine-free doctests to SequentialPlanner.plan, FoldingPlanner.plan, and MarkedPlanner.plan showing the PlanStep output each produces --- src/libtmux/experimental/ops/planner.py | 38 +++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py index 928dfabe8..53f5f23aa 100644 --- a/src/libtmux/experimental/ops/planner.py +++ b/src/libtmux/experimental/ops/planner.py @@ -53,7 +53,15 @@ class SequentialPlanner: """Dispatch each operation on its own (one tmux call per op).""" def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: - """One single-op step per operation.""" + """One single-op step per operation. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> SequentialPlanner().plan([SendKeys(target=PaneId("%1"), keys="a")]) + [PlanStep(indices=(0,), marked=False)] + """ return [PlanStep((index,)) for index in range(len(operations))] @@ -79,7 +87,19 @@ class FoldingPlanner: """Fold maximal runs of chainable ops into one ``;`` dispatch each.""" def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: - """Chain consecutive chainable ops; dispatch the rest alone.""" + """Chain consecutive chainable ops; dispatch the rest alone. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> ops = [ + ... SendKeys(target=PaneId("%1"), keys="a"), + ... SendKeys(target=PaneId("%1"), keys="b"), + ... ] + >>> FoldingPlanner().plan(ops) + [PlanStep(indices=(0, 1), marked=False)] + """ return _fold_runs(operations, 0) @@ -93,7 +113,19 @@ class MarkedPlanner: """ def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: - """Emit ``{marked}`` folds where possible, else fold normally.""" + """Emit ``{marked}`` folds where possible, else fold normally. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import SlotRef, WindowId + >>> ops = [ + ... SplitWindow(target=WindowId("@1")), + ... SendKeys(target=SlotRef(0), keys="vim", enter=True), + ... ] + >>> MarkedPlanner().plan(ops) + [PlanStep(indices=(0, 1), marked=True)] + """ steps: list[PlanStep] = [] index = 0 total = len(operations) From 2606c3a1326016881d0770ceb2b4289db559c5de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 15:40:16 -0500 Subject: [PATCH 032/154] Ops(fix): Resolve decorate src_target in {marked} folds why: The earlier src_target fix covered the single-op and chain dispatch paths but not the {marked} fold: _drive built decorates raw, so a chainable dual-target decorate (swap/join/move) whose src_target is a forward slot reached render_marked unresolved and raised TypeError. A decorate's target is the same-fold create (addressed via {marked}); only its src_target -- which points at an earlier bound step -- can and must be resolved. what: - Add _resolve_src() (resolves only a SlotRef src_target, leaving the {marked}-bound target to render_marked) and use it for decorates in the _drive marked branch - Add parametrized test_marked_plan_resolves_decorate_src_target (swap/join/move decorate referencing an earlier bound pane) --- src/libtmux/experimental/ops/plan.py | 23 ++++++++++++++++++- tests/experimental/ops/test_plan.py | 34 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 38c292a11..f8fb2723e 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -106,6 +106,25 @@ def _resolve( return dataclasses.replace(operation, **changes) +def _resolve_src( + operation: Operation[t.Any], + bindings: dict[int, str], +) -> Operation[t.Any]: + """Resolve only a :class:`SlotRef` ``src_target``. + + A ``{marked}`` decorate's ``target`` is this same fold's create, which has no + captured id yet -- it is addressed through tmux's ``{marked}`` register by + :func:`~._chain.render_marked`, so only ``src_target`` (which references an + already-bound earlier step) is substituted here. + """ + if isinstance(operation.src_target, SlotRef): + return dataclasses.replace( + operation, + src_target=_resolve_slot(operation.src_target, bindings), + ) + return operation + + @dataclass(frozen=True) class PlanResult: """The outcome of executing a :class:`LazyPlan`. @@ -213,7 +232,9 @@ def _drive( if step.marked: create_idx, *decorate_idx = step.indices create = _resolve(self._operations[create_idx], bindings) - decorates = [self._operations[i] for i in decorate_idx] + decorates = [ + _resolve_src(self._operations[i], bindings) for i in decorate_idx + ] merged: CommandResult = yield _Chain( render_marked(create, decorates, version), ) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index c48ecea17..812d20665 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -12,6 +12,7 @@ BreakPane, JoinPane, LazyPlan, + MarkedPlanner, MovePane, SendKeys, SplitWindow, @@ -76,6 +77,39 @@ def test_plan_resolves_src_target(test_id: str, op: Operation[t.Any]) -> None: assert outcome.results[1].argv[-2:] == ("-s", "%1") +class MarkedSrcCase(t.NamedTuple): + """A {marked} decorate whose ``src_target`` references an earlier bound slot.""" + + test_id: str + op: Operation[t.Any] + + +MARKED_SRC_CASES = ( + MarkedSrcCase("swap_pane", SwapPane(target=SlotRef(1), src_target=SlotRef(0))), + MarkedSrcCase("join_pane", JoinPane(target=SlotRef(1), src_target=SlotRef(0))), + MarkedSrcCase("move_pane", MovePane(target=SlotRef(1), src_target=SlotRef(0))), +) + + +@pytest.mark.parametrize( + list(MarkedSrcCase._fields), + MARKED_SRC_CASES, + ids=[c.test_id for c in MARKED_SRC_CASES], +) +def test_marked_plan_resolves_decorate_src_target( + test_id: str, + op: Operation[t.Any], +) -> None: + """A {marked} decorate's ``src_target`` SlotRef resolves to the bound id.""" + plan = LazyPlan() + plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 (own dispatch) + plan.add(SplitWindow(target=WindowId("@1"))) # slot 1 -> the marked-fold creator + plan.add(op) # slot 2 -> decorate: target {marked}, src_target -> slot 0 + outcome = plan.execute(ConcreteEngine(), planner=MarkedPlanner()) + assert outcome.ok + assert outcome.results[2].argv[-2:] == ("-s", "%1") + + def test_plan_aexecute_matches_execute() -> None: """The async driver resolves refs identically to the sync driver.""" plan = LazyPlan() From 2df41862d8c8dae22a871a10018fc926eace7166 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 15:45:44 -0500 Subject: [PATCH 033/154] Ops(fix): Keep ; a bare separator in control-mode engines why: ControlModeEngine and AsyncControlModeEngine serialized each command with shlex.join, which quotes a folded chain's standalone ";" token to "';'". tmux -C then read it as a literal argument, so a FoldingPlanner/MarkedPlanner chain over control mode ran as one malformed command and the chained commands silently never executed. what: - Add render_control_line() to engines/base.py: shell-quote each token but leave a standalone ";" bare - Use it in both control-mode run_batch payloads (the only send path); drop the now-unused shlex imports - Add parametrized test_render_control_line (plain, quoted, chain) --- .../engines/async_control_mode.py | 6 ++- src/libtmux/experimental/engines/base.py | 18 +++++++ .../experimental/engines/control_mode.py | 7 +-- tests/experimental/engines/test_base.py | 50 +++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 tests/experimental/engines/test_base.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index ccfaafdbe..25bc00eef 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -27,12 +27,12 @@ import asyncio import collections import contextlib -import shlex import shutil import typing as t from dataclasses import dataclass from libtmux import exc +from libtmux.experimental.engines.base import render_control_line from libtmux.experimental.engines.control_mode import ( ControlModeError, ControlModeParser, @@ -211,7 +211,9 @@ async def run_batch( future: asyncio.Future[CommandResult] = loop.create_future() self._pending.append(_PendingCommand(future, argv)) futures.append(future) - payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + payload = b"".join( + (render_control_line(argv) + "\n").encode() for argv in rendered + ) try: proc.stdin.write(payload) await proc.stdin.drain() diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 9195dbb61..9c9ca86c9 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -11,6 +11,7 @@ from __future__ import annotations import enum +import shlex import typing as t from dataclasses import dataclass, field @@ -19,6 +20,23 @@ from collections.abc import Sequence +def render_control_line(argv: Sequence[str]) -> str: + """Render a tmux argv as a control-mode (``tmux -C``) command line. + + Each token is quoted for the control parser, but a standalone ``;`` separator + is left bare so a folded ``a ; b`` chain dispatches as two commands instead of + one command with a literal ``';'`` argument. + + Examples + -------- + >>> render_control_line(("rename-window", "-t", "@1", "a b")) + "rename-window -t @1 'a b'" + >>> render_control_line(("rename-window", "a", ";", "kill-window", "@2")) + 'rename-window a ; kill-window @2' + """ + return " ".join(token if token == ";" else shlex.quote(token) for token in argv) + + @dataclass(frozen=True) class CommandRequest: """A rendered tmux command, ready for an engine to execute. diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 57e4f6f88..3a92d177f 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -21,7 +21,6 @@ import logging import os import selectors -import shlex import shutil import subprocess import threading @@ -29,7 +28,7 @@ import typing as t from libtmux import exc -from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.engines.base import CommandResult, render_control_line if t.TYPE_CHECKING: import types @@ -204,7 +203,9 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: # buffered from earlier activity, so they cannot be mis-attributed # to this batch's commands. self._drain_unsolicited() - payload = b"".join((shlex.join(argv) + "\n").encode() for argv in rendered) + payload = b"".join( + (render_control_line(argv) + "\n").encode() for argv in rendered + ) self._write(payload) blocks = self._read_blocks(len(rendered)) return [ diff --git a/tests/experimental/engines/test_base.py b/tests/experimental/engines/test_base.py new file mode 100644 index 000000000..5484f9a7d --- /dev/null +++ b/tests/experimental/engines/test_base.py @@ -0,0 +1,50 @@ +"""Tests for engine base helpers.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.base import render_control_line + + +class WireCase(t.NamedTuple): + """An argv and the control-mode wire line it should render to.""" + + test_id: str + argv: tuple[str, ...] + expected: str + + +WIRE_CASES = ( + WireCase( + test_id="plain", + argv=("rename-window", "-t", "@1", "edit"), + expected="rename-window -t @1 edit", + ), + WireCase( + test_id="quotes_spaces", + argv=("set-option", "@x", "a b"), + expected="set-option @x 'a b'", + ), + WireCase( + test_id="chain_keeps_bare_semicolon", + argv=("rename-window", "a", ";", "kill-window", "@2"), + expected="rename-window a ; kill-window @2", + ), +) + + +@pytest.mark.parametrize( + list(WireCase._fields), + WIRE_CASES, + ids=[c.test_id for c in WIRE_CASES], +) +def test_render_control_line( + test_id: str, + argv: tuple[str, ...], + expected: str, +) -> None: + """A standalone ``;`` stays a separator; other tokens are shell-quoted.""" + assert render_control_line(argv) == expected From 38422046c7ac59c33ae30d2d187deec0b23ab508 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 15:51:27 -0500 Subject: [PATCH 034/154] Ops(fix): Treat a blank captured id as no id in marked folds why: attribute_marked derived new_id from stdout[0].strip() and only guarded against None. A whitespace-only capture became "" and passed the guard, so the plan would bind an empty id and later raise ValueError when Special("") was constructed during slot resolution. what: - Coerce a blank captured id to None (`... or None`) so it is never bound as "" - Add test_attribute_marked_blank_stdout_is_no_id --- src/libtmux/experimental/ops/_chain.py | 2 +- tests/experimental/ops/test_chain.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 36ccdc389..29ea11f85 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -129,7 +129,7 @@ def attribute_marked( version: str | None = None, ) -> tuple[Result, list[Result], str | None]: """Split a ``{marked}`` dispatch result into the create's + decorates' results.""" - new_id = merged.stdout[0].strip() if merged.stdout else None + new_id = (merged.stdout[0].strip() if merged.stdout else "") or None # Attribute over the {marked}-retargeted decorates -- their original SlotRef # target is unresolved and cannot render. marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 35a7dfe90..4742ab0f5 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -213,6 +213,17 @@ def test_attribute_marked( assert [r.status for r in decorated] == decorate_statuses +def test_attribute_marked_blank_stdout_is_no_id() -> None: + """A whitespace-only captured id is treated as no id (never bound as '').""" + merged = CommandResult(cmd=("tmux",), stdout=(" ",), returncode=0) + _created, _decorated, new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert new_id is None + + def test_add_chain() -> None: """A composed OpChain can be added to a plan in order.""" plan = LazyPlan() From 8b9f49ccb4c463f206cd6fc3217cebcc14235527 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:31:38 -0500 Subject: [PATCH 035/154] Ops(fix): Complete a marked fold whose creator does not capture why: When a {marked} fold's creator emits no id (capture=False) but tmux succeeded, attribute_marked took the no-id branch and forced returncode `or 1`, falsely reporting the create as failed and all decorates as skipped even though every command ran. what: - In the no-id branch, when returncode==0 and no stderr, report the create complete and all decorates complete (a non-capturing success) - Add the capture_false_success case to test_attribute_marked --- src/libtmux/experimental/ops/_chain.py | 8 ++++++++ tests/experimental/ops/test_chain.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 29ea11f85..34a838765 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -134,6 +134,14 @@ def attribute_marked( # target is unresolved and cannot render. marked = [dataclasses.replace(op, target=Special("{marked}")) for op in decorates] if new_id is None: + if merged.returncode == 0 and not merged.stderr: + # A non-capturing creator (capture=False) succeeded but emitted no + # id; every command in the fold ran. + create_result = create.build_result(returncode=0, version=version) + decorated = [ + op.result_with_status("complete", version=version) for op in marked + ] + return create_result, decorated, None # The create step failed: tmux stopped, so no decorate ran -- skip them # all rather than blaming the first. create_result = create.build_result( diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 4742ab0f5..7cb40ff26 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -182,6 +182,13 @@ class MarkedAttrCase(t.NamedTuple): create_status="failed", decorate_statuses=["skipped", "skipped"], ), + MarkedAttrCase( + test_id="capture_false_success", + merged=CommandResult(cmd=("tmux",), returncode=0), + new_id=None, + create_status="complete", + decorate_statuses=["complete", "complete"], + ), MarkedAttrCase( test_id="decorate_fails", merged=CommandResult( From 85493c3160d88331bffc5d321e296e87dfe1f59a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:35:49 -0500 Subject: [PATCH 036/154] Ops(fix): Drop create stdout when attributing marked decorates why: On a successful create with a failing decorate, attribute_marked passed the whole merged result -- whose stdout[0] is the create's captured pane id -- to the chain attributor, so the failed decorate's result carried the pane id as its stdout instead of error output. what: - Attribute decorates over a copy of the merged result with stdout cleared, so a failed decorate is not credited with the new pane id - Add test_attribute_marked_failed_decorate_drops_create_stdout --- src/libtmux/experimental/ops/_chain.py | 5 ++++- tests/experimental/ops/test_chain.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 34a838765..bb45b8855 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -152,7 +152,10 @@ def attribute_marked( decorated = [op.result_with_status("skipped", version=version) for op in marked] return create_result, decorated, None create_result = create.build_result(returncode=0, stdout=(new_id,), version=version) - return create_result, attribute(marked, merged, version), new_id + # Attribute decorates without the create's captured id in stdout, so a failed + # decorate is not credited with the new pane id as its output. + decorated = attribute(marked, dataclasses.replace(merged, stdout=()), version) + return create_result, decorated, new_id @dataclass(frozen=True) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 7cb40ff26..5e2409bc8 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -220,6 +220,20 @@ def test_attribute_marked( assert [r.status for r in decorated] == decorate_statuses +def test_attribute_marked_failed_decorate_drops_create_stdout() -> None: + """A failed decorate is not credited with the create's captured pane id.""" + merged = CommandResult( + cmd=("tmux",), stdout=("%2",), returncode=1, stderr=("boom",) + ) + _created, decorated, _new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert decorated[0].status == "failed" + assert "%2" not in decorated[0].stdout + + def test_attribute_marked_blank_stdout_is_no_id() -> None: """A whitespace-only captured id is treated as no id (never bound as '').""" merged = CommandResult(cmd=("tmux",), stdout=(" ",), returncode=0) From 25c4fed4e06b840d37827f6622a5c5222ef69ccf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:39:44 -0500 Subject: [PATCH 037/154] Ops(fix): Target the concrete pane in marked decorate results why: After a {marked} fold, decorate results kept operation.target set to Special("{marked}"), the render-time register -- not the created pane. Serializing or replaying such a result would re-dispatch to whatever {marked} points at later, not the intended pane. what: - Attribute decorates over copies retargeted to PaneId(new_id), so each decorate result's operation addresses the concrete pane (render_marked still uses {marked} for the live dispatch) - Add test_attribute_marked_decorate_target_is_concrete_pane --- src/libtmux/experimental/ops/_chain.py | 11 +++++++---- tests/experimental/ops/test_chain.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index bb45b8855..2cb254109 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -20,7 +20,7 @@ import typing as t from dataclasses import dataclass -from libtmux.experimental.ops._types import Special +from libtmux.experimental.ops._types import PaneId, Special from libtmux.experimental.ops.exc import OperationError if t.TYPE_CHECKING: @@ -152,9 +152,12 @@ def attribute_marked( decorated = [op.result_with_status("skipped", version=version) for op in marked] return create_result, decorated, None create_result = create.build_result(returncode=0, stdout=(new_id,), version=version) - # Attribute decorates without the create's captured id in stdout, so a failed - # decorate is not credited with the new pane id as its output. - decorated = attribute(marked, dataclasses.replace(merged, stdout=()), version) + # Attribute over decorates retargeted to the concrete new pane (not + # ``{marked}``) so each result's operation serializes and replays to the real + # pane; drop the create's captured id from stdout so a failed decorate is not + # credited with it. + resolved = [dataclasses.replace(op, target=PaneId(new_id)) for op in decorates] + decorated = attribute(resolved, dataclasses.replace(merged, stdout=()), version) return create_result, decorated, new_id diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 5e2409bc8..d8babf1af 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -220,6 +220,17 @@ def test_attribute_marked( assert [r.status for r in decorated] == decorate_statuses +def test_attribute_marked_decorate_target_is_concrete_pane() -> None: + """Decorate results address the concrete new pane, not {marked} (for replay).""" + merged = CommandResult(cmd=("tmux",), stdout=("%2",), returncode=0) + _created, decorated, _new_id = attribute_marked( + _MARK_CREATE, + _MARK_DECORATES, + merged, + ) + assert all(r.operation.target == PaneId("%2") for r in decorated) + + def test_attribute_marked_failed_decorate_drops_create_stdout() -> None: """A failed decorate is not credited with the create's captured pane id.""" merged = CommandResult( From 0392f7233ee71e7030d709f215170ae09e32385f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:43:38 -0500 Subject: [PATCH 038/154] Ops(fix): Decode SubprocessEngine output as UTF-8 why: SubprocessEngine ran tmux with text=True but no explicit encoding, so on a non-UTF-8 locale it decoded with the platform default -- diverging from common.tmux_cmd (which pins encoding="utf-8" per #679) and breaking the docstring's byte-for-byte claim, e.g. the format separator (U+241E) could be mangled. what: - Pass encoding="utf-8" to the Popen call - Drop the unused logging import / module logger - Add test_subprocess_engine_decodes_utf8 (monkeypatched Popen kwargs) --- .../experimental/engines/subprocess.py | 4 +- tests/experimental/engines/test_subprocess.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 tests/experimental/engines/test_subprocess.py diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py index f76b59c41..4b76bd25f 100644 --- a/src/libtmux/experimental/engines/subprocess.py +++ b/src/libtmux/experimental/engines/subprocess.py @@ -11,7 +11,6 @@ from __future__ import annotations -import logging import shutil import subprocess import typing as t @@ -25,8 +24,6 @@ from libtmux.experimental.engines.base import CommandRequest -logger = logging.getLogger(__name__) - class SubprocessEngine: """Execute tmux commands by forking the tmux CLI binary. @@ -72,6 +69,7 @@ def run(self, request: CommandRequest) -> CommandResult: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", errors="backslashreplace", ) stdout, stderr = process.communicate() diff --git a/tests/experimental/engines/test_subprocess.py b/tests/experimental/engines/test_subprocess.py new file mode 100644 index 000000000..111bb2444 --- /dev/null +++ b/tests/experimental/engines/test_subprocess.py @@ -0,0 +1,39 @@ +"""Tests for the classic SubprocessEngine.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import SubprocessEngine +from libtmux.experimental.engines.base import CommandRequest + + +class _FakeProcess: + """Minimal stand-in for a Popen process.""" + + returncode = 0 + + def communicate(self) -> tuple[str, str]: + """Return empty stdout/stderr.""" + return ("", "") + + +def test_subprocess_engine_decodes_utf8(monkeypatch: pytest.MonkeyPatch) -> None: + """The engine decodes tmux output as UTF-8 (matching common.tmux_cmd).""" + captured: dict[str, t.Any] = {} + + def fake_popen(_cmd: t.Any, **kwargs: t.Any) -> _FakeProcess: + captured.update(kwargs) + return _FakeProcess() + + monkeypatch.setattr( + "libtmux.experimental.engines.subprocess.subprocess.Popen", + fake_popen, + ) + + engine = SubprocessEngine(tmux_bin="tmux") + engine.run(CommandRequest.from_args("display-message", "-p", "x")) + + assert captured["encoding"] == "utf-8" From 666335721c623108406942f9b59c46b3397062d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:47:29 -0500 Subject: [PATCH 039/154] Models(refactor): Use namespaced dataclasses.replace in snapshots why: snapshots.py imported `replace` directly from dataclasses, but the project's import convention only exempts `dataclass` and `field` from namespace imports; the rest of the experimental tree already calls dataclasses.replace via the namespace. what: - Import dataclasses and call dataclasses.replace at the 4 call sites; drop `replace` from the from-import (no behavior change) --- src/libtmux/experimental/models/snapshots.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py index 7e2f1b5e3..4af4652d0 100644 --- a/src/libtmux/experimental/models/snapshots.py +++ b/src/libtmux/experimental/models/snapshots.py @@ -16,8 +16,9 @@ from __future__ import annotations +import dataclasses import typing as t -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field if t.TYPE_CHECKING: from collections.abc import Iterable, Mapping @@ -205,7 +206,7 @@ def to_dict(self) -> dict[str, t.Any]: @classmethod def from_dict(cls, data: Mapping[str, t.Any]) -> WindowSnapshot: """Reconstruct from :meth:`to_dict` output.""" - return replace( + return dataclasses.replace( cls.from_format(data["fields"]), panes=tuple(PaneSnapshot.from_dict(p) for p in data.get("panes", [])), ) @@ -241,7 +242,7 @@ def to_dict(self) -> dict[str, t.Any]: @classmethod def from_dict(cls, data: Mapping[str, t.Any]) -> SessionSnapshot: """Reconstruct from :meth:`to_dict` output.""" - return replace( + return dataclasses.replace( cls.from_format(data["fields"]), windows=tuple(WindowSnapshot.from_dict(w) for w in data.get("windows", [])), ) @@ -309,10 +310,10 @@ def from_pane_rows( window_panes[window_id].append(PaneSnapshot.from_format(row)) sessions = tuple( - replace( + dataclasses.replace( SessionSnapshot.from_format(session_fields[session_id]), windows=tuple( - replace( + dataclasses.replace( WindowSnapshot.from_format(window_fields[window_id]), panes=tuple(window_panes[window_id]), ) From 81310e2fe47d0e1e4befde64a14007a0dfc0a73a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:50:55 -0500 Subject: [PATCH 040/154] Ops(docs): Fix PipePane -o flag description why: The toggle docstring said -o stops the pipe "if already piping the same command", but tmux's -o makes no command comparison -- it only opens the pipe when no pipe is already open on the pane. what: - Reword the toggle parameter to describe -o accurately --- src/libtmux/experimental/ops/_ops/pipe_pane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/pipe_pane.py b/src/libtmux/experimental/ops/_ops/pipe_pane.py index 0e9b6ddec..1ed4c5223 100644 --- a/src/libtmux/experimental/ops/_ops/pipe_pane.py +++ b/src/libtmux/experimental/ops/_ops/pipe_pane.py @@ -24,7 +24,7 @@ class PipePane(Operation[AckResult]): stdout : bool Connect the pane's output to the command (``-O``). toggle : bool - Only toggle: stop if already piping the same command (``-o``). + Only open the pipe if no pipe is already open on the pane (``-o``). Examples -------- From 0cf925e7ef7337f22bec34a3b480e46ce2849aea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 16:55:03 -0500 Subject: [PATCH 041/154] Ops(fix): Mark save-buffer mutating (it writes a file) why: save-buffer was tiered "readonly", but it writes paste-buffer contents to a filesystem path -- a side effect. Under the MCP safety vocabulary readonly means no side effects, so an auto-approve gate keyed on safety=="readonly" would wave through arbitrary file writes. effects.read_only stays true: it changes no tmux state. what: - Set SaveBuffer safety="mutating" (effects.read_only kept) - Relax the registry invariant to "readonly => read_only" (an op may be read_only w.r.t. tmux yet still mutating via an external effect) - Drop save_buffer from the readonly registry doctest and test list --- src/libtmux/experimental/ops/_ops/save_buffer.py | 4 ++-- src/libtmux/experimental/ops/registry.py | 4 ++-- tests/experimental/ops/test_registry.py | 13 +++++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/save_buffer.py b/src/libtmux/experimental/ops/_ops/save_buffer.py index 215a078a4..19176b8c0 100644 --- a/src/libtmux/experimental/ops/_ops/save_buffer.py +++ b/src/libtmux/experimental/ops/_ops/save_buffer.py @@ -36,8 +36,8 @@ class SaveBuffer(Operation[AckResult]): command = "save-buffer" scope = "server" result_cls = AckResult - safety = "readonly" - effects = Effects(read_only=True, idempotent=True) + safety = "mutating" + effects = Effects(read_only=True) path: str buffer_name: str | None = None diff --git a/src/libtmux/experimental/ops/registry.py b/src/libtmux/experimental/ops/registry.py index c986e5cdc..e0538ac6a 100644 --- a/src/libtmux/experimental/ops/registry.py +++ b/src/libtmux/experimental/ops/registry.py @@ -168,8 +168,8 @@ def select( >>> from libtmux.experimental.ops import registry >>> [s.kind for s in registry.select(lambda s: s.safety == "readonly")] ['capture_pane', 'display_message', 'has_session', 'list_clients', - 'list_panes', 'list_sessions', 'list_windows', 'save_buffer', - 'show_buffer', 'show_options'] + 'list_panes', 'list_sessions', 'list_windows', 'show_buffer', + 'show_options'] """ specs = sorted(self._specs.values(), key=lambda spec: spec.kind) if predicate is None: diff --git a/tests/experimental/ops/test_registry.py b/tests/experimental/ops/test_registry.py index f7fac59cc..793e2aa82 100644 --- a/tests/experimental/ops/test_registry.py +++ b/tests/experimental/ops/test_registry.py @@ -53,7 +53,6 @@ def test_list_predicate_filters() -> None: "list_panes", "list_sessions", "list_windows", - "save_buffer", "show_buffer", "show_options", ] @@ -64,9 +63,15 @@ def test_list_predicate_filters() -> None: list(registry.select()), ids=[spec.kind for spec in registry.select()], ) -def test_readonly_safety_matches_read_only_effect(spec: OpSpec) -> None: - """Every op's ``safety == "readonly"`` agrees with ``effects.read_only``.""" - assert spec.effects.read_only == (spec.safety == "readonly") +def test_readonly_safety_implies_read_only_effect(spec: OpSpec) -> None: + """A ``safety == "readonly"`` op must declare ``effects.read_only``. + + The converse need not hold: an op can leave tmux state unchanged + (``read_only``) yet still be ``mutating`` because of an external side effect + -- e.g. ``save-buffer`` writes a file. + """ + if spec.safety == "readonly": + assert spec.effects.read_only def test_register_duplicate_fails_closed() -> None: From 0f139d7d31e5dbb5ebea63a65d93c754c764ad94 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:22:49 -0500 Subject: [PATCH 042/154] Ops(fix): Correlate control-mode blocks per command and by flags why: A folded ; chain is sent as one control-mode line that tmux runs as N commands emitting N %begin/%end blocks, but the engines collected one block per request -- so a FoldingPlanner/MarkedPlanner chain reported only the first sub-command and silently swallowed later failures, and a hook-triggered (unsolicited) block arriving mid-batch was mis-attributed to a real command. what: - Add command_count() (bare ; separators + 1) and _merge_blocks() (concat stdout; fail if any sub-command block errored) - Sync run_batch reads sum(command_count) blocks and groups/merges per request; _read_blocks keeps only solicited blocks (flags == 1) - Async _PendingCommand accumulates its command_count blocks before resolving; _dispatch_block skips flags != 1 blocks - Add test_control_mode_correlation.py (command_count/_merge_blocks units + live fold failure-detection and run-all over ControlModeEngine) --- .../engines/async_control_mode.py | 33 +++-- .../experimental/engines/control_mode.py | 62 ++++++--- .../engines/test_control_mode_correlation.py | 131 ++++++++++++++++++ 3 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 tests/experimental/engines/test_control_mode_correlation.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 25bc00eef..9514a3cf8 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -29,14 +29,15 @@ import contextlib import shutil import typing as t -from dataclasses import dataclass +from dataclasses import dataclass, field from libtmux import exc from libtmux.experimental.engines.base import render_control_line from libtmux.experimental.engines.control_mode import ( ControlModeError, ControlModeParser, - _result_from_block, + _merge_blocks, + command_count, ) if t.TYPE_CHECKING: @@ -44,6 +45,7 @@ from collections.abc import AsyncIterator, Sequence from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.engines.control_mode import ControlModeBlock _READ_CHUNK = 65536 _DEFAULT_TIMEOUT = 30.0 @@ -81,6 +83,8 @@ def parse(cls, line: bytes) -> ControlNotification: class _PendingCommand: future: asyncio.Future[CommandResult] argv: tuple[str, ...] + expected: int + blocks: list[ControlModeBlock] = field(default_factory=list) class AsyncControlModeEngine: @@ -209,7 +213,9 @@ async def run_batch( raise ControlModeError(msg) for argv in rendered: future: asyncio.Future[CommandResult] = loop.create_future() - self._pending.append(_PendingCommand(future, argv)) + self._pending.append( + _PendingCommand(future, argv, command_count(argv)), + ) futures.append(future) payload = b"".join( (render_control_line(argv) + "\n").encode() for argv in rendered @@ -305,13 +311,24 @@ async def _reader(self) -> None: except Exception as error: self._mark_dead(ControlModeError(f"control-mode reader failed: {error}")) - def _dispatch_block(self, block: t.Any) -> None: - """Resolve the next pending command, or skip an unsolicited block.""" + def _dispatch_block(self, block: ControlModeBlock) -> None: + """Accumulate a solicited block; resolve the command once it has them all. + + A ``;``-folded command emits one block per sub-command; unsolicited blocks + (hook-triggered commands, the startup ACK) carry flags 0 and are skipped, + so FIFO correlation never desyncs. + """ + if block.flags != 1: + return # unsolicited (hook-triggered command or startup ACK): skip if not self._pending: - return # startup ACK or hook-triggered command: not ours, skip - pending = self._pending.popleft() + return + pending = self._pending[0] + pending.blocks.append(block) + if len(pending.blocks) < pending.expected: + return + self._pending.popleft() if not pending.future.done(): - pending.future.set_result(_result_from_block(block, pending.argv)) + pending.future.set_result(_merge_blocks(pending.blocks, pending.argv)) def _publish(self, line: bytes) -> None: """Enqueue a notification, dropping the oldest on overflow.""" diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 3a92d177f..591812f06 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -193,10 +193,15 @@ def run(self, request: CommandRequest) -> CommandResult: return self.run_batch([request])[0] def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: - """Pipeline a batch of commands; one result block per request.""" + """Pipeline a batch of commands; one result per request. + + A ``;``-folded request runs as several tmux commands, so its blocks are + grouped (by ``;``-count) and merged into one result. + """ if not requests: return [] rendered = [tuple(req.args) for req in requests] + counts = [command_count(argv) for argv in rendered] with self._lock: self._ensure_started() # Discard any unsolicited blocks (hook-triggered commands) left @@ -207,11 +212,13 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: (render_control_line(argv) + "\n").encode() for argv in rendered ) self._write(payload) - blocks = self._read_blocks(len(rendered)) - return [ - _result_from_block(block, argv) - for block, argv in zip(blocks, rendered, strict=True) - ] + blocks = self._read_blocks(sum(counts)) + results: list[CommandResult] = [] + index = 0 + for argv, count in zip(rendered, counts, strict=True): + results.append(_merge_blocks(blocks[index : index + count], argv)) + index += count + return results def close(self) -> None: """Tear down the control-mode subprocess (lock-guarded).""" @@ -366,9 +373,10 @@ def _read_blocks(self, count: int) -> list[ControlModeBlock]: elif key.data == "stderr": self._read_stderr() for block in self._parser.blocks(): - blocks.append(block) - if len(blocks) == count: - break + # Skip unsolicited blocks (hook-triggered commands carry flags 0); + # only solicited command blocks (flags 1) belong to this batch. + if block.flags == 1 and len(blocks) < count: + blocks.append(block) self._parser.notifications() # sync engine ignores notifications return blocks @@ -453,16 +461,38 @@ def _matches_pending_close(line: bytes, pending_number: int) -> bool: return False -def _result_from_block( - block: ControlModeBlock, +def command_count(argv: tuple[str, ...]) -> int: + """How many tmux commands a rendered argv runs (bare ``;`` separators + 1).""" + return sum(1 for token in argv if token == ";") + 1 + + +def _merge_blocks( + blocks: Sequence[ControlModeBlock], argv: tuple[str, ...], ) -> CommandResult: - """Convert a parsed control-mode block into a :class:`CommandResult`.""" - lines = tuple(line.decode(errors="replace") for line in block.body) + """Merge one request's blocks (one per ``;``-folded sub-command) into a result. + + A ``;``-folded line runs as several tmux commands, each emitting its own + block; stdout/stderr are concatenated and the result fails if any sub-command + errored, matching the subprocess engine's view of one ``;`` chain process. + """ cmd = ("tmux", "-C", *argv) - if block.is_error: - return CommandResult(cmd=cmd, stdout=(), stderr=_trim(lines), returncode=1) - return CommandResult(cmd=cmd, stdout=_trim(lines), stderr=(), returncode=0) + stdout: list[str] = [] + stderr: list[str] = [] + returncode = 0 + for block in blocks: + lines = tuple(line.decode(errors="replace") for line in block.body) + if block.is_error: + stderr.extend(lines) + returncode = returncode or 1 + else: + stdout.extend(lines) + return CommandResult( + cmd=cmd, + stdout=_trim(tuple(stdout)), + stderr=_trim(tuple(stderr)), + returncode=returncode, + ) def _trim(lines: tuple[str, ...]) -> tuple[str, ...]: diff --git a/tests/experimental/engines/test_control_mode_correlation.py b/tests/experimental/engines/test_control_mode_correlation.py new file mode 100644 index 000000000..e6c68fc76 --- /dev/null +++ b/tests/experimental/engines/test_control_mode_correlation.py @@ -0,0 +1,131 @@ +"""Tests for control-mode block correlation (folded chains, merge).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.control_mode import ( + ControlModeBlock, + _merge_blocks, + command_count, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class CountCase(t.NamedTuple): + """An argv and the number of tmux commands it runs.""" + + test_id: str + argv: tuple[str, ...] + expected: int + + +COUNT_CASES = ( + CountCase("single", ("rename-window", "-t", "@1", "a"), 1), + CountCase("two", ("rename-window", "a", ";", "kill-window", "@2"), 2), + CountCase("three", ("a", ";", "b", ";", "c"), 3), + CountCase("literal_semicolon_arg", ("send-keys", "-t", "%1", "a;b"), 1), +) + + +@pytest.mark.parametrize( + list(CountCase._fields), + COUNT_CASES, + ids=[c.test_id for c in COUNT_CASES], +) +def test_command_count(test_id: str, argv: tuple[str, ...], expected: int) -> None: + """Only a standalone ``;`` token counts as a command separator.""" + assert command_count(argv) == expected + + +def _block(*, is_error: bool, body: tuple[bytes, ...]) -> ControlModeBlock: + return ControlModeBlock(number=1, flags=1, is_error=is_error, body=body) + + +class MergeCase(t.NamedTuple): + """Blocks from one (possibly folded) request and the merged result.""" + + test_id: str + blocks: list[ControlModeBlock] + returncode: int + stdout: tuple[str, ...] + stderr: tuple[str, ...] + + +MERGE_CASES = ( + MergeCase("single_ok", [_block(is_error=False, body=(b"%1",))], 0, ("%1",), ()), + MergeCase("single_err", [_block(is_error=True, body=(b"boom",))], 1, (), ("boom",)), + MergeCase( + "chain_all_ok", + [_block(is_error=False, body=(b"a",)), _block(is_error=False, body=(b"b",))], + 0, + ("a", "b"), + (), + ), + MergeCase( + "chain_second_fails", + [_block(is_error=False, body=(b"a",)), _block(is_error=True, body=(b"boom",))], + 1, + ("a",), + ("boom",), + ), +) + + +@pytest.mark.parametrize( + list(MergeCase._fields), + MERGE_CASES, + ids=[c.test_id for c in MERGE_CASES], +) +def test_merge_blocks( + test_id: str, + blocks: list[ControlModeBlock], + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], +) -> None: + """A folded request's blocks merge; any sub-command error fails the result.""" + result = _merge_blocks(blocks, ("cmd",)) + assert result.returncode == returncode + assert result.stdout == stdout + assert result.stderr == stderr + + +def test_control_mode_fold_detects_failure_live(session: Session) -> None: + """A folded chain over control mode surfaces a later sub-command's failure.""" + from libtmux.experimental.engines.control_mode import ControlModeEngine + from libtmux.experimental.ops import FoldingPlanner, LazyPlan, RenameWindow + from libtmux.experimental.ops._types import WindowId + + window = session.active_window + assert window.window_id is not None + with ControlModeEngine.for_server(session.server) as engine: + plan = LazyPlan() + plan.add(RenameWindow(target=WindowId(window.window_id), name="ok")) + plan.add(RenameWindow(target=WindowId("@999999"), name="x")) # bad target + outcome = plan.execute(engine, planner=FoldingPlanner()) + # The second sub-command's failure is no longer swallowed (was reported ok). + assert not outcome.ok + + +def test_control_mode_fold_runs_all_live(session: Session) -> None: + """A folded chain over control mode runs every sub-command.""" + from libtmux.experimental.engines.control_mode import ControlModeEngine + from libtmux.experimental.ops import FoldingPlanner, LazyPlan, RenameWindow + from libtmux.experimental.ops._types import WindowId + + second = session.new_window(window_name="orig") + first = session.active_window + assert first.window_id is not None and second.window_id is not None + with ControlModeEngine.for_server(session.server) as engine: + plan = LazyPlan() + plan.add(RenameWindow(target=WindowId(first.window_id), name="one")) + plan.add(RenameWindow(target=WindowId(second.window_id), name="two")) + outcome = plan.execute(engine, planner=FoldingPlanner()) + assert outcome.ok + second.refresh() + assert second.window_name == "two" From abd8814af8342c322dfbd19e539f271582f27671 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:27:49 -0500 Subject: [PATCH 043/154] Ops(fix): Clear pending futures on async control-mode write failure why: AsyncControlModeEngine.run_batch appended one future per request to the FIFO before writing; if the stdin write/drain failed it raised but left those futures queued, so the next batch's result blocks resolved against the orphans -- permanently desyncing correlation. what: - On a write/drain failure, remove the just-queued futures from _pending and fail them with the ControlModeError before raising - Add test_async_control_write_failure_clears_pending (fake proc whose stdin.write raises; asserts _pending is emptied) --- .../engines/async_control_mode.py | 18 ++++++++---- .../engines/test_control_mode_correlation.py | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 9514a3cf8..c9939c52d 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -211,11 +211,12 @@ async def run_batch( if proc is None or proc.stdin is None: msg = "control-mode subprocess is not connected" raise ControlModeError(msg) + appended: list[_PendingCommand] = [] for argv in rendered: future: asyncio.Future[CommandResult] = loop.create_future() - self._pending.append( - _PendingCommand(future, argv, command_count(argv)), - ) + pending = _PendingCommand(future, argv, command_count(argv)) + self._pending.append(pending) + appended.append(pending) futures.append(future) payload = b"".join( (render_control_line(argv) + "\n").encode() for argv in rendered @@ -224,8 +225,15 @@ async def run_batch( proc.stdin.write(payload) await proc.stdin.drain() except (BrokenPipeError, OSError) as error: - msg = f"tmux control-mode write failed: {error}" - raise ControlModeError(msg) from error + # Remove the futures we just queued so a write failure cannot + # leave orphans that desync FIFO correlation for the next batch. + cm_error = ControlModeError(f"tmux control-mode write failed: {error}") + for queued in appended: + with contextlib.suppress(ValueError): + self._pending.remove(queued) + if not queued.future.done(): + queued.future.set_exception(cm_error) + raise cm_error from error try: return await asyncio.wait_for( diff --git a/tests/experimental/engines/test_control_mode_correlation.py b/tests/experimental/engines/test_control_mode_correlation.py index e6c68fc76..6cd20d77c 100644 --- a/tests/experimental/engines/test_control_mode_correlation.py +++ b/tests/experimental/engines/test_control_mode_correlation.py @@ -95,6 +95,34 @@ def test_merge_blocks( assert result.stderr == stderr +def test_async_control_write_failure_clears_pending() -> None: + """A write failure removes the queued futures so the FIFO stays aligned.""" + import asyncio + + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + from libtmux.experimental.engines.base import CommandRequest + from libtmux.experimental.engines.control_mode import ControlModeError + + class _FakeStdin: + def write(self, _data: bytes) -> None: + raise BrokenPipeError + + async def drain(self) -> None: ... + + class _FakeProc: + stdin = _FakeStdin() + + async def _check() -> None: + engine = AsyncControlModeEngine() + engine._started = True + engine._proc = t.cast("t.Any", _FakeProc()) + with pytest.raises(ControlModeError): + await engine.run_batch([CommandRequest.from_args("list-sessions")]) + assert not engine._pending + + asyncio.run(_check()) + + def test_control_mode_fold_detects_failure_live(session: Session) -> None: """A folded chain over control mode surfaces a later sub-command's failure.""" from libtmux.experimental.engines.control_mode import ControlModeEngine From 7d06535951e38f60b3d507e7c0904963ec985d1b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:33:38 -0500 Subject: [PATCH 044/154] Ops(fix): Suppress ProcessLookupError on async cancel terminate why: AsyncSubprocessEngine.run() called process.terminate() unguarded in its CancelledError handler; if the child already exited, terminate raised ProcessLookupError, masking the cancellation (every sibling engine suppresses it). what: - Wrap process.terminate() in contextlib.suppress(ProcessLookupError) so CancelledError propagates - Add test_async_run_cancellation_suppresses_terminate_lookup (fake process whose terminate() raises ProcessLookupError) --- src/libtmux/experimental/engines/asyncio.py | 6 +++- .../contract/test_async_engine.py | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py index 3880efd36..bd57987e5 100644 --- a/src/libtmux/experimental/engines/asyncio.py +++ b/src/libtmux/experimental/engines/asyncio.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import contextlib import shutil import typing as t @@ -82,7 +83,10 @@ async def run(self, request: CommandRequest) -> CommandResult: try: stdout_bytes, stderr_bytes = await process.communicate() except asyncio.CancelledError: - process.terminate() + # The child may have already exited (terminate races the reap); + # suppress so the cancellation propagates, not ProcessLookupError. + with contextlib.suppress(ProcessLookupError): + process.terminate() await process.wait() raise diff --git a/tests/experimental/contract/test_async_engine.py b/tests/experimental/contract/test_async_engine.py index 1c37eb07e..95581b4ff 100644 --- a/tests/experimental/contract/test_async_engine.py +++ b/tests/experimental/contract/test_async_engine.py @@ -9,6 +9,8 @@ import asyncio import typing as t +import pytest + from libtmux.experimental.engines import AsyncSubprocessEngine, SubprocessEngine from libtmux.experimental.ops import SplitWindow, arun, run from libtmux.experimental.ops._types import WindowId @@ -18,6 +20,37 @@ from libtmux.session import Session +def test_async_run_cancellation_suppresses_terminate_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cancellation propagates even when terminate() races a process exit.""" + from libtmux.experimental.engines.base import CommandRequest + + class _FakeProc: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + raise asyncio.CancelledError + + def terminate(self) -> None: + raise ProcessLookupError + + async def wait(self) -> int: + return 0 + + async def _fake_exec(*_args: object, **_kwargs: object) -> _FakeProc: + return _FakeProc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + engine = AsyncSubprocessEngine(tmux_bin="tmux") + + async def _check() -> None: + with pytest.raises(asyncio.CancelledError): + await engine.run(CommandRequest.from_args("display-message", "-p", "x")) + + asyncio.run(_check()) + + def test_async_split_creates_real_pane(session: Session) -> None: """An async split returns a typed result whose new pane really exists.""" server = session.server From 05afaa25713afc25414269aab62c83eaead64b23 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:38:14 -0500 Subject: [PATCH 045/154] Engines(fix): Remove the unreachable asyncio engine kind why: EngineKind.ASYNCIO and EngineSpec.asyncio() were first-class public API but never registered, so create_engine("asyncio") failed closed. The async engines are constructed directly (ctor / for_server) and bypass the sync registry; the kind was a false promise. what: - Drop EngineKind.ASYNCIO and EngineSpec.asyncio() (referenced nowhere) - Add test_registry.py covering available_engines, the removed kind, create_engine for registered kinds, and unknown-name failure --- src/libtmux/experimental/engines/base.py | 6 -- tests/experimental/engines/test_registry.py | 63 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 tests/experimental/engines/test_registry.py diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 9c9ca86c9..8516eb028 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -103,7 +103,6 @@ class EngineKind(str, enum.Enum): SUBPROCESS = "subprocess" CONCRETE = "concrete" CONTROL_MODE = "control_mode" - ASYNCIO = "asyncio" IMSG = "imsg" @@ -150,11 +149,6 @@ def control_mode(cls) -> EngineSpec: """Build a control-mode engine spec.""" return cls(kind=EngineKind.CONTROL_MODE) - @classmethod - def asyncio(cls) -> EngineSpec: - """Build an asyncio engine spec.""" - return cls(kind=EngineKind.ASYNCIO) - @classmethod def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec: """Build an imsg (native binary) engine spec.""" diff --git a/tests/experimental/engines/test_registry.py b/tests/experimental/engines/test_registry.py new file mode 100644 index 000000000..7e315cc60 --- /dev/null +++ b/tests/experimental/engines/test_registry.py @@ -0,0 +1,63 @@ +"""Tests for the engine registry and EngineKind/EngineSpec.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux import exc +from libtmux.experimental.engines import ( + EngineKind, + EngineSpec, + available_engines, + create_engine, +) + + +def test_available_engines_are_registered() -> None: + """The registry exposes exactly the constructable (sync) engine kinds.""" + assert set(available_engines()) == { + "subprocess", + "concrete", + "control_mode", + "imsg", + } + + +def test_asyncio_kind_removed() -> None: + """The unwired ``asyncio`` kind/spec is gone; async engines are direct-ctor.""" + assert "asyncio" not in {kind.value for kind in EngineKind} + assert not hasattr(EngineSpec, "asyncio") + + +class CreateCase(t.NamedTuple): + """A registered engine name that ``create_engine`` should build.""" + + test_id: str + name: str + + +CREATE_CASES = ( + CreateCase("subprocess", "subprocess"), + CreateCase("concrete", "concrete"), + CreateCase("control_mode", "control_mode"), +) + + +@pytest.mark.parametrize( + list(CreateCase._fields), + CREATE_CASES, + ids=[c.test_id for c in CREATE_CASES], +) +def test_create_engine_builds_registered(test_id: str, name: str) -> None: + """create_engine returns an engine with the run/run_batch protocol.""" + engine = create_engine(name) + assert hasattr(engine, "run") + assert hasattr(engine, "run_batch") + + +def test_create_engine_unknown_fails() -> None: + """An unregistered name (incl. the removed 'asyncio') fails closed.""" + with pytest.raises(exc.LibTmuxException, match="unknown tmux engine"): + create_engine("asyncio") From a7566c8086c166dc7bdcdab3a1378e33dcda2665 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:44:16 -0500 Subject: [PATCH 046/154] Ops(fix): Normalize tmux master version in operation gates why: Operation.check_version/flag_available compared with bare LooseVersion, so a "master"/suffixed tmux version (e.g. "3.7-master" from get_version) sorted below a higher gate -- wrongly rejecting an op or dropping a supported flag. neo._normalize_tmux_version already maps master to a high sentinel. what: - Use neo._normalize_tmux_version in check_version and flag_available (drop the bare LooseVersion import) - Add a flag gate to the _FutureOp test helper + parametrized test_version_gates_normalize_master (master/suffixed/exact/old/none) --- src/libtmux/experimental/ops/operation.py | 6 ++-- tests/experimental/ops/test_operation.py | 39 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/ops/operation.py b/src/libtmux/experimental/ops/operation.py index 84c2bdd30..ae947cf52 100644 --- a/src/libtmux/experimental/ops/operation.py +++ b/src/libtmux/experimental/ops/operation.py @@ -19,10 +19,10 @@ import typing as t from dataclasses import dataclass -from libtmux._compat import LooseVersion from libtmux.experimental.ops._types import render_target from libtmux.experimental.ops.exc import VersionUnsupported from libtmux.experimental.ops.results import Result, status_for +from libtmux.neo import _normalize_tmux_version if t.TYPE_CHECKING: from collections.abc import Mapping, Sequence @@ -162,7 +162,7 @@ def check_version(self, version: str | None) -> None: """ if version is None or self.min_version is None: return - if LooseVersion(version) < LooseVersion(self.min_version): + if _normalize_tmux_version(version) < _normalize_tmux_version(self.min_version): raise VersionUnsupported( self.kind, need=self.min_version, @@ -197,7 +197,7 @@ def flag_available(self, label: str, version: str | None) -> bool: need = self.flag_version_map.get(label) if need is None or version is None: return True - return LooseVersion(version) >= LooseVersion(need) + return _normalize_tmux_version(version) >= _normalize_tmux_version(need) def src_args(self) -> tuple[str, ...]: """Render the ``-s`` source target, or ``()`` when there is none. diff --git a/tests/experimental/ops/test_operation.py b/tests/experimental/ops/test_operation.py index 80320c732..39b14529e 100644 --- a/tests/experimental/ops/test_operation.py +++ b/tests/experimental/ops/test_operation.py @@ -29,6 +29,7 @@ class _FutureOp(Operation[Result]): result_cls = Result effects = Effects() min_version = "99.0" + flag_version_map: t.ClassVar[dict[str, str]] = {"feat": "99.0"} def test_render_includes_target_then_args() -> None: @@ -65,6 +66,44 @@ def test_check_version_passes_when_satisfied() -> None: assert op.render(version="99.0") == ("future-cmd",) +class VersionCase(t.NamedTuple): + """A tmux version string and whether the 99.0-gated op accepts it.""" + + test_id: str + version: str | None + satisfied: bool + + +VERSION_CASES = ( + VersionCase("master_suffix", "3.7-master", True), + VersionCase("bare_master", "master", True), + VersionCase("none", None, True), + VersionCase("exact", "99.0", True), + VersionCase("too_old", "3.4", False), +) + + +@pytest.mark.parametrize( + list(VersionCase._fields), + VERSION_CASES, + ids=[c.test_id for c in VERSION_CASES], +) +def test_version_gates_normalize_master( + test_id: str, + version: str | None, + satisfied: bool, +) -> None: + """A "master"/suffixed version sorts above tagged releases for both gates.""" + op = _FutureOp() + if satisfied: + op.check_version(version) # no raise + assert op.flag_available("feat", version) is True + else: + with pytest.raises(VersionUnsupported): + op.check_version(version) + assert op.flag_available("feat", version) is False + + def test_build_result_parses_payload() -> None: """``split-window`` parses the captured new-pane id into its result.""" op = SplitWindow(target=WindowId("@1")) From 0bf9d6f1a401f63564eb7f284d1bc4e40fdc7892 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:49:41 -0500 Subject: [PATCH 047/154] Ops(fix): Reject SendKeys literal+enter combination why: SendKeys(literal=True, enter=True) rendered 'send-keys -l Enter', but tmux's -l sends every arg literally, so "Enter" was typed as five characters and the line was never submitted. what: - __post_init__ raises ValueError on literal+enter (fail closed); the correct pattern is two operations - Document the constraint on the enter parameter - Add parametrized test_send_keys_literal_enter_guard --- .../experimental/ops/_ops/send_keys.py | 13 ++++++- tests/experimental/ops/test_ack_ops.py | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py index e7c009796..3f4cd4c54 100644 --- a/src/libtmux/experimental/ops/_ops/send_keys.py +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -20,7 +20,9 @@ class SendKeys(Operation[AckResult]): keys : str The key string to send. enter : bool - Append a literal ``Enter`` key after the input. + Append an ``Enter`` key after the input. Cannot be combined with + *literal* -- under ``-l`` tmux would type the text "Enter" rather than + pressing Return; send the keys and Enter as two operations instead. literal : bool Send keys literally without tmux key-name lookup (``-l``). @@ -44,6 +46,15 @@ class SendKeys(Operation[AckResult]): enter: bool = False literal: bool = False + def __post_init__(self) -> None: + """Reject literal+enter (fail closed): tmux ``-l`` types "Enter".""" + if self.literal and self.enter: + msg = ( + "send-keys cannot combine literal=True with enter=True; under -l " + "tmux types the text 'Enter' -- send the keys and Enter separately" + ) + raise ValueError(msg) + def args(self, *, version: str | None = None) -> tuple[str, ...]: """Render ``send-keys`` flags and the key string.""" out: list[str] = [] diff --git a/tests/experimental/ops/test_ack_ops.py b/tests/experimental/ops/test_ack_ops.py index 2ccc63ee8..3e03d8b54 100644 --- a/tests/experimental/ops/test_ack_ops.py +++ b/tests/experimental/ops/test_ack_ops.py @@ -91,3 +91,40 @@ def test_destructive_safety_metadata() -> None: assert safety["kill_window"] == "destructive" assert safety["kill_pane"] == "destructive" assert safety["rename_window"] == "mutating" + + +class SendKeysGuardCase(t.NamedTuple): + """A literal/enter combination and whether SendKeys rejects it.""" + + test_id: str + literal: bool + enter: bool + raises: bool + + +SEND_KEYS_GUARD_CASES = ( + SendKeysGuardCase("plain", literal=False, enter=False, raises=False), + SendKeysGuardCase("enter_only", literal=False, enter=True, raises=False), + SendKeysGuardCase("literal_only", literal=True, enter=False, raises=False), + SendKeysGuardCase("literal_and_enter", literal=True, enter=True, raises=True), +) + + +@pytest.mark.parametrize( + list(SendKeysGuardCase._fields), + SEND_KEYS_GUARD_CASES, + ids=[c.test_id for c in SEND_KEYS_GUARD_CASES], +) +def test_send_keys_literal_enter_guard( + test_id: str, + literal: bool, + enter: bool, + raises: bool, +) -> None: + """literal=True with enter=True is rejected (tmux -l would type 'Enter').""" + if raises: + with pytest.raises(ValueError, match="literal"): + SendKeys(target=PaneId("%1"), keys="x", literal=literal, enter=enter) + else: + op = SendKeys(target=PaneId("%1"), keys="x", literal=literal, enter=enter) + assert op.render()[0] == "send-keys" From 9752624011a39ec679f71453b60a7d91bdc5061f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:53:24 -0500 Subject: [PATCH 048/154] Ops(fix): Keep all lines of a display-message result why: DisplayMessageResult.text took only stdout[0], silently dropping all but the first line of a multi-line display-message format. what: - Join all stdout lines into .text (matching ShowBuffer); single-line output is unchanged - Add a multi-line parse case to test_read_breadth --- src/libtmux/experimental/ops/_ops/display_message.py | 4 ++-- tests/experimental/ops/test_read_breadth.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/ops/_ops/display_message.py b/src/libtmux/experimental/ops/_ops/display_message.py index 68821960a..38ec8052c 100644 --- a/src/libtmux/experimental/ops/_ops/display_message.py +++ b/src/libtmux/experimental/ops/_ops/display_message.py @@ -53,7 +53,7 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> DisplayMessageResult: - """Expose the printed line as :attr:`~.DisplayMessageResult.text`.""" + """Expose the printed output as :attr:`~.DisplayMessageResult.text`.""" return DisplayMessageResult( operation=self, argv=argv, @@ -61,5 +61,5 @@ def _make_result( returncode=returncode, stdout=stdout, stderr=stderr, - text=stdout[0] if stdout else "", + text="\n".join(stdout), ) diff --git a/tests/experimental/ops/test_read_breadth.py b/tests/experimental/ops/test_read_breadth.py index 276ff9052..486971b81 100644 --- a/tests/experimental/ops/test_read_breadth.py +++ b/tests/experimental/ops/test_read_breadth.py @@ -112,6 +112,13 @@ class ParseCase(t.NamedTuple): stdout=("%1",), expected={"text": "%1"}, ), + ParseCase( + test_id="display_message_multiline", + op=DisplayMessage(message="#{pane_id}"), + returncode=0, + stdout=("line1", "line2"), + expected={"text": "line1\nline2"}, + ), ParseCase( test_id="display_message_empty", op=DisplayMessage(message="#{pane_id}"), From b8c19ef6fb68b83079e4c8f5c826edfbee3215f0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 18:58:11 -0500 Subject: [PATCH 049/154] Ops(fix): Centralize the has-session stderr->stdout fold in the op why: subprocess/asyncio/imsg each folded has-session's stderr into stdout but control mode did not, so HasSession's result diverged by engine. The fold is a has-session concern, not an engine concern. what: - HasSession._make_result surfaces stderr[0] in stdout when stdout is empty, so every engine yields a consistent result - Remove the per-engine `"has-session" in cmd` fold from subprocess, asyncio, imsg; soften the subprocess/asyncio docstrings accordingly - Add test_has_session_folds_stderr_to_stdout --- src/libtmux/experimental/engines/asyncio.py | 7 ++----- src/libtmux/experimental/engines/imsg/base.py | 2 -- src/libtmux/experimental/engines/subprocess.py | 13 +++++-------- src/libtmux/experimental/ops/_ops/has_session.py | 9 ++++++++- tests/experimental/ops/test_read_breadth.py | 11 +++++++++++ 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py index bd57987e5..7ce87d0d8 100644 --- a/src/libtmux/experimental/engines/asyncio.py +++ b/src/libtmux/experimental/engines/asyncio.py @@ -4,8 +4,8 @@ not a thread wrapper around the sync engine. On cancellation it terminates the child process before propagating :class:`asyncio.CancelledError`, so a cancelled ``arun`` leaks no tmux process. It mirrors the classic engine's output handling -(``backslashreplace`` decoding, trailing-blank stripping, ``has-session`` fold) -so it returns the *same* typed result the classic engine does. +(``backslashreplace`` decoding, trailing-blank stripping) so it returns the +*same* typed result the classic engine does. """ from __future__ import annotations @@ -98,9 +98,6 @@ async def run(self, request: CommandRequest) -> CommandResult: stdout_lines.pop() stderr_lines = [line for line in stderr.split("\n") if line] - if "has-session" in cmd and stderr_lines and not stdout_lines: - stdout_lines = [stderr_lines[0]] - return CommandResult( cmd=tuple(cmd), stdout=tuple(stdout_lines), diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 34c8d9da2..d447bc40b 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -608,8 +608,6 @@ def _run_socket_command( stderr_lines = _split_output(bytes(stderr_buffer)) if exit_message: stderr_lines.append(exit_message) - if "has-session" in cmd and stderr_lines and not stdout_lines: - stdout_lines = [stderr_lines[0]] return CommandResult( cmd=tuple(cmd), diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py index 4b76bd25f..639d829e3 100644 --- a/src/libtmux/experimental/engines/subprocess.py +++ b/src/libtmux/experimental/engines/subprocess.py @@ -1,10 +1,10 @@ """The classic subprocess engine. -Executes tmux via the CLI binary, one fork per command, reproducing today's -:class:`libtmux.common.tmux_cmd` behaviour byte-for-byte: ``backslashreplace`` -decoding, trailing-blank stripping, and the ``has-session`` stderr-into-stdout -fold. A tmux-side failure is returned as data (nonzero ``returncode`` plus -``stderr``); only a missing binary raises. ``server_args`` carries the +Executes tmux via the CLI binary, one fork per command, mirroring today's +:class:`libtmux.common.tmux_cmd` output handling: ``backslashreplace`` decoding +and trailing-blank stripping. A tmux-side failure is returned as data (nonzero +``returncode`` plus ``stderr``); only a missing binary raises. ``server_args`` +carries the connection flags (``-L``/``-S``/``-f``/``-2``) so the engine can target a specific tmux server. """ @@ -82,9 +82,6 @@ def run(self, request: CommandRequest) -> CommandResult: stdout_lines.pop() stderr_lines = [line for line in stderr.split("\n") if line] - if "has-session" in cmd and stderr_lines and not stdout_lines: - stdout_lines = [stderr_lines[0]] - return CommandResult( cmd=tuple(cmd), stdout=tuple(stdout_lines), diff --git a/src/libtmux/experimental/ops/_ops/has_session.py b/src/libtmux/experimental/ops/_ops/has_session.py index 0c236763e..2f1fa9c8b 100644 --- a/src/libtmux/experimental/ops/_ops/has_session.py +++ b/src/libtmux/experimental/ops/_ops/has_session.py @@ -53,7 +53,14 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> HasSessionResult: - """Map the exit code to existence; the query itself always completes.""" + """Map the exit code to existence; the query itself always completes. + + ``has-session`` writes its "can't find session" message to stderr; surface + it in stdout here (rather than in each engine) so the result is consistent + across engines. + """ + if stderr and not stdout: + stdout = (stderr[0],) return HasSessionResult( operation=self, argv=argv, diff --git a/tests/experimental/ops/test_read_breadth.py b/tests/experimental/ops/test_read_breadth.py index 486971b81..463171764 100644 --- a/tests/experimental/ops/test_read_breadth.py +++ b/tests/experimental/ops/test_read_breadth.py @@ -171,6 +171,17 @@ def test_read_result_round_trip( assert result_from_dict(result_to_dict(result)) == result +def test_has_session_folds_stderr_to_stdout() -> None: + """A missing session's stderr is surfaced in stdout (engine-agnostic).""" + result = HasSession(target=SessionId("$9")).build_result( + returncode=1, + stderr=("can't find session: $9",), + ) + assert result.exists is False + assert result.stdout == ("can't find session: $9",) + assert result.stderr == ("can't find session: $9",) + + def test_has_session_live(session: Session) -> None: """has-session answers True for the fixture session, False for a fake one.""" from libtmux.experimental.engines import SubprocessEngine From f16a6819964db9b6222d303180fc8379ad5d4fdb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:01:21 -0500 Subject: [PATCH 050/154] Engines(docs): Note ConcreteEngine query-simulation limits why: ConcreteEngine is stateless, so has-session (and other existence queries) always report success -- HasSession.exists is always True through it. That surprise should be documented. what: - Add a Notes section to ConcreteEngine documenting the stateless simulation and that queries like has-session need a live engine --- src/libtmux/experimental/engines/concrete.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index 841c4f8c8..e8f84e3de 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -61,6 +61,13 @@ class ConcreteEngine: capture_lines : Sequence[str] Lines that ``capture-pane`` returns. + Notes + ----- + The simulation is stateless -- it fabricates ids for ``-P -F`` creators and + returns canned ``capture-pane`` lines, but has no notion of which objects + exist, so queries like ``has-session`` always succeed (``HasSession.exists`` + is always ``True``). Use a live engine for those. + Examples -------- >>> from libtmux.experimental.ops import SplitWindow, CapturePane, run From 48c08b3ca09bb5be75491302d00e54b5ff41b58a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:07:55 -0500 Subject: [PATCH 051/154] Engines(fix): Avoid imsg UnboundLocalError on socket() failure why: imsg _connect created the socket inside the try whose except calls sock.close(); if socket() itself failed (e.g. fd exhaustion), sock was unbound and the handler raised UnboundLocalError, masking the real OSError. what: - Create the socket before the try so the except only runs once sock exists - Add test_imsg_connect_socket_failure_raises_oserror (monkeypatched socket.socket) --- src/libtmux/experimental/engines/imsg/base.py | 4 +++- tests/experimental/engines/test_imsg.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index d447bc40b..d998cd872 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -397,8 +397,10 @@ def _connect( *, socket_path: str, ) -> socket.socket: + # Create the socket outside the try so the except never references an + # unbound `sock` if socket() itself fails (e.g. fd exhaustion). + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect(socket_path) except OSError as error: sock.close() diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py index 0715c2025..2ee662846 100644 --- a/tests/experimental/engines/test_imsg.py +++ b/tests/experimental/engines/test_imsg.py @@ -30,6 +30,24 @@ ) +@needs_af_unix +def test_imsg_connect_socket_failure_raises_oserror( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A socket() failure surfaces as OSError, not UnboundLocalError.""" + import errno + + def _boom(*_args: object, **_kwargs: object) -> object: + raise OSError(errno.EMFILE, "too many open files") + + monkeypatch.setattr( + "libtmux.experimental.engines.imsg.base.socket.socket", + _boom, + ) + with pytest.raises(OSError, match="too many open files"): + ImsgEngine()._connect(socket_path="/nonexistent") + + def test_imsg_registered() -> None: """The imsg engine is registered and constructible by name.""" assert "imsg" in available_engines() From 4a2d60233ed459b4e9a4ba4db44f5f917a7b7326 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:11:58 -0500 Subject: [PATCH 052/154] Engines(fix): Return imsg exit result on clean close after MSG_EXIT why: If the tmux server closed the socket right after MSG_EXIT (before MSG_EXITED), recv_frame raised ImsgProtocolError, which run() did not catch -- so a normal command exit became an exception, diverging from the subprocess engine. what: - Catch ImsgProtocolError around recv_frame; once seen_exit is set, treat a clean close as the end and return the computed exit result --- src/libtmux/experimental/engines/imsg/base.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index d998cd872..241d97b4e 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -506,7 +506,14 @@ def _run_socket_command( transport.send_frame(codec, command_frame) while True: - frame = transport.recv_frame(codec) + try: + frame = transport.recv_frame(codec) + except ImsgProtocolError: + # The server may close the socket right after MSG_EXIT, + # before MSG_EXITED; the exit result is already computed. + if seen_exit: + break + raise msg_type = frame.header.msg_type peer = frame.header.peer_id pid = frame.header.pid From 1ef87ab4b489fac7bf6b20c2ed1b5eb07c3b29c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 19:18:25 -0500 Subject: [PATCH 053/154] Engines(fix): Close imsg dup'd fds if the identify send never happens why: _run_socket_command duplicated stdin/stdout fds for SCM_RIGHTS transfer, but if building the identify frames or opening the transport raised before send_frames ran, those descriptors leaked (send_frames only closes the fds once it owns the frames). what: - Close the dup'd fds if codec.identify_messages or the transport constructor raises; once send_frames runs it owns/closes them --- src/libtmux/experimental/engines/imsg/base.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 241d97b4e..61e03f221 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -471,17 +471,24 @@ def _run_socket_command( for key in self._probe_env_keys if key in os.environ } - identify_frames = codec.identify_messages( - cwd=str(pathlib.Path.cwd()), - term=os.environ.get("TERM", "unknown") or "unknown", - tty_name="", - client_pid=os.getpid(), - environ=environ_to_send, - flags=self._client_flags(), - features=0, - stdin_fd=stdin_fd, - stdout_fd=stdout_fd, - ) + # The dup'd fds are owned by send_frames once the identify burst is sent; + # close them if building the frames or opening the transport fails first. + try: + identify_frames = codec.identify_messages( + cwd=str(pathlib.Path.cwd()), + term=os.environ.get("TERM", "unknown") or "unknown", + tty_name="", + client_pid=os.getpid(), + environ=environ_to_send, + flags=self._client_flags(), + features=0, + stdin_fd=stdin_fd, + stdout_fd=stdout_fd, + ) + except BaseException: + _close_fd(stdin_fd) + _close_fd(stdout_fd) + raise logger.debug( "sending imsg identify burst", extra={ @@ -499,7 +506,12 @@ def _run_socket_command( exit_message: str | None = None seen_exit = False - transport = _SelectorSocketTransport(sock) + try: + transport = _SelectorSocketTransport(sock) + except BaseException: + _close_fd(stdin_fd) + _close_fd(stdout_fd) + raise try: transport.send_frames(codec, identify_frames) command_frame = codec.command_message(command_argv, peer_id=peer_id) From 43ff3015b9d3cdae7ff97f1cdccaeb0ab046ce7a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 20:40:18 -0500 Subject: [PATCH 054/154] Imsg(fix): Send identify LONGFLAGS frame once why: The v8 identify burst sent MSG_IDENTIFY_LONGFLAGS twice -- a byte-identical copy-paste in the initial codec. A real tmux client sends it once; the duplicate is harmless (the server sets the flags idempotently) but is redundant wire traffic. what: - Drop the duplicate MSG_IDENTIFY_LONGFLAGS frame in ProtocolV8Codec.identify_messages - Add a parametrized regression test asserting each identify frame type is emitted the expected number of times (LONGFLAGS once) --- src/libtmux/experimental/engines/imsg/v8.py | 5 --- tests/experimental/engines/test_imsg.py | 44 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/engines/imsg/v8.py b/src/libtmux/experimental/engines/imsg/v8.py index 3221da5f5..c1687f3f5 100644 --- a/src/libtmux/experimental/engines/imsg/v8.py +++ b/src/libtmux/experimental/engines/imsg/v8.py @@ -306,11 +306,6 @@ def identify_messages( """Build the identify handshake messages for a tmux client.""" peer_id = int(self.version) messages = [ - self.frame_message( - MessageType.MSG_IDENTIFY_LONGFLAGS, - _UINT64.pack(flags), - peer_id=peer_id, - ), self.frame_message( MessageType.MSG_IDENTIFY_LONGFLAGS, _UINT64.pack(flags), diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py index 2ee662846..c70e39abe 100644 --- a/tests/experimental/engines/test_imsg.py +++ b/tests/experimental/engines/test_imsg.py @@ -19,7 +19,11 @@ available_engines, create_engine, ) -from libtmux.experimental.engines.imsg.v8 import IMSG_HEADER_SIZE, ProtocolV8Codec +from libtmux.experimental.engines.imsg.v8 import ( + IMSG_HEADER_SIZE, + MessageType, + ProtocolV8Codec, +) if t.TYPE_CHECKING: from libtmux.session import Session @@ -78,6 +82,44 @@ def test_v8_command_message_packs_argc_and_argv() -> None: assert frame.payload.endswith(b"#{session_id}\x00") +class IdentifyFrameCase(t.NamedTuple): + """One expected identify-burst frame count.""" + + test_id: str + msg_type: MessageType + expected: int + + +IDENTIFY_FRAME_CASES = ( + IdentifyFrameCase("longflags-once", MessageType.MSG_IDENTIFY_LONGFLAGS, 1), + IdentifyFrameCase("stdin-once", MessageType.MSG_IDENTIFY_STDIN, 1), + IdentifyFrameCase("stdout-once", MessageType.MSG_IDENTIFY_STDOUT, 1), + IdentifyFrameCase("done-once", MessageType.MSG_IDENTIFY_DONE, 1), + IdentifyFrameCase("environ-one-per-var", MessageType.MSG_IDENTIFY_ENVIRON, 2), +) + + +@pytest.mark.parametrize( + "case", + IDENTIFY_FRAME_CASES, + ids=[case.test_id for case in IDENTIFY_FRAME_CASES], +) +def test_identify_burst_frame_counts(case: IdentifyFrameCase) -> None: + """The identify burst emits each message type the expected number of times. + + A real tmux client sends ``MSG_IDENTIFY_LONGFLAGS`` exactly once. + """ + frames = ProtocolV8Codec().identify_messages( + cwd="/tmp", + term="xterm", + tty_name="", + client_pid=123, + environ={"A": "1", "B": "2"}, + ) + count = sum(1 for frame in frames if frame.header.msg_type == int(case.msg_type)) + assert count == case.expected + + def _socket_prefix(server: t.Any) -> tuple[str, ...]: """Build the -L/-S flag that targets the test server's socket.""" if server.socket_name: From 10582d4795976ed6c3c3c6f6fce819bccdd05a2b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 17:12:41 -0500 Subject: [PATCH 055/154] Engines(test): Widen async control-mode coverage why: The async control-mode tests only dispatched single-command operations on happy paths, leaving the reader's multi-block correlation, batch pipelining, lifecycle short-circuits, and the error-as-data policy uncovered. what: - Cover %output and no-% notification parsing - Test run_batch([]) short-circuit and aclose-before-start no-op - Assert for_server threads the live socket into server_args - Pipeline two requests through one run_batch call - Fold a ; chain via FoldingPlanner (exercises expected>1 blocks) - Confirm a rejected command returns a failed result, no raise --- .../contract/test_async_control_engine.py | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/tests/experimental/contract/test_async_control_engine.py b/tests/experimental/contract/test_async_control_engine.py index b6d9ec5ac..bcdc24580 100644 --- a/tests/experimental/contract/test_async_control_engine.py +++ b/tests/experimental/contract/test_async_control_engine.py @@ -13,13 +13,24 @@ from libtmux.experimental.engines import ( AsyncConcreteEngine, AsyncControlModeEngine, + CommandRequest, ControlNotification, ) -from libtmux.experimental.ops import SplitWindow, arun +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + RenameWindow, + SplitWindow, + arun, +) from libtmux.experimental.ops._types import WindowId from libtmux.experimental.ops.results import SplitWindowResult if t.TYPE_CHECKING: + from libtmux.experimental.engines import CommandResult + from libtmux.experimental.ops.plan import PlanResult + from libtmux.experimental.ops.results import AckResult + from libtmux.server import Server from libtmux.session import Session @@ -30,6 +41,21 @@ def test_notification_parse() -> None: assert notif.args == ("@3",) +def test_notification_parse_output_keeps_payload() -> None: + """An ``%output`` line keeps the pane id and the whole payload as args.""" + notif = ControlNotification.parse(b"%output %1 hello world") + assert notif.kind == "output" + assert notif.args == ("%1", "hello", "world") + assert notif.raw == "%output %1 hello world" + + +def test_notification_parse_line_without_percent() -> None: + """A line lacking the ``%`` prefix still parses to a kind and args.""" + notif = ControlNotification.parse(b"window-renamed @1 new") + assert notif.kind == "window-renamed" + assert notif.args == ("@1", "new") + + def test_async_control_split_creates_real_pane(session: Session) -> None: """An async control-mode split returns a typed result; the pane exists.""" server = session.server @@ -96,3 +122,91 @@ async def main() -> ControlNotification: notif = asyncio.run(main()) assert notif.kind assert notif.raw.startswith("%") + + +def test_async_control_empty_batch_short_circuits() -> None: + """``run_batch([])`` returns ``[]`` without ever spawning a tmux process.""" + engine = AsyncControlModeEngine() + assert asyncio.run(engine.run_batch([])) == [] + + +def test_async_control_aclose_without_start_is_safe() -> None: + """Closing an engine that was never started is a no-op, not an error.""" + engine = AsyncControlModeEngine() + asyncio.run(engine.aclose()) + assert engine.dropped_notifications == 0 + + +def test_async_control_for_server_carries_socket(server: Server) -> None: + """``for_server`` threads the live server's socket into the connection flags.""" + engine = AsyncControlModeEngine.for_server(server) + assert any(arg.startswith(("-L", "-S")) for arg in engine.server_args) + assert engine.tmux_bin == server.tmux_bin + + +def test_async_control_run_batch_pipelines_one_call(session: Session) -> None: + """One ``run_batch`` call dispatches several requests, one result each, in order.""" + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + request = CommandRequest.from_args( + *SplitWindow(target=WindowId(window_id)).render() + ) + + async def main() -> list[CommandResult]: + async with AsyncControlModeEngine.for_server(server) as engine: + return await engine.run_batch([request, request]) + + results = asyncio.run(main()) + assert len(results) == 2 + assert all(result.returncode == 0 for result in results) + # Each split captured a distinct new pane id on its own block. + assert results[0].stdout and results[1].stdout + assert results[0].stdout[0] != results[1].stdout[0] + + +def test_async_control_folds_chain_over_one_dispatch(session: Session) -> None: + """A folded ``;`` chain dispatches as one multi-block command; each op completes. + + The other tests dispatch only single-command operations, so the reader's + "wait for ``expected`` blocks" correlation (``command_count`` > 1) is never + exercised. A ``FoldingPlanner`` chain of two renames sends one ``a ; b`` line + that tmux answers with two blocks, proving block accumulation and per-op + attribution over the async connection. + """ + server = session.server + window_id = session.active_window.window_id + assert window_id is not None + plan = LazyPlan() + plan.add_chain( + RenameWindow(target=WindowId(window_id), name="first") + >> RenameWindow(target=WindowId(window_id), name="folded"), + ) + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await plan.aexecute(engine, planner=FoldingPlanner()) + + outcome = asyncio.run(main()) + assert outcome.ok + assert [result.status for result in outcome.results] == ["complete", "complete"] + # The last rename in the folded line won, proving both sub-commands ran. + renamed = server.windows.get(window_id=window_id) + assert renamed is not None + assert renamed.window_name == "folded" + + +def test_async_control_failure_is_data_not_raised(session: Session) -> None: + """A tmux-rejected command yields a failed result; the engine does not raise.""" + server = session.server + + async def main() -> AckResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await arun( + RenameWindow(target=WindowId("@999999"), name="nope"), + engine, + ) + + result = asyncio.run(main()) + assert result.ok is False + assert result.returncode != 0 From acff025f00df281adc45978def941750fe27202d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 17:13:10 -0500 Subject: [PATCH 056/154] Ops(feat[send_keys]): Add suppress_history flag why: Declarative workspace builders (tmuxp-style) need injected setup commands kept out of shell history. tmux has no native flag; the convention is a leading space honored by HISTCONTROL=ignorespace. what: - Add suppress_history field (default False, opt-in, no behavior change) - Prepend a space to the keys when set and not literal - Document the convention and add a render doctest --- src/libtmux/experimental/ops/_ops/send_keys.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/ops/_ops/send_keys.py b/src/libtmux/experimental/ops/_ops/send_keys.py index 3f4cd4c54..4a2fe5b48 100644 --- a/src/libtmux/experimental/ops/_ops/send_keys.py +++ b/src/libtmux/experimental/ops/_ops/send_keys.py @@ -25,6 +25,10 @@ class SendKeys(Operation[AckResult]): pressing Return; send the keys and Enter as two operations instead. literal : bool Send keys literally without tmux key-name lookup (``-l``). + suppress_history : bool + Prepend a single space to the command so an ``ignorespace``-configured + shell (``HISTCONTROL=ignorespace``) keeps it out of history -- the same + trick tmuxp uses. No-op when *literal* is set. Examples -------- @@ -33,6 +37,8 @@ class SendKeys(Operation[AckResult]): ('send-keys', '-t', '%1', 'echo hi', 'Enter') >>> SendKeys(target=PaneId("%1"), keys="q", literal=True).render() ('send-keys', '-t', '%1', '-l', 'q') + >>> SendKeys(target=PaneId("%1"), keys="vim", suppress_history=True).render() + ('send-keys', '-t', '%1', ' vim') """ kind = "send_keys" @@ -45,6 +51,7 @@ class SendKeys(Operation[AckResult]): keys: str enter: bool = False literal: bool = False + suppress_history: bool = False def __post_init__(self) -> None: """Reject literal+enter (fail closed): tmux ``-l`` types "Enter".""" @@ -60,7 +67,10 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out: list[str] = [] if self.literal: out.append("-l") - out.append(self.keys) + keys = self.keys + if self.suppress_history and not self.literal: + keys = f" {keys}" + out.append(keys) if self.enter: out.append("Enter") return tuple(out) From 2e592d170fa35a9590525ba665c8e1a927fedd5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 17:20:45 -0500 Subject: [PATCH 057/154] Ops(feat): Capture implicit child ids on create why: A declarative WorkspaceBuilder must target the first pane of a created window (e.g. to focus it) without the caller handling ids. The implicit first pane had no captured id, so it could only be addressed as the active pane -- which moves once the window is split. what: - NewSession.capture_panes / NewWindow.capture_pane (opt-in): emit a multi-id -F so the result also carries first_window_id/first_pane_id - CreateResult gains first_window_id/first_pane_id + created_subids - SlotRef.part ("self"/"window"/"pane") + .window/.pane sub-refs; the plan binds created_subids so a sub-ref resolves to its captured id - ConcreteEngine fabricates one id per #{*_id} token (single-token formats unchanged, preserving existing fabricated-id sequences) --- src/libtmux/experimental/engines/concrete.py | 24 ++++++++++---- .../experimental/ops/_ops/new_session.py | 31 ++++++++++++++++--- .../experimental/ops/_ops/new_window.py | 23 +++++++++++--- src/libtmux/experimental/ops/_types.py | 22 +++++++++++-- src/libtmux/experimental/ops/plan.py | 30 ++++++++++++------ src/libtmux/experimental/ops/results.py | 25 +++++++++++++++ 6 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index e8f84e3de..50774e431 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -22,12 +22,24 @@ def _fabricate(fmt: str, counters: dict[str, int]) -> str: - """Return the next fabricated id for a ``#{..._id}`` capture format.""" - for key, sigil in (("pane_id", "%"), ("window_id", "@"), ("session_id", "$")): - if key in fmt: - counters[key] += 1 - return f"{sigil}{counters[key]}" - return "?" + """Fabricate one id per ``#{..._id}`` token in *fmt*, in their order. + + A single-token format (e.g. ``#{pane_id}``) yields one id, preserving the + historical behaviour; a multi-token capture (e.g. ``new-session -F + '#{session_id} #{window_id} #{pane_id}'``) yields a space-joined id per token. + """ + found: list[tuple[int, str, str]] = [] + for key, sigil in (("session_id", "$"), ("window_id", "@"), ("pane_id", "%")): + index = fmt.find(f"#{{{key}}}") + if index != -1: + found.append((index, key, sigil)) + if not found: + return "?" + parts: list[str] = [] + for _index, key, sigil in sorted(found): + counters[key] += 1 + parts.append(f"{sigil}{counters[key]}") + return " ".join(parts) def _simulate( diff --git a/src/libtmux/experimental/ops/_ops/new_session.py b/src/libtmux/experimental/ops/_ops/new_session.py index 74925d91b..b92759c24 100644 --- a/src/libtmux/experimental/ops/_ops/new_session.py +++ b/src/libtmux/experimental/ops/_ops/new_session.py @@ -21,12 +21,27 @@ class NewSession(Operation[CreateResult]): """Create a detached session; capture the new session's id. + Parameters + ---------- + capture_panes : bool + Also capture the new session's first window id and first pane id (into + :attr:`~.results.CreateResult.first_window_id` / + :attr:`~.results.CreateResult.first_pane_id`), so a plan can target them + via ``slot.window`` / ``slot.pane``. + Examples -------- >>> NewSession(session_name="work").render() ('new-session', '-d', '-s', 'work', '-P', '-F', '#{session_id}') + >>> NewSession(session_name="work", capture_panes=True).render()[-1] + '#{session_id} #{window_id} #{pane_id}' >>> NewSession().build_result(returncode=0, stdout=("$2",)).new_id '$2' + >>> r = NewSession(capture_panes=True).build_result( + ... returncode=0, stdout=("$2 @3 %4",) + ... ) + >>> (r.new_id, r.first_window_id, r.first_pane_id) + ('$2', '@3', '%4') """ kind = "new_session" @@ -44,6 +59,7 @@ class NewSession(Operation[CreateResult]): width: int | None = None height: int | None = None capture: bool = True + capture_panes: bool = False def args(self, *, version: str | None = None) -> tuple[str, ...]: """Render ``new-session`` flags (always detached for headless use).""" @@ -59,7 +75,12 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: if self.height is not None: out.extend(("-y", str(self.height))) if self.capture: - out.extend(("-P", "-F", "#{session_id}")) + fmt = ( + "#{session_id} #{window_id} #{pane_id}" + if self.capture_panes + else "#{session_id}" + ) + out.extend(("-P", "-F", fmt)) return tuple(out) def _make_result( @@ -71,8 +92,8 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> CreateResult: - """Parse the captured new-session id.""" - new_id = stdout[0].strip() if status == "complete" and stdout else None + """Parse the captured session id (and first window/pane id if captured).""" + ids = stdout[0].split() if status == "complete" and stdout else [] return CreateResult( operation=self, argv=argv, @@ -80,5 +101,7 @@ def _make_result( returncode=returncode, stdout=stdout, stderr=stderr, - new_id=new_id, + new_id=ids[0] if ids else None, + first_window_id=ids[1] if len(ids) > 1 else None, + first_pane_id=ids[2] if len(ids) > 2 else None, ) diff --git a/src/libtmux/experimental/ops/_ops/new_window.py b/src/libtmux/experimental/ops/_ops/new_window.py index 73091a437..d6820c787 100644 --- a/src/libtmux/experimental/ops/_ops/new_window.py +++ b/src/libtmux/experimental/ops/_ops/new_window.py @@ -23,15 +23,27 @@ class NewWindow(Operation[CreateResult]): ``target`` is the session the window is created in. + Parameters + ---------- + capture_pane : bool + Also capture the new window's first pane id (into + :attr:`~.results.CreateResult.first_pane_id`), so a plan can target it + via ``slot.pane``. + Examples -------- >>> from libtmux.experimental.ops._types import SessionId >>> NewWindow(target=SessionId("$0"), name="build").render() ('new-window', '-t', '$0', '-d', '-n', 'build', '-P', '-F', '#{window_id}') + >>> NewWindow(target=SessionId("$0"), capture_pane=True).render()[-1] + '#{window_id} #{pane_id}' >>> NewWindow(target=SessionId("$0")).build_result( ... returncode=0, stdout=("@5",) ... ).new_id '@5' + >>> r = NewWindow(capture_pane=True).build_result(returncode=0, stdout=("@5 %6",)) + >>> (r.new_id, r.first_pane_id) + ('@5', '%6') """ kind = "new_window" @@ -48,6 +60,7 @@ class NewWindow(Operation[CreateResult]): environment: Mapping[str, str] | None = None detach: bool = True capture: bool = True + capture_pane: bool = False window_shell: str | None = None def args(self, *, version: str | None = None) -> tuple[str, ...]: @@ -62,7 +75,8 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: if self.environment and self.flag_available("environment", version): out.extend(f"-e{key}={value}" for key, value in self.environment.items()) if self.capture: - out.extend(("-P", "-F", "#{window_id}")) + fmt = "#{window_id} #{pane_id}" if self.capture_pane else "#{window_id}" + out.extend(("-P", "-F", fmt)) if self.window_shell is not None: out.append(self.window_shell) return tuple(out) @@ -76,8 +90,8 @@ def _make_result( stderr: tuple[str, ...], version: str | None = None, ) -> CreateResult: - """Parse the captured new-window id.""" - new_id = stdout[0].strip() if status == "complete" and stdout else None + """Parse the captured window id (and first pane id if captured).""" + ids = stdout[0].split() if status == "complete" and stdout else [] return CreateResult( operation=self, argv=argv, @@ -85,5 +99,6 @@ def _make_result( returncode=returncode, stdout=stdout, stderr=stderr, - new_id=new_id, + new_id=ids[0] if ids else None, + first_pane_id=ids[1] if len(ids) > 1 else None, ) diff --git a/src/libtmux/experimental/ops/_types.py b/src/libtmux/experimental/ops/_types.py index 897052591..d0220bbca 100644 --- a/src/libtmux/experimental/ops/_types.py +++ b/src/libtmux/experimental/ops/_types.py @@ -277,16 +277,34 @@ class SlotRef: lets a command needing a qualified target -- e.g. ``new-window -t $N:`` -- reuse a plain captured ``$N``. + ``part`` selects which captured id to resolve: the slot's own created object + (``"self"``, the default), or an *implicit child* the creator captured -- a + new session/window's first window (``"window"``) or first pane (``"pane"``). + Use the :attr:`window` / :attr:`pane` convenience properties. + Examples -------- >>> SlotRef(0) - SlotRef(slot=0, suffix='') + SlotRef(slot=0, suffix='', part='self') >>> SlotRef(0, ":") - SlotRef(slot=0, suffix=':') + SlotRef(slot=0, suffix=':', part='self') + >>> SlotRef(0).pane + SlotRef(slot=0, suffix='', part='pane') """ slot: int suffix: str = "" + part: t.Literal["self", "window", "pane"] = "self" + + @property + def window(self) -> SlotRef: + """A sub-ref to the first window the slot's creator captured.""" + return SlotRef(self.slot, self.suffix, "window") + + @property + def pane(self) -> SlotRef: + """A sub-ref to the first pane the slot's creator captured.""" + return SlotRef(self.slot, self.suffix, "pane") def render(self) -> str: """Raise -- an unresolved deferred ref cannot be rendered.""" diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index f8fb2723e..e530a9867 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -78,14 +78,20 @@ def _target_from_id(value: str) -> Target: return Special(value) -def _resolve_slot(ref: SlotRef, bindings: dict[int, str]) -> Target: +def _resolve_slot( + ref: SlotRef, + bindings: dict[int | tuple[int, str], str], +) -> Target: """Map a :class:`SlotRef` to the captured concrete target it points at.""" + key: int | tuple[int, str] = ( + ref.slot if ref.part == "self" else (ref.slot, ref.part) + ) try: - concrete = bindings[ref.slot] + ref.suffix + concrete = bindings[key] + ref.suffix except KeyError as error: msg = ( - f"slot {ref.slot} has no captured id yet; a plan step can only " - f"reference an earlier step that creates an object" + f"slot {ref.slot} (part {ref.part!r}) has no captured id yet; a plan " + f"step can only reference an earlier step that creates that object" ) raise OperationError(msg) from error return _target_from_id(concrete) @@ -93,7 +99,7 @@ def _resolve_slot(ref: SlotRef, bindings: dict[int, str]) -> Target: def _resolve( operation: Operation[t.Any], - bindings: dict[int, str], + bindings: dict[int | tuple[int, str], str], ) -> Operation[t.Any]: """Substitute any :class:`SlotRef` ``target``/``src_target`` with its id.""" changes: dict[str, Target] = {} @@ -108,7 +114,7 @@ def _resolve( def _resolve_src( operation: Operation[t.Any], - bindings: dict[int, str], + bindings: dict[int | tuple[int, str], str], ) -> Operation[t.Any]: """Resolve only a :class:`SlotRef` ``src_target``. @@ -133,12 +139,14 @@ class PlanResult: ---------- results : tuple[Result, ...] One result per recorded operation, in order. - bindings : dict[int, str] - Maps a creating step's index to the concrete id it produced. + bindings : dict[int | tuple[int, str], str] + Maps a creating step's index to the concrete id it produced; a + ``(index, part)`` key holds an implicit child's id (e.g. a new window's + first pane), bound when the creator opts into capturing it. """ results: tuple[Result, ...] - bindings: dict[int, str] = field(default_factory=dict) + bindings: dict[int | tuple[int, str], str] = field(default_factory=dict) @property def ok(self) -> bool: @@ -226,7 +234,7 @@ def _drive( The sync and async drivers differ only in ``run`` vs ``await arun`` and ``engine.run`` vs ``await engine.run``. """ - bindings: dict[int, str] = {} + bindings: dict[int | tuple[int, str], str] = {} results: dict[int, Result] = {} for step in planner.plan(self._operations): if step.marked: @@ -254,6 +262,8 @@ def _drive( results[index] = result if result.created_id is not None: bindings[index] = result.created_id + for sub_part, sub_id in result.created_subids.items(): + bindings[index, sub_part] = sub_id else: group = [_resolve(self._operations[i], bindings) for i in step.indices] merged = yield _Chain(render_chain(group, version)) diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index 1e0dfad70..ae34b9463 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -141,6 +141,16 @@ def created_id(self) -> str | None: """ return None + @property + def created_subids(self) -> Mapping[str, str]: + """Ids of implicit children this op created (e.g. a window's first pane). + + Keyed by part (``"window"`` / ``"pane"``); empty by default. A lazy plan + binds these so a :class:`~._types.SlotRef` sub-reference (``slot.pane`` / + ``slot.window``) can target an object created as a side effect. + """ + return {} + def raise_for_status(self) -> Self: """Raise :class:`~.exc.TmuxCommandError` if the result is not OK. @@ -215,15 +225,30 @@ class CreateResult(Result): Shared by ``new-window`` / ``new-session`` (and other ``-P -F``-capturing creators); :attr:`new_id` holds the created object's id (``@N``/``$N``). + When the creator opts into capturing its implicit children (a session's first + window/pane, a window's first pane), those ids land in + :attr:`first_window_id` / :attr:`first_pane_id` and in :attr:`created_subids`. """ new_id: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None @property def created_id(self) -> str | None: """The created object's id.""" return self.new_id + @property + def created_subids(self) -> Mapping[str, str]: + """The captured implicit children, keyed by ``"window"`` / ``"pane"``.""" + out: dict[str, str] = {} + if self.first_window_id is not None: + out["window"] = self.first_window_id + if self.first_pane_id is not None: + out["pane"] = self.first_pane_id + return out + @dataclass(frozen=True) class CapturePaneResult(Result): From a0d3baafde5722b320322c495575f9d31327d4cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 18:12:46 -0500 Subject: [PATCH 058/154] Workspace(feat): Declarative WorkspaceBuilder on the typed-ops Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxp-style workspace creation needs a structural, declarative object language (à la SQLAlchemy Declarative on Core) -- declare the shape of a session/windows/panes and let a compiler lower it to Core operations, engine- and sync/async-neutral, instead of hand-driving ops. what: - New experimental.workspace package: analyzer (tmuxp YAML/dict -> IR), ir (Workspace/Window/Pane specs), compiler (spec -> Core LazyPlan, wiring first-pane sub-refs so the user never handles an id), runner (build/abuild over any engine + host steps + idempotent replace), confirm (live structure diff) - Workspace.compile()/build()/abuild(); on_exists error|replace|reuse - Robust QA: offline (op order, plan serialize round-trip, 3-way planner equivalence) + live (rich 3-window build over subprocess and async-control: names/order, pane counts, focus, options, env, cwd, commands) --- .../experimental/workspace/__init__.py | 51 +++ .../experimental/workspace/analyzer.py | 120 +++++++ .../experimental/workspace/compiler.py | 236 +++++++++++++ src/libtmux/experimental/workspace/confirm.py | 88 +++++ src/libtmux/experimental/workspace/ir.py | 185 +++++++++++ src/libtmux/experimental/workspace/runner.py | 146 +++++++++ ..._async_control_engine_workspace_builder.py | 309 ++++++++++++++++++ 7 files changed, 1135 insertions(+) create mode 100644 src/libtmux/experimental/workspace/__init__.py create mode 100644 src/libtmux/experimental/workspace/analyzer.py create mode 100644 src/libtmux/experimental/workspace/compiler.py create mode 100644 src/libtmux/experimental/workspace/confirm.py create mode 100644 src/libtmux/experimental/workspace/ir.py create mode 100644 src/libtmux/experimental/workspace/runner.py create mode 100644 tests/experimental/contract/test_async_control_engine_workspace_builder.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py new file mode 100644 index 000000000..9b1f34bfe --- /dev/null +++ b/src/libtmux/experimental/workspace/__init__.py @@ -0,0 +1,51 @@ +"""Declarative WorkspaceBuilder: a structural object language over the Core ops. + +The *Declarative* tier (à la SQLAlchemy Declarative on Core). Declare a workspace +shape with :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane`; +:func:`~.analyzer.analyze` builds that tree from a tmuxp-style YAML/dict; the +compiler lowers it to a Core :class:`~libtmux.experimental.ops.plan.LazyPlan`; the +runner executes it over any engine, sync or async; :func:`~.confirm.confirm` +verifies the live result. + +Everything here is experimental and outside the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> ws = analyze({ +... "session_name": "dev", +... "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], +... }) +>>> ws.build(ConcreteEngine(), preflight=False).ok +True +""" + +from __future__ import annotations + +from libtmux.experimental.workspace.analyzer import analyze +from libtmux.experimental.workspace.compiler import ( + Compiled, + HostStep, + WorkspaceCompileError, + compile_full, + compile_workspace, +) +from libtmux.experimental.workspace.confirm import ConfirmReport, confirm +from libtmux.experimental.workspace.ir import Pane, Window, Workspace +from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace + +__all__ = ( + "Compiled", + "ConfirmReport", + "HostStep", + "Pane", + "Window", + "Workspace", + "WorkspaceCompileError", + "abuild_workspace", + "analyze", + "build_workspace", + "compile_full", + "compile_workspace", + "confirm", +) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py new file mode 100644 index 000000000..55118204f --- /dev/null +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -0,0 +1,120 @@ +"""Analyze a tmuxp-style YAML/dict workspace into the declarative IR. + +A small subset of tmuxp's ``loader.expand``/``trickle``: it normalizes shorthand +(a bare-string pane, a string/list ``shell_command``, a ``cmd``-dict list) into the +canonical :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane` tree. +Pure -- no tmux. + +Examples +-------- +>>> ws = analyze({ +... "session_name": "dev", +... "start_directory": "~/work", +... "windows": [ +... {"window_name": "editor", "layout": "main-vertical", +... "panes": ["vim", {"shell_command": ["cd src", "pytest -q"]}]}, +... {"window_name": "logs", "panes": ["tail -f app.log"]}, +... ], +... }) +>>> ws.name +'dev' +>>> [w.name for w in ws.windows] +['editor', 'logs'] +>>> ws.windows[0].panes[0].commands +('vim',) +>>> ws.windows[0].panes[1].commands +('cd src', 'pytest -q') +""" + +from __future__ import annotations + +import collections.abc +import typing as t + +from libtmux.experimental.workspace.ir import Pane, Window, Workspace + + +def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: + """Normalize a tmuxp-style config (dict or YAML string) into a Workspace.""" + data = _load(raw) + windows = [_window(w) for w in data.get("windows", []) or []] + return Workspace( + name=data["session_name"], + dimensions=_dimensions(data.get("dimensions")), + start_directory=data.get("start_directory"), + environment=dict(data.get("environment", {}) or {}), + options=dict(data.get("options", {}) or {}), + windows=windows, + before_script=data.get("before_script"), + on_exists=data.get("on_exists", "error"), + ) + + +def _load( + raw: collections.abc.Mapping[str, t.Any] | str, +) -> collections.abc.Mapping[str, t.Any]: + """Return a mapping from a dict or a YAML string.""" + if isinstance(raw, str): + import yaml # type: ignore[import-untyped] + + loaded = yaml.safe_load(raw) + if not isinstance(loaded, collections.abc.Mapping): + msg = "workspace YAML must be a mapping" + raise TypeError(msg) + return loaded + return raw + + +def _dimensions(value: t.Any) -> tuple[int, int] | None: + """Coerce a ``[x, y]`` / ``{width, height}`` value to a dimensions tuple.""" + if value is None: + return None + if isinstance(value, collections.abc.Mapping): + return (int(value["width"]), int(value["height"])) + width, height = value + return (int(width), int(height)) + + +def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: + """Normalize one window config.""" + return Window( + name=raw.get("window_name"), + layout=raw.get("layout"), + start_directory=raw.get("start_directory"), + focus=bool(raw.get("focus", False)), + options=dict(raw.get("options", {}) or {}), + panes=[_pane(p) for p in raw.get("panes", []) or []], + ) + + +def _pane(raw: t.Any) -> Pane: + """Normalize one pane config (None / bare string / mapping).""" + if raw is None: + return Pane() + if isinstance(raw, str): + return Pane(run=raw) + if isinstance(raw, collections.abc.Mapping): + return Pane( + run=_shell_commands(raw.get("shell_command")), + focus=bool(raw.get("focus", False)), + start_directory=raw.get("start_directory"), + sleep_before=raw.get("sleep_before"), + sleep_after=raw.get("sleep_after"), + ) + msg = f"unsupported pane config: {raw!r}" + raise TypeError(msg) + + +def _shell_commands(value: t.Any) -> tuple[str, ...]: + """Normalize a ``shell_command`` (None / string / list of str|{cmd}).""" + if value is None: + return () + if isinstance(value, str): + return (value,) + out: list[str] = [] + for item in t.cast("collections.abc.Sequence[t.Any]", value): + if isinstance(item, str): + out.append(item) + elif isinstance(item, collections.abc.Mapping): + out.append(str(item["cmd"])) + return tuple(out) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py new file mode 100644 index 000000000..8dd9e3efc --- /dev/null +++ b/src/libtmux/experimental/workspace/compiler.py @@ -0,0 +1,236 @@ +"""Compile a declarative :class:`~.ir.Workspace` into a Core ``LazyPlan``. + +This is the *unit-of-work* of the Declarative tier: it walks the structural spec +tree and emits Core operations in tmuxp-faithful order (create session -> per +window: create/rename, window options, reuse the first pane, split the rest, send +keys, apply layout, focus panes; then focus the window last), wiring +:class:`~libtmux.experimental.ops._types.SlotRef` forward references so the caller +never handles a tmux id. + +Implicit-object strategy: creators opt into capturing their implicit children's +ids (``NewSession(capture_panes=True)`` -> session/first-window/first-pane; +``NewWindow(capture_pane=True)`` -> window/first-pane), so every window's first +pane has a real captured id reachable as ``slot.pane`` / ``session.pane``. The +session's first window is reused as window 1 (addressed via ``session.window`` / +``session.pane``); windows 2..N are created detached. Because the first pane has a +concrete id, first-pane focus and any-order sends work, and the +``compile() -> LazyPlan`` stays executable by Core (the sub-ids bind in +``_drive``). + +Host-side steps (sleep / before_script) are returned alongside the plan in a +:class:`Compiled` schedule -- they are *not* recorded as operations, keeping the +Core op spine pure. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops import ( + LazyPlan, + NewSession, + NewWindow, + RenameWindow, + SelectLayout, + SelectPane, + SelectWindow, + SendKeys, + SetEnvironment, + SetOption, + SetWindowOption, + SplitWindow, +) +from libtmux.experimental.workspace.ir import Pane + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import SlotRef + from libtmux.experimental.workspace.ir import Window, Workspace + + +class WorkspaceCompileError(ValueError): + """A declared workspace cannot be lowered to Core operations.""" + + +@dataclass(frozen=True) +class HostStep: + """A host-side step interleaved by the runner (not a tmux operation).""" + + kind: t.Literal["sleep", "script"] + seconds: float | None = None + command: str | None = None + cwd: str | None = None + + +@dataclass(frozen=True) +class Compiled: + """A compiled workspace: the Core plan plus its host-step schedule. + + Parameters + ---------- + plan : LazyPlan + The pure Core operations (executable by any engine via ``execute``). + host_after : Mapping[int, tuple[HostStep, ...]] + Host steps to run *after* the operation at the given index. + pre : tuple[HostStep, ...] + Host steps to run before any operation (e.g. ``before_script``). + """ + + plan: LazyPlan + host_after: Mapping[int, tuple[HostStep, ...]] = field(default_factory=dict) + pre: tuple[HostStep, ...] = () + + +def compile_workspace(ws: Workspace, *, version: str | None = None) -> LazyPlan: + """Lower a declarative workspace into a Core ``LazyPlan`` (ops only). + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace(name="dev", windows=[Window("editor", panes=[Pane(run="vim")])]) + >>> [op.kind for op in compile_workspace(ws).operations] + ['new_session', 'rename_window', 'send_keys'] + """ + return compile_full(ws, version=version).plan + + +def _schedule_before( + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + next_index: int, + step: HostStep, +) -> None: + """Schedule *step* to run just before the op that will land at *next_index*.""" + after = next_index - 1 + if after < 0: + pre.append(step) + else: + host_after.setdefault(after, []).append(step) + + +def _emit_window( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + window: Window, + window_ref: SlotRef, + first_pane_ref: SlotRef, +) -> None: + """Emit a window's options, panes, sends, layout, and pane focus. + + *window_ref* addresses the window (rename/options/layout); *first_pane_ref* is + the captured id of the window's first pane. + """ + for key, value in window.options.items(): + plan.add(SetWindowOption(target=window_ref, option=key, value=value)) + + panes = list(window.panes) or [Pane()] + prev: SlotRef = first_pane_ref + focus_targets: list[SlotRef] = [] + for pane_index, pane in enumerate(panes): + if pane_index == 0: + target: SlotRef = first_pane_ref + else: + target = plan.add( + SplitWindow( + target=prev, + start_directory=( + pane.start_directory + or window.start_directory + or ws.start_directory + ), + ), + ) + if pane.commands: + if pane.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=pane.sleep_before), + ) + for command in pane.commands: + plan.add( + SendKeys( + target=target, + keys=command, + enter=True, + suppress_history=pane.suppress_history, + ), + ) + if pane.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=pane.sleep_after), + ) + if pane.focus: + focus_targets.append(target) + prev = target + + if window.layout is not None: + plan.add(SelectLayout(target=window_ref, layout=window.layout)) + for target in focus_targets: + plan.add(SelectPane(target=target)) + + +def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: + """Lower a workspace into a Core plan plus its host-step schedule.""" + if not ws.windows: + msg = f"workspace {ws.name!r} declares no windows" + raise WorkspaceCompileError(msg) + + plan = LazyPlan() + host_after: dict[int, list[HostStep]] = {} + pre: list[HostStep] = [] + if ws.before_script: + pre.append(HostStep("script", command=ws.before_script, cwd=ws.start_directory)) + + width = ws.dimensions[0] if ws.dimensions else None + height = ws.dimensions[1] if ws.dimensions else None + session = plan.add( + NewSession( + session_name=ws.name, + start_directory=ws.start_directory, + width=width, + height=height, + capture_panes=True, + ), + ) + for key, value in ws.environment.items(): + plan.add(SetEnvironment(target=session, name=key, value=value)) + for key, value in ws.options.items(): + plan.add(SetOption(target=session, option=key, value=value)) + + window_refs: list[SlotRef] = [] + for index, window in enumerate(ws.windows): + if index == 0: + # Reuse the session's implicit first window via its captured ids. + window_ref: SlotRef = session.window + first_pane_ref = session.pane + if window.name is not None: + plan.add(RenameWindow(target=window_ref, name=window.name)) + else: + slot = plan.add( + NewWindow( + target=session, + name=window.name, + start_directory=window.start_directory or ws.start_directory, + capture_pane=True, + ), + ) + window_ref = slot + first_pane_ref = slot.pane + window_refs.append(window_ref) + _emit_window(plan, host_after, pre, ws, window, window_ref, first_pane_ref) + + for index, window in enumerate(ws.windows): + if window.focus: + plan.add(SelectWindow(target=window_refs[index])) + + return Compiled( + plan, + {key: tuple(value) for key, value in host_after.items()}, + tuple(pre), + ) diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py new file mode 100644 index 000000000..020f2e992 --- /dev/null +++ b/src/libtmux/experimental/workspace/confirm.py @@ -0,0 +1,88 @@ +"""Confirm a built workspace matches its declarative spec (live introspection). + +Reads the live server through the classic libtmux objects and diffs the observed +session/window/pane structure against the declared :class:`~.ir.Workspace`. Used by +the live test track; the offline (``ConcreteEngine``) track asserts on the +compiled plan instead, since a stateless engine has no structure to read back. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.experimental.workspace.ir import Workspace + from libtmux.server import Server + + +@dataclass +class ConfirmReport: + """The outcome of confirming a built workspace against its spec.""" + + ok: bool + problems: tuple[str, ...] + + +def confirm(ws: Workspace, server: Server, *, timeout: float = 5.0) -> ConfirmReport: + """Diff the live server against the declared workspace; report mismatches.""" + problems: list[str] = [] + sessions = server.sessions.filter(session_name=ws.name) + if not sessions: + return ConfirmReport(ok=False, problems=(f"session {ws.name!r} not found",)) + session = sessions[0] + + windows = list(session.windows) + if len(windows) != len(ws.windows): + problems.append(f"window count {len(windows)} != declared {len(ws.windows)}") + + for spec, live in zip(ws.windows, windows, strict=False): + if spec.name is not None and live.window_name != spec.name: + problems.append( + f"window name {live.window_name!r} != declared {spec.name!r}" + ) + live_panes = list(live.panes) + expected_panes = max(1, len(spec.panes)) + if len(live_panes) != expected_panes: + problems.append( + f"window {spec.name!r} pane count " + f"{len(live_panes)} != declared {expected_panes}", + ) + focused_panes = [i for i, p in enumerate(spec.panes) if p.focus] + if focused_panes and focused_panes[-1] < len(live_panes): + want_idx = focused_panes[-1] + active_pane = live.active_pane + if active_pane is None or ( + active_pane.pane_id != live_panes[want_idx].pane_id + ): + problems.append( + f"window {spec.name!r} active pane != declared focus " + f"(pane index {want_idx})", + ) + + focused = [w for w in ws.windows if w.focus] + if focused and focused[-1].name is not None: + want = focused[-1].name + active_name = session.active_window.window_name + if active_name != want: + problems.append( + f"active window {active_name!r} != declared focus {want!r}", + ) + + if ws.start_directory and windows: + want_cwd = ws.start_directory + session_id = session.session_id + + def _cwd_ok() -> bool: + fresh = server.sessions.filter(session_id=session_id) + if not fresh: + return False + pane = next(iter(fresh[0].windows)).active_pane + return pane is not None and pane.pane_current_path == want_cwd + + if not retry_until(_cwd_ok, timeout, raises=False): + problems.append(f"first pane cwd != declared {want_cwd!r}") + + return ConfirmReport(ok=not problems, problems=tuple(problems)) diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py new file mode 100644 index 000000000..85e34edca --- /dev/null +++ b/src/libtmux/experimental/workspace/ir.py @@ -0,0 +1,185 @@ +"""Declarative workspace specs -- the structural object language. + +The *Declarative* tier (à la SQLAlchemy Declarative on Core): the user declares +the **shape** of a workspace as a tree of :class:`Workspace` / :class:`Window` / +:class:`Pane` values, and the compiler lowers that tree into a Core +:class:`~libtmux.experimental.ops.plan.LazyPlan`. The specs are pure, immutable +data -- no tmux, no engine -- so they round-trip to/from YAML and can be inspected +before anything runs. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane +>>> ws = Workspace( +... name="dev", +... windows=[ +... Window("editor", layout="main-vertical", panes=[ +... Pane(run="vim"), +... Pane(run="pytest -q", focus=True), +... ]), +... Window("logs", panes=[Pane(run="tail -f app.log")]), +... ], +... ) +>>> ws.compile().operations[0].kind +'new_session' +>>> ws.build(ConcreteEngine(), preflight=False).ok +True +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.plan import LazyPlan, PlanResult + + +@dataclass(frozen=True) +class Pane: + """A pane in the declared workspace. + + Parameters + ---------- + run : str or Sequence[str] or None + Command(s) to send after the pane is created (a bare string is one + command). + focus : bool + Select this pane once its window's panes are built. Focusing the *first* + pane of a multi-pane window is rejected at compile time (the implicit + first pane has no captured id after the window is split). + start_directory : str or None + Working directory (inherited from window/session when unset). + suppress_history : bool + Keep sent commands out of shell history (leading-space trick). + sleep_before, sleep_after : float or None + Host-side delays around this pane's commands (orchestration, not tmux). + """ + + run: str | Sequence[str] | None = None + focus: bool = False + start_directory: str | None = None + suppress_history: bool = True + sleep_before: float | None = None + sleep_after: float | None = None + + @property + def commands(self) -> tuple[str, ...]: + """The pane's commands as a tuple (a bare string becomes one command).""" + if self.run is None: + return () + if isinstance(self.run, str): + return (self.run,) + return tuple(self.run) + + +@dataclass(frozen=True) +class Window: + """A window in the declared workspace. + + Parameters + ---------- + name : str or None + Window name. + layout : str or None + A tmux layout applied after the panes exist (e.g. ``main-vertical``). + start_directory : str or None + Working directory for the window's panes. + focus : bool + Select this window at the end of the build. + options : Mapping[str, str] + ``set-window-option`` key/values. + panes : Sequence[Pane] + The window's panes (the first reuses the window's implicit pane). + """ + + name: str | None = None + layout: str | None = None + start_directory: str | None = None + focus: bool = False + options: Mapping[str, str] = field(default_factory=dict) + panes: Sequence[Pane] = () + + +@dataclass(frozen=True) +class Workspace: + """A declared workspace: a session shape that compiles to Core operations. + + Parameters + ---------- + name : str + Session name. + dimensions : tuple[int, int] or None + ``(width, height)`` for the session (``-x``/``-y``). + start_directory : str or None + Working directory for the session. + environment : Mapping[str, str] + ``set-environment`` key/values. + options : Mapping[str, str] + ``set-option`` (session) key/values. + windows : Sequence[Window] + The session's windows. + before_script : str or None + A host shell command run once before building (orchestration). + on_exists : {"error", "replace", "reuse"} + What to do if a session of this name already exists. + """ + + name: str + dimensions: tuple[int, int] | None = None + start_directory: str | None = None + environment: Mapping[str, str] = field(default_factory=dict) + options: Mapping[str, str] = field(default_factory=dict) + windows: Sequence[Window] = () + before_script: str | None = None + on_exists: t.Literal["error", "replace", "reuse"] = "error" + + def compile(self, *, version: str | None = None) -> LazyPlan: + """Lower this declared workspace into a Core ``LazyPlan`` (ops only). + + The returned plan is the escape hatch to the Core tier: inspect it, + serialize it, or execute it directly with any engine. Host steps + (sleep/before_script) and idempotent replace are applied by + :meth:`build`/:meth:`abuild`, not recorded in the plan. + """ + from libtmux.experimental.workspace.compiler import compile_workspace + + return compile_workspace(self, version=version) + + def build( + self, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + ) -> PlanResult: + """Compile and execute this workspace synchronously over *engine*. + + Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check + (e.g. against the stateless ``ConcreteEngine``, which has no real + sessions to detect). + """ + from libtmux.experimental.workspace.runner import build_workspace + + return build_workspace(self, engine, version=version, preflight=preflight) + + async def abuild( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + ) -> PlanResult: + """Compile and execute this workspace asynchronously over *engine*.""" + from libtmux.experimental.workspace.runner import abuild_workspace + + return await abuild_workspace( + self, + engine, + version=version, + preflight=preflight, + ) diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py new file mode 100644 index 000000000..e99e4aac3 --- /dev/null +++ b/src/libtmux/experimental/workspace/runner.py @@ -0,0 +1,146 @@ +"""Execute a compiled workspace over any engine, sync or async. + +The runner is the Declarative tier's *bound* layer. It keeps the Core operation +spine pure: it drives the compiled plan one operation at a time (reusing Core's +:func:`~libtmux.experimental.ops.plan._resolve` forward-ref resolution) and +interleaves host-side steps (sleep / before_script) *between* operations rather +than weaving them into Core's ``_drive`` generator. Idempotent replace is handled +*around* the build via a ``has-session`` pre-check. + +The same compiled plan runs identically through any engine and through either the +sync (:func:`build_workspace`) or async (:func:`abuild_workspace`) driver -- the +only difference is ``run`` vs ``await arun`` and the host-step executor. + +``preflight=False`` skips the ``on_exists`` ``has-session`` check; use it offline +against the stateless ``ConcreteEngine`` (whose ``has-session`` is always true). +""" + +from __future__ import annotations + +import asyncio +import subprocess +import time +import typing as t + +from libtmux.experimental.ops import HasSession, KillSession, arun, run +from libtmux.experimental.ops._types import NameRef +from libtmux.experimental.ops.plan import PlanResult, _resolve +from libtmux.experimental.workspace.compiler import compile_full + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.results import Result + from libtmux.experimental.workspace.compiler import HostStep + from libtmux.experimental.workspace.ir import Workspace + + +def _run_host_sync(step: HostStep) -> None: + """Execute one host step synchronously.""" + if step.kind == "sleep" and step.seconds is not None: + time.sleep(step.seconds) + elif step.kind == "script" and step.command is not None: + subprocess.run(step.command, shell=True, cwd=step.cwd, check=False) + + +async def _run_host_async(step: HostStep) -> None: + """Execute one host step asynchronously.""" + if step.kind == "sleep" and step.seconds is not None: + await asyncio.sleep(step.seconds) + elif step.kind == "script" and step.command is not None: + proc = await asyncio.create_subprocess_shell(step.command, cwd=step.cwd) + await proc.wait() + + +def _preflight_sync(ws: Workspace, engine: TmuxEngine, version: str | None) -> bool: + """Apply the ``on_exists`` policy; return ``True`` if the build should skip.""" + exists = run(HasSession(target=NameRef(ws.name)), engine, version=version).exists + if not exists: + return False + if ws.on_exists == "replace": + run(KillSession(target=NameRef(ws.name)), engine, version=version) + return False + if ws.on_exists == "reuse": + return True + msg = f"session {ws.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +async def _preflight_async( + ws: Workspace, + engine: AsyncTmuxEngine, + version: str | None, +) -> bool: + """Async sibling of :func:`_preflight_sync`.""" + result = await arun(HasSession(target=NameRef(ws.name)), engine, version=version) + if not result.exists: + return False + if ws.on_exists == "replace": + await arun(KillSession(target=NameRef(ws.name)), engine, version=version) + return False + if ws.on_exists == "reuse": + return True + msg = f"session {ws.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +def build_workspace( + ws: Workspace, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanResult: + """Compile and execute *ws* synchronously over *engine*. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace(name="dev", windows=[Window("w", panes=[Pane(run="vim")])]) + >>> build_workspace(ws, ConcreteEngine(), preflight=False).ok + True + """ + if preflight and _preflight_sync(ws, engine, version): + return PlanResult((), {}) + compiled = compile_full(ws, version=version) + for step in compiled.pre: + _run_host_sync(step) + bindings: dict[int | tuple[int, str], str] = {} + results: list[Result] = [] + for index, op in enumerate(compiled.plan.operations): + result = run(_resolve(op, bindings), engine, version=version) + results.append(result) + if result.created_id is not None: + bindings[index] = result.created_id + for part, sub in result.created_subids.items(): + bindings[index, part] = sub + for step in compiled.host_after.get(index, ()): + _run_host_sync(step) + return PlanResult(tuple(results), bindings) + + +async def abuild_workspace( + ws: Workspace, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanResult: + """Compile and execute *ws* asynchronously over *engine* (same resolution).""" + if preflight and await _preflight_async(ws, engine, version): + return PlanResult((), {}) + compiled = compile_full(ws, version=version) + for step in compiled.pre: + await _run_host_async(step) + bindings: dict[int | tuple[int, str], str] = {} + results: list[Result] = [] + for index, op in enumerate(compiled.plan.operations): + result = await arun(_resolve(op, bindings), engine, version=version) + results.append(result) + if result.created_id is not None: + bindings[index] = result.created_id + for part, sub in result.created_subids.items(): + bindings[index, part] = sub + for step in compiled.host_after.get(index, ()): + await _run_host_async(step) + return PlanResult(tuple(results), bindings) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py new file mode 100644 index 000000000..33eff2d77 --- /dev/null +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -0,0 +1,309 @@ +"""Declarative WorkspaceBuilder over the typed-ops Core, on real tmux. + +Replicates a tmuxp-style workspace build through the Declarative tier +(:mod:`libtmux.experimental.workspace`): a YAML/dict is analyzed into a structural +``Workspace`` spec, compiled to a Core ``LazyPlan``, and executed. Two tracks: + +* **offline** -- compile against the in-memory ``ConcreteEngine`` and assert the + op sequence and planner-equivalence (no tmux); +* **live** -- build over the async control-mode engine *and* the sync subprocess + engine against a real tmux server, then confirm the live structure matches the + spec. The same spec drives every engine and both sync and async. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ( + AsyncControlModeEngine, + ConcreteEngine, + SubprocessEngine, +) +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + MarkedPlanner, + SequentialPlanner, +) +from libtmux.experimental.workspace import ( + Workspace, + WorkspaceCompileError, + analyze, + confirm, +) +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from pathlib import Path + + from libtmux.experimental.ops.plan import PlanResult + from libtmux.session import Session + +_YAML = """ +session_name: ws-offline +start_directory: /tmp +windows: + - window_name: editor + layout: main-vertical + panes: + - echo top + - shell_command: + - echo bottom-1 + - echo bottom-2 + focus: true + - window_name: logs + focus: true + panes: + - echo logging +""" + + +def _spec(start_directory: str, name: str = "ws-live") -> Workspace: + """Return a two-window workspace spec rooted at *start_directory*.""" + return analyze( + { + "session_name": name, + "start_directory": start_directory, + "on_exists": "replace", + "windows": [ + { + "window_name": "editor", + "layout": "main-vertical", + "panes": [ + # First pane focused in a multi-pane window -- the case the + # spike broke; first-pane-id capture makes it work. + {"shell_command": ["echo top"], "focus": True}, + "echo bottom", + ], + }, + {"window_name": "logs", "focus": True, "panes": ["echo logging"]}, + ], + }, + ) + + +def test_workspace_analyze_normalizes_shorthand() -> None: + """The analyzer expands tmuxp shorthand into the canonical spec tree.""" + ws = analyze(_YAML) + assert ws.name == "ws-offline" + assert [w.name for w in ws.windows] == ["editor", "logs"] + assert ws.windows[0].panes[0].commands == ("echo top",) + assert ws.windows[0].panes[1].commands == ("echo bottom-1", "echo bottom-2") + assert ws.windows[0].panes[1].focus is True + + +def test_workspace_compiles_to_core_ops() -> None: + """Compiling the declared workspace emits Core ops in tmuxp-faithful order.""" + kinds = [op.kind for op in analyze(_YAML).compile().operations] + assert kinds[0] == "new_session" + assert kinds.count("new_window") == 1 # window 1 is reused, window 2 created + assert "split_window" in kinds + assert "select_layout" in kinds + assert kinds[-1] == "select_window" # window focus is emitted last + + +def test_workspace_offline_build_and_planner_equivalence() -> None: + """The compiled plan runs offline and the optimizer preserves the result.""" + plan = analyze(_YAML).compile() + sequential = plan.execute(ConcreteEngine(), planner=SequentialPlanner()) + folded = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + assert sequential.ok + assert folded.ok + assert [r.status for r in sequential.results] == [r.status for r in folded.results] + + +def test_first_pane_focus_multipane_compiles() -> None: + """Focusing the first pane of a multi-pane window now compiles (captured id).""" + ws = analyze( + { + "session_name": "ws-focus", + "windows": [ + { + "window_name": "w", + "panes": [{"shell_command": ["echo a"], "focus": True}, "echo b"], + }, + ], + }, + ) + plan = ws.compile() + assert "select_pane" in [op.kind for op in plan.operations] + # offline execution resolves the first-pane sub-ref without error + assert plan.execute(ConcreteEngine()).ok + + +def test_empty_workspace_is_rejected() -> None: + """A workspace with no windows fails closed at compile.""" + with pytest.raises(WorkspaceCompileError): + Workspace(name="empty").compile() + + +def test_workspace_builder_async_control_live( + session: Session, + tmp_path: Path, +) -> None: + """Build a workspace over the async control engine; confirm live structure.""" + server = session.server + spec = _spec(str(tmp_path), name="ws-async") + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await spec.abuild(engine) + + result = asyncio.run(main()) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + +def test_workspace_builder_subprocess_live( + session: Session, + tmp_path: Path, +) -> None: + """The same spec builds synchronously over the subprocess engine (neutrality).""" + server = session.server + spec = _spec(str(tmp_path), name="ws-sync") + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + +# --- Robust QA: a rich workspace exercising the full feature surface --- + + +def _rich_spec(start_directory: str, name: str = "ws-rich") -> Workspace: + """Return a three-window workspace: layouts, options, env, focus, multi-pane.""" + return analyze( + { + "session_name": name, + "start_directory": start_directory, + "on_exists": "replace", + "environment": {"WS_BUILDER": "1"}, + "options": {"history-limit": "5000"}, + "windows": [ + { + "window_name": "editor", + "layout": "main-vertical", + "options": {"main-pane-height": "12"}, + "panes": [ + {"shell_command": ["echo EDITORZERO"], "focus": True}, + "echo editor-one", + ], + }, + {"window_name": "logs", "panes": ["echo logs-zero"]}, + { + "window_name": "shell", + "focus": True, + "panes": [ + "echo shell-zero", + {"shell_command": ["echo SHELLONE"], "focus": True}, + "echo shell-two", + ], + }, + ], + }, + ) + + +def test_workspace_plan_serializes_round_trip(tmp_path: Path) -> None: + """The compiled plan (incl. SlotRef sub-refs) round-trips through to_list.""" + plan = _rich_spec(str(tmp_path)).compile() + data = plan.to_list() + restored = LazyPlan.from_list(data) + assert restored.to_list() == data + assert [o.kind for o in restored.operations] == [o.kind for o in plan.operations] + + +def test_workspace_all_planners_agree(tmp_path: Path) -> None: + """Sequential, Folding, and Marked planners give an identical PlanResult.""" + plan = _rich_spec(str(tmp_path)).compile() + runs = { + name: plan.execute(ConcreteEngine(), planner=planner()) + for name, planner in ( + ("sequential", SequentialPlanner), + ("folding", FoldingPlanner), + ("marked", MarkedPlanner), + ) + } + statuses = {name: [r.status for r in run.results] for name, run in runs.items()} + assert all(run.ok for run in runs.values()) + assert statuses["sequential"] == statuses["folding"] == statuses["marked"] + + +def test_workspace_builder_rich_subprocess(session: Session, tmp_path: Path) -> None: + """A rich workspace builds correctly: structure, focus, options, env, cwd, cmds.""" + server = session.server + spec = _rich_spec(str(tmp_path), name="ws-rich-sync") + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + built = server.sessions.filter(session_name="ws-rich-sync")[0] + windows = list(built.windows) + + # structure: names, order, per-window pane counts + assert [w.window_name for w in windows] == ["editor", "logs", "shell"] + assert [len(list(w.panes)) for w in windows] == [2, 1, 3] + + # window focus -> shell is the active window + assert built.active_window.window_name == "shell" + + # pane focus: editor's first pane + shell's middle pane are active in-window + editor, shell = windows[0], windows[2] + assert editor.active_pane is not None + assert editor.active_pane.pane_id == next(iter(editor.panes)).pane_id + assert shell.active_pane is not None + assert shell.active_pane.pane_id == list(shell.panes)[1].pane_id + + # session + window options applied + assert str(built.show_option("history-limit")) == "5000" + assert str(editor.show_option("main-pane-height")) == "12" + + # session environment set + assert built.show_environment().get("WS_BUILDER") == "1" + + # start_directory honored on a split pane (cwd settles after the shell starts) + want_cwd = str(tmp_path) + split_pane_id = list(editor.panes)[1].pane_id + + def _cwd_ok() -> bool: + pane = server.panes.get(pane_id=split_pane_id) + return pane is not None and pane.pane_current_path == want_cwd + + assert retry_until(_cwd_ok, 5, raises=False) + + # a command actually ran in the right pane + first_pane_id = next(iter(editor.panes)).pane_id + + def _cmd_ran() -> bool: + pane = server.panes.get(pane_id=first_pane_id) + return pane is not None and "EDITORZERO" in "\n".join(pane.capture_pane()) + + assert retry_until(_cmd_ran, 5, raises=False) + + +def test_workspace_builder_rich_async(session: Session, tmp_path: Path) -> None: + """The rich spec builds identically over the async control engine (neutrality).""" + server = session.server + spec = _rich_spec(str(tmp_path), name="ws-rich-async") + + async def main() -> PlanResult: + async with AsyncControlModeEngine.for_server(server) as engine: + return await spec.abuild(engine) + + result = asyncio.run(main()) + assert result.ok + report = confirm(spec, server) + assert report.ok, report.problems + + built = server.sessions.filter(session_name="ws-rich-async")[0] + assert [w.window_name for w in built.windows] == ["editor", "logs", "shell"] + assert built.active_window.window_name == "shell" + assert str(built.show_option("history-limit")) == "5000" From 325977e5e569fecc4d6133b8f01665d78bc58c13 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 18:16:16 -0500 Subject: [PATCH 059/154] Ops(feat): Serialize bindings + add plan preview why: The MCP tier needs to round-trip a plan's forward-ref bindings through JSON (tuple keys are not JSON-native) and to dry-run a plan's argv without an engine. what: - serialize.bindings_to_dict / bindings_from_dict ((slot, part) <-> "slot:part") - LazyPlan.preview(): render each op's argv, None for unresolved SlotRefs --- src/libtmux/experimental/ops/plan.py | 26 ++++++++++++++++ src/libtmux/experimental/ops/serialize.py | 36 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index e530a9867..aad70b331 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -221,6 +221,32 @@ def add_chain(self, chain: OpChain) -> None: """Record every operation of an :class:`~._chain.OpChain` in order.""" self._operations.extend(chain.ops) + def preview(self, *, version: str | None = None) -> list[tuple[str, ...] | None]: + """Render each recorded operation's argv without executing it. + + A pure dry-run: an operation whose target is still an unresolved + :class:`~._types.SlotRef` renders as ``None`` (it needs a captured id + from an earlier step, supplied only at execution time). + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) + >>> _ = plan.add(SendKeys(target=pane, keys="vim", enter=True)) + >>> plan.preview() + [('split-window', '-t', '@1', '-v', '-P', '-F', '#{pane_id}'), None] + """ + + def _render(op: Operation[t.Any]) -> tuple[str, ...] | None: + try: + return op.render(version=version) + except TypeError: # unresolved SlotRef -- needs a captured id + return None + + return [_render(op) for op in self._operations] + def _drive( self, version: str | None, diff --git a/src/libtmux/experimental/ops/serialize.py b/src/libtmux/experimental/ops/serialize.py index e96de5ff2..bfbf4066e 100644 --- a/src/libtmux/experimental/ops/serialize.py +++ b/src/libtmux/experimental/ops/serialize.py @@ -179,3 +179,39 @@ def result_from_dict(data: Mapping[str, t.Any]) -> Result: continue kwargs[field.name] = _coerce_field(data[field.name]) return result_cls(**kwargs) + + +def bindings_to_dict(bindings: Mapping[int | tuple[int, str], str]) -> dict[str, str]: + """Serialize plan bindings to a JSON-friendly ``str``-keyed dict. + + A plain slot key ``N`` becomes ``"N"``; a sub-ref key ``(N, part)`` becomes + ``"N:part"`` (e.g. ``(0, "pane")`` -> ``"0:pane"``) so a forward-ref binding + survives a JSON round-trip. + + Examples + -------- + >>> bindings_to_dict({0: "$1", (0, "pane"): "%2"}) + {'0': '$1', '0:pane': '%2'} + """ + out: dict[str, str] = {} + for key, value in bindings.items(): + out[f"{key[0]}:{key[1]}" if isinstance(key, tuple) else str(key)] = value + return out + + +def bindings_from_dict(data: Mapping[str, str]) -> dict[int | tuple[int, str], str]: + """Reconstruct plan bindings from :func:`bindings_to_dict` output. + + Examples + -------- + >>> bindings_from_dict({"0": "$1", "0:pane": "%2"}) == {0: "$1", (0, "pane"): "%2"} + True + """ + out: dict[int | tuple[int, str], str] = {} + for key, value in data.items(): + if ":" in key: + slot, part = key.split(":", 1) + out[int(slot), part] = value + else: + out[int(key)] = value + return out From da89b73204f234229ad0ac86ee206b873c19ab85 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 19:16:38 -0500 Subject: [PATCH 060/154] Mcp(feat): Add framework-agnostic tool projection why: Expose the typed-operations Core + Declarative tiers as a typed, chained, toolable command surface for agents, without coupling the library to any MCP framework. what: - experimental.mcp: ToolDescriptor/ParamDescriptor + OperationToolRegistry (per-op descriptors generated from the registry), an optional-pydantic schema builder, and TargetResolver (string/dict -> typed Target) - plan tools: preview_plan (dry-run), execute_plan (+ bindings), result_schema introspection, build_workspace - curated vocabulary: intuitive named tools mirroring libtmux's ORM (create_session/split_pane/send_input/list_*/kill_*/...) -> typed results - pure (ConcreteEngine) + live tmux tests; no fastmcp dependency --- src/libtmux/experimental/mcp/__init__.py | 98 +++++ src/libtmux/experimental/mcp/descriptor.py | 116 ++++++ src/libtmux/experimental/mcp/plan_tools.py | 144 +++++++ src/libtmux/experimental/mcp/registry.py | 166 ++++++++ src/libtmux/experimental/mcp/schema.py | 75 ++++ .../experimental/mcp/target_resolver.py | 87 ++++ src/libtmux/experimental/mcp/vocabulary.py | 373 ++++++++++++++++++ tests/experimental/mcp/__init__.py | 1 + tests/experimental/mcp/test_mcp_projection.py | 132 +++++++ tests/experimental/mcp/test_vocabulary.py | 107 +++++ 10 files changed, 1299 insertions(+) create mode 100644 src/libtmux/experimental/mcp/__init__.py create mode 100644 src/libtmux/experimental/mcp/descriptor.py create mode 100644 src/libtmux/experimental/mcp/plan_tools.py create mode 100644 src/libtmux/experimental/mcp/registry.py create mode 100644 src/libtmux/experimental/mcp/schema.py create mode 100644 src/libtmux/experimental/mcp/target_resolver.py create mode 100644 src/libtmux/experimental/mcp/vocabulary.py create mode 100644 tests/experimental/mcp/__init__.py create mode 100644 tests/experimental/mcp/test_mcp_projection.py create mode 100644 tests/experimental/mcp/test_vocabulary.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py new file mode 100644 index 000000000..eeaf2e73a --- /dev/null +++ b/src/libtmux/experimental/mcp/__init__.py @@ -0,0 +1,98 @@ +"""Framework-agnostic MCP projection: typed, chained, toolable tmux commands. + +The third tier over the Core (ops/plan/engines) and Declarative +(:mod:`libtmux.experimental.workspace`) tiers. It projects each operation into a +typed :class:`~.descriptor.ToolDescriptor` (via +:class:`~.registry.OperationToolRegistry`), resolves agent string/dict targets +(:func:`~.target_resolver.resolve_target`), and exposes plan tools +(:func:`~.plan_tools.preview_plan`, :func:`~.plan_tools.execute_plan`, +:func:`~.plan_tools.result_schema`) plus :func:`~.plan_tools.build_workspace`. + +It has **no** MCP-framework dependency (no fastmcp/pydantic at import time); a +thin adapter in a server (e.g. libtmux-mcp) binds these descriptors at runtime. +Everything here is experimental and outside the versioning policy. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> reg = OperationToolRegistry() +>>> reg.descriptor("new_session").safety +'mutating' +>>> resolve_target("%1") +PaneId(value='%1') +""" + +from __future__ import annotations + +from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor +from libtmux.experimental.mcp.plan_tools import ( + PlanOutcome, + PlanPreview, + ResultSchema, + aexecute_plan, + build_workspace, + execute_plan, + preview_plan, + result_schema, +) +from libtmux.experimental.mcp.registry import OperationToolRegistry +from libtmux.experimental.mcp.schema import schema_for_type +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary import ( + Listing, + PaneCapture, + PaneResult, + SessionResult, + WindowResult, + capture_pane, + create_session, + create_window, + kill_pane, + kill_session, + kill_window, + list_panes, + list_sessions, + list_windows, + rename_session, + rename_window, + select_layout, + select_pane, + send_input, + split_pane, +) + +__all__ = ( + "Listing", + "OperationToolRegistry", + "PaneCapture", + "PaneResult", + "ParamDescriptor", + "PlanOutcome", + "PlanPreview", + "ResultSchema", + "SessionResult", + "ToolDescriptor", + "WindowResult", + "aexecute_plan", + "build_workspace", + "capture_pane", + "create_session", + "create_window", + "execute_plan", + "kill_pane", + "kill_session", + "kill_window", + "list_panes", + "list_sessions", + "list_windows", + "preview_plan", + "rename_session", + "rename_window", + "resolve_target", + "result_schema", + "schema_for_type", + "select_layout", + "select_pane", + "send_input", + "split_pane", +) diff --git a/src/libtmux/experimental/mcp/descriptor.py b/src/libtmux/experimental/mcp/descriptor.py new file mode 100644 index 000000000..929a9c414 --- /dev/null +++ b/src/libtmux/experimental/mcp/descriptor.py @@ -0,0 +1,116 @@ +"""Framework-agnostic typed tool descriptors. + +A :class:`ToolDescriptor` is the projection of one tmux :class:`~..ops.operation. +Operation` into a tool: its name, typed parameters, safety annotations, result +schema, and a :meth:`~ToolDescriptor.build` factory that turns agent-supplied +params into a typed operation (resolving targets). It holds **no** MCP framework +object -- a thin adapter (fastmcp, click, …) binds it at runtime. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.mcp.target_resolver import resolve_target + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops.operation import Operation + +_JSON_TYPES = { + "int": "integer", + "float": "number", + "str": "string", + "bool": "boolean", + "list": "array", + "dict": "object", +} + + +@dataclass(frozen=True, slots=True) +class ParamDescriptor: + """One typed tool parameter, projected from an operation dataclass field.""" + + name: str + origin: str + is_required: bool = True + item_origin: str | None = None + description: str | None = None + version_gate: str | None = None + + def to_json_schema(self) -> dict[str, t.Any]: + """Render this parameter as a JSON-schema fragment. + + Examples + -------- + >>> p = ParamDescriptor("horizontal", "bool", description="split L/R") + >>> p.to_json_schema() + {'type': 'boolean', 'description': 'split L/R'} + """ + schema: dict[str, t.Any] = {"type": _JSON_TYPES.get(self.origin, "string")} + if self.origin == "list": + schema["items"] = { + "type": _JSON_TYPES.get(self.item_origin or "str", "string") + } + if self.description: + schema["description"] = self.description + return schema + + +@dataclass(frozen=True) +class ToolDescriptor: + """A typed tool projected from one operation -- metadata plus a builder. + + Parameters + ---------- + name, title, description + Identity and human text (``name`` is the operation ``kind``). + scope, safety + tmux object scope and the safety tier (drives annotations/tags). + params + Typed parameter descriptors (target/src_target handled by :meth:`build`). + result_type, result_schema + The result class name and a JSON schema for its payload. + annotations, tags + MCP-style hints derived from safety/effects. + operation_cls + The operation class :meth:`build` instantiates. + """ + + name: str + title: str + description: str + scope: str + safety: str + params: Mapping[str, ParamDescriptor] + result_type: str + result_schema: Mapping[str, t.Any] + annotations: Mapping[str, bool] + tags: frozenset[str] + version_gates: Mapping[str, str] + effects: Mapping[str, t.Any] + operation_cls: type[Operation[t.Any]] + + def input_schema(self) -> dict[str, t.Any]: + """Render the JSON schema for this tool's input object.""" + props = {name: param.to_json_schema() for name, param in self.params.items()} + required = [name for name, param in self.params.items() if param.is_required] + schema: dict[str, t.Any] = {"type": "object", "properties": props} + if required: + schema["required"] = required + return schema + + def build(self, **kwargs: t.Any) -> Operation[t.Any]: + """Construct the typed operation from agent params, resolving targets. + + ``target`` / ``src_target`` accept the polymorphic forms + :func:`~.target_resolver.resolve_target` understands; the rest are passed + through as operation fields (an unknown field fails closed via + ``TypeError``). + """ + fields = dict(kwargs) + target = resolve_target(fields.pop("target", None)) + src_target = resolve_target(fields.pop("src_target", None)) + return self.operation_cls(target=target, src_target=src_target, **fields) diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py new file mode 100644 index 000000000..bc5bc7101 --- /dev/null +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -0,0 +1,144 @@ +"""Plan-tier tools: preview a plan, execute it (with bindings), introspect. + +These wrap the Core :class:`~..ops.plan.LazyPlan` for an agent: a pure dry-run, a +typed execution that returns JSON-serialisable per-op results plus a forward-ref +``bindings`` map, and a result-schema query so an agent can learn what ids a step +will yield *before* composing the next step. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops.serialize import ( + bindings_to_dict, + operation_to_dict, + result_to_dict, +) + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.mcp.registry import OperationToolRegistry + from libtmux.experimental.ops.plan import LazyPlan + from libtmux.experimental.ops.planner import Planner + + +@dataclass(frozen=True) +class PlanPreview: + """A pure dry-run of a plan: per-op dicts + rendered argv (or ``None``).""" + + operations: list[dict[str, t.Any]] + argv: list[tuple[str, ...] | None] + + @property + def ok(self) -> bool: + """Whether every operation rendered (no unresolved forward refs).""" + return all(item is not None for item in self.argv) + + +def preview_plan(plan: LazyPlan, *, version: str | None = None) -> PlanPreview: + """Render a plan without executing it (forward-ref steps render as ``None``).""" + return PlanPreview( + operations=[operation_to_dict(op) for op in plan.operations], + argv=plan.preview(version=version), + ) + + +@dataclass(frozen=True) +class PlanOutcome: + """The result of executing a plan: per-op result dicts + a bindings map.""" + + ok: bool + results: list[dict[str, t.Any]] + bindings: dict[str, str] + + +def execute_plan( + plan: LazyPlan, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, +) -> PlanOutcome: + """Execute *plan* over *engine*; return JSON-friendly results + bindings.""" + result = plan.execute(engine, version=version, planner=planner) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) + + +async def aexecute_plan( + plan: LazyPlan, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, +) -> PlanOutcome: + """Async sibling of :func:`execute_plan` (same shape).""" + result = await plan.aexecute(engine, version=version, planner=planner) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) + + +@dataclass(frozen=True) +class ResultSchema: + """A result type's schema + the fields an agent can bind downstream.""" + + kind: str + result_type: str + schema: dict[str, t.Any] + binding_fields: list[str] + + +def result_schema(registry: OperationToolRegistry, kind: str) -> ResultSchema: + """Introspect what *kind* returns -- so an agent can plan forward refs. + + ``binding_fields`` are the result fields carrying ids an agent would reference + in a later step (``*_id`` / ``new_id``); they are read from the result + dataclass directly, so they do not depend on the JSON-schema backend. + """ + import dataclasses + + from libtmux.experimental.ops import registry as ops_registry + + descriptor = registry.descriptor(kind) + fields = [ + field.name for field in dataclasses.fields(ops_registry.get(kind).result_cls) + ] + binding_fields = [ + name for name in fields if name == "new_id" or name.endswith("_id") + ] + return ResultSchema( + kind=kind, + result_type=descriptor.result_type, + schema=dict(descriptor.result_schema), + binding_fields=binding_fields, + ) + + +def build_workspace( + spec: t.Mapping[str, t.Any] | str, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanOutcome: + """Build a declarative workspace (the Declarative tier) as one tool call. + + *spec* is a tmux-style mapping or YAML string (see + :func:`~..workspace.analyzer.analyze`). + """ + from libtmux.experimental.workspace import analyze + + result = analyze(spec).build(engine, version=version, preflight=preflight) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) diff --git a/src/libtmux/experimental/mcp/registry.py b/src/libtmux/experimental/mcp/registry.py new file mode 100644 index 000000000..6f153d35e --- /dev/null +++ b/src/libtmux/experimental/mcp/registry.py @@ -0,0 +1,166 @@ +"""Generate :class:`~.descriptor.ToolDescriptor` values from the op registry. + +One descriptor per registered operation ``kind``, derived by introspecting the +operation dataclass (fields + type hints + NumPy-docstring params) and its +``OpSpec`` metadata (scope/safety/effects/version gates). Zero MCP-framework +coupling: the result is plain data + a builder. +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor +from libtmux.experimental.mcp.schema import schema_for_type +from libtmux.experimental.ops import registry as ops_registry + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.registry import OpSpec + +_ANNOTATIONS: dict[str, dict[str, bool]] = { + "readonly": {"readOnlyHint": True}, + "mutating": {"readOnlyHint": False}, + "destructive": {"readOnlyHint": False, "destructiveHint": True}, +} +_SKIP_FIELDS = frozenset({"target", "src_target"}) +_SCALAR_NAME = {"bool": "bool", "int": "int", "float": "float", "str": "str"} +_LIST_BASES = frozenset({"list", "tuple", "Sequence", "frozenset", "set"}) +_DICT_BASES = frozenset({"dict", "Mapping", "MutableMapping"}) + + +def _origin_of(annotation: t.Any) -> tuple[str, str | None]: + """Map a field annotation to a ``(origin, item_origin)`` schema pair. + + Parses the annotation *string* (operations use ``from __future__ import + annotations``, and their hints reference ``TYPE_CHECKING``-only names like + ``Mapping`` that ``get_type_hints`` cannot resolve at runtime), so it never + needs to import the annotated types. + """ + text = ( + annotation + if isinstance(annotation, str) + else getattr(annotation, "__name__", str(annotation)) + ) + text = text.replace(" ", "") + if "|" in text: + members = [member for member in text.split("|") if member and member != "None"] + text = members[0] if members else "str" + base = text.split("[", 1)[0] + if base in _LIST_BASES: + inner = text[len(base) + 1 : -1] if "[" in text else "" + item = inner.split("[", 1)[0].split(",", 1)[0] if inner else "str" + return "list", _SCALAR_NAME.get(item, "str") + if base in _DICT_BASES: + return "dict", None + return _SCALAR_NAME.get(base, "str"), None + + +def _docstring_params(doc: str | None) -> dict[str, str]: + """Parse ``name : type`` entries from a NumPy docstring Parameters block.""" + if not doc: + return {} + out: dict[str, str] = {} + in_params = False + pending: str | None = None + for raw in doc.splitlines(): + line = raw.rstrip() + if line.strip() in {"Parameters", "Attributes"}: + in_params = True + continue + if not in_params: + continue + if line.strip().startswith(("Returns", "Examples", "Notes", "Raises")): + break + if " : " in line and not line.startswith(" "): + pending = line.split(" : ", 1)[0].strip() + elif pending and line.startswith(" ") and line.strip(): + out.setdefault(pending, line.strip()) + pending = None + return out + + +def _summary(doc: str | None) -> str: + """Return the first non-empty docstring line.""" + for line in (doc or "").splitlines(): + if line.strip(): + return line.strip() + return "" + + +class OperationToolRegistry: + """Build (and cache) a :class:`~.descriptor.ToolDescriptor` per operation. + + Examples + -------- + >>> reg = OperationToolRegistry() + >>> d = reg.descriptor("split_window") + >>> d.name, d.scope, d.safety + ('split_window', 'window', 'mutating') + >>> d.params["horizontal"].origin + 'bool' + >>> d.build(target="@1", horizontal=True).render() + ('split-window', '-t', '@1', '-h', '-P', '-F', '#{pane_id}') + >>> len(reg.descriptors()) == len(list(reg.kinds())) + True + """ + + def __init__(self) -> None: + self._cache: dict[str, ToolDescriptor] = {} + + def kinds(self) -> tuple[str, ...]: + """Return every registered operation kind, sorted.""" + return ops_registry.kinds() + + def descriptor(self, kind: str) -> ToolDescriptor: + """Return (building + caching) the descriptor for *kind*.""" + cached = self._cache.get(kind) + if cached is not None: + return cached + built = self._build(ops_registry.get(kind)) + self._cache[kind] = built + return built + + def descriptors(self) -> list[ToolDescriptor]: + """Return a descriptor for every registered operation, sorted by name.""" + return [self.descriptor(spec.kind) for spec in ops_registry.select()] + + def _build(self, spec: OpSpec) -> ToolDescriptor: + """Project one ``OpSpec`` into a tool descriptor.""" + return ToolDescriptor( + name=spec.kind, + title=spec.kind.replace("_", " ").title(), + description=_summary(spec.operation_cls.__doc__), + scope=spec.scope, + safety=spec.safety, + params=self._params(spec), + result_type=spec.result_cls.__name__, + result_schema=schema_for_type(spec.result_cls), + annotations=_ANNOTATIONS.get(spec.safety, {}), + tags=frozenset({spec.safety}), + version_gates=dict(spec.flag_version_map), + effects=dataclasses.asdict(spec.effects), + operation_cls=spec.operation_cls, + ) + + def _params(self, spec: OpSpec) -> dict[str, ParamDescriptor]: + """Extract typed parameter descriptors from the operation's fields.""" + operation_cls = spec.operation_cls + docs = _docstring_params(operation_cls.__doc__) + params: dict[str, ParamDescriptor] = {} + for field in dataclasses.fields(operation_cls): + if field.name in _SKIP_FIELDS: + continue + origin, item = _origin_of(field.type) + params[field.name] = ParamDescriptor( + name=field.name, + origin=origin, + item_origin=item, + is_required=( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ), + description=docs.get(field.name), + version_gate=spec.flag_version_map.get(field.name), + ) + return params diff --git a/src/libtmux/experimental/mcp/schema.py b/src/libtmux/experimental/mcp/schema.py new file mode 100644 index 000000000..cedbfe811 --- /dev/null +++ b/src/libtmux/experimental/mcp/schema.py @@ -0,0 +1,75 @@ +"""Best-effort JSON-schema generation for result types. + +The single place pydantic is *optional*: if installed, its ``TypeAdapter`` gives a +precise schema; otherwise a small stdlib introspection produces a serviceable one. +Either way the projection core has no hard pydantic dependency. +""" + +from __future__ import annotations + +import collections.abc +import dataclasses +import typing as t + +_SCALARS: dict[type, str] = { + int: "integer", + float: "number", + str: "string", + bool: "boolean", +} + + +def schema_for_type(tp: type) -> dict[str, t.Any]: + """Return a JSON-schema dict for *tp* (pydantic if available, else stdlib). + + Examples + -------- + >>> schema_for_type(int) + {'type': 'integer'} + >>> schema_for_type(str) + {'type': 'string'} + """ + import importlib + + try: + type_adapter = importlib.import_module("pydantic").TypeAdapter + except ImportError: + return _introspect(tp) + try: + return dict(type_adapter(tp).json_schema()) + except Exception: # pydantic rejects some dataclasses -- fall back to stdlib + return _introspect(tp) + + +def _introspect(tp: t.Any) -> dict[str, t.Any]: + """Render a coarse JSON schema by walking dataclass fields / generics.""" + if dataclasses.is_dataclass(tp) and isinstance(tp, type): + try: + hints = t.get_type_hints(tp) + except Exception: + hints = {} + props: dict[str, t.Any] = {} + required: list[str] = [] + for field in dataclasses.fields(tp): + if field.name == "operation": # back-reference to the source op, not output + continue + props[field.name] = _introspect(hints.get(field.name, str)) + if ( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ): + required.append(field.name) + schema: dict[str, t.Any] = {"type": "object", "properties": props} + if required: + schema["required"] = required + return schema + if tp in _SCALARS: + return {"type": _SCALARS[tp]} + origin = t.get_origin(tp) + if origin in (list, tuple): + args = t.get_args(tp) + item = _introspect(args[0]) if args else {"type": "string"} + return {"type": "array", "items": item} + if origin in (dict, collections.abc.Mapping): + return {"type": "object"} + return {"type": "string"} diff --git a/src/libtmux/experimental/mcp/target_resolver.py b/src/libtmux/experimental/mcp/target_resolver.py new file mode 100644 index 000000000..000c9b59e --- /dev/null +++ b/src/libtmux/experimental/mcp/target_resolver.py @@ -0,0 +1,87 @@ +"""Resolve agent-supplied targets to typed :data:`~..ops._types.Target` values. + +The string/dict boundary between an MCP client (which speaks JSON) and the typed +operation spine. Fail-closed: an unrecognised target raises rather than guessing. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.ops._types import ( + ClientName, + IndexRef, + NameRef, + PaneId, + SessionId, + SlotRef, + Special, + WindowId, +) +from libtmux.experimental.ops.serialize import target_from_dict + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Target + +_TARGET_CLASSES = ( + PaneId, + WindowId, + SessionId, + ClientName, + NameRef, + IndexRef, + Special, + SlotRef, +) + + +def resolve_target(value: str | Mapping[str, t.Any] | Target | None) -> Target | None: + """Coerce a target spec into a typed :data:`~..ops._types.Target`. + + Accepts an already-typed target (passthrough), the tagged dict form from + :func:`~..ops.serialize.target_to_dict`, ``None``, or a string using tmux + sigils: ``%``→pane, ``@``→window, ``$``→session, ``/``→client, ``{...}``→ + special, ``=name``→exact name, otherwise a prefix-matched name. + + Examples + -------- + >>> resolve_target("%1") + PaneId(value='%1') + >>> resolve_target("@2") + WindowId(value='@2') + >>> resolve_target("work") + NameRef(name='work', exact=False) + >>> resolve_target({"type": "PaneId", "value": "%3"}) + PaneId(value='%3') + >>> resolve_target(None) is None + True + """ + if value is None: + return None + if isinstance(value, _TARGET_CLASSES): + return value + if isinstance(value, str): + return _from_string(value) + return target_from_dict(value) + + +def _from_string(value: str) -> Target: + """Parse a target string by its tmux sigil (fail-closed on empty).""" + if not value: + msg = "empty target string" + raise ValueError(msg) + if value.startswith("%"): + return PaneId(value) + if value.startswith("@"): + return WindowId(value) + if value.startswith("$"): + return SessionId(value) + if value.startswith("/"): + return ClientName(value) + if value.startswith("{") and value.endswith("}"): + return Special(value) + if value.startswith("="): + return NameRef(value[1:], exact=True) + return NameRef(value) diff --git a/src/libtmux/experimental/mcp/vocabulary.py b/src/libtmux/experimental/mcp/vocabulary.py new file mode 100644 index 000000000..ec44f519a --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary.py @@ -0,0 +1,373 @@ +"""Curated core vocabulary -- the intuitive, named tmux tools. + +The Layer-1 surface: a small set of hand-written, framework-agnostic functions +that mirror libtmux's own ORM (``server.new_session`` / ``window.split_window`` / +``pane.send_keys``) but run over any engine and return small, typed result +objects exposing just the ids/names a caller cares about. Each is a thin wrapper: +resolve the target, build one operation, :func:`~..ops.execute.run` it, raise on +failure, return a typed result. Power users drop to the per-op descriptors, +plans, or ops directly. + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> engine = ConcreteEngine() +>>> session = create_session(engine, name="dev") +>>> session.session_id +'$1' +>>> pane = split_pane(engine, session.first_pane_id or "%1", horizontal=True) +>>> pane.pane_id +'%2' +>>> send_input(engine, pane.pane_id, "pytest -q", enter=True) is None +True +""" + +from __future__ import annotations + +import collections.abc +from dataclasses import dataclass + +from libtmux.experimental.engines.base import TmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.ops import ( + CapturePane, + KillPane, + KillSession, + KillWindow, + ListPanes, + ListSessions, + ListWindows, + NewSession, + NewWindow, + RenameSession, + RenameWindow, + SelectLayout, + SelectPane, + SendKeys, + SplitWindow, + run, +) +from libtmux.experimental.ops._types import Target + +# TmuxEngine / Target are imported at runtime (not under TYPE_CHECKING) so the +# fastmcp adapter's get_type_hints() can resolve these annotations when it builds +# tool schemas from these functions. + + +@dataclass(frozen=True) +class SessionResult: + """A created session: its id, name, and captured first window/pane ids.""" + + session_id: str + name: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class WindowResult: + """A created window: its id, name, and captured first pane id.""" + + window_id: str + name: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class PaneResult: + """A created pane: its id.""" + + pane_id: str + + +@dataclass(frozen=True) +class PaneCapture: + """Captured pane contents.""" + + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class Listing: + """A list query result: one mapping (tmux format row) per object.""" + + rows: tuple[collections.abc.Mapping[str, str], ...] + + +def create_session( + engine: TmuxEngine, + *, + name: str | None = None, + start_directory: str | None = None, + environment: collections.abc.Mapping[str, str] | None = None, + width: int | None = None, + height: int | None = None, + version: str | None = None, +) -> SessionResult: + """Create a detached session (mirrors ``server.new_session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> r = create_session(ConcreteEngine(), name="work") + >>> (r.session_id, r.name, r.first_pane_id) + ('$1', 'work', '%1') + """ + result = run( + NewSession( + session_name=name, + start_directory=start_directory, + environment=environment, + width=width, + height=height, + capture_panes=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return SessionResult( + session_id=result.new_id or "", + name=name, + first_window_id=result.first_window_id, + first_pane_id=result.first_pane_id, + ) + + +def create_window( + engine: TmuxEngine, + target: str | Target, + *, + name: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> WindowResult: + """Create a window in a session (mirrors ``session.new_window``).""" + result = run( + NewWindow( + target=resolve_target(target), + name=name, + start_directory=start_directory, + capture_pane=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult( + window_id=result.new_id or "", + name=name, + first_pane_id=result.first_pane_id, + ) + + +def split_pane( + engine: TmuxEngine, + target: str | Target, + *, + horizontal: bool = False, + start_directory: str | None = None, + version: str | None = None, +) -> PaneResult: + """Split a pane, creating a new one (mirrors ``window.split_window``).""" + result = run( + SplitWindow( + target=resolve_target(target), + horizontal=horizontal, + start_directory=start_directory, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneResult(pane_id=result.new_pane_id or "") + + +def send_input( + engine: TmuxEngine, + target: str | Target, + keys: str, + *, + enter: bool = False, + literal: bool = False, + suppress_history: bool = False, + version: str | None = None, +) -> None: + """Send keys to a pane (mirrors ``pane.send_keys``).""" + run( + SendKeys( + target=resolve_target(target), + keys=keys, + enter=enter, + literal=literal, + suppress_history=suppress_history, + ), + engine, + version=version, + ).raise_for_status() + + +def capture_pane( + engine: TmuxEngine, + target: str | Target, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture a pane's contents (mirrors ``pane.capture_pane``).""" + result = run( + CapturePane( + target=resolve_target(target), + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +def list_sessions(engine: TmuxEngine, *, version: str | None = None) -> Listing: + """List the server's sessions (mirrors ``server.sessions``).""" + result = run(ListSessions(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +def list_windows( + engine: TmuxEngine, + target: str | Target | None = None, + *, + all_windows: bool = False, + version: str | None = None, +) -> Listing: + """List windows of a session, or all windows (mirrors ``session.windows``).""" + result = run( + ListWindows(target=resolve_target(target), all_windows=all_windows), + engine, + version=version, + ) + result.raise_for_status() + return Listing(rows=result.rows) + + +def list_panes( + engine: TmuxEngine, + target: str | Target | None = None, + *, + all_panes: bool = False, + version: str | None = None, +) -> Listing: + """List panes of a window, or all panes (mirrors ``window.panes``).""" + result = run( + ListPanes(target=resolve_target(target), all_panes=all_panes), + engine, + version=version, + ) + result.raise_for_status() + return Listing(rows=result.rows) + + +def kill_pane( + engine: TmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a pane (or all others in its window with ``others=True``).""" + run( + KillPane(target=resolve_target(target), others=others), + engine, + version=version, + ).raise_for_status() + + +def kill_window( + engine: TmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a window (or all others in its session with ``others=True``).""" + run( + KillWindow(target=resolve_target(target), others=others), + engine, + version=version, + ).raise_for_status() + + +def kill_session( + engine: TmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Kill a session (mirrors ``session.kill``).""" + run( + KillSession(target=resolve_target(target)), engine, version=version + ).raise_for_status() + + +def rename_window( + engine: TmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a window (mirrors ``window.rename_window``).""" + run( + RenameWindow(target=resolve_target(target), name=name), + engine, + version=version, + ).raise_for_status() + + +def rename_session( + engine: TmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a session (mirrors ``session.rename_session``).""" + run( + RenameSession(target=resolve_target(target), name=name), + engine, + version=version, + ).raise_for_status() + + +def select_layout( + engine: TmuxEngine, + target: str | Target, + *, + layout: str | None = None, + version: str | None = None, +) -> None: + """Apply a layout to a window (mirrors ``window.select_layout``).""" + run( + SelectLayout(target=resolve_target(target), layout=layout), + engine, + version=version, + ).raise_for_status() + + +def select_pane( + engine: TmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Make a pane active (mirrors ``window.select_pane``).""" + run( + SelectPane(target=resolve_target(target)), engine, version=version + ).raise_for_status() diff --git a/tests/experimental/mcp/__init__.py b/tests/experimental/mcp/__init__.py new file mode 100644 index 000000000..83655d469 --- /dev/null +++ b/tests/experimental/mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the framework-agnostic MCP projection tier.""" diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py new file mode 100644 index 000000000..237b6d0d6 --- /dev/null +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -0,0 +1,132 @@ +"""The framework-agnostic MCP projection tier (no fastmcp required). + +Exercises descriptor generation from the operation registry, agent target +resolution, plan preview/execute with forward-ref bindings, result-schema +introspection, and the build_workspace tool -- all against the in-memory +``ConcreteEngine`` so the projection is provably correct offline. +""" + +from __future__ import annotations + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.mcp import ( + OperationToolRegistry, + build_workspace, + execute_plan, + preview_plan, + resolve_target, + result_schema, +) +from libtmux.experimental.ops import ( + LazyPlan, + NewSession, + SendKeys, + SplitWindow, + registry, +) +from libtmux.experimental.ops._types import NameRef, PaneId, SessionId, WindowId +from libtmux.experimental.ops.serialize import bindings_from_dict, bindings_to_dict + + +def test_every_operation_has_a_descriptor() -> None: + """The registry projects one valid descriptor per registered operation kind.""" + reg = OperationToolRegistry() + descriptors = reg.descriptors() + assert {d.name for d in descriptors} == set(registry.kinds()) + for d in descriptors: + schema = d.input_schema() + assert schema["type"] == "object" + assert d.safety in {"readonly", "mutating", "destructive"} + + +def test_split_window_descriptor_shape() -> None: + """A per-op descriptor carries typed params, scope, safety, and annotations.""" + desc = OperationToolRegistry().descriptor("split_window") + assert desc.name == "split_window" + assert desc.scope == "window" + assert desc.safety == "mutating" + assert desc.annotations == {"readOnlyHint": False} + assert desc.result_type == "SplitWindowResult" + assert desc.params["horizontal"].origin == "bool" + assert desc.params["horizontal"].is_required is False + + +def test_readonly_op_annotation() -> None: + """A readonly operation projects a readOnlyHint annotation + tag.""" + desc = OperationToolRegistry().descriptor("has_session") + assert desc.annotations == {"readOnlyHint": True} + assert "readonly" in desc.tags + + +def test_descriptor_build_resolves_targets() -> None: + """ToolDescriptor.build turns agent params into a typed operation.""" + desc = OperationToolRegistry().descriptor("split_window") + op = desc.build(target="@1", horizontal=True) + assert isinstance(op, SplitWindow) + assert op.target == WindowId("@1") + assert op.render() == ("split-window", "-t", "@1", "-h", "-P", "-F", "#{pane_id}") + + +def test_resolve_target_forms() -> None: + """resolve_target coerces every supported spec into a typed Target.""" + assert resolve_target("%1") == PaneId("%1") + assert resolve_target("@2") == WindowId("@2") + assert resolve_target("$0") == SessionId("$0") + assert resolve_target("work") == NameRef("work") + assert resolve_target({"type": "PaneId", "value": "%3"}) == PaneId("%3") + assert resolve_target(PaneId("%4")) == PaneId("%4") + assert resolve_target(None) is None + + +def test_bindings_round_trip() -> None: + """Plan bindings (incl. sub-ref tuple keys) survive a JSON round-trip.""" + original: dict[int | tuple[int, str], str] = {0: "$1", (0, "pane"): "%2", 1: "@3"} + assert bindings_from_dict(bindings_to_dict(original)) == original + + +def test_preview_plan_marks_unresolved_forward_refs() -> None: + """preview_plan renders a pure dry-run; forward-ref steps render as None.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + preview = preview_plan(plan) + assert preview.argv[0] is not None + assert preview.argv[1] is None # SendKeys targets the not-yet-created pane + assert preview.ok is False + + +def test_execute_plan_returns_bindings() -> None: + """execute_plan resolves forward refs and returns a JSON bindings map.""" + plan = LazyPlan() + session = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.add(SendKeys(target=session.pane, keys="vim", enter=True)) + outcome = execute_plan(plan, ConcreteEngine()) + assert outcome.ok + assert outcome.bindings["0"].startswith("$") + assert outcome.bindings["0:pane"].startswith("%") + assert outcome.results[1]["argv"][0] == "send-keys" + + +def test_result_schema_introspection() -> None: + """result_schema reports the id fields an agent can bind downstream.""" + split = result_schema(OperationToolRegistry(), "split_window") + assert split.result_type == "SplitWindowResult" + assert "new_pane_id" in split.binding_fields + + session = result_schema(OperationToolRegistry(), "new_session") + assert "first_pane_id" in session.binding_fields + assert "first_window_id" in session.binding_fields + + +def test_build_workspace_tool_offline() -> None: + """build_workspace runs the declarative tier as one tool call (offline).""" + outcome = build_workspace( + { + "session_name": "dev", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + }, + ConcreteEngine(), + preflight=False, + ) + assert outcome.ok + assert outcome.bindings["0"].startswith("$") diff --git a/tests/experimental/mcp/test_vocabulary.py b/tests/experimental/mcp/test_vocabulary.py new file mode 100644 index 000000000..790112953 --- /dev/null +++ b/tests/experimental/mcp/test_vocabulary.py @@ -0,0 +1,107 @@ +"""The curated core vocabulary -- intuitive named tmux tools. + +Pure tests run the vocabulary against the in-memory ``ConcreteEngine`` (no tmux); +a live test drives a real tmux server end to end (create -> window -> split -> +send -> capture -> rename -> kill) over the subprocess engine. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp import ( + capture_pane, + create_session, + create_window, + kill_session, + list_panes, + list_sessions, + list_windows, + rename_window, + send_input, + split_pane, +) +from libtmux.experimental.ops._types import SessionId +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from pathlib import Path + + from libtmux.session import Session + + +def test_create_session_returns_typed_result() -> None: + """create_session yields a typed result with the captured first pane id.""" + result = create_session(ConcreteEngine(), name="dev") + assert result.session_id == "$1" + assert result.name == "dev" + assert result.first_window_id == "@1" + assert result.first_pane_id == "%1" + + +def test_create_window_then_split() -> None: + """create_window captures a first pane id that split_pane can target.""" + engine = ConcreteEngine() + session = create_session(engine, name="dev") + window = create_window(engine, session.session_id, name="logs") + assert window.window_id.startswith("@") + assert window.first_pane_id is not None + pane = split_pane(engine, window.first_pane_id, horizontal=True) + assert pane.pane_id.startswith("%") + + +def test_send_input_is_fire_and_forget() -> None: + """send_input runs without returning a value (and without raising).""" + send_input(ConcreteEngine(), "%1", "echo hi", enter=True) + + +def test_capture_pane_returns_lines() -> None: + """capture_pane surfaces the pane's lines.""" + engine = ConcreteEngine(capture_lines=("line-1", "line-2")) + assert capture_pane(engine, "%1").lines == ("line-1", "line-2") + + +def test_list_tools_return_listings() -> None: + """The list_* tools return a Listing of format rows.""" + engine = ConcreteEngine() + assert isinstance(list_sessions(engine).rows, tuple) + assert isinstance(list_windows(engine).rows, tuple) + assert isinstance(list_panes(engine).rows, tuple) + + +def test_target_accepts_string_or_typed() -> None: + """A vocabulary target may be a string or an already-typed Target.""" + engine = ConcreteEngine() + assert create_window(engine, "$1").window_id.startswith("@") + assert create_window(engine, SessionId("$1")).window_id.startswith("@") + + +def test_vocabulary_live(session: Session, tmp_path: Path) -> None: + """Drive a real tmux server through the curated vocabulary end to end.""" + server = session.server + engine = SubprocessEngine.for_server(server) + + created = create_session(engine, name="vocab-live", start_directory=str(tmp_path)) + try: + assert server.sessions.filter(session_name="vocab-live") + assert created.first_pane_id is not None + + window = create_window(engine, created.session_id, name="extra") + assert window.first_pane_id is not None + pane = split_pane(engine, window.first_pane_id, horizontal=True) + send_input(engine, pane.pane_id, "echo VOCABMARK", enter=True) + + def _ran() -> bool: + live = server.panes.get(pane_id=pane.pane_id) + return live is not None and "VOCABMARK" in "\n".join(live.capture_pane()) + + assert retry_until(_ran, 5, raises=False) + + rename_window(engine, window.window_id, "renamed") + renamed = server.windows.get(window_id=window.window_id) + assert renamed is not None + assert renamed.window_name == "renamed" + finally: + kill_session(engine, created.session_id) + assert not server.sessions.filter(session_name="vocab-live") From bb4b296f8b780015a290bd4d9ae8752ab3be9cfb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 20:26:28 -0500 Subject: [PATCH 061/154] Mcp(feat): Add optional fastmcp adapter (libtmux[mcp]) why: Prove the projection drives a real MCP server and give downstream servers a one-call binding -- behind an optional extra so the core stays dependency-free. what: - fastmcp_adapter.build_server(engine): register the curated vocabulary as typed FastMCP tools (engine bound out of the schema, safety -> ToolAnnotations) - add fastmcp to a new [project.optional-dependencies] mcp extra - in-process tests (offline + live) call the tools via fastmcp's Client --- pyproject.toml | 5 + .../experimental/mcp/fastmcp_adapter.py | 127 ++ .../experimental/mcp/test_fastmcp_adapter.py | 90 ++ uv.lock | 1301 ++++++++++++++++- 4 files changed, 1516 insertions(+), 7 deletions(-) create mode 100644 src/libtmux/experimental/mcp/fastmcp_adapter.py create mode 100644 tests/experimental/mcp/test_fastmcp_adapter.py diff --git a/pyproject.toml b/pyproject.toml index 69db3d394..056b076f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,11 @@ lint = [ [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +[project.optional-dependencies] +mcp = [ + "fastmcp>=3.4.2", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py new file mode 100644 index 000000000..c172e0788 --- /dev/null +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -0,0 +1,127 @@ +"""Optional fastmcp adapter -- expose the curated vocabulary on a FastMCP server. + +This is the thin, framework-specific edge. It requires the ``mcp`` extra +(``pip install libtmux[mcp]``); fastmcp is imported lazily so the rest of +:mod:`libtmux.experimental.mcp` stays dependency-free. The agent-facing ``engine`` +is bound out of each tool's schema with :func:`functools.partial`, and the safety +tier becomes the tool's ``ToolAnnotations`` + tag. + +Examples +-------- +>>> import asyncio +>>> from fastmcp import Client # doctest: +SKIP +>>> from libtmux.experimental.engines import ConcreteEngine +>>> server = build_server(ConcreteEngine()) # doctest: +SKIP +>>> async def main(): # doctest: +SKIP +... async with Client(server) as client: +... return (await client.call_tool("create_session", {"name": "dev"})).data +>>> asyncio.run(main()) # doctest: +SKIP +""" + +from __future__ import annotations + +import inspect +import typing as t + +from libtmux.experimental.mcp import vocabulary + +if t.TYPE_CHECKING: + from collections.abc import Callable + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import TmuxEngine + +# (function, safety tier) -- every curated tool takes ``engine`` as its first arg. +_VOCABULARY: tuple[tuple[Callable[..., t.Any], str], ...] = ( + (vocabulary.create_session, "mutating"), + (vocabulary.create_window, "mutating"), + (vocabulary.split_pane, "mutating"), + (vocabulary.send_input, "mutating"), + (vocabulary.capture_pane, "readonly"), + (vocabulary.list_sessions, "readonly"), + (vocabulary.list_windows, "readonly"), + (vocabulary.list_panes, "readonly"), + (vocabulary.rename_window, "mutating"), + (vocabulary.rename_session, "mutating"), + (vocabulary.select_layout, "mutating"), + (vocabulary.select_pane, "mutating"), + (vocabulary.kill_pane, "destructive"), + (vocabulary.kill_window, "destructive"), + (vocabulary.kill_session, "destructive"), +) + +_INSTRUCTIONS = ( + "Drive tmux through typed tools. Targets accept tmux ids (%pane, @window, " + "$session), names, or 'session:window.pane'. Creating a session/window also " + "returns the new first-pane id for chaining." +) + + +def _summary(doc: str | None) -> str | None: + """Return the first non-empty docstring line.""" + for line in (doc or "").splitlines(): + if line.strip(): + return line.strip() + return None + + +def _bind_engine( + fn: Callable[..., t.Any], + engine: TmuxEngine, +) -> Callable[..., t.Any]: + """Bind *engine* out of *fn*, returning a wrapper fastmcp can introspect. + + Unlike :func:`functools.partial`, this carries *pre-resolved* annotations + (with ``engine`` removed) and an explicit ``__signature__``, so fastmcp's + ``get_type_hints`` never has to re-evaluate the forward references in *fn* + (it would do so against the wrong module globals and fail). + """ + hints = t.get_type_hints(fn) + signature = inspect.signature(fn) + params = [p for name, p in signature.parameters.items() if name != "engine"] + + def tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + return fn(engine, *args, **kwargs) + + tool.__name__ = fn.__name__ + tool.__qualname__ = fn.__name__ + tool.__doc__ = fn.__doc__ + tool.__signature__ = signature.replace(parameters=params) # type: ignore[attr-defined] + tool.__annotations__ = {k: v for k, v in hints.items() if k != "engine"} + return tool + + +def register_vocabulary(mcp: FastMCP, engine: TmuxEngine) -> None: + """Register the curated vocabulary as tools on *mcp*, bound to *engine*.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + for fn, safety in _VOCABULARY: + annotations = ToolAnnotations( + title=fn.__name__, + readOnlyHint=safety == "readonly", + destructiveHint=safety == "destructive", + ) + tool = FunctionTool.from_function( + _bind_engine(fn, engine), + name=fn.__name__, + description=_summary(fn.__doc__), + tags={safety}, + annotations=annotations, + ) + mcp.add_tool(tool) + + +def build_server( + engine: TmuxEngine, + *, + name: str = "libtmux", + instructions: str | None = None, +) -> FastMCP: + """Build a FastMCP server exposing the curated vocabulary over *engine*.""" + from fastmcp import FastMCP + + mcp: FastMCP = FastMCP(name=name, instructions=instructions or _INSTRUCTIONS) + register_vocabulary(mcp, engine) + return mcp diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py new file mode 100644 index 000000000..e52ae7a34 --- /dev/null +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -0,0 +1,90 @@ +"""The optional fastmcp adapter on a real FastMCP server (in-process). + +Proves the framework-agnostic projection actually drives fastmcp: the curated +vocabulary registers as typed tools (engine bound out of the schema, safety -> +annotations), and an in-process client can list and call them -- offline against +the ``ConcreteEngine`` and live against a real tmux server. Skipped entirely when +the ``mcp`` extra (fastmcp) is not installed. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.fastmcp_adapter import build_server + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_adapter_registers_typed_tools() -> None: + """The curated vocabulary appears as typed tools with safety annotations.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + tools = asyncio.run(main()) + by_name = {tool.name: tool for tool in tools} + assert { + "create_session", + "create_window", + "split_pane", + "send_input", + "capture_pane", + "list_sessions", + "kill_session", + } <= set(by_name) + + # safety tier -> ToolAnnotations + assert by_name["capture_pane"].annotations.readOnlyHint is True + assert by_name["kill_session"].annotations.destructiveHint is True + assert by_name["create_session"].annotations.readOnlyHint is False + + # the engine is injected, not an agent-facing parameter + properties = by_name["create_session"].inputSchema.get("properties", {}) + assert "engine" not in properties + assert "name" in properties + + +def test_adapter_calls_tool_offline() -> None: + """Calling a tool through the in-process client returns structured output.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool("create_session", {"name": "dev"}) + + result = asyncio.run(main()) + payload = result.structured_content or {} + assert payload.get("session_id") == "$1" + assert payload.get("first_pane_id") == "%1" + + +def test_adapter_live(session: Session) -> None: + """Drive a real tmux server through fastmcp tools end to end.""" + server = session.server + mcp = build_server(SubprocessEngine.for_server(server)) + + async def main() -> str | None: + async with fastmcp.Client(mcp) as client: + created = await client.call_tool("create_session", {"name": "fastmcp-live"}) + session_id = (created.structured_content or {}).get("session_id") + await client.call_tool( + "split_pane", + {"target": session_id, "horizontal": True}, + ) + await client.call_tool("kill_session", {"target": "fastmcp-live"}) + return session_id + + session_id = asyncio.run(main()) + assert session_id is not None + assert session_id.startswith("$") + assert not server.sessions.filter(session_name="fastmcp-live") diff --git a/uv.lock b/uv.lock index d3f238bb1..a3324190f 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,10 @@ revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -42,6 +45,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + +[[package]] +name = "aiofile" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, +] + [[package]] name = "alabaster" version = "1.0.0" @@ -51,6 +88,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.14.0" @@ -105,6 +151,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -114,6 +182,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -127,6 +213,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -136,6 +260,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -378,6 +584,98 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/e7/4b3048094559b86a800e0f0de7faf8e6d8213727cf31553ec58453f25abc/cyclopts-4.19.0.tar.gz", hash = "sha256:c7532803ab8560d4de8600769793c3de4b2dc8c3b23ec707b989d84d9bae6ff4", size = 189274, upload-time = "2026-06-22T23:58:54.176Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/e8/b84c32f35a19107b55b8ceed260de9469206464da26bd0b979db470782c3/cyclopts-4.19.0-py3-none-any.whl", hash = "sha256:a8c11adb45afae25db310122950c7639c70b56e2ce341e5b29848bf3a8ecc1d4", size = 228005, upload-time = "2026-06-22T23:58:52.495Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -387,12 +685,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -408,6 +719,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "fastmcp" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/18/46beaec18c9f86a599ae3f9cdf6677dd6b50240cfd844d18233710b47f13/fastmcp-3.4.2.tar.gz", hash = "sha256:b468722946fc467c3796a6572f7a14d93d48c014cf8fea12910245220cbbe4e1", size = 28756849, upload-time = "2026-06-06T01:30:35.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/4d/8b1ba42251160e11ca34686344572121432c23a082d56ef6bbdec5888fc1/fastmcp-3.4.2-py3-none-any.whl", hash = "sha256:c87a62b029f0c5400ada85f683629345d2466c39169f0cb853e487b2f7308c08", size = 8018, upload-time = "2026-06-06T01:30:38.118Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "joserfc" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pyperclip" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "starlette" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + [[package]] name = "gp-furo-theme" version = "0.0.1a33" @@ -468,6 +842,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/3c/02dd665b29fad830a696873a9f33abc675c2259a0add1c596279a7526836/gp_sphinx-0.0.1a33-py3-none-any.whl", hash = "sha256:c5447abf6c300514accf917e93fd93d1893dda5c1d929e0b2f31d2e0967ff1d5", size = 20282, upload-time = "2026-07-05T20:58:53.655Z" }, ] +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -477,6 +860,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -495,6 +915,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -504,6 +936,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -516,6 +993,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -606,6 +1165,11 @@ name = "libtmux" version = "0.61.0" source = { editable = "." } +[package.optional-dependencies] +mcp = [ + { name = "fastmcp" }, +] + [package.dev-dependencies] coverage = [ { name = "codecov" }, @@ -655,6 +1219,8 @@ testing = [ ] [package.metadata] +requires-dist = [{ name = "fastmcp", marker = "extra == 'mcp'", specifier = ">=3.4.2" }] +provides-extras = ["mcp"] [package.metadata.requires-dev] coverage = [ @@ -735,7 +1301,10 @@ version = "4.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "mdurl" }, @@ -830,6 +1399,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.6.1" @@ -852,6 +1446,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -946,7 +1549,10 @@ version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "docutils" }, @@ -961,6 +1567,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, ] +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -970,6 +1600,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -979,6 +1618,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -988,6 +1636,191 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "py-key-value-aio" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -997,6 +1830,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1080,6 +1939,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1144,6 +2055,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1159,6 +2085,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, +] + [[package]] name = "roman-numerals" version = "4.1.0" @@ -1180,6 +2133,275 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + [[package]] name = "ruff" version = "0.15.19" @@ -1205,6 +2427,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "snowballstemmer" version = "3.1.1" @@ -1260,7 +2495,10 @@ version = "8.2.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster" }, @@ -1312,7 +2550,10 @@ version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "colorama" }, @@ -1419,7 +2660,10 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'win32'", ] dependencies = [ { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, @@ -1601,6 +2845,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/55/ab40a0d1378ee5c859590a633052cf1d0a1f8435af87558a9f7cd576601a/sphinxext_rediraffe-0.3.0-py3-none-any.whl", hash = "sha256:f4220beafa99c99177488276b8e4fcf61fbeeec4253c1e4aae841a18c475330c", size = 7194, upload-time = "2025-09-28T15:31:52.388Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -1711,6 +2968,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0" @@ -1720,6 +2989,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] +[[package]] +name = "uncalled-for" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1959,3 +3237,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From 307b5d182f632901aaff0d9dbac5201f8766afa6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 20:49:46 -0500 Subject: [PATCH 062/154] Mcp(feat): Per-op + plan tools and a stdio server why: The fastmcp adapter only exposed the curated vocabulary -- agents had no access to the full operation set or to plan composition, and the server could not be launched on its own. what: - Register one op_ tool per operation via a dynamic-schema Tool subclass (engine + descriptor on PrivateAttr, explicit parameters schema), re-injecting target/src_target at the adapter edge; tagged per-op and hidden by default (expose_operations=True reveals them) - Register plan tools (preview_plan/execute_plan/result_schema/ build_workspace) taking serialized operations + a planner name - Add build_server flags: include_operations/expose_operations/ include_plan_tools - Make the server runnable: main()/default_server(), __main__.py, fastmcp.json, and a libtmux-engine-mcp console script - Extend adapter tests: offline per-op/plan/workspace, default-server, --help exit, live plan execution --- fastmcp.json | 11 + pyproject.toml | 5 + src/libtmux/experimental/mcp/__init__.py | 61 ++++ src/libtmux/experimental/mcp/__main__.py | 7 + .../experimental/mcp/fastmcp_adapter.py | 281 +++++++++++++++++- .../experimental/mcp/test_fastmcp_adapter.py | 138 +++++++++ 6 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 fastmcp.json create mode 100644 src/libtmux/experimental/mcp/__main__.py diff --git a/fastmcp.json b/fastmcp.json new file mode 100644 index 000000000..f4fe92417 --- /dev/null +++ b/fastmcp.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "type": "filesystem", + "path": "src/libtmux/experimental/mcp/__init__.py", + "entrypoint": "default_server" + }, + "deployment": { + "transport": "stdio" + } +} diff --git a/pyproject.toml b/pyproject.toml index 056b076f0..a53e7378e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,11 @@ lint = [ [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +[project.scripts] +# Experimental typed-ops MCP server (stdio). Requires the `mcp` extra; +# `main` prints an install hint and exits non-zero when fastmcp is absent. +libtmux-engine-mcp = "libtmux.experimental.mcp:main" + [project.optional-dependencies] mcp = [ "fastmcp>=3.4.2", diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index eeaf2e73a..a683f1933 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -24,6 +24,8 @@ from __future__ import annotations +import typing as t + from libtmux.experimental.mcp.descriptor import ParamDescriptor, ToolDescriptor from libtmux.experimental.mcp.plan_tools import ( PlanOutcome, @@ -61,6 +63,63 @@ split_pane, ) +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from fastmcp import FastMCP + + +def default_server(*, expose_operations: bool = False) -> FastMCP: + """Build a FastMCP server over a default :class:`~..engines.SubprocessEngine`. + + A convenience factory (also the ``fastmcp.json`` entrypoint) for embedding or + deploying the server with the default tmux socket. Requires the ``mcp`` + extra (``pip install 'libtmux[mcp]'``). + """ + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + + return build_server(SubprocessEngine(), expose_operations=expose_operations) + + +def main(argv: Sequence[str] | None = None) -> None: + """Run the libtmux-engine MCP server over stdio (console-script entry). + + Wired to the ``libtmux-engine-mcp`` console script and + ``python -m libtmux.experimental.mcp``. Requires the ``mcp`` extra. + """ + import argparse + import sys + + parser = argparse.ArgumentParser( + prog="libtmux-engine-mcp", + description="Run the experimental libtmux typed-ops MCP server (stdio).", + ) + parser.add_argument("--name", default="libtmux-engine", help="server name") + parser.add_argument( + "--operations", + action="store_true", + help="expose the full per-operation tool surface (op_*)", + ) + args = parser.parse_args(argv) + + try: + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + except ImportError: + sys.stderr.write( + "libtmux-engine-mcp requires the 'mcp' extra: pip install 'libtmux[mcp]'\n", + ) + raise SystemExit(1) from None + + server = build_server( + SubprocessEngine(), + name=args.name, + expose_operations=args.operations, + ) + server.run(transport="stdio") + + __all__ = ( "Listing", "OperationToolRegistry", @@ -78,6 +137,7 @@ "capture_pane", "create_session", "create_window", + "default_server", "execute_plan", "kill_pane", "kill_session", @@ -85,6 +145,7 @@ "list_panes", "list_sessions", "list_windows", + "main", "preview_plan", "rename_session", "rename_window", diff --git a/src/libtmux/experimental/mcp/__main__.py b/src/libtmux/experimental/mcp/__main__.py new file mode 100644 index 000000000..348b9381c --- /dev/null +++ b/src/libtmux/experimental/mcp/__main__.py @@ -0,0 +1,7 @@ +"""Support ``python -m libtmux.experimental.mcp``.""" + +from __future__ import annotations + +from libtmux.experimental.mcp import main + +main() diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index c172e0788..05a5216da 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -1,9 +1,23 @@ -"""Optional fastmcp adapter -- expose the curated vocabulary on a FastMCP server. +"""Optional fastmcp adapter -- expose the typed projection on a FastMCP server. This is the thin, framework-specific edge. It requires the ``mcp`` extra (``pip install libtmux[mcp]``); fastmcp is imported lazily so the rest of -:mod:`libtmux.experimental.mcp` stays dependency-free. The agent-facing ``engine`` -is bound out of each tool's schema with :func:`functools.partial`, and the safety +:mod:`libtmux.experimental.mcp` stays dependency-free. :func:`build_server` +projects three layers of tools over one engine: + +1. **Curated vocabulary** -- the intuitive, hand-written tools + (:mod:`~libtmux.experimental.mcp.vocabulary`), always visible. +2. **Per-operation tools** -- one ``op_`` per registered operation, + auto-derived from the :class:`~..registry.OperationToolRegistry`. These carry + a precomputed JSON schema (dynamic params), so each is a :class:`fastmcp.tools. + Tool` subclass with an explicit ``parameters`` schema rather than an + introspected signature. Tagged ``per-op`` and hidden by default (the full + surface is large); ``expose_operations=True`` reveals them. +3. **Plan tools** -- :func:`preview_plan`/:func:`execute_plan`/ + :func:`result_schema`/:func:`build_workspace`, taking serialized operations so + an agent can compose and run a whole :class:`~..ops.plan.LazyPlan`. + +The agent-facing ``engine`` is bound out of each tool's schema, and the safety tier becomes the tool's ``ToolAnnotations`` + tag. Examples @@ -20,10 +34,12 @@ from __future__ import annotations +import dataclasses import inspect import typing as t from libtmux.experimental.mcp import vocabulary +from libtmux.experimental.mcp.registry import OperationToolRegistry if t.TYPE_CHECKING: from collections.abc import Callable @@ -31,6 +47,8 @@ from fastmcp import FastMCP from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.mcp.descriptor import ToolDescriptor + from libtmux.experimental.ops.plan import LazyPlan # (function, safety tier) -- every curated tool takes ``engine`` as its first arg. _VOCABULARY: tuple[tuple[Callable[..., t.Any], str], ...] = ( @@ -54,7 +72,14 @@ _INSTRUCTIONS = ( "Drive tmux through typed tools. Targets accept tmux ids (%pane, @window, " "$session), names, or 'session:window.pane'. Creating a session/window also " - "returns the new first-pane id for chaining." + "returns the new first-pane id for chaining. The curated tools cover most " + "needs; the full per-operation surface (op_*) and the plan tools " + "(preview_plan/execute_plan/result_schema/build_workspace) are available for " + "power use." +) + +_TARGET_HELP = ( + "tmux target: an id (%pane, @window, $session), a name, or 'session:window.pane'" ) @@ -113,15 +138,259 @@ def register_vocabulary(mcp: FastMCP, engine: TmuxEngine) -> None: mcp.add_tool(tool) +def _op_input_schema(descriptor: ToolDescriptor) -> dict[str, t.Any]: + """Return the per-op tool's input schema, re-adding the target params. + + The :class:`~..registry.OperationToolRegistry` omits ``target`` / + ``src_target`` from a descriptor's params (they are polymorphic + :data:`~..ops._types.Target` values, handled by + :meth:`~..descriptor.ToolDescriptor.build`), so the schema is re-completed + here -- at the framework edge -- as plain ``string`` params. Required-ness + follows the operation field's default. + """ + schema = descriptor.input_schema() + fields = { + field.name: field for field in dataclasses.fields(descriptor.operation_cls) + } + target_props: dict[str, t.Any] = {} + required = list(schema.get("required", [])) + for name in ("target", "src_target"): + field = fields.get(name) + if field is None: + continue + target_props[name] = {"type": "string", "description": _TARGET_HELP} + is_required = ( + field.default is dataclasses.MISSING + and field.default_factory is dataclasses.MISSING + ) + if is_required and name not in required: + required.append(name) + if target_props: + schema["properties"] = {**target_props, **schema["properties"]} + if required: + schema["required"] = required + return schema + + +def register_operations( + mcp: FastMCP, + engine: TmuxEngine, + *, + registry: OperationToolRegistry | None = None, + hidden: bool = True, +) -> None: + """Register one ``op_`` tool per registered operation. + + Each tool carries the operation's precomputed JSON schema and dispatches to + :meth:`~..descriptor.ToolDescriptor.build` + :func:`~..ops.execute.run`, + returning the serialized result. Tools are tagged ``per-op`` plus their + safety tier; when *hidden* (the default) the ``per-op`` tag is disabled so + the large surface does not clutter an agent's tool list (re-enable with + ``mcp.enable(tags={"per-op"})``). + """ + from fastmcp.tools import Tool, ToolResult + from mcp.types import ToolAnnotations + from pydantic import PrivateAttr + + from libtmux.experimental.ops import run as run_op + from libtmux.experimental.ops.serialize import result_to_dict + + class _OperationTool(Tool): + """A per-operation tool: explicit schema + dispatch to the registry.""" + + _descriptor: t.Any = PrivateAttr(default=None) + _engine: t.Any = PrivateAttr(default=None) + + async def run(self, arguments: dict[str, t.Any]) -> ToolResult: + operation = self._descriptor.build(**arguments) + result = run_op(operation, self._engine) + return ToolResult( + structured_content=result_to_dict(result), + is_error=not result.ok, + ) + + reg = registry if registry is not None else OperationToolRegistry() + for descriptor in reg.descriptors(): + annotations = ToolAnnotations( + title=descriptor.title, + readOnlyHint=descriptor.safety == "readonly", + destructiveHint=descriptor.safety == "destructive", + ) + tool = _OperationTool( + name=f"op_{descriptor.name}", + description=descriptor.description or None, + parameters=_op_input_schema(descriptor), + tags={*descriptor.tags, "per-op"}, + annotations=annotations, + ) + tool._descriptor = descriptor + tool._engine = engine + mcp.add_tool(tool) + if hidden: + mcp.disable(tags={"per-op"}) + + +def register_plan_tools( + mcp: FastMCP, + engine: TmuxEngine, + *, + registry: OperationToolRegistry | None = None, +) -> None: + """Register the plan-tier tools (compose + run serialized :class:`LazyPlan`s). + + ``preview_plan`` renders without executing; ``execute_plan`` runs a + serialized plan (forward refs resolved via bindings); ``result_schema`` + reports what a kind returns; ``build_workspace`` builds the Declarative tier + in one call. All take JSON-serializable inputs (operation dicts from + :func:`~..ops.serialize.operation_to_dict`). + """ + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + from libtmux.experimental.mcp import plan_tools as _plan + from libtmux.experimental.ops import LazyPlan + from libtmux.experimental.ops.planner import ( + FoldingPlanner, + MarkedPlanner, + Planner, + SequentialPlanner, + ) + from libtmux.experimental.ops.serialize import operation_from_dict + + reg = registry if registry is not None else OperationToolRegistry() + planners: dict[str, type[Planner]] = { + "sequential": SequentialPlanner, + "folding": FoldingPlanner, + "marked": MarkedPlanner, + } + + def _plan_from_dicts(operations: list[dict[str, t.Any]]) -> LazyPlan: + plan = LazyPlan() + for data in operations: + plan.add(operation_from_dict(data)) + return plan + + def preview_plan( + operations: list[dict[str, t.Any]], + version: str | None = None, + ) -> dict[str, t.Any]: + """Render a serialized plan without executing it (refs render as null).""" + preview = _plan.preview_plan(_plan_from_dicts(operations), version=version) + return { + "ok": preview.ok, + "operations": preview.operations, + "argv": [list(item) if item is not None else None for item in preview.argv], + } + + def execute_plan( + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + chosen = planners.get(planner) + if chosen is None: + msg = f"unknown planner {planner!r}; choose from {sorted(planners)}" + raise ValueError(msg) + outcome = _plan.execute_plan( + _plan_from_dicts(operations), + engine, + version=version, + planner=chosen(), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + def result_schema(kind: str) -> dict[str, t.Any]: + """Report what an operation kind returns, for planning forward refs.""" + schema = _plan.result_schema(reg, kind) + return { + "kind": schema.kind, + "result_type": schema.result_type, + "schema": schema.schema, + "binding_fields": schema.binding_fields, + } + + def build_workspace( + spec: dict[str, t.Any], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build a declarative workspace (the Declarative tier) in one call.""" + outcome = _plan.build_workspace( + spec, + engine, + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools: tuple[tuple[Callable[..., t.Any], str], ...] = ( + (preview_plan, "readonly"), + (result_schema, "readonly"), + (execute_plan, "mutating"), + (build_workspace, "mutating"), + ) + for fn, safety in tools: + annotations = ToolAnnotations( + title=fn.__name__, + readOnlyHint=safety == "readonly", + destructiveHint=False, + ) + tool = FunctionTool.from_function( + fn, + name=fn.__name__, + description=_summary(fn.__doc__), + tags={"plan", safety}, + annotations=annotations, + ) + mcp.add_tool(tool) + + def build_server( engine: TmuxEngine, *, - name: str = "libtmux", + name: str = "libtmux-engine", instructions: str | None = None, + include_operations: bool = True, + expose_operations: bool = False, + include_plan_tools: bool = True, ) -> FastMCP: - """Build a FastMCP server exposing the curated vocabulary over *engine*.""" + """Build a FastMCP server exposing the typed tool surface over *engine*. + + Parameters + ---------- + engine + The :class:`~..engines.base.TmuxEngine` every tool runs against. + name, instructions + Server identity (``instructions`` defaults to a built-in primer). + include_operations + Register the auto-derived ``op_`` per-operation tools. + expose_operations + Reveal those per-operation tools by default (otherwise they are + registered but hidden behind the ``per-op`` tag). + include_plan_tools + Register the plan-tier tools. + """ from fastmcp import FastMCP mcp: FastMCP = FastMCP(name=name, instructions=instructions or _INSTRUCTIONS) + registry = OperationToolRegistry() register_vocabulary(mcp, engine) + if include_operations: + register_operations( + mcp, + engine, + registry=registry, + hidden=not expose_operations, + ) + if include_plan_tools: + register_plan_tools(mcp, engine, registry=registry) return mcp diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py index e52ae7a34..80dc91278 100644 --- a/tests/experimental/mcp/test_fastmcp_adapter.py +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -16,6 +16,8 @@ from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server +from libtmux.experimental.ops import NewSession +from libtmux.experimental.ops.serialize import operation_to_dict fastmcp = pytest.importorskip("fastmcp") @@ -88,3 +90,139 @@ async def main() -> str | None: assert session_id is not None assert session_id.startswith("$") assert not server.sessions.filter(session_name="fastmcp-live") + + +def test_adapter_operations_hidden_by_default() -> None: + """Per-operation tools are registered but hidden; plan tools stay visible.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + names = {tool.name for tool in asyncio.run(main())} + assert not any(name.startswith("op_") for name in names) + assert { + "preview_plan", + "execute_plan", + "result_schema", + "build_workspace", + } <= names + + +def test_adapter_exposes_per_op_tools() -> None: + """``expose_operations`` reveals one typed ``op_`` per operation.""" + server = build_server(ConcreteEngine(), expose_operations=True) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + by_name = {tool.name: tool for tool in asyncio.run(main())} + assert "op_split_window" in by_name + assert "op_new_session" in by_name + + # the target the registry omits is re-injected into the per-op schema + properties = by_name["op_split_window"].inputSchema.get("properties", {}) + assert "target" in properties + assert "horizontal" in properties + + # safety tier -> annotations + assert by_name["op_kill_session"].annotations.destructiveHint is True + assert by_name["op_capture_pane"].annotations.readOnlyHint is True + + +def test_adapter_per_op_call_offline() -> None: + """A per-op tool builds + runs its operation, returning the serialized result.""" + server = build_server(ConcreteEngine(), expose_operations=True) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool("op_new_session", {"session_name": "raw"}) + + payload = asyncio.run(main()).structured_content or {} + assert payload["operation"]["kind"] == "new_session" + assert payload["new_id"] == "$1" + + +def test_adapter_plan_tools_offline() -> None: + """preview/execute/result_schema drive a serialized plan with forward refs.""" + server = build_server(ConcreteEngine()) + operations = [operation_to_dict(NewSession(session_name="dev", capture_panes=True))] + + async def main() -> tuple[t.Any, t.Any, t.Any]: + async with fastmcp.Client(server) as client: + preview = await client.call_tool("preview_plan", {"operations": operations}) + outcome = await client.call_tool("execute_plan", {"operations": operations}) + schema = await client.call_tool("result_schema", {"kind": "new_session"}) + return preview, outcome, schema + + preview, outcome, schema = asyncio.run(main()) + assert preview.structured_content["ok"] is True + assert outcome.structured_content["ok"] is True + # the new session's captured sub-ids surface as forward-ref bindings + assert outcome.structured_content["bindings"]["0"] == "$1" + assert outcome.structured_content["bindings"]["0:pane"] == "%1" + assert "first_pane_id" in schema.structured_content["binding_fields"] + + +def test_adapter_build_workspace_offline() -> None: + """The workspace tool builds a declarative spec in one call (preflight off).""" + server = build_server(ConcreteEngine()) + spec = { + "session_name": "ws", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + } + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.call_tool( + "build_workspace", + {"spec": spec, "preflight": False}, + ) + + payload = asyncio.run(main()).structured_content or {} + assert payload["ok"] is True + + +def test_default_server_builds() -> None: + """The packaged ``default_server`` factory exposes the curated + plan tools.""" + from libtmux.experimental.mcp import default_server + + server = default_server() + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.list_tools() + + names = {tool.name for tool in asyncio.run(main())} + assert "create_session" in names + assert "execute_plan" in names + + +def test_main_help_exits() -> None: + """The console-script entry parses ``--help`` and exits cleanly.""" + from libtmux.experimental.mcp import main + + with pytest.raises(SystemExit) as excinfo: + main(["--help"]) + assert excinfo.value.code == 0 + + +def test_adapter_plan_live(session: Session) -> None: + """Execute a serialized plan over real tmux through the execute_plan tool.""" + server = session.server + mcp = build_server(SubprocessEngine.for_server(server)) + operations = [ + operation_to_dict(NewSession(session_name="plan-live", capture_panes=True)), + ] + + async def main() -> t.Any: + async with fastmcp.Client(mcp) as client: + return await client.call_tool("execute_plan", {"operations": operations}) + + outcome = asyncio.run(main()).structured_content + assert outcome["ok"] is True + assert outcome["bindings"]["0"].startswith("$") + assert server.sessions.filter(session_name="plan-live") + server.cmd("kill-session", "-t", "plan-live") From 12751dc6fea25acbd5190c23ade6d2ae4c7b5341 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 21:43:13 -0500 Subject: [PATCH 063/154] Mcp(feat): Port mcp_swap config-swap dev script why: To test and dogfood the MCP server in local agent CLIs, we need a way to point Claude/Codex/Cursor/Gemini at this checkout instead of a pinned release. what: - Port scripts/mcp_swap.py from libtmux-mcp (PEP 723 uv-script, tomlkit): detect/status/use-local/revert with timestamped backups, dry-run, and Claude user/project scopes - Derive the server slug from the [project.scripts] entry (libtmux-engine-mcp -> libtmux-engine) instead of project.name, so it stays distinct from a sibling libtmux server; a strict generalization (libtmux-mcp still resolves to libtmux) - Namespace the swap state dir libtmux-engine-mcp-dev - Add tests: console-script registration (always-on) + slug/local-spec derivation (tomlkit-gated) --- scripts/mcp_swap.py | 1083 ++++++++++++++++++++++++++++++++++++++++ tests/test_mcp_swap.py | 61 +++ 2 files changed, 1144 insertions(+) create mode 100644 scripts/mcp_swap.py create mode 100644 tests/test_mcp_swap.py diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py new file mode 100644 index 000000000..6e68b6d1c --- /dev/null +++ b/scripts/mcp_swap.py @@ -0,0 +1,1083 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["tomlkit>=0.13"] +# /// +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini CLIs. + +Use when you want every installed agent CLI to run a local checkout of an +MCP server (editable) instead of a pinned release. ``use-local`` rewrites +each CLI's config to invoke the checkout via ``uv --directory run +``; ``revert`` restores from the timestamped backup the swap wrote. + +Defaults are derived from the current repo's ``pyproject.toml``: + +- entry command = first key of ``[project.scripts]`` +- server name = that entry with a trailing ``-mcp`` stripped + (``libtmux-engine-mcp`` -> ``libtmux-engine``), falling back to + ``project.name`` when the entry has no ``-mcp`` suffix. Deriving the + slug from the entry (not ``project.name``) keeps this repo's server + key distinct from a sibling package whose ``project.name`` differs + from its console-script name. + +Examples +-------- +```console +$ uv run scripts/mcp_swap.py detect +$ uv run scripts/mcp_swap.py status +$ uv run scripts/mcp_swap.py use-local --dry-run +$ uv run scripts/mcp_swap.py use-local +$ uv run scripts/mcp_swap.py revert +``` + +Scope +----- +This script is best-effort and intentionally narrow: + +- **Global configs only.** Writes to ``~/.cursor/mcp.json``, + ``~/.claude.json``, ``~/.codex/config.toml``, and + ``~/.gemini/settings.json``. Workspace / project-local configs + (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, + per-project ``projects..mcpServers`` entries inside + ``~/.claude.json`` *are* recognised for Claude only) are NOT + walked — workspace files for Cursor/Gemini are silently ignored. + When workspace precedence matters, run the CLI's own + ``cursor mcp add ...`` / ``gemini mcp add ...`` directly. + +- **Claude scope.** ``use-local`` and ``revert`` accept + ``--scope {user,project}``. The default ``project`` writes the + per-project entry under ``projects[].mcpServers`` — + only the current repo's directory sees the swap, matching + pre-flag behaviour. ``--scope user`` writes Claude's top-level + ``mcpServers`` fallback so every project that has no per-project + override picks up the swap; useful when QA-ing a branch across + many directories. Codex, Cursor, and Gemini have no per-project + layer in their config files; the flag is silently coerced to + ``user`` for them. Both Claude scopes can coexist with + independent backups; full ``revert`` unwinds in LIFO order. +- **Simple binary detection.** Probing is ``shutil.which()`` + plus ``.exists()``. Custom install locations + (Homebrew, npm prefixes, ``~/.npm-global/bin``, + ``~/.claude/local/claude``, ``~/.gemini/local/gemini``) are picked + up only if the binary is on ``PATH``. FastMCP's installer probes + these locations directly; this script does not. +- **Single config shape per CLI.** No fallback paths, no merge of + multiple sources. If your setup deviates from the defaults above, + use the CLI's native ``mcp`` subcommand instead. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import json +import os +import pathlib +import shutil +import sys +import tempfile +import time +import typing as t + +import tomlkit +import tomlkit.items + +CLIName = t.Literal["claude", "codex", "cursor", "gemini"] +ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini") + +#: Claude config scope: ``"user"`` targets the user/system-level top-level +#: ``mcpServers`` fallback that applies to every project without its own +#: override; ``"project"`` targets the project-level per-project +#: ``projects..mcpServers`` node. Codex / Cursor / Gemini have no +#: per-project scope in their config files, so for those CLIs the scope +#: is always normalised to ``"user"`` regardless of what was passed. +Scope = t.Literal["user", "project"] +ALL_SCOPES: tuple[Scope, ...] = ("user", "project") + + +def _normalize_scope(cli: CLIName, scope: Scope | None) -> Scope: + """Coerce ``scope`` to the value that actually applies to ``cli``. + + Non-Claude CLIs have no per-project config layer — every write to + them is necessarily user-level — so the flag is silently coerced to + ``"user"`` for those. For Claude, ``None`` defaults to ``"project"`` + to preserve pre-flag behaviour where the script always wrote the + per-project entry. + """ + if cli != "claude": + return "user" + return scope if scope is not None else "project" + + +def _state_key(cli: CLIName, scope: Scope) -> str: + """Compose the ``cli:scope`` key used inside the state file.""" + return f"{cli}:{scope}" + + +def _parse_state_key(key: str) -> tuple[CLIName, Scope] | None: + """Decode a ``cli:scope`` state key, returning ``None`` for malformed input. + + The script declares no compatibility contract for its state file — + schema is internal — so this only accepts the canonical + ``f"{cli}:{scope}"`` form. Hand-edited or unrecognised keys return + ``None`` so ``load_state`` can drop them without crashing. + """ + if ":" not in key: + return None + cli_str, _, scope_str = key.partition(":") + if cli_str in ALL_CLIS and scope_str in ALL_SCOPES: + return cli_str, scope_str + return None + + +def _parse_state_entry(v: dict[str, t.Any]) -> SwapEntry | None: + """Build a :class:`SwapEntry` from a raw state-file dict, or ``None``. + + Validates at the trust boundary so a hand-edited ``state.json`` can't + crash later code paths — particularly :func:`cmd_revert`'s LIFO sort, + which compares ``SwapEntry.seq_no`` and would raise ``TypeError`` on a + mixed ``int``/``str`` ordering. ``seq_no`` is coerced via ``int()``; + any ``KeyError`` (missing required field), ``ValueError`` (non-numeric + string), or ``TypeError`` (wrong shape, extra keys for the dataclass) + drops the entry silently. Same drop-on-malformed posture as + :func:`_parse_state_key`. + + Mirrors CPython's ``Lib/sched.py`` discipline: validate at the + counter's *origin* (``enterabs`` for sched, ``load_state`` here), not + at sort time. State-file schema is internal — no compatibility + contract — so silent drop is the right failure mode. + """ + try: + v = {**v, "seq_no": int(v["seq_no"])} + return SwapEntry(**v) + except (KeyError, TypeError, ValueError): + return None + + +def _xdg_state_home() -> pathlib.Path: + """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. + + Defaults to ``~/.local/state`` when the env var is unset or empty. + State is the right XDG bucket here (vs. cache / config / data): the + file is machine-written, must persist across runs so ``revert`` can + locate the right backup, but is not safely deletable like cache nor + user-edited like config. + """ + env = os.environ.get("XDG_STATE_HOME") + if env: + return pathlib.Path(env) + return pathlib.Path.home() / ".local" / "state" + + +# ``-dev`` suffix in the namespace makes it loud that this is dev-only +# tooling state, distinct from the runtime ``libtmux`` package and from +# any sibling ``libtmux-mcp-dev`` swap state. +STATE_DIR = _xdg_state_home() / "libtmux-engine-mcp-dev" / "swap" +STATE_FILE = STATE_DIR / "state.json" + +BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class CLIInfo: + """Static descriptor for a CLI's config file and discovery heuristics.""" + + name: CLIName + binary: str + config_path: pathlib.Path + fmt: t.Literal["json", "toml"] + + +CLIS: dict[CLIName, CLIInfo] = { + "claude": CLIInfo( + name="claude", + binary="claude", + config_path=pathlib.Path.home() / ".claude.json", + fmt="json", + ), + "codex": CLIInfo( + name="codex", + binary="codex", + config_path=pathlib.Path.home() / ".codex" / "config.toml", + fmt="toml", + ), + "cursor": CLIInfo( + name="cursor", + binary="cursor-agent", + config_path=pathlib.Path.home() / ".cursor" / "mcp.json", + fmt="json", + ), + "gemini": CLIInfo( + name="gemini", + binary="gemini", + config_path=pathlib.Path.home() / ".gemini" / "settings.json", + fmt="json", + ), +} + + +@dataclasses.dataclass +class McpServerSpec: + """The portable shape shared across CLI configs.""" + + command: str + args: list[str] = dataclasses.field(default_factory=list) + env: dict[str, str] = dataclasses.field(default_factory=dict) + + def to_json_dict(self, *, include_stdio_type: bool = False) -> dict[str, t.Any]: + """Serialize to the JSON shape (Claude-extended when ``include_stdio_type``).""" + # Claude's format always includes ``type`` and ``env`` (even when empty); + # Cursor/Gemini omit both. include_stdio_type selects Claude shape. + if include_stdio_type: + return { + "type": "stdio", + "command": self.command, + "args": list(self.args), + "env": dict(self.env), + } + out: dict[str, t.Any] = {"command": self.command, "args": list(self.args)} + if self.env: + out["env"] = dict(self.env) + return out + + def is_local_uv_directory(self) -> bool: + """Return True for a ``uv --directory run `` shape.""" + return ( + self.command == "uv" and "--directory" in self.args and "run" in self.args + ) + + def local_repo_path(self) -> pathlib.Path | None: + """Extract the ``--directory`` argument, if any.""" + try: + i = self.args.index("--directory") + except ValueError: + return None + if i + 1 >= len(self.args): + return None + return pathlib.Path(self.args[i + 1]) + + +@dataclasses.dataclass +class SwapEntry: + """One CLI's bookkeeping for a swap, written to the state file.""" + + config_path: str + backup_path: str + server: str + action: t.Literal["replaced", "added"] + #: ``YYYYMMDDHHMMSS`` registration timestamp, human-readable for + #: anyone inspecting ``state.json`` directly. Sort order is enforced + #: separately via :attr:`seq_no` so this field stays purely + #: descriptive. + swapped_at: str + #: Monotonic registration counter — the primary LIFO sort key for + #: ``cmd_revert``. ``cmd_use_local`` computes the next value as + #: ``max(existing seq_nos, default=-1) + 1`` so it strictly + #: increases per swap regardless of wall-clock collisions or dict + #: iteration order. Same explicit-counter pattern CPython's + #: ``Lib/sched.py`` uses to break ties on ``Event(time, priority, + #: sequence, …)``. + seq_no: int + + +# --------------------------------------------------------------------------- +# Config IO — per format +# --------------------------------------------------------------------------- + + +def load_config(info: CLIInfo) -> t.Any: + """Parse a CLI's config file (JSON or TOML) into an editable structure.""" + raw = info.config_path.read_bytes() + if info.fmt == "json": + return json.loads(raw) + return tomlkit.parse(raw.decode()) + + +def dump_config_bytes(info: CLIInfo, config: t.Any) -> bytes: + """Serialize an edited config back to bytes in its original format.""" + if info.fmt == "json": + return (json.dumps(config, indent=2) + "\n").encode() + return tomlkit.dumps(config).encode() + + +def atomic_write(path: pathlib.Path, data: bytes) -> None: + """Write bytes to ``path`` via tempfile + ``os.replace`` to avoid partial writes.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + tmp = pathlib.Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + tmp.replace(path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +# --------------------------------------------------------------------------- +# Per-CLI get / set / delete (the only CLI-specific logic) +# --------------------------------------------------------------------------- + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[True], +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[False], +) -> dict[str, t.Any] | None: ... + + +def _claude_project_node( + config: dict[str, t.Any], repo: pathlib.Path, *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the ``projects.`` node Claude keys per-project. + + With ``create=True``, the node is unconditionally created if missing + and the return type is statically narrowed to ``dict[str, t.Any]``; + callers can drop runtime ``assert node is not None`` defensiveness. + With ``create=False``, the absence of the node is a real return value + and the type stays ``dict[str, t.Any] | None``. + + Raises ``RuntimeError`` if Claude's config layout is not the + expected ``projects..mcpServers`` mapping shape — the layout + is undocumented Claude Code internal state, so a clear error before + the atomic write beats a silent partial mutation that the backup + defense would be asked to recover from. + """ + key = str(repo.resolve()) + projects_node = config.get("projects") + if projects_node is not None and not isinstance(projects_node, dict): + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects' to be a mapping but got " + f"{type(projects_node).__name__}" + ) + raise RuntimeError(msg) + projects = ( + config.setdefault("projects", {}) if create else config.get("projects", {}) + ) + raw_node = projects.get(key) + node: dict[str, t.Any] | None = None + if isinstance(raw_node, dict): + node = raw_node + elif raw_node is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects[{key!r}]' to be a mapping but got " + f"{type(raw_node).__name__}" + ) + raise RuntimeError(msg) + if node is None and create: + node = {"allowedTools": [], "mcpContextUris": [], "mcpServers": {}, "env": {}} + projects[key] = node + return node + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[True] +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[False] +) -> dict[str, t.Any] | None: ... + + +def _claude_user_servers( + config: dict[str, t.Any], *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the top-level ``mcpServers`` dict — Claude user scope. + + Mirrors :func:`_claude_project_node` for the user-scope path so the + shape guard is centralised once and reused across read / write / + delete instead of duplicated at each call site (or worse, missing + on read and delete the way the inline write-side guard left them). + Same reasoning applies as for the project-scope helper: Claude's + config shape is undocumented internal state, so a clear + ``RuntimeError`` before the atomic write beats an opaque + ``AttributeError`` from ``.setdefault()`` on a non-dict. + + With ``create=True`` the dict is initialised when missing and the + return type narrows to ``dict[str, t.Any]``. With ``create=False`` + a missing key returns ``None``. + """ + raw = config.get("mcpServers") + existing: dict[str, t.Any] | None = None + if isinstance(raw, dict): + existing = raw + elif raw is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'mcpServers' to be a mapping but got " + f"{type(raw).__name__}" + ) + raise RuntimeError(msg) + if existing is None and create: + existing = {} + config["mcpServers"] = existing + return existing + + +def get_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> McpServerSpec | None: + """Fetch the MCP server entry for ``name`` from a CLI's config, if present. + + ``scope`` only affects Claude (see :data:`Scope` for the layered shape + of ``~/.claude.json``); for Codex / Cursor / Gemini the parameter is + accepted-but-ignored because their config has no per-project layer. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + entry = servers.get(name) if servers else None + else: + node = _claude_project_node(config, repo, create=False) + if not node: + return None + entry = node.get("mcpServers", {}).get(name) + elif cli in ("cursor", "gemini"): + entry = config.get("mcpServers", {}).get(name) + else: # cli == "codex" + entry = config.get("mcp_servers", {}).get(name) + if entry is None: + return None + return _spec_from_entry(entry, fmt=CLIS[cli].fmt) + + +def set_server( + cli: CLIName, + config: t.Any, + name: str, + spec: McpServerSpec, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> t.Literal["replaced", "added"]: + """Write ``spec`` under ``name`` in a CLI's config, returning replaced/added. + + ``scope == "user"`` for Claude writes the top-level ``mcpServers`` + fallback used by every project that has no per-project override; + ``"project"`` (the default, preserving pre-flag behaviour) writes + under ``projects[abs(repo)].mcpServers``. The parameter is silently + ignored for non-Claude CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=True) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + node = _claude_project_node(config, repo, create=True) + servers = node.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + if cli in ("cursor", "gemini"): + servers = config.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict() + return "replaced" if had else "added" + if cli == "codex": + # tomlkit: top-level tables are accessed via dict protocol too. + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + mcp_servers = tomlkit.table() + config["mcp_servers"] = mcp_servers + had = name in mcp_servers + table = tomlkit.table() + table["command"] = spec.command + table["args"] = list(spec.args) + if spec.env: + env_tbl = tomlkit.table() + for k, v in spec.env.items(): + env_tbl[k] = v + table["env"] = env_tbl + mcp_servers[name] = table + return "replaced" if had else "added" + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def delete_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> bool: + """Remove the entry for ``name`` from a CLI's config; return whether it existed. + + See :func:`set_server` for the meaning of ``scope`` — the parameter + is honoured for Claude and ignored for the other CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + if servers is not None and name in servers: + del servers[name] + return True + return False + node = _claude_project_node(config, repo, create=False) + if not node: + return False + servers = node.get("mcpServers", {}) + return servers.pop(name, None) is not None + if cli in ("cursor", "gemini"): + return config.get("mcpServers", {}).pop(name, None) is not None + if cli == "codex": + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + return False + if name in mcp_servers: + del mcp_servers[name] + return True + return False + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def _spec_from_entry(entry: t.Any, *, fmt: t.Literal["json", "toml"]) -> McpServerSpec: + """Convert a raw config entry (dict or tomlkit Table) into an McpServerSpec.""" + # tomlkit items quack like dicts/lists; coerce to plain Python for our spec. + if fmt == "toml": + entry = ( + tomlkit.items.Table.unwrap(entry) + if isinstance(entry, tomlkit.items.Table) + else dict(entry) + ) + command = str(entry.get("command", "")) + raw_args = entry.get("args", []) + args = [str(a) for a in raw_args] if raw_args else [] + raw_env = entry.get("env") or {} + env = {str(k): str(v) for k, v in dict(raw_env).items()} + return McpServerSpec(command=command, args=args, env=env) + + +# --------------------------------------------------------------------------- +# Repo metadata +# --------------------------------------------------------------------------- + + +def resolve_repo_meta(repo: pathlib.Path) -> tuple[str, str]: + """Derive (server_name, entry_command) from the repo's pyproject.toml. + + The server name is the registration slug used as the config-file key + (``mcpServers.`` in JSON, ``[mcp_servers.]`` in TOML). + Default: the first ``[project.scripts]`` entry with a trailing + ``-mcp`` stripped (``libtmux-engine-mcp`` → ``libtmux-engine``), + falling back to ``project.name`` when the entry has no ``-mcp`` + suffix. Deriving the slug from the entry rather than ``project.name`` + keeps this repo's server key (``libtmux-engine``) distinct from a + sibling package whose ``project.name`` is ``libtmux`` — both can be + registered side by side. Pass ``--server `` to override. + """ + pyproject = repo / "pyproject.toml" + doc = tomlkit.parse(pyproject.read_text()) + project = doc.get("project") + if project is None: + msg = f"{pyproject} has no [project] table" + raise RuntimeError(msg) + scripts = project.get("scripts") or {} + if not scripts: + msg = f"{pyproject} has no [project.scripts] — cannot derive entry" + raise RuntimeError(msg) + entry = next(iter(scripts)) + server = entry[: -len("-mcp")] if entry.endswith("-mcp") else str(project["name"]) + return server, entry + + +def build_local_spec(repo: pathlib.Path, entry: str) -> McpServerSpec: + """Build the ``uv --directory run `` spec used by ``use-local``.""" + return McpServerSpec( + command="uv", + args=["--directory", str(repo.resolve()), "run", entry], + ) + + +# --------------------------------------------------------------------------- +# State file +# --------------------------------------------------------------------------- + + +def load_state() -> dict[tuple[CLIName, Scope], SwapEntry]: + """Read the swap-state file, returning an empty mapping when absent. + + The state file's schema is internal — no compatibility contract — + so this loader assumes a single canonical shape. Malformed keys + (those that don't parse as ``cli:scope``) and entries with a + non-coercible ``seq_no`` or missing required fields are dropped + silently so a hand-edited file cannot crash the script. + """ + if not STATE_FILE.exists(): + return {} + raw = json.loads(STATE_FILE.read_text()) + entries = raw.get("entries", {}) + out: dict[tuple[CLIName, Scope], SwapEntry] = {} + for k, v in entries.items(): + parsed = _parse_state_key(k) + if parsed is None: + continue + entry = _parse_state_entry(v) + if entry is None: + continue + out[parsed] = entry + return out + + +def save_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: + """Write the swap-state file atomically.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "entries": { + _state_key(cli, scope): dataclasses.asdict(v) + for (cli, scope), v in entries.items() + }, + } + atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) + + +def clear_state(keys: t.Iterable[tuple[CLIName, Scope]]) -> None: + """Remove the given ``(cli, scope)`` keys; delete the file if empty.""" + current = load_state() + for key in keys: + current.pop(key, None) + if current: + save_state(current) + elif STATE_FILE.exists(): + STATE_FILE.unlink() + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Presence: + """Detection outcome for a CLI: binary on PATH and config file present.""" + + cli: CLIName + binary_found: bool + config_found: bool + + @property + def present(self) -> bool: + """Return True only when both the binary and the config file were found.""" + return self.binary_found and self.config_found + + +def detect_clis() -> list[Presence]: + """Probe all supported CLIs and return their detection results.""" + return [ + Presence( + cli=info.name, + binary_found=shutil.which(info.binary) is not None, + config_found=info.config_path.exists(), + ) + for info in CLIS.values() + ] + + +def present_clis() -> list[CLIName]: + """Return the list of CLIs that have both a binary and a config present.""" + return [p.cli for p in detect_clis() if p.present] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +def cmd_detect(args: argparse.Namespace) -> int: + """Print detection results for every supported CLI.""" + for p in detect_clis(): + flag = "yes" if p.present else " no" + extra = [] + if not p.binary_found: + extra.append("binary missing") + if not p.config_found: + extra.append(f"config missing: {CLIS[p.cli].config_path}") + suffix = f" ({', '.join(extra)})" if extra else "" + print(f" [{flag}] {p.cli:<7}{suffix}") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + """Print the current MCP server entry per detected CLI. + + For Claude, prints separate lines for the user-level fallback + (``[claude:user]``) and the per-project override + (``[claude:project]``) when both exist; if only one exists, only + that line shows. ``args.scope`` (when set) restricts Claude output + to the matching layer only. Other CLIs print a single line as + ``[]`` since their config has no scope concept and ignore + ``args.scope``. + """ + repo = pathlib.Path(args.repo).resolve() + server = args.server or resolve_repo_meta(repo)[0] + scope_filter: Scope | None = args.scope + for cli in args.cli or present_clis(): + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{cli}] (no config at {info.config_path})") + continue + # Wrap the read + shape-guarded queries in try/except RuntimeError + # so a malformed Claude config surfaces as a clean per-CLI error + # instead of aborting status output for the rest of the CLIs. + try: + config = load_config(info) + if cli == "claude": + # Lazy reads: skip the get_server call entirely for the + # filtered-out scope so a malformed projects node doesn't + # raise when the user only asked about user scope. + user_spec = ( + get_server(cli, config, server, repo, scope="user") + if scope_filter in (None, "user") + else None + ) + project_spec = ( + get_server(cli, config, server, repo, scope="project") + if scope_filter in (None, "project") + else None + ) + shown = False + if user_spec is not None: + tag = _describe_spec(user_spec, repo) + print( + f"[claude:user] {server} = {user_spec.command} " + f"{' '.join(user_spec.args)} ({tag})" + ) + shown = True + if project_spec is not None: + tag = _describe_spec(project_spec, repo) + print( + f"[claude:project] {server} = {project_spec.command} " + f"{' '.join(project_spec.args)} ({tag})" + ) + shown = True + if not shown: + label = f"claude:{scope_filter}" if scope_filter else "claude" + print(f"[{label}] no entry for {server!r}") + else: + spec = get_server(cli, config, server, repo) + if spec is None: + print(f"[{cli}] no entry for {server!r}") + continue + tag = _describe_spec(spec, repo) + print( + f"[{cli}] {server} = {spec.command} {' '.join(spec.args)} ({tag})" + ) + except RuntimeError as exc: + print(f"[{cli}] {exc}", file=sys.stderr) + continue + return 0 + + +def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: + """Return a short label classifying a spec (local/pypi-pin/other).""" + if spec.is_local_uv_directory(): + local = spec.local_repo_path() + if local and local.resolve() == repo.resolve(): + return "local: this repo" + return f"local: {local}" + if spec.command == "uvx": + pinned = next((a for a in spec.args if "==" in a or "@" in a), None) + return f"pypi pin: {pinned}" if pinned else "pypi (unpinned)" + return "other" + + +def cmd_use_local(args: argparse.Namespace) -> int: + """Rewrite each target CLI's config to run the repo's checkout via ``uv``. + + The optional ``--scope`` flag selects Claude's user-level fallback + vs. per-project override; see :data:`Scope`. The flag is silently + coerced to ``"user"`` for non-Claude CLIs by :func:`_normalize_scope`. + """ + repo = pathlib.Path(args.repo).resolve() + server, default_entry = resolve_repo_meta(repo) + server = args.server or server + entry = args.entry or default_entry + spec = build_local_spec(repo, entry) + + targets = args.cli or present_clis() + if not targets: + print("no CLIs detected — nothing to do", file=sys.stderr) + return 1 + + ts = time.strftime("%Y%m%d%H%M%S") + state = load_state() + had_error = 0 + for cli in targets: + scope = _normalize_scope(cli, args.scope) + label = f"{cli}:{scope}" if cli == "claude" else cli + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{label}] skip — config not found at {info.config_path}") + continue + # Wrap the read + shape-guarded mutation in try/except RuntimeError + # so a malformed Claude config (top-level mcpServers / projects not a + # mapping) surfaces as a clean per-CLI error instead of an uncaught + # traceback. Same per-CLI continuation pattern the inner write-failure + # handler below uses. + try: + original_bytes = info.config_path.read_bytes() + config = load_config(info) + current = get_server(cli, config, server, repo, scope=scope) + if ( + current + and current.is_local_uv_directory() + and current.local_repo_path() == repo + ): + print(f"[{label}] already local (this repo) — no change") + continue + # Preserve the existing entry's env on replacement. ``build_local_spec`` + # writes an empty env, so without this merge a swap would silently drop + # client-side settings (LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev + # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on + # the read side. + cli_spec = ( + dataclasses.replace(spec, env={**current.env}) if current else spec + ) + action = set_server(cli, config, server, cli_spec, repo, scope=scope) + new_bytes = dump_config_bytes(info, config) + except RuntimeError as exc: + print(f"[{label}] {exc}", file=sys.stderr) + had_error = 1 + continue + + if args.dry_run: + print(f"--- {info.config_path} (current)") + print(f"+++ {info.config_path} (proposed)") + diff = difflib.unified_diff( + original_bytes.decode(errors="replace").splitlines(keepends=True), + new_bytes.decode(errors="replace").splitlines(keepends=True), + lineterm="", + ) + sys.stdout.writelines(diff) + continue + + # Claude is the only CLI where two swaps (different scopes) can + # touch the same config file in one second; embed the scope so + # the second backup doesn't overwrite the first. Non-Claude + # backup filenames carry no scope suffix. + backup_suffix = f"{BACKUP_SUFFIX_PREFIX}{ts}" + if cli == "claude": + backup_suffix += f"-{scope}" + backup_path = info.config_path.with_suffix( + info.config_path.suffix + backup_suffix + ) + backup_path.write_bytes(original_bytes) + try: + atomic_write(info.config_path, new_bytes) + _revalidate(info) + except Exception as exc: + atomic_write(info.config_path, original_bytes) + print( + f"[{label}] write failed ({exc}); backup at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + next_seq = max((e.seq_no for e in state.values()), default=-1) + 1 + state[(cli, scope)] = SwapEntry( + config_path=str(info.config_path), + backup_path=str(backup_path), + server=server, + action=action, + swapped_at=ts, + seq_no=next_seq, + ) + print(f"[{label}] {action}; backup: {backup_path}") + + if not args.dry_run: + save_state(state) + return had_error + + +def _revalidate(info: CLIInfo) -> None: + """Re-parse the file after writing; raise on failure.""" + load_config(info) + + +def cmd_revert(args: argparse.Namespace) -> int: + """Restore each target CLI's config from the backup recorded in the state file. + + Without ``--scope``, every recorded entry for the targeted CLIs is + reverted (so a Claude install that has both user-scope and + project-scope swaps gets both restored). With ``--scope``, only + the matching scope is reverted; the parameter is silently coerced + to ``"user"`` for non-Claude CLIs. + """ + state = load_state() + # Without --cli, revert every CLI that has any recorded swap. + targets = list(args.cli) if args.cli else list({cli for cli, _scope in state}) + if not targets: + print("no recorded swaps — nothing to revert", file=sys.stderr) + return 1 + + reverted: list[tuple[CLIName, Scope]] = [] + for cli in targets: + if args.scope is not None: + wanted_scopes: tuple[Scope, ...] = (_normalize_scope(cli, args.scope),) + else: + wanted_scopes = ALL_SCOPES + cli_keys = [ + (sc_cli, sc_scope) + for (sc_cli, sc_scope) in state + if sc_cli == cli and sc_scope in wanted_scopes + ] + if not cli_keys: + label = f"{cli}:{args.scope}" if args.scope and cli == "claude" else cli + print(f"[{label}] no state entry — skip") + continue + # Unwind in reverse-registration order (LIFO) — sort by the + # explicit ``SwapEntry.seq_no`` counter so order is independent + # of JSON parse order, dict iteration, and wall-clock + # collisions. ``seq_no`` is coerced to ``int`` at load time by + # ``_parse_state_entry``; entries with a non-coercible value + # are dropped before they reach this sort, so the comparison + # is always int vs int. When two scopes back the same physical + # file (Claude user + project), the later swap's backup + # contains the earlier swap's modifications, so each backup + # must restore its own layer before the prior one is restored. + # Same explicit counter pattern CPython's ``Lib/sched.py`` uses + # to break ties on ``Event(time, priority, sequence, …)``. + cli_keys.sort(key=lambda k: state[k].seq_no, reverse=True) + for key in cli_keys: + sc_cli, sc_scope = key + entry = state[key] + label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli + backup = pathlib.Path(entry.backup_path) + dest = pathlib.Path(entry.config_path) + if not backup.exists(): + print(f"[{label}] backup missing: {backup}", file=sys.stderr) + continue + if args.dry_run: + print(f"[{label}] would restore {dest} from {backup}") + continue + atomic_write(dest, backup.read_bytes()) + # Backup served its purpose; LIFO unwind for this layer is + # complete. Delete on success, keep on error — same idiom + # CPython's ``tempfile.NamedTemporaryFile`` uses + # (Lib/tempfile.py:614-618). If ``atomic_write`` had raised, + # this line wouldn't run and the backup would survive for + # post-mortem; on success the backup is redundant and would + # otherwise accumulate forever across swap/revert cycles. + backup.unlink() + print(f"[{label}] restored from {backup}") + reverted.append(key) + + if not args.dry_run and reverted: + clear_state(reverted) + return 0 + + +# --------------------------------------------------------------------------- +# argparse glue +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + """Construct the ``argparse`` parser for ``mcp_swap``.""" + p = argparse.ArgumentParser(prog="mcp_swap", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser( + "detect", help="list installed CLIs and their config presence" + ).set_defaults(func=cmd_detect) + + ps = sub.add_parser("status", help="show the current MCP server entry per CLI") + ps.add_argument("--repo", default=".", help="repo root (default: .)") + ps.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + ps.add_argument( + "--cli", action="append", choices=ALL_CLIS, help="limit to one or more CLIs" + ) + ps.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit Claude output to one scope: 'user' shows only the " + "top-level mcpServers fallback, 'project' shows only the " + "projects..mcpServers entry. Without this flag, both " + "Claude scopes print when both have an entry. No-op for " + "non-Claude CLIs (their config has no per-project layer)." + ), + ) + ps.set_defaults(func=cmd_status) + + pu = sub.add_parser("use-local", help="rewrite configs to run this checkout") + pu.add_argument("--repo", default=".", help="repo root (default: .)") + pu.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + pu.add_argument( + "--entry", help="uv run entry command (default: [project.scripts] first key)" + ) + pu.add_argument("--cli", action="append", choices=ALL_CLIS) + pu.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Claude config scope: 'user' rewrites the top-level mcpServers " + "fallback (every project without an override picks it up), " + "'project' rewrites projects..mcpServers under this repo. " + "Default 'project'. Silently coerced to 'user' for non-Claude CLIs." + ), + ) + pu.add_argument("--dry-run", action="store_true") + pu.set_defaults(func=cmd_use_local) + + pr = sub.add_parser("revert", help="restore each CLI's config from its swap backup") + pr.add_argument("--cli", action="append", choices=ALL_CLIS) + pr.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit revert to one Claude scope. Without this flag, every " + "recorded scope for the targeted CLIs is reverted." + ), + ) + pr.add_argument("--dry-run", action="store_true") + pr.set_defaults(func=cmd_revert) + + return p + + +def main(argv: list[str] | None = None) -> int: + """Entry point — dispatches to the selected subcommand.""" + args = build_parser().parse_args(argv) + return t.cast("int", args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py new file mode 100644 index 000000000..e7261ed9d --- /dev/null +++ b/tests/test_mcp_swap.py @@ -0,0 +1,61 @@ +"""The ported ``scripts/mcp_swap.py`` dev tool resolves this repo's identity. + +``mcp_swap`` swaps MCP server configs across agent CLIs to point at a local +checkout. The only port-specific change is the slug derivation: this repo's +package is ``libtmux`` but its MCP console script is ``libtmux-engine-mcp``, so +the slug must come from the *entry* (yielding ``libtmux-engine``) to stay +distinct from a sibling ``libtmux`` server. These tests lock that in, plus the +packaging wiring that makes the server runnable. +""" + +from __future__ import annotations + +import importlib.metadata +import importlib.util +import pathlib +import sys +import typing as t + +import pytest + +_REPO = pathlib.Path(__file__).resolve().parent.parent +_SCRIPT = _REPO / "scripts" / "mcp_swap.py" + + +def _load_mcp_swap() -> t.Any: + """Import the PEP 723 script as a module (registered so dataclasses resolve).""" + spec = importlib.util.spec_from_file_location("mcp_swap", _SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["mcp_swap"] = module + spec.loader.exec_module(module) + return module + + +def test_console_script_registered() -> None: + """The ``libtmux-engine-mcp`` console script points at a loadable entry.""" + scripts = importlib.metadata.entry_points(group="console_scripts") + entry = next((ep for ep in scripts if ep.name == "libtmux-engine-mcp"), None) + assert entry is not None + assert entry.value == "libtmux.experimental.mcp:main" + + +def test_resolve_repo_meta_derives_engine_identity() -> None: + """Slug derives from the entry (``libtmux-engine``), not project.name.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + server, entry = mcp_swap.resolve_repo_meta(_REPO) + assert server == "libtmux-engine" + assert entry == "libtmux-engine-mcp" + + +def test_build_local_spec_uv_directory() -> None: + """``use-local`` writes a ``uv --directory run `` invocation.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + _, entry = mcp_swap.resolve_repo_meta(_REPO) + spec = mcp_swap.build_local_spec(_REPO, entry) + assert spec.command == "uv" + assert spec.args == ["--directory", str(_REPO), "run", "libtmux-engine-mcp"] + assert spec.is_local_uv_directory() From e63421039af47ded36d4e1a35801dfd2b168cc0b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 22 Jun 2026 21:44:33 -0500 Subject: [PATCH 064/154] Tests(chore): Run the mcp adapter suite in the gate why: fastmcp is only the optional `mcp` extra, so a plain `uv sync` prunes it -- which silently turns `uv run mypy` red and makes every fastmcp adapter test importorskip away. The committed adapter's green gate depended on fastmcp happening to be installed. what: - Add fastmcp + tomlkit to the dev and testing dependency-groups so the standard gate type-checks and runs the adapter + mcp_swap tests (fastmcp also stays the `mcp` extra for end users) - Add --ignore=docs/_build to pytest addopts: `docs` is a testpath, so a stale built-HTML tree poisons collection (the gate's rm docs/_build first-step was the only guard) - Reformat ops/plan.py (pre-existing blank-line drift surfaced by ruff) - uv.lock: add tomlkit (no other version churn) --- pyproject.toml | 15 ++++++++++++++- uv.lock | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a53e7378e..e481dad46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,12 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + # MCP adapter — the optional `mcp` extra, included here so the gate + # type-checks (mypy) and exercises the fastmcp adapter + its tests + # instead of silently skipping them. + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", # Coverage "codecov", "coverage", @@ -88,6 +94,10 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + # MCP adapter (optional `mcp` extra) so the adapter tests run here too + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", ] coverage =[ "codecov", @@ -315,7 +325,10 @@ addopts = [ "--showlocals", "--doctest-docutils-modules", "-p no:doctest", - "--reruns=2" + "--reruns=2", + # Built HTML lives under docs/_build and `docs` is a testpath; never + # collect generated artifacts (their relative directives fail to parse). + "--ignore=docs/_build", ] doctest_optionflags = [ "ELLIPSIS", diff --git a/uv.lock b/uv.lock index a3324190f..f8d179172 100644 --- a/uv.lock +++ b/uv.lock @@ -1179,6 +1179,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "fastmcp" }, { name = "gp-libs" }, { name = "gp-sphinx" }, { name = "mypy" }, @@ -1193,6 +1194,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-pytest-fixtures" }, + { name = "tomlkit" }, { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, @@ -1210,11 +1212,13 @@ lint = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ + { name = "fastmcp" }, { name = "gp-libs" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -1231,6 +1235,7 @@ coverage = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "fastmcp" }, { name = "gp-libs", specifier = ">=0.0.18" }, { name = "gp-sphinx", specifier = "==0.0.1a33" }, { name = "mypy" }, @@ -1244,6 +1249,7 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a32" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a32" }, + { name = "tomlkit" }, { name = "ty" }, { name = "types-docutils" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, @@ -1260,11 +1266,13 @@ lint = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ + { name = "fastmcp" }, { name = "gp-libs", specifier = ">=0.0.18" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, + { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -2925,6 +2933,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "ty" version = "0.0.53" From 60bd985fc11b98a6bee5f139a88298c9134e415b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 17:33:56 -0500 Subject: [PATCH 065/154] Workspace(test): Cover analyzer, compiler, runner why: The Declarative WorkspaceBuilder tier had thin coverage -- a single analyzer shorthand case, and nothing exercising the compiler's host-step schedule or the runner's on_exists preflight policy. Lock in those behaviors so a regression in the Declarative-to-Core lowering or the host-side orchestration is caught. what: - Analyzer/IR (offline): dimensions in both [x, y] and {width, height} forms; shell_command shorthand (string / list / {cmd} items); the None-pane and unsupported-pane TypeError paths; non-mapping-YAML rejection; session-field passthrough; per-pane orchestration fields; and Pane.commands run-form normalization - Compiler (offline): dimensions threaded into new-session -x/-y; env/option/window-option ops emitted with their values; the before_script and pane sleep_before/sleep_after host-step schedule asserted off the pure op spine (anchored by send-keys position, not literal index); first-window reuse vs create-the-rest; and Workspace.compile() == compile_full().plan - Runner/confirm (live tmux): before_script runs as a host step in start_directory; on_exists='reuse' short-circuits to an empty-but-ok result leaving the session untouched while 'error' raises FileExistsError; and confirm() flags a structural mismatch --- ..._async_control_engine_workspace_builder.py | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 33eff2d77..826bb4033 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -27,12 +27,20 @@ FoldingPlanner, LazyPlan, MarkedPlanner, + NewSession, SequentialPlanner, + SetEnvironment, + SetOption, + SetWindowOption, ) from libtmux.experimental.workspace import ( + HostStep, + Pane, + Window, Workspace, WorkspaceCompileError, analyze, + compile_full, confirm, ) from libtmux.test.retry import retry_until @@ -307,3 +315,310 @@ async def main() -> PlanResult: assert [w.window_name for w in built.windows] == ["editor", "logs", "shell"] assert built.active_window.window_name == "shell" assert str(built.show_option("history-limit")) == "5000" + + +# --- Analyzer + IR normalization (offline, no tmux) --- + + +def test_pane_commands_normalizes_run_forms() -> None: + """Pane.commands turns run (None / str / sequence) into a command tuple.""" + assert Pane().commands == () + assert Pane(run="vim").commands == ("vim",) + assert Pane(run=["cd src", "pytest -q"]).commands == ("cd src", "pytest -q") + + +def test_analyze_dimensions_list_and_mapping() -> None: + """The analyzer coerces both ``[x, y]`` and ``{width, height}`` dimensions.""" + panes = {"windows": [{"panes": ["echo a"]}]} + listed = analyze({"session_name": "s", "dimensions": [200, 50], **panes}) + mapped = analyze( + {"session_name": "s", "dimensions": {"width": 100, "height": 40}, **panes}, + ) + unset = analyze({"session_name": "s", **panes}) + assert listed.dimensions == (200, 50) + assert mapped.dimensions == (100, 40) + assert unset.dimensions is None + + +def test_analyze_shell_command_shorthand_forms() -> None: + """shell_command shorthand expands: bare string, list, and ``{cmd}`` items.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + {"shell_command": "echo solo"}, + {"shell_command": ["echo a", {"cmd": "echo b"}]}, + None, + ], + }, + ], + }, + ) + panes = ws.windows[0].panes + assert panes[0].commands == ("echo solo",) + assert panes[1].commands == ("echo a", "echo b") + assert panes[2].commands == () # a None pane is an empty (implicit) pane + + +def test_analyze_passes_through_session_fields() -> None: + """Session-level fields (env/options/before_script/on_exists) survive analysis.""" + ws = analyze( + { + "session_name": "s", + "on_exists": "replace", + "before_script": "echo setup", + "environment": {"E": "1"}, + "options": {"history-limit": "5000"}, + "windows": [{"panes": ["echo a"]}], + }, + ) + assert ws.on_exists == "replace" + assert ws.before_script == "echo setup" + assert dict(ws.environment) == {"E": "1"} + assert dict(ws.options) == {"history-limit": "5000"} + + +def test_analyze_normalizes_pane_orchestration_fields() -> None: + """Per-pane orchestration (sleeps, start_directory, focus) lands on the Pane.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + { + "shell_command": ["echo x"], + "sleep_before": 0.1, + "sleep_after": 0.2, + "start_directory": "/tmp", + "focus": True, + }, + ], + }, + ], + }, + ) + pane = ws.windows[0].panes[0] + assert (pane.sleep_before, pane.sleep_after) == (0.1, 0.2) + assert pane.start_directory == "/tmp" + assert pane.focus is True + + +def test_analyze_rejects_non_mapping_yaml() -> None: + """A YAML scalar (not a mapping) is rejected rather than silently mis-parsed.""" + with pytest.raises(TypeError): + analyze("just-a-scalar") + + +def test_analyze_rejects_unsupported_pane() -> None: + """A pane that is neither None, a string, nor a mapping fails closed.""" + with pytest.raises(TypeError): + analyze({"session_name": "s", "windows": [{"panes": [123]}]}) + + +# --- Compiler: op emission + host-step schedule (offline, no tmux) --- + + +def test_compile_threads_dimensions_into_new_session() -> None: + """Workspace dimensions become the new-session ``-x``/``-y`` width/height.""" + ws = Workspace( + name="ws-dim", + dimensions=(120, 40), + windows=[Window("w", panes=[Pane(run="echo a")])], + ) + new_session = compile_full(ws).plan.operations[0] + assert isinstance(new_session, NewSession) # first op, narrowed for its fields + assert (new_session.width, new_session.height) == (120, 40) + + +def test_compile_emits_environment_and_options() -> None: + """Session env/options and window options compile to their write ops, valued.""" + ws = Workspace( + name="ws-opts", + environment={"WS_E": "1"}, + options={"history-limit": "9000"}, + windows=[ + Window("w", options={"main-pane-height": "10"}, panes=[Pane(run="echo a")]), + ], + ) + ops = compile_full(ws).plan.operations + set_env = next(op for op in ops if isinstance(op, SetEnvironment)) + set_opt = next(op for op in ops if isinstance(op, SetOption)) + set_wopt = next(op for op in ops if isinstance(op, SetWindowOption)) + assert (set_env.name, set_env.value) == ("WS_E", "1") + assert (set_opt.option, set_opt.value) == ("history-limit", "9000") + assert (set_wopt.option, set_wopt.value) == ("main-pane-height", "10") + + +def test_compile_schedules_host_steps_off_the_op_spine() -> None: + """before_script and pane sleeps become host steps, not recorded operations.""" + ws = Workspace( + name="ws-hosts", + start_directory="/tmp", + before_script="echo hi", + windows=[ + Window( + "w", + panes=[ + Pane(run="echo a", sleep_before=0.5), + Pane(run="echo b", sleep_after=0.7), + ], + ), + ], + ) + compiled = compile_full(ws) + operations = compiled.plan.operations + + # no orchestration leaks into the pure op spine + assert {"sleep", "script"}.isdisjoint(op.kind for op in operations) + + # before_script runs before any op, carrying the session cwd + assert compiled.pre == (HostStep("script", command="echo hi", cwd="/tmp"),) + + # sleep_before is anchored just before its pane's first send-keys; + # sleep_after just after the last send-keys -- asserted by position, not index + sends = [i for i, op in enumerate(operations) if op.kind == "send_keys"] + assert HostStep("sleep", seconds=0.5) in compiled.host_after[min(sends) - 1] + assert HostStep("sleep", seconds=0.7) in compiled.host_after[max(sends)] + + +def test_compile_reuses_first_window_creating_only_the_rest() -> None: + """Window 0 reuses the session's implicit window; only 2..N create windows.""" + unnamed = compile_full(Workspace(name="s", windows=[Window(panes=[Pane(run="x")])])) + unnamed_kinds = [op.kind for op in unnamed.plan.operations] + assert "new_window" not in unnamed_kinds + assert "rename_window" not in unnamed_kinds # nothing to rename when unnamed + + named = compile_full(Workspace(name="s", windows=[Window("w", panes=[Pane("x")])])) + named_kinds = [op.kind for op in named.plan.operations] + assert "new_window" not in named_kinds + assert named_kinds.count("rename_window") == 1 # first window renamed in place + + two = compile_full( + Workspace( + name="s", + windows=[Window("a", panes=[Pane("x")]), Window("b", panes=[Pane("y")])], + ), + ) + assert [op.kind for op in two.plan.operations].count("new_window") == 1 + + +def test_compile_workspace_method_matches_compile_full_plan() -> None: + """``Workspace.compile()`` returns exactly ``compile_full().plan`` (same ops).""" + ws = Workspace(name="s", windows=[Window("w", panes=[Pane("echo a"), Pane("b")])]) + via_method = [op.kind for op in ws.compile().operations] + via_full = [op.kind for op in compile_full(ws).plan.operations] + assert via_method == via_full + + +# --- Runner preflight + confirm negative path (live tmux) --- + + +def test_workspace_before_script_runs_as_host_step( + session: Session, + tmp_path: Path, +) -> None: + """before_script executes on the host, in start_directory, before the build.""" + server = session.server + sentinel = tmp_path / "before_script.ran" + spec = analyze( + { + "session_name": "ws-before", + "start_directory": str(tmp_path), + "on_exists": "replace", + # relative path -> proves the step runs with cwd == start_directory + "before_script": f"echo ok > {sentinel.name}", + "windows": [{"window_name": "w", "panes": ["echo a"]}], + }, + ) + + result = spec.build(SubprocessEngine.for_server(server)) + assert result.ok + assert sentinel.exists() + assert sentinel.read_text().strip() == "ok" + + +def test_workspace_on_exists_reuse_skips_existing( + session: Session, + tmp_path: Path, +) -> None: + """on_exists='reuse' leaves an existing session untouched and skips the build.""" + server = session.server + engine = SubprocessEngine.for_server(server) + spec = analyze( + { + "session_name": "ws-reuse", + "start_directory": str(tmp_path), + "on_exists": "reuse", + "windows": [{"window_name": "only", "panes": ["echo a"]}], + }, + ) + + assert spec.build(engine).ok + before = [ + w.window_id for w in server.sessions.filter(session_name="ws-reuse")[0].windows + ] + + # the second build sees the session and short-circuits: empty but ok + second = spec.build(engine) + assert second.ok + assert second.results == () + after = [ + w.window_id for w in server.sessions.filter(session_name="ws-reuse")[0].windows + ] + assert before == after # untouched -- same windows, not rebuilt + + +def test_workspace_on_exists_error_raises( + session: Session, + tmp_path: Path, +) -> None: + """on_exists='error' refuses to clobber an existing session of the same name.""" + server = session.server + engine = SubprocessEngine.for_server(server) + spec = analyze( + { + "session_name": "ws-error", + "start_directory": str(tmp_path), + "on_exists": "error", + "windows": [{"window_name": "w", "panes": ["echo a"]}], + }, + ) + + assert spec.build(engine).ok + with pytest.raises(FileExistsError): + spec.build(engine) + + +def test_workspace_confirm_detects_structural_mismatch( + session: Session, + tmp_path: Path, +) -> None: + """Confirm flags a problem when the live session diverges from the spec.""" + server = session.server + built = analyze( + { + "session_name": "ws-confirm", + "start_directory": str(tmp_path), + "on_exists": "replace", + "windows": [{"window_name": "only", "panes": ["echo a"]}], + }, + ) + assert built.build(SubprocessEngine.for_server(server)).ok + assert confirm(built, server).ok # matches what was actually built + + # a spec declaring more windows than were built must be flagged + divergent = analyze( + { + "session_name": "ws-confirm", + "windows": [ + {"window_name": "only", "panes": ["echo a"]}, + {"window_name": "extra", "panes": ["echo b"]}, + ], + }, + ) + report = confirm(divergent, server) + assert not report.ok + assert any("window count" in problem for problem in report.problems) From 4c8331337902af3eeb5bca162481703712eb079a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 17:36:20 -0500 Subject: [PATCH 066/154] Mcp(feat): Add grok + agy CLIs to mcp_swap why: The swap tool covered Claude / Codex / Cursor / Gemini but not the Grok or Antigravity (agy) CLIs, so a local-checkout swap could not reach two installed agents. Extending the registry lets one use-local repoint the tmux MCP across all six. what: - Register grok (~/.grok/config.toml, TOML "mcp_servers" table, same shape as codex) and agy/Antigravity (~/.gemini/antigravity/mcp_config.json, JSON "mcpServers", same shape as cursor/gemini) in CLIName / ALL_CLIS / CLIS - Route grok through the existing codex branch and agy through the cursor/gemini branch in get_server / set_server / delete_server - Tolerate an empty JSON config in load_config so the swap can seed Antigravity's initially-empty mcp_config.json instead of raising - Note in the docstring that the Antigravity IDE and the agy CLI may read different profiles; only the documented profile path is written - Tests: grok (TOML) and agy (JSON) set/get/delete round-trips, the empty-JSON tolerance, and the registry shapes --- scripts/mcp_swap.py | 55 +++++++++++++++++++++++++++++---------- tests/test_mcp_swap.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 6e68b6d1c..db487ddad 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -3,7 +3,7 @@ # requires-python = ">=3.10" # dependencies = ["tomlkit>=0.13"] # /// -"""Swap MCP server configs across Claude / Codex / Cursor / Gemini CLIs. +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini / Grok / Antigravity. Use when you want every installed agent CLI to run a local checkout of an MCP server (editable) instead of a pinned release. ``use-local`` rewrites @@ -35,8 +35,13 @@ This script is best-effort and intentionally narrow: - **Global configs only.** Writes to ``~/.cursor/mcp.json``, - ``~/.claude.json``, ``~/.codex/config.toml``, and - ``~/.gemini/settings.json``. Workspace / project-local configs + ``~/.claude.json``, ``~/.codex/config.toml``, + ``~/.gemini/settings.json``, ``~/.grok/config.toml`` (TOML + ``mcp_servers``, same shape as Codex), and + ``~/.gemini/antigravity/mcp_config.json`` (Antigravity, JSON + ``mcpServers``). The Antigravity desktop IDE and the ``agy`` CLI may + read different profiles; only the documented profile path above is + written. Workspace / project-local configs (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, per-project ``projects..mcpServers`` entries inside ``~/.claude.json`` *are* recognised for Claude only) are NOT @@ -83,8 +88,8 @@ import tomlkit import tomlkit.items -CLIName = t.Literal["claude", "codex", "cursor", "gemini"] -ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini") +CLIName = t.Literal["claude", "codex", "cursor", "gemini", "grok", "agy"] +ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini", "grok", "agy") #: Claude config scope: ``"user"`` targets the user/system-level top-level #: ``mcpServers`` fallback that applies to every project without its own @@ -219,6 +224,24 @@ class CLIInfo: config_path=pathlib.Path.home() / ".gemini" / "settings.json", fmt="json", ), + "grok": CLIInfo( + name="grok", + binary="grok", + config_path=pathlib.Path.home() / ".grok" / "config.toml", + fmt="toml", + ), + # Antigravity (the ``agy`` CLI). Its MCP config is the standard JSON + # ``mcpServers`` shape (same as Cursor / Gemini) under the + # Antigravity profile dir. The file may not exist until the IDE/CLI + # writes it and starts empty; ``load_config`` tolerates a 0-byte + # JSON file as ``{}``. Note: the desktop IDE and the ``agy`` CLI may + # read different profiles; this targets the documented profile path. + "agy": CLIInfo( + name="agy", + binary="agy", + config_path=pathlib.Path.home() / ".gemini" / "antigravity" / "mcp_config.json", + fmt="json", + ), } @@ -292,10 +315,16 @@ class SwapEntry: def load_config(info: CLIInfo) -> t.Any: - """Parse a CLI's config file (JSON or TOML) into an editable structure.""" + """Parse a CLI's config file (JSON or TOML) into an editable structure. + + An empty JSON file is treated as an empty object ``{}`` rather than a + parse error: Antigravity's ``mcp_config.json`` is created empty until + a server is added, so a swap must be able to seed the first entry. + """ raw = info.config_path.read_bytes() if info.fmt == "json": - return json.loads(raw) + text = raw.decode().strip() + return json.loads(text) if text else {} return tomlkit.parse(raw.decode()) @@ -459,9 +488,9 @@ def get_server( if not node: return None entry = node.get("mcpServers", {}).get(name) - elif cli in ("cursor", "gemini"): + elif cli in ("cursor", "gemini", "agy"): entry = config.get("mcpServers", {}).get(name) - else: # cli == "codex" + else: # cli in ("codex", "grok") — TOML "mcp_servers" table entry = config.get("mcp_servers", {}).get(name) if entry is None: return None @@ -496,12 +525,12 @@ def set_server( had = name in servers servers[name] = spec.to_json_dict(include_stdio_type=True) return "replaced" if had else "added" - if cli in ("cursor", "gemini"): + if cli in ("cursor", "gemini", "agy"): servers = config.setdefault("mcpServers", {}) had = name in servers servers[name] = spec.to_json_dict() return "replaced" if had else "added" - if cli == "codex": + if cli in ("codex", "grok"): # tomlkit: top-level tables are accessed via dict protocol too. mcp_servers = config.get("mcp_servers") if mcp_servers is None: @@ -547,9 +576,9 @@ def delete_server( return False servers = node.get("mcpServers", {}) return servers.pop(name, None) is not None - if cli in ("cursor", "gemini"): + if cli in ("cursor", "gemini", "agy"): return config.get("mcpServers", {}).pop(name, None) is not None - if cli == "codex": + if cli in ("codex", "grok"): mcp_servers = config.get("mcp_servers") if mcp_servers is None: return False diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index e7261ed9d..20f0e0f65 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -59,3 +59,62 @@ def test_build_local_spec_uv_directory() -> None: assert spec.command == "uv" assert spec.args == ["--directory", str(_REPO), "run", "libtmux-engine-mcp"] assert spec.is_local_uv_directory() + + +def test_grok_and_agy_registered() -> None: + """The grok and agy CLIs join the registry with their config shapes.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + assert "grok" in mcp_swap.ALL_CLIS + assert "agy" in mcp_swap.ALL_CLIS + assert mcp_swap.CLIS["grok"].fmt == "toml" + assert mcp_swap.CLIS["grok"].config_path.name == "config.toml" + assert mcp_swap.CLIS["agy"].fmt == "json" + assert mcp_swap.CLIS["agy"].config_path.name == "mcp_config.json" + + +def test_grok_set_get_delete_roundtrip() -> None: + """The grok CLI reads/writes the TOML ``[mcp_servers]`` table like codex.""" + tomlkit = pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + config = tomlkit.parse("") + spec = mcp_swap.McpServerSpec( + command="uv", args=["--directory", str(_REPO), "run", "x"] + ) + assert mcp_swap.set_server("grok", config, "tmux", spec, _REPO) == "added" + assert "mcp_servers" in config # TOML table, not the JSON "mcpServers" + got = mcp_swap.get_server("grok", config, "tmux", _REPO) + assert got is not None + assert got.is_local_uv_directory() + assert mcp_swap.set_server("grok", config, "tmux", spec, _REPO) == "replaced" + assert mcp_swap.delete_server("grok", config, "tmux", _REPO) + assert mcp_swap.get_server("grok", config, "tmux", _REPO) is None + + +def test_agy_set_get_delete_roundtrip() -> None: + """The agy CLI reads/writes the JSON ``mcpServers`` map like cursor/gemini.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + config: dict[str, t.Any] = {} + spec = mcp_swap.McpServerSpec( + command="uv", args=["--directory", str(_REPO), "run", "x"] + ) + assert mcp_swap.set_server("agy", config, "tmux", spec, _REPO) == "added" + # JSON (non-Claude) shape: no Claude-style "type", no empty "env" + assert "type" not in config["mcpServers"]["tmux"] + assert "env" not in config["mcpServers"]["tmux"] + got = mcp_swap.get_server("agy", config, "tmux", _REPO) + assert got is not None + assert got.is_local_uv_directory() + assert mcp_swap.delete_server("agy", config, "tmux", _REPO) + assert mcp_swap.get_server("agy", config, "tmux", _REPO) is None + + +def test_load_config_tolerates_empty_json(tmp_path: pathlib.Path) -> None: + """An empty JSON config (Antigravity's initial mcp_config.json) loads as {}.""" + pytest.importorskip("tomlkit") + mcp_swap = _load_mcp_swap() + cfg = tmp_path / "mcp_config.json" + cfg.write_text("") + info = mcp_swap.CLIInfo(name="agy", binary="agy", config_path=cfg, fmt="json") + assert mcp_swap.load_config(info) == {} From 21eb3b314c1a3cb585ba6e0130ee514bfb219d64 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:23:59 -0500 Subject: [PATCH 067/154] Mcp(feat): Caller-aware async tmux tool surface why: The experimental MCP exposed only a thin synchronous projection. Agents driving tmux need an intuitive, non-blocking surface that knows which pane they are calling from, resolves "the pane relative to me" in one call, and never silently targets the wrong pane. what: - Refactor the curated vocabulary into an async-first package (session/window/pane/buffer/option/server): each tool is one async def over arun, with a derived sync twin via a sans-I/O trampoline (_bridge.synced) -- a single source of truth per tool. - Expand the lean curated set with high-value verbs and conveniences (grep_pane, capture_active_pane, geometry-resolved relative/corner pane tools, directional select_pane) plus a guarded run_tmux hatch. - Add build_async_server (default AsyncControlModeEngine) awaited on FastMCP's loop; build_server stays the sync wrapper; main() and fastmcp.json go async-first. - Add a live event stream (events.py): a push watch_events tool and a pull tmux://events ring buffer, selected by LIBTMUX_MCP_EVENTS. - Make the surface caller-aware: server name 'tmux' plus steering instructions (when/anti-triggers/concrete-id rule); CallerContext reads TMUX_PANE/TMUX from the server's own env, socket-scoped; get_caller_context anchor; is_caller on list_panes/search_panes rows; the relative tools default to and require the caller pane origin; capture_relative_pane/grep_relative_pane/search_panes. - Reject relative special targets ({up-of}/{down-of}/...) on capture, grep, send, and destructive pane tools with a hint pointing to the relative tools; anchor specials ({marked}/{last}) pass through. - Cover with experimental tests + doctests (no pytest-asyncio). --- fastmcp.json | 2 +- src/libtmux/experimental/mcp/__init__.py | 85 ++- src/libtmux/experimental/mcp/events.py | 233 +++++++ .../experimental/mcp/fastmcp_adapter.py | 481 +++++++++----- src/libtmux/experimental/mcp/vocabulary.py | 373 ----------- .../experimental/mcp/vocabulary/__init__.py | 226 +++++++ .../experimental/mcp/vocabulary/_bridge.py | 103 +++ .../experimental/mcp/vocabulary/_caller.py | 168 +++++ .../experimental/mcp/vocabulary/_geometry.py | 162 +++++ .../experimental/mcp/vocabulary/_resolve.py | 198 ++++++ .../experimental/mcp/vocabulary/_results.py | 106 ++++ .../experimental/mcp/vocabulary/buffer.py | 66 ++ .../experimental/mcp/vocabulary/option.py | 73 +++ .../experimental/mcp/vocabulary/pane.py | 599 ++++++++++++++++++ .../experimental/mcp/vocabulary/server.py | 71 +++ .../experimental/mcp/vocabulary/session.py | 134 ++++ .../experimental/mcp/vocabulary/window.py | 182 ++++++ tests/experimental/mcp/test_adapter_async.py | 93 +++ tests/experimental/mcp/test_caller.py | 193 ++++++ tests/experimental/mcp/test_events.py | 140 ++++ .../mcp/test_relative_special_guard.py | 119 ++++ .../mcp/test_vocabulary_extended.py | 243 +++++++ 22 files changed, 3516 insertions(+), 534 deletions(-) create mode 100644 src/libtmux/experimental/mcp/events.py delete mode 100644 src/libtmux/experimental/mcp/vocabulary.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/__init__.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_bridge.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_caller.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_geometry.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_resolve.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/_results.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/buffer.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/option.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/pane.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/server.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/session.py create mode 100644 src/libtmux/experimental/mcp/vocabulary/window.py create mode 100644 tests/experimental/mcp/test_adapter_async.py create mode 100644 tests/experimental/mcp/test_caller.py create mode 100644 tests/experimental/mcp/test_events.py create mode 100644 tests/experimental/mcp/test_relative_special_guard.py create mode 100644 tests/experimental/mcp/test_vocabulary_extended.py diff --git a/fastmcp.json b/fastmcp.json index f4fe92417..31bbfab36 100644 --- a/fastmcp.json +++ b/fastmcp.json @@ -3,7 +3,7 @@ "source": { "type": "filesystem", "path": "src/libtmux/experimental/mcp/__init__.py", - "entrypoint": "default_server" + "entrypoint": "default_async_server" }, "deployment": { "transport": "stdio" diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index a683f1933..2861957b7 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -70,11 +70,12 @@ def default_server(*, expose_operations: bool = False) -> FastMCP: - """Build a FastMCP server over a default :class:`~..engines.SubprocessEngine`. + """Build a synchronous FastMCP server over a :class:`~..engines.SubprocessEngine`. - A convenience factory (also the ``fastmcp.json`` entrypoint) for embedding or - deploying the server with the default tmux socket. Requires the ``mcp`` - extra (``pip install 'libtmux[mcp]'``). + A convenience factory for embedding or deploying the *synchronous* server + with the default tmux socket. Prefer :func:`default_async_server` for the + async-first surface and the live event stream. Requires the ``mcp`` extra + (``pip install 'libtmux[mcp]'``). """ from libtmux.experimental.engines import SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server @@ -82,41 +83,96 @@ def default_server(*, expose_operations: bool = False) -> FastMCP: return build_server(SubprocessEngine(), expose_operations=expose_operations) +def default_async_server( + *, + expose_operations: bool = False, + events: str = "push", + event_source: str = "subscription", +) -> FastMCP: + """Build the async-first FastMCP server over an :class:`AsyncControlModeEngine`. + + The default deployment: tools are awaited on FastMCP's loop and the live + event stream is wired up. The control-mode connection opens lazily on first + use. Requires the ``mcp`` extra. + """ + import typing as t + + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.events import EventMode, EventSource + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + return build_async_server( + AsyncControlModeEngine(), + expose_operations=expose_operations, + events=t.cast("EventMode", events), + event_source=t.cast("EventSource", event_source), + ) + + def main(argv: Sequence[str] | None = None) -> None: """Run the libtmux-engine MCP server over stdio (console-script entry). - Wired to the ``libtmux-engine-mcp`` console script and - ``python -m libtmux.experimental.mcp``. Requires the ``mcp`` extra. + Async-first by default (an :class:`AsyncControlModeEngine`); pass ``--sync`` + for the subprocess-backed synchronous server. Event mode/source default from + ``LIBTMUX_MCP_EVENTS`` / ``LIBTMUX_MCP_EVENT_SOURCE``. Wired to the + ``libtmux-engine-mcp`` console script and ``python -m + libtmux.experimental.mcp``. Requires the ``mcp`` extra. """ import argparse + import os import sys parser = argparse.ArgumentParser( prog="libtmux-engine-mcp", description="Run the experimental libtmux typed-ops MCP server (stdio).", ) - parser.add_argument("--name", default="libtmux-engine", help="server name") + parser.add_argument("--name", default="tmux", help="server name") parser.add_argument( "--operations", action="store_true", help="expose the full per-operation tool surface (op_*)", ) + parser.add_argument( + "--sync", + action="store_true", + help="use the synchronous subprocess server instead of async-first", + ) + parser.add_argument( + "--events", + choices=("off", "push", "pull", "both"), + default=os.environ.get("LIBTMUX_MCP_EVENTS", "push"), + help="live event mechanism (async server only)", + ) + parser.add_argument( + "--event-source", + choices=("subscription", "output"), + default=os.environ.get("LIBTMUX_MCP_EVENT_SOURCE", "subscription"), + help="event substrate (async server only)", + ) args = parser.parse_args(argv) try: - from libtmux.experimental.engines import SubprocessEngine - from libtmux.experimental.mcp.fastmcp_adapter import build_server + if args.sync: + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_server + + server = build_server( + SubprocessEngine(), + name=args.name, + expose_operations=args.operations, + ) + else: + server = default_async_server( + expose_operations=args.operations, + events=args.events, + event_source=args.event_source, + ) except ImportError: sys.stderr.write( "libtmux-engine-mcp requires the 'mcp' extra: pip install 'libtmux[mcp]'\n", ) raise SystemExit(1) from None - server = build_server( - SubprocessEngine(), - name=args.name, - expose_operations=args.operations, - ) server.run(transport="stdio") @@ -137,6 +193,7 @@ def main(argv: Sequence[str] | None = None) -> None: "capture_pane", "create_session", "create_window", + "default_async_server", "default_server", "execute_plan", "kill_pane", diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py new file mode 100644 index 000000000..2869884ce --- /dev/null +++ b/src/libtmux/experimental/mcp/events.py @@ -0,0 +1,233 @@ +"""Live tmux event stream over MCP -- two interchangeable mechanisms (A/B). + +A control-mode engine exposes tmux's asynchronous notifications (``%output``, +``%window-add``, ``%session-changed``, ...) as an ``async for`` stream via +``subscribe()``. FastMCP 3.x has no resource-subscription handshake and buffers a +tool's async generator into one list, so a live stream must be surfaced as +either: + +- **push** -- a long-running ``watch_events`` tool that holds a ``Context`` and + pushes each event as an MCP notification (real-time; best over streamable-http). +- **pull** -- a ``tmux://events`` resource backed by a ring buffer a background + task fills, plus a ``poll_events`` tool; clients poll (stdio-friendly). + +Which is registered is chosen by :func:`register_events` (driven by the +``LIBTMUX_MCP_EVENTS`` env var at the entrypoint). Both consume the engine's +single notification queue, so run one *or* the other per process when comparing. + +The ``source`` axis selects the substrate: ``"output"`` streams raw +notifications; ``"subscription"`` first installs ``refresh-client -B`` format +subscriptions, tmux's debounced, server-side change detection. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import typing as t + +from fastmcp import Context + +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator, Sequence + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine, CommandResult + +EventMode = t.Literal["off", "push", "pull", "both"] +EventSource = t.Literal["subscription", "output"] + +_RING_SIZE = 1024 + + +class _StreamEngine(t.Protocol): + """An async engine that also exposes a ``subscribe()`` notification stream. + + The general :class:`~..engines.base.AsyncTmuxEngine` protocol does not declare + ``subscribe`` (only the control-mode engine has it), so the event tools type + against this narrower protocol after the :func:`_supports_stream` guard. + """ + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute a batch of tmux commands.""" + ... + + def subscribe(self) -> AsyncIterator[t.Any]: + """Yield tmux notifications as they arrive.""" + ... + + +def _supports_stream(engine: AsyncTmuxEngine) -> bool: + """Whether *engine* exposes a ``subscribe()`` notification stream.""" + return callable(getattr(engine, "subscribe", None)) + + +def _event_dict(notification: t.Any) -> dict[str, t.Any]: + """Project a ``ControlNotification`` to a JSON-friendly dict.""" + return { + "kind": notification.kind, + "args": list(notification.args), + "raw": notification.raw, + } + + +async def _install_subscriptions( + engine: _StreamEngine, + specs: list[str] | None, +) -> None: + """Install ``refresh-client -B`` format subscriptions (``name:what:format``).""" + for spec in specs or []: + await engine.run(CommandRequest.from_args("refresh-client", "-B", spec)) + + +class _EventRing: + """A bounded ring buffer fed by a single background ``subscribe()`` reader. + + Each event gets a monotonic sequence number so a ``poll_events`` caller can + ask for "everything since N" without re-reading the whole buffer. + """ + + def __init__(self, engine: _StreamEngine, maxlen: int = _RING_SIZE) -> None: + self._engine = engine + self._buffer: collections.deque[tuple[int, dict[str, t.Any]]] = ( + collections.deque(maxlen=maxlen) + ) + self._seq = 0 + self._task: asyncio.Task[None] | None = None + + def _ensure_started(self) -> None: + """Start the drainer task once, lazily, on the running loop.""" + if self._task is None: + self._task = asyncio.create_task(self._drain(), name="libtmux-mcp-events") + + async def _drain(self) -> None: + """Copy every notification into the ring buffer.""" + stream: AsyncIterator[t.Any] = self._engine.subscribe() + async for notification in stream: + self._seq += 1 + self._buffer.append((self._seq, _event_dict(notification))) + + def since(self, seq: int) -> dict[str, t.Any]: + """Return buffered events with sequence number greater than *seq*.""" + self._ensure_started() + events = [event for n, event in self._buffer if n > seq] + return {"events": events, "cursor": self._seq} + + +def register_events( + mcp: FastMCP, + engine: AsyncTmuxEngine, + *, + mode: EventMode = "push", + source: EventSource = "subscription", +) -> None: + """Register the event stream tools/resource on *mcp* per *mode*. + + Does nothing when *mode* is ``"off"`` or *engine* has no ``subscribe()`` + stream (e.g. a subprocess engine) -- the live stream is a control-mode + feature. + """ + if mode == "off" or not _supports_stream(engine): + return + stream = t.cast("_StreamEngine", engine) + if mode in ("push", "both"): + _register_push(mcp, stream, source=source) + if mode in ("pull", "both"): + _register_pull(mcp, stream) + + +def _register_push( + mcp: FastMCP, + engine: _StreamEngine, + *, + source: EventSource, +) -> None: + """Register the long-running ``watch_events`` push tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + async def watch_events( + ctx: Context, + kinds: list[str] | None = None, + max_events: int = 20, + timeout: float = 30.0, + subscriptions: list[str] | None = None, + ) -> dict[str, t.Any]: + """Stream live tmux notifications, pushing each as an MCP log message. + + Returns after *max_events* notifications or *timeout* seconds, whichever + comes first. ``kinds`` filters by notification kind (e.g. ``window-add``, + ``output``). With ``source="subscription"``, pass ``subscriptions`` as + ``name:what:format`` specs to install ``refresh-client -B`` watches first. + """ + if source == "subscription": + await _install_subscriptions(engine, subscriptions) + collected: list[dict[str, t.Any]] = [] + + async def _collect() -> None: + async for notification in engine.subscribe(): + if kinds and notification.kind not in kinds: + continue + await ctx.info(notification.raw) + collected.append(_event_dict(notification)) + if max_events and len(collected) >= max_events: + return + + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(_collect(), timeout=timeout) + return {"events": collected, "count": len(collected)} + + tool = FunctionTool.from_function( + watch_events, + name="watch_events", + description="Stream live tmux notifications as MCP messages", + tags={"readonly", "events"}, + annotations=ToolAnnotations(title="watch_events", readOnlyHint=True), + ) + mcp.add_tool(tool) + + +def _register_pull(mcp: FastMCP, engine: _StreamEngine) -> None: + """Register the ``tmux://events`` resource + ``poll_events`` pull tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + ring = _EventRing(engine) + + async def read_events() -> dict[str, t.Any]: + """Return all buffered tmux events (starts the reader on first read).""" + return ring.since(0) + + mcp.resource( + "tmux://events", + name="tmux-events", + description="Buffered tmux control-mode notifications", + )(read_events) + + async def poll_events(since: int = 0) -> dict[str, t.Any]: + """Return tmux events with sequence number greater than *since*. + + The response ``cursor`` is the latest sequence number; pass it back as + ``since`` next call to receive only newer events. + """ + return ring.since(since) + + tool = FunctionTool.from_function( + poll_events, + name="poll_events", + description="Poll buffered tmux events since a cursor", + tags={"readonly", "events"}, + annotations=ToolAnnotations(title="poll_events", readOnlyHint=True), + ) + mcp.add_tool(tool) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 05a5216da..363b5c380 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -2,34 +2,21 @@ This is the thin, framework-specific edge. It requires the ``mcp`` extra (``pip install libtmux[mcp]``); fastmcp is imported lazily so the rest of -:mod:`libtmux.experimental.mcp` stays dependency-free. :func:`build_server` -projects three layers of tools over one engine: +:mod:`libtmux.experimental.mcp` stays dependency-free. + +The vocabulary is **async-first**: :func:`build_async_server` registers the +``async def`` tools so FastMCP awaits them directly on its event loop (the right +fit for the persistent control-mode connection's loop affinity), and adds the +live event stream. :func:`build_server` is the synchronous wrapper -- it +registers the derived sync twins, which FastMCP offloads to a worker thread. + +Both project the same three tool layers over one engine: 1. **Curated vocabulary** -- the intuitive, hand-written tools (:mod:`~libtmux.experimental.mcp.vocabulary`), always visible. -2. **Per-operation tools** -- one ``op_`` per registered operation, - auto-derived from the :class:`~..registry.OperationToolRegistry`. These carry - a precomputed JSON schema (dynamic params), so each is a :class:`fastmcp.tools. - Tool` subclass with an explicit ``parameters`` schema rather than an - introspected signature. Tagged ``per-op`` and hidden by default (the full - surface is large); ``expose_operations=True`` reveals them. -3. **Plan tools** -- :func:`preview_plan`/:func:`execute_plan`/ - :func:`result_schema`/:func:`build_workspace`, taking serialized operations so - an agent can compose and run a whole :class:`~..ops.plan.LazyPlan`. - -The agent-facing ``engine`` is bound out of each tool's schema, and the safety -tier becomes the tool's ``ToolAnnotations`` + tag. - -Examples --------- ->>> import asyncio ->>> from fastmcp import Client # doctest: +SKIP ->>> from libtmux.experimental.engines import ConcreteEngine ->>> server = build_server(ConcreteEngine()) # doctest: +SKIP ->>> async def main(): # doctest: +SKIP -... async with Client(server) as client: -... return (await client.call_tool("create_session", {"name": "dev"})).data ->>> asyncio.run(main()) # doctest: +SKIP +2. **Per-operation tools** -- one ``op_`` per registered operation, hidden + behind the ``per-op`` tag by default (the full surface is large). +3. **Plan tools** -- compose and run a whole :class:`~..ops.plan.LazyPlan`. """ from __future__ import annotations @@ -40,49 +27,143 @@ from libtmux.experimental.mcp import vocabulary from libtmux.experimental.mcp.registry import OperationToolRegistry +from libtmux.experimental.mcp.vocabulary._caller import CallerContext if t.TYPE_CHECKING: from collections.abc import Callable from fastmcp import FastMCP - from libtmux.experimental.engines.base import TmuxEngine + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.mcp.descriptor import ToolDescriptor - from libtmux.experimental.ops.plan import LazyPlan - -# (function, safety tier) -- every curated tool takes ``engine`` as its first arg. -_VOCABULARY: tuple[tuple[Callable[..., t.Any], str], ...] = ( - (vocabulary.create_session, "mutating"), - (vocabulary.create_window, "mutating"), - (vocabulary.split_pane, "mutating"), - (vocabulary.send_input, "mutating"), - (vocabulary.capture_pane, "readonly"), - (vocabulary.list_sessions, "readonly"), - (vocabulary.list_windows, "readonly"), - (vocabulary.list_panes, "readonly"), - (vocabulary.rename_window, "mutating"), - (vocabulary.rename_session, "mutating"), - (vocabulary.select_layout, "mutating"), - (vocabulary.select_pane, "mutating"), - (vocabulary.kill_pane, "destructive"), - (vocabulary.kill_window, "destructive"), - (vocabulary.kill_session, "destructive"), + from libtmux.experimental.mcp.events import EventMode, EventSource + +# (public tool name, safety tier). The async tool is ``a`` and the sync +# twin is ```` -- a single table drives both surfaces. +_TOOLS: tuple[tuple[str, str], ...] = ( + ("create_session", "mutating"), + ("create_window", "mutating"), + ("split_pane", "mutating"), + ("send_input", "mutating"), + ("capture_pane", "readonly"), + ("capture_active_pane", "readonly"), + ("grep_pane", "readonly"), + ("list_sessions", "readonly"), + ("list_windows", "readonly"), + ("list_panes", "readonly"), + ("list_clients", "readonly"), + ("has_session", "readonly"), + ("show_options", "readonly"), + ("show_buffer", "readonly"), + ("display_message", "readonly"), + ("resolve_relative_pane", "readonly"), + ("capture_relative_pane", "readonly"), + ("grep_relative_pane", "readonly"), + ("search_panes", "readonly"), + ("find_pane_by_position", "readonly"), + ("rename_window", "mutating"), + ("rename_session", "mutating"), + ("select_window", "mutating"), + ("select_layout", "mutating"), + ("select_pane", "mutating"), + ("move_window", "mutating"), + ("swap_window", "mutating"), + ("resize_pane", "mutating"), + ("swap_pane", "mutating"), + ("join_pane", "mutating"), + ("break_pane", "mutating"), + ("respawn_pane", "mutating"), + ("set_option", "mutating"), + ("set_buffer", "mutating"), + ("paste_buffer", "mutating"), + ("run_tmux", "mutating"), + ("kill_pane", "destructive"), + ("kill_window", "destructive"), + ("kill_session", "destructive"), ) -_INSTRUCTIONS = ( - "Drive tmux through typed tools. Targets accept tmux ids (%pane, @window, " - "$session), names, or 'session:window.pane'. Creating a session/window also " - "returns the new first-pane id for chaining. The curated tools cover most " - "needs; the full per-operation surface (op_*) and the plan tools " - "(preview_plan/execute_plan/result_schema/build_workspace) are available for " - "power use." +# Read-only discovery anchors -- the tools an agent should reach for first. +# Tagged with vendor metadata best-effort (fastmcp 3.4.2 passes ``_meta`` through +# but assigns it no semantics, so this is advisory only). +_ANCHORS = frozenset( + { + "list_panes", + "search_panes", + "grep_relative_pane", + "capture_active_pane", + "get_caller_context", + }, ) +# Fail loud at import if an anchor name drifts from the registered tool set +# (the alwaysLoad metadata is opaque, so a typo would otherwise fail silently). +_unknown_anchors = _ANCHORS - ({name for name, _ in _TOOLS} | {"get_caller_context"}) +if _unknown_anchors: # pragma: no cover - import-time guard + _msg = f"unknown anchor tools: {sorted(_unknown_anchors)}" + raise RuntimeError(_msg) + _TARGET_HELP = ( "tmux target: an id (%pane, @window, $session), a name, or 'session:window.pane'" ) +def _agent_context_segment(ctx: CallerContext) -> str: + """Return the agent-context paragraph naming the caller's pane.""" + if ctx.in_tmux and ctx.pane_id: + socket = ctx.socket_path or "default" + session = f" (session {ctx.session_id})" if ctx.session_id else "" + return ( + f"Agent context: this MCP runs from pane {ctx.pane_id} on socket " + f'{socket}{session}. That pane is flagged is_caller ("1" in ' + "list_panes rows, true in search_panes matches) -- call " + "get_caller_context to read it. " + "Omitting a target/origin on the caller-aware tools " + "(resolve_relative_pane/capture_relative_pane/grep_relative_pane) " + "means YOUR pane." + ) + return ( + "Agent context: this MCP is not running inside a tmux pane, so there is no " + "caller pane and no row is flagged is_caller; the relative tools " + "(resolve_relative_pane/capture_relative_pane/grep_relative_pane) require an " + "explicit origin pane id here." + ) + + +def _instructions(ctx: CallerContext) -> str: + """Compose the server instructions, woven with the live caller context.""" + return "\n\n".join( + ( + "This MCP drives a real tmux server through typed tools: sessions, " + "windows, panes, terminal scrollback, send-keys, copy-mode buffers. " + "Targets accept tmux ids (%pane, @window, $session), names, or " + "'session:window.pane'.", + "When to invoke: managing tmux panes/windows/sessions; reading " + "terminal scrollback (capture_pane/grep_pane/search_panes); sending " + "keystrokes to a running shell or REPL (send_input); copy-mode and " + "paste-buffer work; operating on a pane relative to another or to you " + "(capture_relative_pane/grep_relative_pane).", + "Do NOT invoke for: editor panes you edit via file tools; browser tabs " + "or web content; GUI application windows; notebook cells; any non-tmux " + "terminal surface. tmux only sees terminal panes -- it cannot read a " + "browser or GUI app.", + "Prefer a concrete %N pane id; resolve relative or caller-relative " + "targets to a concrete %N before capture/send. Never hand a directional " + "special target ({up-of}/{down-of}/{left-of}/{right-of}) to " + "capture_pane/grep_pane/send_input -- those resolve against THIS MCP's " + "control client, not your pane; use capture_relative_pane / " + "grep_relative_pane / resolve_relative_pane instead.", + _agent_context_segment(ctx), + "list_panes/list_windows/show_options query tmux metadata (format " + "fields); grep_pane (one pane) and search_panes (across panes) search " + "terminal text (scrollback). Pick the right one for 'which pane shows X'.", + "The curated tools cover most needs; the per-operation surface (op_*) " + "and the plan tools (preview_plan/execute_plan/result_schema/" + "build_workspace) are power-use; watch_events streams live notifications " + "on a control-mode engine.", + ), + ) + + def _summary(doc: str | None) -> str | None: """Return the first non-empty docstring line.""" for line in (doc or "").splitlines(): @@ -93,60 +174,101 @@ def _summary(doc: str | None) -> str | None: def _bind_engine( fn: Callable[..., t.Any], - engine: TmuxEngine, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, ) -> Callable[..., t.Any]: """Bind *engine* out of *fn*, returning a wrapper fastmcp can introspect. - Unlike :func:`functools.partial`, this carries *pre-resolved* annotations - (with ``engine`` removed) and an explicit ``__signature__``, so fastmcp's - ``get_type_hints`` never has to re-evaluate the forward references in *fn* - (it would do so against the wrong module globals and fail). + Carries *pre-resolved* annotations (with ``engine`` removed) and an explicit + ``__signature__`` so fastmcp's ``get_type_hints`` never re-evaluates the + forward references against the wrong module globals. The async branch returns + a coroutine function so FastMCP awaits it on the loop; the sync branch a plain + function (offloaded to a thread). """ hints = t.get_type_hints(fn) signature = inspect.signature(fn) params = [p for name, p in signature.parameters.items() if name != "engine"] - def tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + async def _async_tool(*args: t.Any, **kwargs: t.Any) -> t.Any: + return await fn(engine, *args, **kwargs) + + def _sync_tool(*args: t.Any, **kwargs: t.Any) -> t.Any: return fn(engine, *args, **kwargs) + # Typed Any so the dunder rebinds below are not checked against a plain + # Callable (which carries no __name__/__signature__ in mypy's view). + tool: t.Any = _async_tool if is_async else _sync_tool tool.__name__ = fn.__name__ tool.__qualname__ = fn.__name__ tool.__doc__ = fn.__doc__ - tool.__signature__ = signature.replace(parameters=params) # type: ignore[attr-defined] + tool.__signature__ = signature.replace(parameters=params) tool.__annotations__ = {k: v for k, v in hints.items() if k != "engine"} - return tool + return t.cast("Callable[..., t.Any]", tool) -def register_vocabulary(mcp: FastMCP, engine: TmuxEngine) -> None: +def register_vocabulary( + mcp: FastMCP, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, +) -> None: """Register the curated vocabulary as tools on *mcp*, bound to *engine*.""" from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations - for fn, safety in _VOCABULARY: + for name, safety in _TOOLS: + fn = getattr(vocabulary, ("a" + name) if is_async else name) annotations = ToolAnnotations( - title=fn.__name__, + title=name, readOnlyHint=safety == "readonly", destructiveHint=safety == "destructive", ) tool = FunctionTool.from_function( - _bind_engine(fn, engine), - name=fn.__name__, + _bind_engine(fn, engine, is_async=is_async), + name=name, description=_summary(fn.__doc__), tags={safety}, annotations=annotations, + meta={"anthropic/alwaysLoad": True} if name in _ANCHORS else None, ) mcp.add_tool(tool) +def register_caller_context(mcp: FastMCP, ctx: CallerContext) -> None: + """Register the ``get_caller_context`` anchor returning the build-time context. + + It closes over the context read once from the server's environment -- it must + *not* re-query tmux, which would answer for the control client, not the + caller. + """ + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + def get_caller_context() -> CallerContext: + """Return the tmux pane/server that launched this MCP (from its env).""" + return ctx + + tool = FunctionTool.from_function( + get_caller_context, + name="get_caller_context", + description=_summary(get_caller_context.__doc__), + tags={"readonly"}, + annotations=ToolAnnotations(title="get_caller_context", readOnlyHint=True), + meta=( + {"anthropic/alwaysLoad": True} if "get_caller_context" in _ANCHORS else None + ), + ) + mcp.add_tool(tool) + + def _op_input_schema(descriptor: ToolDescriptor) -> dict[str, t.Any]: """Return the per-op tool's input schema, re-adding the target params. The :class:`~..registry.OperationToolRegistry` omits ``target`` / ``src_target`` from a descriptor's params (they are polymorphic - :data:`~..ops._types.Target` values, handled by - :meth:`~..descriptor.ToolDescriptor.build`), so the schema is re-completed - here -- at the framework edge -- as plain ``string`` params. Required-ness - follows the operation field's default. + :data:`~..ops._types.Target` values), so the schema is re-completed here -- + at the framework edge -- as plain ``string`` params. """ schema = descriptor.input_schema() fields = { @@ -174,25 +296,25 @@ def _op_input_schema(descriptor: ToolDescriptor) -> dict[str, t.Any]: def register_operations( mcp: FastMCP, - engine: TmuxEngine, + engine: TmuxEngine | AsyncTmuxEngine, *, + is_async: bool, registry: OperationToolRegistry | None = None, hidden: bool = True, ) -> None: """Register one ``op_`` tool per registered operation. Each tool carries the operation's precomputed JSON schema and dispatches to - :meth:`~..descriptor.ToolDescriptor.build` + :func:`~..ops.execute.run`, - returning the serialized result. Tools are tagged ``per-op`` plus their - safety tier; when *hidden* (the default) the ``per-op`` tag is disabled so - the large surface does not clutter an agent's tool list (re-enable with - ``mcp.enable(tags={"per-op"})``). + :meth:`~..descriptor.ToolDescriptor.build` + :func:`~..ops.execute.run` (or + :func:`~..ops.execute.arun` for an async engine), returning the serialized + result. Tools are tagged ``per-op`` plus their safety tier; when *hidden* + (the default) the ``per-op`` tag is disabled. """ from fastmcp.tools import Tool, ToolResult from mcp.types import ToolAnnotations from pydantic import PrivateAttr - from libtmux.experimental.ops import run as run_op + from libtmux.experimental.ops import arun as arun_op, run as run_op from libtmux.experimental.ops.serialize import result_to_dict class _OperationTool(Tool): @@ -200,10 +322,14 @@ class _OperationTool(Tool): _descriptor: t.Any = PrivateAttr(default=None) _engine: t.Any = PrivateAttr(default=None) + _is_async: bool = PrivateAttr(default=False) async def run(self, arguments: dict[str, t.Any]) -> ToolResult: operation = self._descriptor.build(**arguments) - result = run_op(operation, self._engine) + if self._is_async: + result = await arun_op(operation, self._engine) + else: + result = run_op(operation, self._engine) return ToolResult( structured_content=result_to_dict(result), is_error=not result.ok, @@ -225,6 +351,7 @@ async def run(self, arguments: dict[str, t.Any]) -> ToolResult: ) tool._descriptor = descriptor tool._engine = engine + tool._is_async = is_async mcp.add_tool(tool) if hidden: mcp.disable(tags={"per-op"}) @@ -232,17 +359,17 @@ async def run(self, arguments: dict[str, t.Any]) -> ToolResult: def register_plan_tools( mcp: FastMCP, - engine: TmuxEngine, + engine: TmuxEngine | AsyncTmuxEngine, *, + is_async: bool, registry: OperationToolRegistry | None = None, ) -> None: """Register the plan-tier tools (compose + run serialized :class:`LazyPlan`s). - ``preview_plan`` renders without executing; ``execute_plan`` runs a - serialized plan (forward refs resolved via bindings); ``result_schema`` - reports what a kind returns; ``build_workspace`` builds the Declarative tier - in one call. All take JSON-serializable inputs (operation dicts from - :func:`~..ops.serialize.operation_to_dict`). + ``preview_plan`` / ``result_schema`` are pure; ``execute_plan`` runs a + serialized plan (via :func:`~..mcp.plan_tools.aexecute_plan` on an async + engine). ``build_workspace`` is registered only on the synchronous server + (the declarative runner is synchronous). """ from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations @@ -270,6 +397,13 @@ def _plan_from_dicts(operations: list[dict[str, t.Any]]) -> LazyPlan: plan.add(operation_from_dict(data)) return plan + def _planner(name: str) -> Planner: + chosen = planners.get(name) + if chosen is None: + msg = f"unknown planner {name!r}; choose from {sorted(planners)}" + raise ValueError(msg) + return chosen() + def preview_plan( operations: list[dict[str, t.Any]], version: str | None = None, @@ -282,28 +416,6 @@ def preview_plan( "argv": [list(item) if item is not None else None for item in preview.argv], } - def execute_plan( - operations: list[dict[str, t.Any]], - planner: str = "sequential", - version: str | None = None, - ) -> dict[str, t.Any]: - """Execute a serialized plan over the engine; return results + bindings.""" - chosen = planners.get(planner) - if chosen is None: - msg = f"unknown planner {planner!r}; choose from {sorted(planners)}" - raise ValueError(msg) - outcome = _plan.execute_plan( - _plan_from_dicts(operations), - engine, - version=version, - planner=chosen(), - ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } - def result_schema(kind: str) -> dict[str, t.Any]: """Report what an operation kind returns, for planning forward refs.""" schema = _plan.result_schema(reg, kind) @@ -314,30 +426,73 @@ def result_schema(kind: str) -> dict[str, t.Any]: "binding_fields": schema.binding_fields, } - def build_workspace( - spec: dict[str, t.Any], - preflight: bool = True, - version: str | None = None, - ) -> dict[str, t.Any]: - """Build a declarative workspace (the Declarative tier) in one call.""" - outcome = _plan.build_workspace( - spec, - engine, - version=version, - preflight=preflight, - ) - return { - "ok": outcome.ok, - "results": outcome.results, - "bindings": outcome.bindings, - } - - tools: tuple[tuple[Callable[..., t.Any], str], ...] = ( + tools: list[tuple[Callable[..., t.Any], str]] = [ (preview_plan, "readonly"), (result_schema, "readonly"), - (execute_plan, "mutating"), - (build_workspace, "mutating"), - ) + ] + + if is_async: + + async def execute_plan( + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + outcome = await _plan.aexecute_plan( + _plan_from_dicts(operations), + t.cast("AsyncTmuxEngine", engine), + version=version, + planner=_planner(planner), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools.append((execute_plan, "mutating")) + else: + + def execute_plan( # type: ignore[misc] + operations: list[dict[str, t.Any]], + planner: str = "sequential", + version: str | None = None, + ) -> dict[str, t.Any]: + """Execute a serialized plan over the engine; return results + bindings.""" + outcome = _plan.execute_plan( + _plan_from_dicts(operations), + t.cast("TmuxEngine", engine), + version=version, + planner=_planner(planner), + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + def build_workspace( + spec: dict[str, t.Any], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build a declarative workspace (the Declarative tier) in one call.""" + outcome = _plan.build_workspace( + spec, + t.cast("TmuxEngine", engine), + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + + tools.append((execute_plan, "mutating")) + tools.append((build_workspace, "mutating")) + for fn, safety in tools: annotations = ToolAnnotations( title=fn.__name__, @@ -357,40 +512,74 @@ def build_workspace( def build_server( engine: TmuxEngine, *, - name: str = "libtmux-engine", + name: str = "tmux", instructions: str | None = None, include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, ) -> FastMCP: - """Build a FastMCP server exposing the typed tool surface over *engine*. - - Parameters - ---------- - engine - The :class:`~..engines.base.TmuxEngine` every tool runs against. - name, instructions - Server identity (``instructions`` defaults to a built-in primer). - include_operations - Register the auto-derived ``op_`` per-operation tools. - expose_operations - Reveal those per-operation tools by default (otherwise they are - registered but hidden behind the ``per-op`` tag). - include_plan_tools - Register the plan-tier tools. + """Build a synchronous FastMCP server over a sync *engine*. + + The sync wrapper: the curated tools are the derived sync twins, which FastMCP + offloads to a worker thread. Prefer :func:`build_async_server` for the + async-first surface and the event stream. """ from fastmcp import FastMCP - mcp: FastMCP = FastMCP(name=name, instructions=instructions or _INSTRUCTIONS) + ctx = CallerContext.from_env() + mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) + registry = OperationToolRegistry() + register_vocabulary(mcp, engine, is_async=False) + register_caller_context(mcp, ctx) + if include_operations: + register_operations( + mcp, + engine, + is_async=False, + registry=registry, + hidden=not expose_operations, + ) + if include_plan_tools: + register_plan_tools(mcp, engine, is_async=False, registry=registry) + return mcp + + +def build_async_server( + engine: AsyncTmuxEngine, + *, + name: str = "tmux", + instructions: str | None = None, + include_operations: bool = True, + expose_operations: bool = False, + include_plan_tools: bool = True, + events: EventMode = "push", + event_source: EventSource = "subscription", +) -> FastMCP: + """Build the async-first FastMCP server over an async *engine*. + + The curated tools and per-op/plan tools are registered as ``async`` and + awaited directly on FastMCP's event loop. When *engine* supports a + notification stream (a control-mode engine), the live event tools are + registered per *events* (``"push"``/``"pull"``/``"both"``/``"off"``). + """ + from fastmcp import FastMCP + + from libtmux.experimental.mcp.events import register_events + + ctx = CallerContext.from_env() + mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) registry = OperationToolRegistry() - register_vocabulary(mcp, engine) + register_vocabulary(mcp, engine, is_async=True) + register_caller_context(mcp, ctx) if include_operations: register_operations( mcp, engine, + is_async=True, registry=registry, hidden=not expose_operations, ) if include_plan_tools: - register_plan_tools(mcp, engine, registry=registry) + register_plan_tools(mcp, engine, is_async=True, registry=registry) + register_events(mcp, engine, mode=events, source=event_source) return mcp diff --git a/src/libtmux/experimental/mcp/vocabulary.py b/src/libtmux/experimental/mcp/vocabulary.py deleted file mode 100644 index ec44f519a..000000000 --- a/src/libtmux/experimental/mcp/vocabulary.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Curated core vocabulary -- the intuitive, named tmux tools. - -The Layer-1 surface: a small set of hand-written, framework-agnostic functions -that mirror libtmux's own ORM (``server.new_session`` / ``window.split_window`` / -``pane.send_keys``) but run over any engine and return small, typed result -objects exposing just the ids/names a caller cares about. Each is a thin wrapper: -resolve the target, build one operation, :func:`~..ops.execute.run` it, raise on -failure, return a typed result. Power users drop to the per-op descriptors, -plans, or ops directly. - -Examples --------- ->>> from libtmux.experimental.engines import ConcreteEngine ->>> engine = ConcreteEngine() ->>> session = create_session(engine, name="dev") ->>> session.session_id -'$1' ->>> pane = split_pane(engine, session.first_pane_id or "%1", horizontal=True) ->>> pane.pane_id -'%2' ->>> send_input(engine, pane.pane_id, "pytest -q", enter=True) is None -True -""" - -from __future__ import annotations - -import collections.abc -from dataclasses import dataclass - -from libtmux.experimental.engines.base import TmuxEngine -from libtmux.experimental.mcp.target_resolver import resolve_target -from libtmux.experimental.ops import ( - CapturePane, - KillPane, - KillSession, - KillWindow, - ListPanes, - ListSessions, - ListWindows, - NewSession, - NewWindow, - RenameSession, - RenameWindow, - SelectLayout, - SelectPane, - SendKeys, - SplitWindow, - run, -) -from libtmux.experimental.ops._types import Target - -# TmuxEngine / Target are imported at runtime (not under TYPE_CHECKING) so the -# fastmcp adapter's get_type_hints() can resolve these annotations when it builds -# tool schemas from these functions. - - -@dataclass(frozen=True) -class SessionResult: - """A created session: its id, name, and captured first window/pane ids.""" - - session_id: str - name: str | None = None - first_window_id: str | None = None - first_pane_id: str | None = None - - -@dataclass(frozen=True) -class WindowResult: - """A created window: its id, name, and captured first pane id.""" - - window_id: str - name: str | None = None - first_pane_id: str | None = None - - -@dataclass(frozen=True) -class PaneResult: - """A created pane: its id.""" - - pane_id: str - - -@dataclass(frozen=True) -class PaneCapture: - """Captured pane contents.""" - - lines: tuple[str, ...] - - -@dataclass(frozen=True) -class Listing: - """A list query result: one mapping (tmux format row) per object.""" - - rows: tuple[collections.abc.Mapping[str, str], ...] - - -def create_session( - engine: TmuxEngine, - *, - name: str | None = None, - start_directory: str | None = None, - environment: collections.abc.Mapping[str, str] | None = None, - width: int | None = None, - height: int | None = None, - version: str | None = None, -) -> SessionResult: - """Create a detached session (mirrors ``server.new_session``). - - Examples - -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> r = create_session(ConcreteEngine(), name="work") - >>> (r.session_id, r.name, r.first_pane_id) - ('$1', 'work', '%1') - """ - result = run( - NewSession( - session_name=name, - start_directory=start_directory, - environment=environment, - width=width, - height=height, - capture_panes=True, - ), - engine, - version=version, - ) - result.raise_for_status() - return SessionResult( - session_id=result.new_id or "", - name=name, - first_window_id=result.first_window_id, - first_pane_id=result.first_pane_id, - ) - - -def create_window( - engine: TmuxEngine, - target: str | Target, - *, - name: str | None = None, - start_directory: str | None = None, - version: str | None = None, -) -> WindowResult: - """Create a window in a session (mirrors ``session.new_window``).""" - result = run( - NewWindow( - target=resolve_target(target), - name=name, - start_directory=start_directory, - capture_pane=True, - ), - engine, - version=version, - ) - result.raise_for_status() - return WindowResult( - window_id=result.new_id or "", - name=name, - first_pane_id=result.first_pane_id, - ) - - -def split_pane( - engine: TmuxEngine, - target: str | Target, - *, - horizontal: bool = False, - start_directory: str | None = None, - version: str | None = None, -) -> PaneResult: - """Split a pane, creating a new one (mirrors ``window.split_window``).""" - result = run( - SplitWindow( - target=resolve_target(target), - horizontal=horizontal, - start_directory=start_directory, - ), - engine, - version=version, - ) - result.raise_for_status() - return PaneResult(pane_id=result.new_pane_id or "") - - -def send_input( - engine: TmuxEngine, - target: str | Target, - keys: str, - *, - enter: bool = False, - literal: bool = False, - suppress_history: bool = False, - version: str | None = None, -) -> None: - """Send keys to a pane (mirrors ``pane.send_keys``).""" - run( - SendKeys( - target=resolve_target(target), - keys=keys, - enter=enter, - literal=literal, - suppress_history=suppress_history, - ), - engine, - version=version, - ).raise_for_status() - - -def capture_pane( - engine: TmuxEngine, - target: str | Target, - *, - start: int | None = None, - end: int | None = None, - join_wrapped: bool = False, - trim_trailing: bool = False, - version: str | None = None, -) -> PaneCapture: - """Capture a pane's contents (mirrors ``pane.capture_pane``).""" - result = run( - CapturePane( - target=resolve_target(target), - start=start, - end=end, - join_wrapped=join_wrapped, - trim_trailing=trim_trailing, - ), - engine, - version=version, - ) - result.raise_for_status() - return PaneCapture(lines=result.lines) - - -def list_sessions(engine: TmuxEngine, *, version: str | None = None) -> Listing: - """List the server's sessions (mirrors ``server.sessions``).""" - result = run(ListSessions(), engine, version=version) - result.raise_for_status() - return Listing(rows=result.rows) - - -def list_windows( - engine: TmuxEngine, - target: str | Target | None = None, - *, - all_windows: bool = False, - version: str | None = None, -) -> Listing: - """List windows of a session, or all windows (mirrors ``session.windows``).""" - result = run( - ListWindows(target=resolve_target(target), all_windows=all_windows), - engine, - version=version, - ) - result.raise_for_status() - return Listing(rows=result.rows) - - -def list_panes( - engine: TmuxEngine, - target: str | Target | None = None, - *, - all_panes: bool = False, - version: str | None = None, -) -> Listing: - """List panes of a window, or all panes (mirrors ``window.panes``).""" - result = run( - ListPanes(target=resolve_target(target), all_panes=all_panes), - engine, - version=version, - ) - result.raise_for_status() - return Listing(rows=result.rows) - - -def kill_pane( - engine: TmuxEngine, - target: str | Target, - *, - others: bool = False, - version: str | None = None, -) -> None: - """Kill a pane (or all others in its window with ``others=True``).""" - run( - KillPane(target=resolve_target(target), others=others), - engine, - version=version, - ).raise_for_status() - - -def kill_window( - engine: TmuxEngine, - target: str | Target, - *, - others: bool = False, - version: str | None = None, -) -> None: - """Kill a window (or all others in its session with ``others=True``).""" - run( - KillWindow(target=resolve_target(target), others=others), - engine, - version=version, - ).raise_for_status() - - -def kill_session( - engine: TmuxEngine, - target: str | Target, - *, - version: str | None = None, -) -> None: - """Kill a session (mirrors ``session.kill``).""" - run( - KillSession(target=resolve_target(target)), engine, version=version - ).raise_for_status() - - -def rename_window( - engine: TmuxEngine, - target: str | Target, - name: str, - *, - version: str | None = None, -) -> None: - """Rename a window (mirrors ``window.rename_window``).""" - run( - RenameWindow(target=resolve_target(target), name=name), - engine, - version=version, - ).raise_for_status() - - -def rename_session( - engine: TmuxEngine, - target: str | Target, - name: str, - *, - version: str | None = None, -) -> None: - """Rename a session (mirrors ``session.rename_session``).""" - run( - RenameSession(target=resolve_target(target), name=name), - engine, - version=version, - ).raise_for_status() - - -def select_layout( - engine: TmuxEngine, - target: str | Target, - *, - layout: str | None = None, - version: str | None = None, -) -> None: - """Apply a layout to a window (mirrors ``window.select_layout``).""" - run( - SelectLayout(target=resolve_target(target), layout=layout), - engine, - version=version, - ).raise_for_status() - - -def select_pane( - engine: TmuxEngine, - target: str | Target, - *, - version: str | None = None, -) -> None: - """Make a pane active (mirrors ``window.select_pane``).""" - run( - SelectPane(target=resolve_target(target)), engine, version=version - ).raise_for_status() diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py new file mode 100644 index 000000000..f39e93832 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -0,0 +1,226 @@ +"""Curated core vocabulary -- the intuitive, named tmux tools. + +The Layer-1 surface: a small set of hand-written functions that mirror libtmux's +ORM (``server.new_session`` / ``window.split_window`` / ``pane.send_keys``) but +run over any engine and return small, typed result objects. Each tool is written +once as an ``async def`` (the ``a``-prefixed names, the canonical async-first +surface) and exposed as a derived synchronous twin under the plain name (see +:mod:`._bridge`). Tools are grouped by scope in submodules +(:mod:`.session` / :mod:`.window` / :mod:`.pane` / :mod:`.buffer` / +:mod:`.option` / :mod:`.server`). + +Examples +-------- +>>> from libtmux.experimental.engines import ConcreteEngine +>>> engine = ConcreteEngine() +>>> session = create_session(engine, name="dev") +>>> session.session_id +'$1' +>>> pane = split_pane(engine, session.first_pane_id or "%1", horizontal=True) +>>> pane.pane_id +'%2' +>>> send_input(engine, pane.pane_id, "pytest -q", enter=True) is None +True +""" + +from __future__ import annotations + +from libtmux.experimental.mcp.vocabulary._caller import CallerContext +from libtmux.experimental.mcp.vocabulary._results import ( + BufferText, + Listing, + MessageText, + OptionMap, + PaneCapture, + PaneMatch, + PaneRef, + PaneResult, + PaneSearch, + RawResult, + SessionResult, + WindowResult, +) +from libtmux.experimental.mcp.vocabulary.buffer import ( + apaste_buffer, + aset_buffer, + ashow_buffer, + paste_buffer, + set_buffer, + show_buffer, +) +from libtmux.experimental.mcp.vocabulary.option import ( + aset_option, + ashow_options, + set_option, + show_options, +) +from libtmux.experimental.mcp.vocabulary.pane import ( + abreak_pane, + acapture_active_pane, + acapture_pane, + acapture_relative_pane, + afind_pane_by_position, + agrep_pane, + agrep_relative_pane, + ajoin_pane, + akill_pane, + alist_panes, + aresize_pane, + aresolve_relative_pane, + arespawn_pane, + asearch_panes, + aselect_pane, + asend_input, + asplit_pane, + aswap_pane, + break_pane, + capture_active_pane, + capture_pane, + capture_relative_pane, + find_pane_by_position, + grep_pane, + grep_relative_pane, + join_pane, + kill_pane, + list_panes, + resize_pane, + resolve_relative_pane, + respawn_pane, + search_panes, + select_pane, + send_input, + split_pane, + swap_pane, +) +from libtmux.experimental.mcp.vocabulary.server import ( + adisplay_message, + alist_clients, + arun_tmux, + display_message, + list_clients, + run_tmux, +) +from libtmux.experimental.mcp.vocabulary.session import ( + acreate_session, + ahas_session, + akill_session, + alist_sessions, + arename_session, + create_session, + has_session, + kill_session, + list_sessions, + rename_session, +) +from libtmux.experimental.mcp.vocabulary.window import ( + acreate_window, + akill_window, + alist_windows, + amove_window, + arename_window, + aselect_layout, + aselect_window, + aswap_window, + create_window, + kill_window, + list_windows, + move_window, + rename_window, + select_layout, + select_window, + swap_window, +) + +__all__ = ( + "BufferText", + "CallerContext", + "Listing", + "MessageText", + "OptionMap", + "PaneCapture", + "PaneMatch", + "PaneRef", + "PaneResult", + "PaneSearch", + "RawResult", + "SessionResult", + "WindowResult", + "abreak_pane", + "acapture_active_pane", + "acapture_pane", + "acapture_relative_pane", + "acreate_session", + "acreate_window", + "adisplay_message", + "afind_pane_by_position", + "agrep_pane", + "agrep_relative_pane", + "ahas_session", + "ajoin_pane", + "akill_pane", + "akill_session", + "akill_window", + "alist_clients", + "alist_panes", + "alist_sessions", + "alist_windows", + "amove_window", + "apaste_buffer", + "arename_session", + "arename_window", + "aresize_pane", + "aresolve_relative_pane", + "arespawn_pane", + "arun_tmux", + "asearch_panes", + "aselect_layout", + "aselect_pane", + "aselect_window", + "asend_input", + "aset_buffer", + "aset_option", + "ashow_buffer", + "ashow_options", + "asplit_pane", + "aswap_pane", + "aswap_window", + "break_pane", + "capture_active_pane", + "capture_pane", + "capture_relative_pane", + "create_session", + "create_window", + "display_message", + "find_pane_by_position", + "grep_pane", + "grep_relative_pane", + "has_session", + "join_pane", + "kill_pane", + "kill_session", + "kill_window", + "list_clients", + "list_panes", + "list_sessions", + "list_windows", + "move_window", + "paste_buffer", + "rename_session", + "rename_window", + "resize_pane", + "resolve_relative_pane", + "respawn_pane", + "run_tmux", + "search_panes", + "select_layout", + "select_pane", + "select_window", + "send_input", + "set_buffer", + "set_option", + "show_buffer", + "show_options", + "split_pane", + "swap_pane", + "swap_window", +) diff --git a/src/libtmux/experimental/mcp/vocabulary/_bridge.py b/src/libtmux/experimental/mcp/vocabulary/_bridge.py new file mode 100644 index 000000000..a473bbe97 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_bridge.py @@ -0,0 +1,103 @@ +"""Sync bridge: drive one async tool body over a synchronous engine. + +The curated vocabulary is written once as ``async def`` over an +:class:`~libtmux.experimental.engines.base.AsyncTmuxEngine` (the canonical, +"async-first" surface). A synchronous twin is *derived* -- not hand-written -- +by :func:`synced`, which wraps a plain :class:`~..engines.base.TmuxEngine` in the +async protocol (:class:`SyncToAsyncEngine`) and drives the coroutine to +completion with a sans-I/O trampoline (:func:`drive_sync`). + +This is sound because every curated tool's only ``await`` is a single +``arun(op, engine)`` -- and the wrapped sync engine's ``run`` returns inline, +never suspending on a real :class:`asyncio.Future`. The trampoline therefore +runs the whole coroutine in one ``send(None)``, needing no event loop, and works +even when called from inside a running loop. A tool that *does* suspend (the +event stream) has no sync twin and raises here, by design. +""" + +from __future__ import annotations + +import functools +import inspect +import typing as t + +# Imported at runtime (not under TYPE_CHECKING) so the derived sync twin's +# ``engine`` annotation resolves when the fastmcp adapter calls get_type_hints(). +from libtmux.experimental.engines.base import TmuxEngine + +if t.TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Sequence + + from libtmux.experimental.engines.base import CommandRequest, CommandResult + +R = t.TypeVar("R") + + +class SyncToAsyncEngine: + """Adapt a synchronous :class:`TmuxEngine` to the async engine protocol. + + Each ``await`` resolves inline (the underlying call is synchronous), so a + coroutine awaiting only this adapter never yields to an event loop. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> bridge = SyncToAsyncEngine(ConcreteEngine()) + >>> hasattr(bridge, "run") and hasattr(bridge, "run_batch") + True + """ + + def __init__(self, engine: TmuxEngine) -> None: + self._engine = engine + + async def run(self, request: CommandRequest) -> CommandResult: + """Run one command on the wrapped sync engine (resolves inline).""" + return self._engine.run(request) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Run a batch on the wrapped sync engine (resolves inline).""" + return self._engine.run_batch(requests) + + +def drive_sync(coro: Awaitable[R]) -> R: + """Run *coro* to completion synchronously, without an event loop. + + Works only for coroutines whose awaits never suspend on a real future + (the curated tools, driven over a :class:`SyncToAsyncEngine`). + + Raises + ------ + RuntimeError + If the coroutine suspends on real I/O -- use the async surface instead. + """ + runner = t.cast("t.Coroutine[t.Any, t.Any, R]", coro) + try: + runner.send(None) + except StopIteration as stop: + return t.cast("R", stop.value) + runner.close() + msg = "sync bridge: tool awaited real I/O; call it on the async surface" + raise RuntimeError(msg) + + +def synced(afn: Callable[..., Awaitable[R]]) -> Callable[..., R]: + """Derive a synchronous twin of an async tool ``afn(engine, ...)``. + + The twin takes a sync :class:`TmuxEngine`, wraps it as async, and drives the + same coroutine to completion -- so each tool's logic is written exactly once. + """ + hints = t.get_type_hints(afn) + signature = inspect.signature(afn) + + @functools.wraps(afn) + def wrapper(engine: TmuxEngine, *args: t.Any, **kwargs: t.Any) -> R: + return drive_sync(afn(SyncToAsyncEngine(engine), *args, **kwargs)) + + twin_hints = dict(hints) + twin_hints["engine"] = TmuxEngine + wrapper.__annotations__ = twin_hints + wrapper.__signature__ = signature # type: ignore[attr-defined] + return wrapper diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py new file mode 100644 index 000000000..7cd6c75c9 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -0,0 +1,168 @@ +"""Caller context: who launched this MCP server, read from its own environment. + +A tmux ``-C`` control client resolves a no-target or relative target against its +*own* cursor pane, never the pane that launched the controlling process -- so the +caller pane is knowable only from the server process's environment. A process +spawned inside a tmux pane inherits ``TMUX_PANE`` (its ``%N`` id) and ``TMUX`` +(``socket-path,server-pid,session-id``); those are fixed for the process +lifetime, so the curated tools can read them at call time and the adapter can +read them once for the server instructions -- both see the same launching pane. + +Everything here is pure (no tmux call, no fastmcp): the whole point is to *avoid* +asking tmux, which would answer for the control client instead of the caller. A +pane id is unique only within one tmux server, so :func:`is_strict_caller` +socket-scopes the comparison rather than trusting a bare ``%N``. +""" + +from __future__ import annotations + +import os +import os.path +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +@dataclass(frozen=True) +class CallerContext: + """The tmux pane/server that launched this MCP, parsed from the environment. + + Examples + -------- + >>> env = {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} + >>> c = CallerContext.from_env(env) + >>> (c.pane_id, c.socket_path, c.session_id, c.in_tmux) + ('%3', '/tmp/tmux-1000/default', '2', True) + >>> CallerContext.from_env({}).in_tmux + False + >>> CallerContext.from_env({"TMUX": "garbage"}).socket_path is None + True + >>> CallerContext.from_env({"TMUX": "/tmp/a,b/sock,1,2"}).socket_path + '/tmp/a,b/sock' + """ + + pane_id: str | None = None + socket_path: str | None = None + server_pid: str | None = None + session_id: str | None = None + in_tmux: bool = False + + @classmethod + def from_env(cls, environ: Mapping[str, str] | None = None) -> CallerContext: + """Parse the caller context from *environ* (defaults to ``os.environ``). + + Degrades gracefully: a missing ``TMUX``/``TMUX_PANE`` yields a context + with ``in_tmux=False``; a malformed ``TMUX`` (not three comma fields) + leaves the socket/pid/session ``None`` but still records the pane. The + ``pid`` and ``session`` are the final two comma fields, so the value is + split from the right -- a socket path may itself contain a comma. + """ + env = os.environ if environ is None else environ + pane = env.get("TMUX_PANE") or None + raw = env.get("TMUX") or None + socket_path = server_pid = session_id = None + if raw is not None: + parts = raw.rsplit(",", 2) + if len(parts) == 3: + socket_path, server_pid, session_id = parts + return cls( + pane_id=pane, + socket_path=socket_path, + server_pid=server_pid, + session_id=session_id, + in_tmux=pane is not None, + ) + + +def _scan_flag(args: Sequence[str], flag: str) -> str | None: + """Read a tmux connection flag's value (joined ``-Sx`` or separated ``-S x``).""" + for index, arg in enumerate(args): + if arg == flag and index + 1 < len(args): + return args[index + 1] or None + if arg.startswith(flag) and len(arg) > len(flag): + return arg[len(flag) :] + return None + + +def engine_socket(engine: t.Any) -> str | None: + """Return the socket selector an engine targets (``-S`` path / ``-L`` name). + + Prefers an explicit ``-S`` path (the most precise selector) over a ``-L`` + name. ``None`` means the engine uses the ambient ``$TMUX`` server -- the same + server as a caller running inside tmux. + + Examples + -------- + >>> import types + >>> engine_socket(types.SimpleNamespace(server_args=("-Lwork",))) + 'work' + >>> engine_socket(types.SimpleNamespace(server_args=("-S", "/tmp/x"))) + '/tmp/x' + >>> engine_socket(types.SimpleNamespace(server_args=())) is None + True + """ + args = tuple(getattr(engine, "server_args", ()) or ()) + path = _scan_flag(args, "-S") + if path is not None: + return path + return _scan_flag(args, "-L") + + +def socket_matches(socket: str | None, caller: CallerContext) -> bool: + """Whether an engine *socket* selector denotes the caller's tmux server. + + A default engine (``socket is None``) talks to the ambient ``$TMUX`` server, + which is the caller's server when the caller is inside tmux *and* its socket + is known. A ``-S`` path is realpath-compared; a ``-L`` name is resolved to its + per-user socket path (honouring ``$TMUX_TMPDIR``) and realpath-compared, so a + bare name cannot collide with an unrelated socket's basename. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> socket_matches(None, caller) + True + >>> socket_matches("/tmp/s", caller) + True + >>> socket_matches("/tmp/other", caller) + False + """ + if socket is None: + return caller.in_tmux and caller.socket_path is not None + if caller.socket_path is None: + return False + if "/" in socket: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" + expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" + return os.path.realpath(expected) == os.path.realpath(caller.socket_path) + + +def is_strict_caller( + pane_id: str | None, + socket: str | None, + caller: CallerContext, +) -> bool: + """Whether *pane_id* on an engine bound to *socket* is the caller's own pane. + + Strict: requires pane-id equality *and* a confirmed socket match, since a + pane id is unique only within one tmux server. Bare pane-id equality is + rejected to avoid a cross-server false positive. + + Examples + -------- + >>> caller = CallerContext.from_env( + ... {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} + ... ) + >>> is_strict_caller("%3", None, caller) + True + >>> is_strict_caller("%9", None, caller) + False + >>> is_strict_caller("%3", "/tmp/tmux-1000/other", caller) + False + """ + if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: + return False + return socket_matches(socket, caller) diff --git a/src/libtmux/experimental/mcp/vocabulary/_geometry.py b/src/libtmux/experimental/mcp/vocabulary/_geometry.py new file mode 100644 index 000000000..9eaeb2944 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_geometry.py @@ -0,0 +1,162 @@ +"""Pure pane-geometry helpers for directional and corner resolution. + +tmux's own ``{up-of}`` / ``{down-of}`` target tokens always pivot on the +*active* pane and vary across tmux versions, so resolving "the pane to the right +of %5" robustly means reading the layout geometry and computing the neighbour +ourselves -- the same lesson libtmux-mcp's ``select_pane``/``find_pane_by_position`` +encode. These helpers operate on the ``pane_left/top/right/bottom`` and +``pane_at_*`` fields the ``list-panes`` template already carries, so they need no +extra tmux round-trip beyond the one list. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + +#: Directions a pane neighbour can be resolved in. +Direction = t.Literal["up", "down", "left", "right"] +#: The four window corners. +Corner = t.Literal["top-left", "top-right", "bottom-left", "bottom-right"] + + +def _as_int(value: str | None) -> int: + """Parse a tmux integer format value, defaulting to ``0``.""" + if value is None or value == "": + return 0 + try: + return int(value) + except ValueError: + return 0 + + +def _as_bool(value: str | None) -> bool: + """Parse a tmux flag value (``"1"``/``"0"``/``""``).""" + return value == "1" + + +@dataclass(frozen=True) +class PaneBox: + """One pane's geometry, parsed from a ``list-panes`` format row.""" + + pane_id: str + left: int + top: int + right: int + bottom: int + at_left: bool + at_right: bool + at_top: bool + at_bottom: bool + active: bool + + @classmethod + def from_row(cls, row: Mapping[str, str]) -> PaneBox: + """Build a box from a tmux format mapping.""" + return cls( + pane_id=row.get("pane_id", ""), + left=_as_int(row.get("pane_left")), + top=_as_int(row.get("pane_top")), + right=_as_int(row.get("pane_right")), + bottom=_as_int(row.get("pane_bottom")), + at_left=_as_bool(row.get("pane_at_left")), + at_right=_as_bool(row.get("pane_at_right")), + at_top=_as_bool(row.get("pane_at_top")), + at_bottom=_as_bool(row.get("pane_at_bottom")), + active=_as_bool(row.get("pane_active")), + ) + + +def parse_boxes(rows: Sequence[Mapping[str, str]]) -> list[PaneBox]: + """Parse ``list-panes`` rows into geometry boxes.""" + return [PaneBox.from_row(row) for row in rows] + + +def _overlap(a0: int, a1: int, b0: int, b1: int) -> int: + """Inclusive 1-D overlap length of ``[a0, a1]`` and ``[b0, b1]``.""" + return max(0, min(a1, b1) - max(a0, b0) + 1) + + +def neighbor( + boxes: Sequence[PaneBox], + origin_id: str, + direction: Direction, +) -> str | None: + """Return the id of the pane adjacent to *origin_id* in *direction*. + + Picks the nearest pane on the requested side that shares a perpendicular + overlap with the origin; ``None`` when the origin is unknown or has no + neighbour that way. + + Examples + -------- + Two side-by-side panes -- the right neighbour of the left pane is the right + one, and the left pane has no neighbour above it: + + >>> rows = [ + ... {"pane_id": "%1", "pane_left": "0", "pane_top": "0", + ... "pane_right": "39", "pane_bottom": "23"}, + ... {"pane_id": "%2", "pane_left": "41", "pane_top": "0", + ... "pane_right": "80", "pane_bottom": "23"}, + ... ] + >>> boxes = parse_boxes(rows) + >>> neighbor(boxes, "%1", "right") + '%2' + >>> neighbor(boxes, "%1", "up") is None + True + """ + origin = next((b for b in boxes if b.pane_id == origin_id), None) + if origin is None: + return None + ranked: list[tuple[int, int, str]] = [] + for box in boxes: + if box.pane_id == origin_id: + continue + vspan = _overlap(box.top, box.bottom, origin.top, origin.bottom) + hspan = _overlap(box.left, box.right, origin.left, origin.right) + if direction == "right" and box.left > origin.right and vspan: + ranked.append((box.left, box.top, box.pane_id)) + elif direction == "left" and box.right < origin.left and vspan: + ranked.append((-box.right, box.top, box.pane_id)) + elif direction == "down" and box.top > origin.bottom and hspan: + ranked.append((box.top, box.left, box.pane_id)) + elif direction == "up" and box.bottom < origin.top and hspan: + ranked.append((-box.bottom, box.left, box.pane_id)) + if not ranked: + return None + ranked.sort() + return ranked[0][2] + + +def corner_pane(boxes: Sequence[PaneBox], corner: Corner) -> str | None: + """Return the id of the pane occupying *corner* of the window. + + Composes the two ``pane_at_*`` edge predicates; ties (e.g. a single pane + touching every edge) break toward the visually innermost pane. + + Examples + -------- + >>> rows = [ + ... {"pane_id": "%1", "pane_left": "0", "pane_top": "0", + ... "pane_at_left": "1", "pane_at_top": "1", "pane_at_right": "0", + ... "pane_at_bottom": "1"}, + ... {"pane_id": "%2", "pane_left": "41", "pane_top": "0", + ... "pane_at_left": "0", "pane_at_top": "1", "pane_at_right": "1", + ... "pane_at_bottom": "1"}, + ... ] + >>> corner_pane(parse_boxes(rows), "top-right") + '%2' + """ + vertical, horizontal = corner.split("-") + matches = [ + box + for box in boxes + if getattr(box, f"at_{vertical}") and getattr(box, f"at_{horizontal}") + ] + if not matches: + return None + matches.sort(key=lambda b: b.left + b.top, reverse=True) + return matches[0].pane_id diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py new file mode 100644 index 000000000..87872daba --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -0,0 +1,198 @@ +"""Shared async resolution helpers for the curated vocabulary. + +The pane-scoped tools need to turn a polymorphic target into a concrete id, find +the window scoping a target, and read a window's panes -- small async steps the +pane/window modules share. Kept here so each category module stays focused on its +verbs. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + engine_socket, + socket_matches, +) +from libtmux.experimental.ops import DisplayMessage, ListPanes, SelectPane, arun +from libtmux.experimental.ops._types import PaneId, Special, Target + +#: Relative directional special tokens that resolve against the control client, +#: not the caller -- rejected with a hint by :func:`reject_relative_special`. +_RELATIVE_SPECIALS = frozenset({"up-of", "down-of", "left-of", "right-of"}) + +#: tmux ``select-pane`` direction flags for the four geometric directions. +DIR_FLAG: dict[str, t.Literal["U", "D", "L", "R"]] = { + "up": "U", + "down": "D", + "left": "L", + "right": "R", +} + + +def opt_target(target: str | Target | None) -> Target | None: + """Resolve an optional target, preserving ``None``.""" + return None if target is None else resolve_target(target) + + +async def pane_id( + engine: AsyncTmuxEngine, + target: str | Target, + version: str | None, +) -> str: + """Resolve *target* to a concrete pane id (``%N``).""" + resolved = resolve_target(target) + if isinstance(resolved, PaneId): + return resolved.value + result = await arun( + DisplayMessage(target=resolved, message="#{pane_id}"), + engine, + version=version, + ) + result.raise_for_status() + return result.text.strip() + + +async def window_id( + engine: AsyncTmuxEngine, + target: str | Target | None, + version: str | None, +) -> str: + """Resolve the window id for *target* (or the active window when ``None``).""" + op = ( + DisplayMessage(message="#{window_id}") + if target is None + else DisplayMessage(target=resolve_target(target), message="#{window_id}") + ) + result = await arun(op, engine, version=version) + result.raise_for_status() + return result.text.strip() + + +async def window_rows( + engine: AsyncTmuxEngine, + window: str, + version: str | None, +) -> list[t.Mapping[str, str]]: + """Return the ``list-panes`` rows belonging to *window*, in tmux order.""" + result = await arun(ListPanes(all_panes=True), engine, version=version) + result.raise_for_status() + return [row for row in result.rows if row.get("window_id") == window] + + +async def run_select( + engine: AsyncTmuxEngine, + op: SelectPane, + version: str | None, +) -> None: + """Run a ``select-pane`` op and raise on failure.""" + (await arun(op, engine, version=version)).raise_for_status() + + +async def select_directional( + engine: AsyncTmuxEngine, + target: str | Target | None, + flag: t.Literal["U", "D", "L", "R"], + version: str | None, +) -> None: + """Move the selection one pane in a tmux direction.""" + await run_select( + engine, SelectPane(target=opt_target(target), direction=flag), version + ) + + +async def select_step( + engine: AsyncTmuxEngine, + target: str | Target | None, + direction: t.Literal["next", "previous"], + version: str | None, +) -> None: + """Select the next/previous pane by absolute id (tmux-version robust).""" + rows = await window_rows(engine, await window_id(engine, target, version), version) + if not rows: + return + ids = [row.get("pane_id", "") for row in rows] + active = next( + (row.get("pane_id", "") for row in rows if row.get("pane_active") == "1"), + ids[0], + ) + step = 1 if direction == "next" else -1 + target_id = ids[(ids.index(active) + step) % len(ids)] + await run_select(engine, SelectPane(target=PaneId(target_id)), version) + + +async def active_pane_id( + engine: AsyncTmuxEngine, + target: str | Target | None, + version: str | None, +) -> str | None: + """Return the active pane id of the window scoping *target*.""" + rows = await window_rows(engine, await window_id(engine, target, version), version) + for row in rows: + if row.get("pane_active") == "1": + return row.get("pane_id") + return rows[0].get("pane_id") if rows else None + + +async def resolve_origin( + engine: AsyncTmuxEngine, + origin: str | Target | None, + version: str | None, +) -> str: + """Resolve a caller-relative origin to a concrete pane id. + + An explicit *origin* is resolved as a target; ``origin=None`` means the + caller's own pane (from the server's environment), socket-scoped exactly + like :func:`~._caller.is_strict_caller` because a ``%N`` is unique only + within one server. When there is no trustworthy caller pane -- the server is + not inside tmux, or its pane belongs to a different server than this engine + targets -- this raises rather than guessing the active pane, so the caller + must pass an explicit origin. + """ + if origin is not None: + return await pane_id(engine, origin, version) + caller = CallerContext.from_env() + if caller.pane_id and socket_matches(engine_socket(engine), caller): + return caller.pane_id + raise_target_hint( + "no caller pane is available (this MCP is not inside the engine's tmux " + "server); pass an explicit origin pane id (e.g. %3) -- list_panes shows " + "the current panes", + ) + + +def raise_target_hint(message: str) -> t.NoReturn: + """Raise a user-facing tool error (``ToolError`` when fastmcp is present). + + Falls back to :class:`ValueError` so the guard is testable on the sync + surface without the ``mcp`` extra installed. + """ + try: + from fastmcp.exceptions import ToolError + except ImportError: + raise ValueError(message) from None + raise ToolError(message) + + +def reject_relative_special(resolved: Target | None) -> None: + """Raise a targeted hint if *resolved* is a relative directional special. + + ``{up-of}`` / ``{down-of}`` / ``{left-of}`` / ``{right-of}`` resolve against + this MCP's own control-mode client, not the caller's pane, so passing one to + a capture/grep/send tool silently targets the wrong pane. Anchor specials + (``{marked}`` / ``{last}`` / ``{mouse}``) are left untouched. + """ + if ( + isinstance(resolved, Special) + and resolved.token.strip("{}").lower() in _RELATIVE_SPECIALS + ): + raise_target_hint( + f"relative special target {resolved.token} resolves against this MCP's " + "control-mode client, not your pane; resolve_relative_pane(direction=...) " + "and pass the returned %N to your capture/grep/send tool, or use the " + "composed capture_relative_pane / grep_relative_pane (origin defaults to " + "your pane)", + ) diff --git a/src/libtmux/experimental/mcp/vocabulary/_results.py b/src/libtmux/experimental/mcp/vocabulary/_results.py new file mode 100644 index 000000000..8ef74f516 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_results.py @@ -0,0 +1,106 @@ +"""Small, typed result values returned by the curated vocabulary. + +Each curated tool returns one of these frozen dataclasses exposing just the +ids/names/lines a caller cares about -- never a live ORM object and never the raw +:class:`~libtmux.experimental.ops.results.Result`. They serialize trivially +(plain scalars and tuples), which is what the MCP edge hands back to an agent. +""" + +from __future__ import annotations + +import collections.abc +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SessionResult: + """A created session: its id, name, and captured first window/pane ids.""" + + session_id: str + name: str | None = None + first_window_id: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class WindowResult: + """A created window: its id, name, and captured first pane id.""" + + window_id: str + name: str | None = None + first_pane_id: str | None = None + + +@dataclass(frozen=True) +class PaneResult: + """A created pane: its id.""" + + pane_id: str + + +@dataclass(frozen=True) +class PaneRef: + """A resolved pane id (or ``None`` when no pane matched the query).""" + + pane_id: str | None + + +@dataclass(frozen=True) +class PaneCapture: + """Captured pane contents.""" + + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class Listing: + """A list query result: one mapping (tmux format row) per object.""" + + rows: tuple[collections.abc.Mapping[str, str], ...] + + +@dataclass(frozen=True) +class OptionMap: + """Parsed ``show-options`` output: ``name -> value`` pairs.""" + + options: collections.abc.Mapping[str, str] + + +@dataclass(frozen=True) +class MessageText: + """The formatted text of a ``display-message -p`` query.""" + + text: str + + +@dataclass(frozen=True) +class BufferText: + """The contents of a paste buffer (``show-buffer``).""" + + text: str + + +@dataclass(frozen=True) +class RawResult: + """The raw outcome of a passthrough ``run_tmux`` invocation.""" + + ok: bool + returncode: int + stdout: tuple[str, ...] + stderr: tuple[str, ...] + + +@dataclass(frozen=True) +class PaneMatch: + """One pane whose terminal text matched a search, with its caller flag.""" + + pane_id: str + is_caller: bool + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class PaneSearch: + """The panes whose scrollback matched a ``search_panes`` query.""" + + matches: tuple[PaneMatch, ...] diff --git a/src/libtmux/experimental/mcp/vocabulary/buffer.py b/src/libtmux/experimental/mcp/vocabulary/buffer.py new file mode 100644 index 000000000..1f18307ed --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/buffer.py @@ -0,0 +1,66 @@ +"""Paste-buffer vocabulary: set, show, paste.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import BufferText +from libtmux.experimental.ops import PasteBuffer, SetBuffer, ShowBuffer, arun +from libtmux.experimental.ops._types import Target + + +async def aset_buffer( + engine: AsyncTmuxEngine, + data: str, + *, + buffer_name: str | None = None, + version: str | None = None, +) -> None: + """Set a paste buffer's contents (``set-buffer``).""" + ( + await arun( + SetBuffer(data=data, buffer_name=buffer_name), + engine, + version=version, + ) + ).raise_for_status() + + +async def ashow_buffer( + engine: AsyncTmuxEngine, + *, + buffer_name: str | None = None, + version: str | None = None, +) -> BufferText: + """Return a paste buffer's contents (``show-buffer``).""" + result = await arun(ShowBuffer(buffer_name=buffer_name), engine, version=version) + result.raise_for_status() + return BufferText(text=result.text) + + +async def apaste_buffer( + engine: AsyncTmuxEngine, + target: str | Target, + *, + buffer_name: str | None = None, + delete: bool = False, + version: str | None = None, +) -> None: + """Paste a buffer into a pane (``paste-buffer``).""" + ( + await arun( + PasteBuffer( + target=resolve_target(target), + buffer_name=buffer_name, + delete=delete, + ), + engine, + version=version, + ) + ).raise_for_status() + + +set_buffer = synced(aset_buffer) +show_buffer = synced(ashow_buffer) +paste_buffer = synced(apaste_buffer) diff --git a/src/libtmux/experimental/mcp/vocabulary/option.py b/src/libtmux/experimental/mcp/vocabulary/option.py new file mode 100644 index 000000000..52f4787a7 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/option.py @@ -0,0 +1,73 @@ +"""Option vocabulary: show and set tmux options.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import OptionMap +from libtmux.experimental.ops import SetOption, ShowOptions, arun +from libtmux.experimental.ops._types import Target + + +def _opt(target: str | Target | None) -> Target | None: + """Resolve an optional target, preserving ``None``.""" + return None if target is None else resolve_target(target) + + +async def ashow_options( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + global_: bool = False, + server: bool = False, + window: bool = False, + version: str | None = None, +) -> OptionMap: + """Show tmux options as ``name -> value`` pairs (``show-options``).""" + result = await arun( + ShowOptions( + target=_opt(target), + global_=global_, + server=server, + window=window, + ), + engine, + version=version, + ) + result.raise_for_status() + return OptionMap(options=result.options) + + +async def aset_option( + engine: AsyncTmuxEngine, + option: str, + value: str | None = None, + target: str | Target | None = None, + *, + global_: bool = False, + server: bool = False, + window: bool = False, + unset: bool = False, + version: str | None = None, +) -> None: + """Set (or unset) a tmux option (``set-option``).""" + ( + await arun( + SetOption( + target=_opt(target), + option=option, + value=value, + global_=global_, + server=server, + window=window, + unset=unset, + ), + engine, + version=version, + ) + ).raise_for_status() + + +show_options = synced(ashow_options) +set_option = synced(aset_option) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py new file mode 100644 index 000000000..69bca9321 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -0,0 +1,599 @@ +"""Pane-scope vocabulary: split, send, capture, resize, swap/join/break, select. + +Beyond thin op wrappers, this module hosts the composed, caller-aware +conveniences an agent reaches for that raw tmux makes awkward: +``capture_active_pane`` (no target), ``grep_pane`` (capture + filter, since tmux +has no server-side grep), ``search_panes`` ("which pane shows X?"), and the +geometry-resolved ``resolve_relative_pane`` / ``capture_relative_pane`` / +``grep_relative_pane`` / ``find_pane_by_position`` / directional ``select_pane``. +The relative tools resolve layout geometry to a concrete ``%N`` (robust across +tmux versions) and default their origin to the *caller's* pane; every +single-target tool that could act on the wrong pane rejects a relative special +target (``{up-of}`` …) with a hint, because those resolve against this MCP's +control client, not the caller. +""" + +from __future__ import annotations + +import re +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + engine_socket, + is_strict_caller, +) +from libtmux.experimental.mcp.vocabulary._geometry import ( + Corner, + Direction, + corner_pane, + neighbor, + parse_boxes, +) +from libtmux.experimental.mcp.vocabulary._resolve import ( + DIR_FLAG, + active_pane_id, + opt_target, + raise_target_hint, + reject_relative_special, + resolve_origin, + run_select, + select_directional, + select_step, + window_id, + window_rows, +) +from libtmux.experimental.mcp.vocabulary._results import ( + Listing, + PaneCapture, + PaneMatch, + PaneRef, + PaneResult, + PaneSearch, + WindowResult, +) +from libtmux.experimental.ops import ( + BreakPane, + CapturePane, + JoinPane, + KillPane, + ListPanes, + ResizePane, + RespawnPane, + SelectPane, + SendKeys, + SplitWindow, + SwapPane, + arun, +) +from libtmux.experimental.ops._types import PaneId, Target + +#: Default ceiling on the panes ``search_panes`` captures, to bound fan-out cost. +_SEARCH_PANE_CAP = 200 + + +def _compile(pattern: str, *, ignore_case: bool) -> re.Pattern[str]: + """Compile a user-supplied regex, routing a bad pattern to a tool hint.""" + try: + return re.compile(pattern, re.IGNORECASE if ignore_case else 0) + except re.error as error: + raise_target_hint(f"invalid search pattern {pattern!r}: {error}") + + +async def asplit_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + horizontal: bool = False, + start_directory: str | None = None, + version: str | None = None, +) -> PaneResult: + """Split a pane, creating a new one (mirrors ``window.split_window``).""" + result = await arun( + SplitWindow( + target=resolve_target(target), + horizontal=horizontal, + start_directory=start_directory, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneResult(pane_id=result.new_pane_id or "") + + +async def asend_input( + engine: AsyncTmuxEngine, + target: str | Target, + keys: str, + *, + enter: bool = False, + literal: bool = False, + suppress_history: bool = False, + version: str | None = None, +) -> None: + """Send keys to a pane (mirrors ``pane.send_keys``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + SendKeys( + target=resolved, + keys=keys, + enter=enter, + literal=literal, + suppress_history=suppress_history, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def acapture_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture one pane's terminal text (mirrors ``pane.capture_pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + result = await arun( + CapturePane( + target=resolved, + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +async def acapture_active_pane( + engine: AsyncTmuxEngine, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture the active pane with no explicit target (current client). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> capture_active_pane(ConcreteEngine(capture_lines=("hi",))).lines + ('hi',) + """ + result = await arun( + CapturePane( + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=result.lines) + + +async def agrep_pane( + engine: AsyncTmuxEngine, + target: str | Target, + pattern: str, + *, + ignore_case: bool = True, + start: int | None = None, + version: str | None = None, +) -> PaneCapture: + """Search one pane's terminal text (scrollback), returning matching lines. + + tmux has no server-side grep, so this captures (joining wrapped lines so a + match is not split across a hard wrap) and filters client-side. Matching is + case-insensitive by default. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + >>> grep_pane(engine, "%1", "foo").lines + ('foo', 'foobar') + """ + resolved = resolve_target(target) + reject_relative_special(resolved) + matcher = _compile(pattern, ignore_case=ignore_case) + result = await arun( + CapturePane(target=resolved, start=start, join_wrapped=True), + engine, + version=version, + ) + result.raise_for_status() + return PaneCapture(lines=tuple(ln for ln in result.lines if matcher.search(ln))) + + +async def asearch_panes( + engine: AsyncTmuxEngine, + pattern: str, + *, + ignore_case: bool = True, + start: int | None = None, + max_panes: int = _SEARCH_PANE_CAP, + version: str | None = None, +) -> PaneSearch: + """Search every pane's terminal text; return the panes that match. + + Answers "which pane shows X?" by capturing each pane's scrollback and + filtering client-side (tmux has no cross-pane search). Each match is flagged + ``is_caller`` when it is the pane that launched this MCP. Captures are + serial, so this is bounded to the first ``max_panes`` panes; panes whose + capture fails are skipped (lenient, like the list accessors). + """ + matcher = _compile(pattern, ignore_case=ignore_case) + listing = await arun(ListPanes(all_panes=True), engine, version=version) + listing.raise_for_status() + caller = CallerContext.from_env() + socket = engine_socket(engine) + matches: list[PaneMatch] = [] + for row in listing.rows[:max_panes]: + pid = row.get("pane_id", "") + if not pid: + continue + cap = await arun( + CapturePane(target=PaneId(pid), start=start, join_wrapped=True), + engine, + version=version, + ) + if not cap.ok: + continue + lines = tuple(ln for ln in cap.lines if matcher.search(ln)) + if lines: + matches.append( + PaneMatch( + pane_id=pid, + is_caller=is_strict_caller(pid, socket, caller), + lines=lines, + ), + ) + return PaneSearch(matches=tuple(matches)) + + +async def aresize_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + width: int | None = None, + height: int | None = None, + zoom: bool = False, + version: str | None = None, +) -> None: + """Resize a pane, or toggle its zoom (``resize-pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + ResizePane(target=resolved, width=width, height=height, zoom=zoom), + engine, + version=version, + ) + ).raise_for_status() + + +async def aswap_pane( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Swap two panes (``swap-pane``: ``-s`` source, ``-t`` destination).""" + src_target = resolve_target(src) + dst_target = resolve_target(dst) + reject_relative_special(src_target) + reject_relative_special(dst_target) + ( + await arun( + SwapPane(target=dst_target, src_target=src_target), + engine, + version=version, + ) + ).raise_for_status() + + +async def ajoin_pane( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + horizontal: bool = False, + size: int | None = None, + version: str | None = None, +) -> None: + """Join a source pane into a destination window/pane (``join-pane``).""" + src_target = resolve_target(src) + dst_target = resolve_target(dst) + reject_relative_special(src_target) + reject_relative_special(dst_target) + ( + await arun( + JoinPane( + target=dst_target, + src_target=src_target, + horizontal=horizontal, + size=size, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def abreak_pane( + engine: AsyncTmuxEngine, + src: str | Target, + *, + name: str | None = None, + version: str | None = None, +) -> WindowResult: + """Break a pane out into a new window (``break-pane``); return its id.""" + src_target = resolve_target(src) + reject_relative_special(src_target) + result = await arun( + BreakPane(src_target=src_target, name=name), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult(window_id=result.new_id or "", name=name) + + +async def arespawn_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + kill: bool = False, + shell: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> None: + """Restart a pane's process in place (``respawn-pane``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + RespawnPane( + target=resolved, + kill=kill, + shell=shell, + start_directory=start_directory, + ), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a pane (or all others in its window with ``others=True``).""" + resolved = resolve_target(target) + reject_relative_special(resolved) + ( + await arun( + KillPane(target=resolved, others=others), + engine, + version=version, + ) + ).raise_for_status() + + +async def alist_panes( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + all_panes: bool = False, + version: str | None = None, +) -> Listing: + """List panes (metadata), flagging the caller's own pane with ``is_caller``. + + Mirrors ``window.panes``; each row gains an ``is_caller`` field (``"1"`` for + the pane that launched this MCP, else ``"0"`` -- the same ``"1"``/``"0"`` + convention as tmux's own ``pane_active``). + """ + result = await arun(ListPanes(all_panes=all_panes), engine, version=version) + result.raise_for_status() + caller = CallerContext.from_env() + socket = engine_socket(engine) + rows = tuple( + { + **row, + "is_caller": "1" + if is_strict_caller(row.get("pane_id"), socket, caller) + else "0", + } + for row in result.rows + ) + return Listing(rows=rows) + + +async def aselect_pane( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + direction: t.Literal["up", "down", "left", "right", "last", "next", "previous"] + | None = None, + version: str | None = None, +) -> PaneRef: + """Focus a pane by id or relative *direction*; return the now-active pane. + + ``up``/``down``/``left``/``right`` use tmux's own directional select; ``last`` + re-selects the previously active pane; ``next``/``previous`` step by pane + order, computed from absolute ids to sidestep tmux-version target quirks. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> select_pane(ConcreteEngine(), "%1", direction="left") + PaneRef(pane_id=None) + """ + if direction in DIR_FLAG: + await select_directional(engine, target, DIR_FLAG[direction], version) + elif direction == "last": + await run_select( + engine, SelectPane(target=opt_target(target), last=True), version + ) + elif direction in ("next", "previous"): + await select_step(engine, target, direction, version) + elif target is not None: + resolved = resolve_target(target) + reject_relative_special(resolved) + await run_select(engine, SelectPane(target=resolved), version) + return PaneRef(pane_id=await active_pane_id(engine, target, version)) + + +async def aresolve_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None = None, + *, + version: str | None = None, +) -> PaneRef: + """Return the id of the pane *direction* of *origin* (caller pane by default). + + Resolved from layout geometry (the ``pane_left/top/right/bottom`` the list + template already carries) -- robust across tmux versions, and without moving + the active pane. ``origin=None`` means the caller's own pane (the pane that + launched this MCP), falling back to the active pane outside tmux. + """ + origin_id = await resolve_origin(engine, origin, version) + if not origin_id: + return PaneRef(pane_id=None) + window = await window_id(engine, origin_id, version) + boxes = parse_boxes(await window_rows(engine, window, version)) + return PaneRef(pane_id=neighbor(boxes, origin_id, direction)) + + +async def acapture_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None = None, + *, + start: int | None = None, + end: int | None = None, + join_wrapped: bool = False, + trim_trailing: bool = False, + version: str | None = None, +) -> PaneCapture: + """Capture the pane *direction* of *origin* (caller pane by default). + + Resolves the neighbour to a concrete ``%N`` first, so it never hands tmux a + relative special target. Raises a hint (naming the resolved origin) when + there is no pane that way. + """ + ref = await aresolve_relative_pane(engine, direction, origin, version=version) + if ref.pane_id is None: + await _raise_no_neighbour(engine, direction, origin, version) + return await acapture_pane( + engine, + PaneId(ref.pane_id or ""), + start=start, + end=end, + join_wrapped=join_wrapped, + trim_trailing=trim_trailing, + version=version, + ) + + +async def agrep_relative_pane( + engine: AsyncTmuxEngine, + direction: Direction, + pattern: str, + origin: str | Target | None = None, + *, + ignore_case: bool = True, + start: int | None = None, + version: str | None = None, +) -> PaneCapture: + """Search the terminal text of the pane *direction* of *origin* (caller default). + + The one-call answer to "what does the pane above/below/beside me show?". + Resolves the neighbour to a concrete ``%N`` first; raises a hint (naming the + resolved origin) when there is no pane that way. + """ + ref = await aresolve_relative_pane(engine, direction, origin, version=version) + if ref.pane_id is None: + await _raise_no_neighbour(engine, direction, origin, version) + return await agrep_pane( + engine, + PaneId(ref.pane_id or ""), + pattern, + ignore_case=ignore_case, + start=start, + version=version, + ) + + +async def afind_pane_by_position( + engine: AsyncTmuxEngine, + corner: Corner, + target: str | Target | None = None, + *, + version: str | None = None, +) -> PaneRef: + """Return the id of the pane occupying *corner* of a window.""" + window = await window_id(engine, target, version) + boxes = parse_boxes(await window_rows(engine, window, version)) + return PaneRef(pane_id=corner_pane(boxes, corner)) + + +async def _raise_no_neighbour( + engine: AsyncTmuxEngine, + direction: Direction, + origin: str | Target | None, + version: str | None, +) -> t.NoReturn: + """Raise a no-neighbour hint naming the concrete origin pane.""" + origin_id = await resolve_origin(engine, origin, version) + where = origin_id or "the caller pane" + raise_target_hint( + f"no pane {direction} of {where}; see list_panes for the current layout", + ) + + +split_pane = synced(asplit_pane) +send_input = synced(asend_input) +capture_pane = synced(acapture_pane) +capture_active_pane = synced(acapture_active_pane) +grep_pane = synced(agrep_pane) +search_panes = synced(asearch_panes) +resize_pane = synced(aresize_pane) +swap_pane = synced(aswap_pane) +join_pane = synced(ajoin_pane) +break_pane = synced(abreak_pane) +respawn_pane = synced(arespawn_pane) +kill_pane = synced(akill_pane) +list_panes = synced(alist_panes) +select_pane = synced(aselect_pane) +resolve_relative_pane = synced(aresolve_relative_pane) +capture_relative_pane = synced(acapture_relative_pane) +grep_relative_pane = synced(agrep_relative_pane) +find_pane_by_position = synced(afind_pane_by_position) diff --git a/src/libtmux/experimental/mcp/vocabulary/server.py b/src/libtmux/experimental/mcp/vocabulary/server.py new file mode 100644 index 000000000..d4f166746 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/server.py @@ -0,0 +1,71 @@ +"""Server-scope vocabulary: list clients, display-message, and the raw escape hatch.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine, CommandRequest +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import Listing, MessageText, RawResult +from libtmux.experimental.ops import DisplayMessage, ListClients, arun +from libtmux.experimental.ops._types import Target + + +async def alist_clients( + engine: AsyncTmuxEngine, + *, + version: str | None = None, +) -> Listing: + """List attached clients (``list-clients``).""" + result = await arun(ListClients(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def adisplay_message( + engine: AsyncTmuxEngine, + target: str | Target, + message: str, + *, + version: str | None = None, +) -> MessageText: + """Expand a tmux format string against *target* (``display-message -p``).""" + result = await arun( + DisplayMessage(target=resolve_target(target), message=message), + engine, + version=version, + ) + result.raise_for_status() + return MessageText(text=result.text) + + +async def arun_tmux( + engine: AsyncTmuxEngine, + args: list[str], + *, + version: str | None = None, +) -> RawResult: + """Run an arbitrary tmux command (the guarded raw escape hatch). + + Returns the structured outcome without raising on a tmux-side failure -- a + nonzero exit or stderr is reported in :class:`~._results.RawResult`. This is + deliberately *not* read-only; servers exposed on a network transport should + gate it more strictly than local ones. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> run_tmux(ConcreteEngine(), ["list-sessions"]).ok + True + """ + raw = await engine.run(CommandRequest.from_args(*args)) + return RawResult( + ok=raw.returncode == 0 and not raw.stderr, + returncode=raw.returncode, + stdout=tuple(raw.stdout), + stderr=tuple(raw.stderr), + ) + + +list_clients = synced(alist_clients) +display_message = synced(adisplay_message) +run_tmux = synced(arun_tmux) diff --git a/src/libtmux/experimental/mcp/vocabulary/session.py b/src/libtmux/experimental/mcp/vocabulary/session.py new file mode 100644 index 000000000..419233265 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/session.py @@ -0,0 +1,134 @@ +"""Session-scope vocabulary: create, rename, kill, list, existence. + +Each tool is written once as an ``async def`` over an +:class:`~libtmux.experimental.engines.base.AsyncTmuxEngine`; the public sync name +is a :func:`~._bridge.synced` twin. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import Listing, SessionResult +from libtmux.experimental.ops import ( + HasSession, + KillSession, + ListSessions, + NewSession, + RenameSession, + arun, +) +from libtmux.experimental.ops._types import Target + + +async def acreate_session( + engine: AsyncTmuxEngine, + *, + name: str | None = None, + start_directory: str | None = None, + environment: t.Mapping[str, str] | None = None, + width: int | None = None, + height: int | None = None, + version: str | None = None, +) -> SessionResult: + """Create a detached session (mirrors ``server.new_session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> r = create_session(ConcreteEngine(), name="work") + >>> (r.session_id, r.name, r.first_pane_id) + ('$1', 'work', '%1') + """ + result = await arun( + NewSession( + session_name=name, + start_directory=start_directory, + environment=environment, + width=width, + height=height, + capture_panes=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return SessionResult( + session_id=result.new_id or "", + name=name, + first_window_id=result.first_window_id, + first_pane_id=result.first_pane_id, + ) + + +async def arename_session( + engine: AsyncTmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a session (mirrors ``session.rename_session``).""" + ( + await arun( + RenameSession(target=resolve_target(target), name=name), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_session( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Kill a session (mirrors ``session.kill``).""" + ( + await arun(KillSession(target=resolve_target(target)), engine, version=version) + ).raise_for_status() + + +async def alist_sessions( + engine: AsyncTmuxEngine, + *, + version: str | None = None, +) -> Listing: + """List the server's sessions (mirrors ``server.sessions``).""" + result = await arun(ListSessions(), engine, version=version) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def ahas_session( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> bool: + """Return whether a session exists (``has-session``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> has_session(ConcreteEngine(), "$1") + True + """ + result = await arun( + HasSession(target=resolve_target(target)), + engine, + version=version, + ) + result.raise_for_status() + return result.exists + + +create_session = synced(acreate_session) +rename_session = synced(arename_session) +kill_session = synced(akill_session) +list_sessions = synced(alist_sessions) +has_session = synced(ahas_session) diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py new file mode 100644 index 000000000..c9d856b53 --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -0,0 +1,182 @@ +"""Window-scope vocabulary: create, rename, select, move, swap, kill, list, layout.""" + +from __future__ import annotations + +from libtmux.experimental.engines.base import AsyncTmuxEngine +from libtmux.experimental.mcp.target_resolver import resolve_target +from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._results import Listing, WindowResult +from libtmux.experimental.ops import ( + KillWindow, + ListWindows, + MoveWindow, + NewWindow, + RenameWindow, + SelectLayout, + SelectWindow, + SwapWindow, + arun, +) +from libtmux.experimental.ops._types import Target + + +async def acreate_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + name: str | None = None, + start_directory: str | None = None, + version: str | None = None, +) -> WindowResult: + """Create a window in a session (mirrors ``session.new_window``). + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> w = create_window(ConcreteEngine(), "$1", name="logs") + >>> w.window_id.startswith("@"), w.name + (True, 'logs') + """ + result = await arun( + NewWindow( + target=resolve_target(target), + name=name, + start_directory=start_directory, + capture_pane=True, + ), + engine, + version=version, + ) + result.raise_for_status() + return WindowResult( + window_id=result.new_id or "", + name=name, + first_pane_id=result.first_pane_id, + ) + + +async def arename_window( + engine: AsyncTmuxEngine, + target: str | Target, + name: str, + *, + version: str | None = None, +) -> None: + """Rename a window (mirrors ``window.rename_window``).""" + ( + await arun( + RenameWindow(target=resolve_target(target), name=name), + engine, + version=version, + ) + ).raise_for_status() + + +async def aselect_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + version: str | None = None, +) -> None: + """Make a window active (mirrors ``window.select_window``).""" + ( + await arun(SelectWindow(target=resolve_target(target)), engine, version=version) + ).raise_for_status() + + +async def amove_window( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Move a window to a new index/session (``move-window``).""" + ( + await arun( + MoveWindow(target=resolve_target(dst), src_target=resolve_target(src)), + engine, + version=version, + ) + ).raise_for_status() + + +async def aswap_window( + engine: AsyncTmuxEngine, + src: str | Target, + dst: str | Target, + *, + version: str | None = None, +) -> None: + """Swap two windows (``swap-window``).""" + ( + await arun( + SwapWindow(target=resolve_target(dst), src_target=resolve_target(src)), + engine, + version=version, + ) + ).raise_for_status() + + +async def akill_window( + engine: AsyncTmuxEngine, + target: str | Target, + *, + others: bool = False, + version: str | None = None, +) -> None: + """Kill a window (or all others in its session with ``others=True``).""" + ( + await arun( + KillWindow(target=resolve_target(target), others=others), + engine, + version=version, + ) + ).raise_for_status() + + +async def alist_windows( + engine: AsyncTmuxEngine, + target: str | Target | None = None, + *, + all_windows: bool = False, + version: str | None = None, +) -> Listing: + """List windows of a session, or all windows (mirrors ``session.windows``).""" + result = await arun( + ListWindows( + target=None if target is None else resolve_target(target), + all_windows=all_windows, + ), + engine, + version=version, + ) + result.raise_for_status() + return Listing(rows=result.rows) + + +async def aselect_layout( + engine: AsyncTmuxEngine, + target: str | Target, + *, + layout: str | None = None, + version: str | None = None, +) -> None: + """Apply a layout to a window (mirrors ``window.select_layout``).""" + ( + await arun( + SelectLayout(target=resolve_target(target), layout=layout), + engine, + version=version, + ) + ).raise_for_status() + + +create_window = synced(acreate_window) +rename_window = synced(arename_window) +select_window = synced(aselect_window) +move_window = synced(amove_window) +swap_window = synced(aswap_window) +kill_window = synced(akill_window) +list_windows = synced(alist_windows) +select_layout = synced(aselect_layout) diff --git a/tests/experimental/mcp/test_adapter_async.py b/tests/experimental/mcp/test_adapter_async.py new file mode 100644 index 000000000..08a7b6904 --- /dev/null +++ b/tests/experimental/mcp/test_adapter_async.py @@ -0,0 +1,93 @@ +"""The async-first FastMCP adapter -- awaited tools, per-op, and plan tiers. + +Exercised offline via an in-process FastMCP client over a sync ``ConcreteEngine`` +wrapped into the async protocol, so the async registration path is validated with +no tmux. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + +fastmcp = pytest.importorskip("fastmcp") + + +def _async_server(**kwargs: t.Any) -> t.Any: + """Build an async server over a wrapped in-memory engine.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + return build_async_server( + SyncToAsyncEngine(ConcreteEngine()), events="off", **kwargs + ) + + +def test_async_server_exposes_curated_and_conveniences() -> None: + """The async server surfaces the curated verbs plus the new conveniences.""" + + async def main() -> set[str]: + async with fastmcp.Client(_async_server()) as client: + return {tool.name for tool in await client.list_tools()} + + names = asyncio.run(main()) + expected = { + "create_session", + "grep_pane", + "capture_active_pane", + "resolve_relative_pane", + "find_pane_by_position", + "select_pane", + "resize_pane", + "run_tmux", + "list_clients", + "has_session", + } + assert expected <= names + # The per-op surface is hidden by default. + assert not any(name.startswith("op_") for name in names) + + +def test_async_tool_call_returns_typed_data() -> None: + """An awaited curated tool returns its typed result over the client.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server()) as client: + result = await client.call_tool("create_session", {"name": "dev"}) + raw = await client.call_tool("run_tmux", {"args": ["list-sessions"]}) + return result.data, raw.data + + session, raw = asyncio.run(main()) + assert session.session_id == "$1" + assert raw.ok is True + + +def test_async_per_op_dispatch() -> None: + """A hidden per-op tool dispatches via the async run path.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server(expose_operations=True)) as client: + result = await client.call_tool("op_list_sessions", {}) + return result.data + + data = asyncio.run(main()) + assert data["status"] == "complete" + + +def test_async_plan_preview() -> None: + """The pure preview_plan tool is registered on the async server.""" + + async def main() -> t.Any: + async with fastmcp.Client(_async_server()) as client: + result = await client.call_tool( + "preview_plan", + {"operations": [{"kind": "start_server"}]}, + ) + return result.data + + data = asyncio.run(main()) + assert data["ok"] is True diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py new file mode 100644 index 000000000..ce208774c --- /dev/null +++ b/tests/experimental/mcp/test_caller.py @@ -0,0 +1,193 @@ +"""Caller context: environment parsing, strict socket scoping, and surfacing. + +Pure tests cover :class:`CallerContext.from_env` and the strict ``is_caller`` +comparator over literal env mappings; in-process FastMCP ``Client`` tests cover +the ``get_caller_context`` tool, the ``is_caller`` instruction sentence, and (live) +the ``is_caller`` row flag against a real tmux server. No pytest-asyncio. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import create_session, kill_session, list_panes +from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine +from libtmux.experimental.mcp.vocabulary._caller import ( + CallerContext, + engine_socket, + is_strict_caller, +) +from libtmux.experimental.mcp.vocabulary._resolve import resolve_origin + +fastmcp = pytest.importorskip("fastmcp") +from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +# --------------------------------------------------------------------------- # +# CallerContext.from_env (pure) +# --------------------------------------------------------------------------- # +def test_from_env_inside_tmux() -> None: + """A full TMUX/TMUX_PANE pair parses into a populated context.""" + ctx = CallerContext.from_env( + {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"}, + ) + assert ctx.in_tmux + assert ctx.pane_id == "%3" + assert ctx.socket_path == "/tmp/tmux-1000/default" + assert ctx.server_pid == "42" + assert ctx.session_id == "2" + + +def test_from_env_outside_tmux() -> None: + """No TMUX/TMUX_PANE yields a context with in_tmux False.""" + assert CallerContext.from_env({}).in_tmux is False + + +def test_from_env_malformed_tmux() -> None: + """A malformed TMUX keeps the pane but leaves socket/pid/session None.""" + ctx = CallerContext.from_env({"TMUX_PANE": "%5", "TMUX": "garbage"}) + assert ctx.pane_id == "%5" + assert ctx.in_tmux is True + assert ctx.socket_path is None + + +# --------------------------------------------------------------------------- # +# Strict caller / engine socket (pure) +# --------------------------------------------------------------------------- # +def test_is_strict_caller_socket_scoped() -> None: + """is_caller requires pane equality and a socket match.""" + caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/a,1,2"}) + assert is_strict_caller("%3", None, caller) is True # default engine, same server + assert is_strict_caller("%3", "/tmp/a", caller) is True # same socket path + assert is_strict_caller("%3", "/tmp/b", caller) is False # cross-socket + assert is_strict_caller("%9", None, caller) is False # different pane + + +def test_is_strict_caller_outside_tmux() -> None: + """Nothing is the caller when the server is not inside tmux.""" + assert is_strict_caller("%1", None, CallerContext.from_env({})) is False + + +def test_engine_socket_parses_server_args() -> None: + """engine_socket reads -L name / -S path, else None for the default socket.""" + + class Named: + server_args = ("-Lwork",) + + class Pathed: + server_args = ("-S/tmp/x",) + + class Default: + server_args = () + + assert engine_socket(Named()) == "work" + assert engine_socket(Pathed()) == "/tmp/x" + assert engine_socket(Default()) is None + + +# --------------------------------------------------------------------------- # +# Surfacing via the server (in-process client) +# --------------------------------------------------------------------------- # +def test_get_caller_context_tool(monkeypatch: pytest.MonkeyPatch) -> None: + """get_caller_context returns the context read from the server's env.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%7") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return (await client.call_tool("get_caller_context", {})).data + + data = asyncio.run(main()) + assert data.pane_id == "%7" + assert data.in_tmux is True + + +def test_instructions_include_caller(monkeypatch: pytest.MonkeyPatch) -> None: + """The instructions name the caller pane when the server is inside tmux.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%7") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + assert "%7" in (server.instructions or "") + assert "is_caller" in (server.instructions or "") + + +def test_instructions_outside_tmux(monkeypatch: pytest.MonkeyPatch) -> None: + """The instructions say so when the server is not inside tmux.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + assert "not running inside a tmux pane" in (server.instructions or "") + + +def test_is_caller_row_flag_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """list_panes flags the caller's own pane when env points at it.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="callerlive") + try: + pane = created.first_pane_id + assert pane is not None + socket = engine_socket(engine) or "" + monkeypatch.setenv("TMUX_PANE", pane) + monkeypatch.setenv("TMUX", f"{socket},0,0") + flagged = [ + row["pane_id"] + for row in list_panes(engine).rows + if row.get("is_caller") == "1" + ] + assert pane in flagged + finally: + kill_session(engine, created.session_id) + + +# --------------------------------------------------------------------------- # +# resolve_origin caller-default is socket-scoped (the behavioural path) +# --------------------------------------------------------------------------- # +def test_resolve_origin_same_server_uses_caller( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """resolve_origin trusts the caller pane when the engine shares its server.""" + monkeypatch.setenv("TMUX_PANE", "%3") + monkeypatch.setenv("TMUX", "/tmp/a,1,2") + engine = SyncToAsyncEngine(ConcreteEngine()) # default socket -> ambient server + + async def main() -> str: + return await resolve_origin(engine, None, None) + + assert asyncio.run(main()) == "%3" + + +def test_resolve_origin_cross_server_requires_explicit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """resolve_origin requires an explicit origin for a cross-server caller.""" + monkeypatch.setenv("TMUX_PANE", "%3") + monkeypatch.setenv("TMUX", "/tmp/a,1,2") + + class CrossServer(SyncToAsyncEngine): + server_args = ("-S", "/tmp/b") # a different socket than the caller's + + engine = CrossServer(ConcreteEngine()) + + async def main() -> str: + return await resolve_origin(engine, None, None) + + # Cross-server: the env %3 is refused; the caller must name an origin. + with pytest.raises(ToolError, match="explicit origin"): + asyncio.run(main()) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py new file mode 100644 index 000000000..4f8a7ef0e --- /dev/null +++ b/tests/experimental/mcp/test_events.py @@ -0,0 +1,140 @@ +"""The live event stream tools -- push, pull, and the registration gate. + +Driven offline against a fake engine that yields a fixed notification sequence, +so the push/pull mechanics are exercised without a real tmux ``-C`` connection. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines.async_control_mode import ControlNotification +from libtmux.experimental.engines.base import CommandResult + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator, Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class FakeStreamEngine: + """An async engine that replays a fixed notification stream.""" + + def __init__(self, raw: tuple[bytes, ...]) -> None: + self._raw = raw + + async def run(self, request: CommandRequest) -> CommandResult: + """Acknowledge any command.""" + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield the fixed notification sequence.""" + for raw in self._raw: + yield ControlNotification.parse(raw) + + +_STREAM = (b"%window-add @3", b"%output %1 hi", b"%window-close @3") + + +def _tool_names(server: t.Any) -> set[str]: + """Return the visible tool names of *server* (via an in-process client).""" + + async def main() -> set[str]: + async with fastmcp.Client(server) as client: + return {tool.name for tool in await client.list_tools()} + + return asyncio.run(main()) + + +def test_push_collects_filtered_events() -> None: + """watch_events streams and returns only the requested notification kinds.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "watch_events", + { + "kinds": ["window-add", "window-close"], + "max_events": 2, + "timeout": 2.0, + }, + ) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert data["count"] == 2 + assert [event["kind"] for event in data["events"]] == ["window-add", "window-close"] + + +def test_pull_buffers_events() -> None: + """poll_events drains the background ring buffer with a cursor.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="pull", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> dict[str, t.Any]: + async with fastmcp.Client(server) as client: + await client.call_tool("poll_events", {"since": 0}) # start the drainer + await asyncio.sleep(0.05) + result = await client.call_tool("poll_events", {"since": 0}) + return t.cast("dict[str, t.Any]", result.data) + + data = asyncio.run(main()) + assert len(data["events"]) == 3 + assert data["cursor"] == 3 + + +def test_both_registers_push_and_pull() -> None: + """events='both' exposes both mechanisms.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="both", + include_operations=False, + include_plan_tools=False, + ) + names = _tool_names(server) + assert {"watch_events", "poll_events"} <= names + + +def test_no_event_tools_without_a_stream() -> None: + """A non-streaming engine registers no event tools, even when asked.""" + from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + + server = build_async_server( + SyncToAsyncEngine(ConcreteEngine()), + events="both", + include_operations=False, + include_plan_tools=False, + ) + names = _tool_names(server) + assert "watch_events" not in names + assert "poll_events" not in names diff --git a/tests/experimental/mcp/test_relative_special_guard.py b/tests/experimental/mcp/test_relative_special_guard.py new file mode 100644 index 000000000..152b68a51 --- /dev/null +++ b/tests/experimental/mcp/test_relative_special_guard.py @@ -0,0 +1,119 @@ +"""The relative-special-target guard and the composed relative tools. + +``capture_pane`` / ``grep_pane`` / ``send_input`` must reject a directional +special target (``{up-of}`` …) with a hint -- those resolve against this MCP's +control client, not the caller. Anchor specials (``{marked}`` / ``{last}``) must +still pass through. The composed ``capture_relative_pane`` resolves the neighbour +to a concrete ``%N`` first (live). +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import ( + break_pane, + capture_pane, + capture_relative_pane, + create_session, + grep_pane, + join_pane, + kill_pane, + kill_session, + resize_pane, + respawn_pane, + select_pane, + send_input, + split_pane, + swap_pane, +) + +fastmcp = pytest.importorskip("fastmcp") +from fastmcp.exceptions import ToolError # noqa: E402 - after importorskip + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@pytest.mark.parametrize("token", ["{up-of}", "{down-of}", "{left-of}", "{right-of}"]) +def test_grep_rejects_relative_special(token: str) -> None: + """grep_pane with a directional special target raises a targeted hint.""" + with pytest.raises(ToolError, match="control-mode client"): + grep_pane(ConcreteEngine(capture_lines=("x",)), token, "x") + + +def test_capture_rejects_relative_special() -> None: + """capture_pane rejects a directional special target.""" + with pytest.raises(ToolError, match="control-mode client"): + capture_pane(ConcreteEngine(), "{down-of}") + + +def test_send_rejects_relative_special() -> None: + """send_input rejects a directional special target.""" + with pytest.raises(ToolError, match="control-mode client"): + send_input(ConcreteEngine(), "{left-of}", "ls") + + +@pytest.mark.parametrize("token", ["{marked}", "{last}"]) +def test_anchor_specials_pass_through(token: str) -> None: + """Anchor special targets are not rejected (real tmux semantics).""" + engine = ConcreteEngine(capture_lines=("hi",)) + assert capture_pane(engine, token).lines == ("hi",) + + +@pytest.mark.parametrize( + "call", + [ + lambda e: kill_pane(e, "{up-of}"), + lambda e: resize_pane(e, "{up-of}", width=80), + lambda e: respawn_pane(e, "{up-of}"), + lambda e: swap_pane(e, "{up-of}", "%1"), + lambda e: swap_pane(e, "%1", "{up-of}"), + lambda e: join_pane(e, "{up-of}", "%1"), + lambda e: break_pane(e, "{up-of}"), + lambda e: select_pane(e, "{up-of}"), + ], + ids=[ + "kill", + "resize", + "respawn", + "swap_src", + "swap_dst", + "join", + "break", + "select", + ], +) +def test_mutating_tools_reject_relative_special(call: t.Any) -> None: + """Destructive/mutating pane tools reject a relative special target too.""" + with pytest.raises(ToolError, match="control-mode client"): + call(ConcreteEngine()) + + +def test_grep_pane_invalid_regex_hint() -> None: + """An invalid search regex is surfaced as a targeted hint, not a raw re.error.""" + with pytest.raises(ToolError, match="invalid search pattern"): + grep_pane(ConcreteEngine(capture_lines=("x",)), "%1", "[unclosed") + + +def test_capture_relative_pane_resolves_concrete_live(session: Session) -> None: + """capture_relative_pane resolves a neighbour to a concrete %N and captures it.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="relcap") + try: + origin = created.first_pane_id + assert origin is not None + split_pane(engine, origin, horizontal=True) + captured = None + for direction in ("left", "right"): + try: + captured = capture_relative_pane(engine, direction, origin) + break + except ToolError: + continue # no neighbour that way; try the other side + assert captured is not None # resolved a concrete pane and captured it + finally: + kill_session(engine, created.session_id) diff --git a/tests/experimental/mcp/test_vocabulary_extended.py b/tests/experimental/mcp/test_vocabulary_extended.py new file mode 100644 index 000000000..81929265c --- /dev/null +++ b/tests/experimental/mcp/test_vocabulary_extended.py @@ -0,0 +1,243 @@ +"""Extended vocabulary -- new verbs, conveniences, the async surface, the bridge. + +Pure tests run against the in-memory ``ConcreteEngine`` and the pure geometry +helpers (no tmux); live tests drive a real tmux server for the geometry-resolved +conveniences (``resolve_relative_pane`` / ``find_pane_by_position`` / directional +``select_pane``) that only mean something against a real layout. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.mcp.vocabulary import ( + PaneRef, + acreate_session, + agrep_pane, + capture_active_pane, + create_session, + grep_pane, + has_session, + resize_pane, + resolve_relative_pane, + run_tmux, + select_pane, +) +from libtmux.experimental.mcp.vocabulary._bridge import ( + SyncToAsyncEngine, + drive_sync, + synced, +) +from libtmux.experimental.mcp.vocabulary._geometry import ( + PaneBox, + corner_pane, + neighbor, + parse_boxes, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +# --------------------------------------------------------------------------- # +# Geometry helpers (pure) +# --------------------------------------------------------------------------- # +def _two_columns() -> list[PaneBox]: + """Build a left|right two-pane layout.""" + return parse_boxes( + [ + { + "pane_id": "%1", + "pane_left": "0", + "pane_top": "0", + "pane_right": "39", + "pane_bottom": "23", + "pane_at_left": "1", + "pane_at_top": "1", + "pane_at_bottom": "1", + "pane_at_right": "0", + }, + { + "pane_id": "%2", + "pane_left": "41", + "pane_top": "0", + "pane_right": "80", + "pane_bottom": "23", + "pane_at_left": "0", + "pane_at_top": "1", + "pane_at_bottom": "1", + "pane_at_right": "1", + }, + ], + ) + + +def test_neighbor_resolves_horizontal() -> None: + """The right neighbour of the left pane is the right pane, and vice versa.""" + boxes = _two_columns() + assert neighbor(boxes, "%1", "right") == "%2" + assert neighbor(boxes, "%2", "left") == "%1" + + +def test_neighbor_none_when_no_pane_that_way() -> None: + """A pane with no neighbour in a direction resolves to None.""" + boxes = _two_columns() + assert neighbor(boxes, "%1", "left") is None + assert neighbor(boxes, "%1", "up") is None + assert neighbor(boxes, "%9", "right") is None # unknown origin + + +def test_corner_pane_uses_edge_predicates() -> None: + """The corner finder composes the pane_at_* predicates.""" + boxes = _two_columns() + assert corner_pane(boxes, "top-left") == "%1" + assert corner_pane(boxes, "bottom-right") == "%2" + + +# --------------------------------------------------------------------------- # +# The sync bridge +# --------------------------------------------------------------------------- # +def test_synced_twin_runs_over_sync_engine() -> None: + """A synced twin drives its async source over a plain sync engine.""" + result = create_session(ConcreteEngine(), name="dev") # create_session is a twin + assert result.session_id == "$1" + + +def test_drive_sync_rejects_real_io() -> None: + """drive_sync refuses a coroutine that suspends on a real await.""" + + async def suspends() -> int: + await asyncio.sleep(0) # yields to the loop -- no loop here + return 1 + + with pytest.raises(RuntimeError, match="real I/O"): + drive_sync(suspends()) + + +def test_synced_preserves_callable() -> None: + """synced() yields a callable with the engine param retyped to sync.""" + + async def tool(engine: t.Any, value: int) -> int: + return value + + twin = synced(tool) + hints = t.get_type_hints(twin) + assert hints["engine"].__name__ == "TmuxEngine" + + +# --------------------------------------------------------------------------- # +# New verbs / conveniences (offline) +# --------------------------------------------------------------------------- # +def test_grep_pane_filters_lines() -> None: + """grep_pane returns only the captured lines matching the pattern.""" + engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + assert grep_pane(engine, "%1", "foo").lines == ("foo", "foobar") + + +def test_grep_pane_ignore_case() -> None: + """grep_pane honours the ignore_case flag.""" + engine = ConcreteEngine(capture_lines=("FOO", "bar")) + assert grep_pane(engine, "%1", "foo", ignore_case=True).lines == ("FOO",) + + +def test_capture_active_pane_needs_no_target() -> None: + """capture_active_pane captures with no explicit target.""" + engine = ConcreteEngine(capture_lines=("hello",)) + assert capture_active_pane(engine).lines == ("hello",) + + +def test_resize_and_run_tmux_offline() -> None: + """resize_pane is fire-and-forget; run_tmux returns a raw outcome.""" + engine = ConcreteEngine() + assert resize_pane(engine, "%1", width=80) is None + raw = run_tmux(engine, ["list-sessions"]) + assert raw.ok and raw.returncode == 0 + + +def test_has_session_returns_bool() -> None: + """has_session answers an existence query as a bool.""" + assert has_session(ConcreteEngine(), "$1") is True + + +def test_geometry_tools_return_paneref_offline() -> None: + """Geometry-resolved tools return a PaneRef even with nothing to resolve.""" + engine = ConcreteEngine() + assert isinstance(resolve_relative_pane(engine, "right", "%1"), PaneRef) + assert isinstance(select_pane(engine, "%1", direction="left"), PaneRef) + + +# --------------------------------------------------------------------------- # +# The async surface +# --------------------------------------------------------------------------- # +def test_async_surface_over_wrapped_engine() -> None: + """The a-prefixed tools run over an async engine (sync engine wrapped).""" + + async def main() -> tuple[str, tuple[str, ...]]: + engine = SyncToAsyncEngine(ConcreteEngine(capture_lines=("x", "y"))) + session = await acreate_session(engine, name="dev") + grep = await agrep_pane(engine, "%1", "x") + return session.session_id, grep.lines + + session_id, lines = asyncio.run(main()) + assert session_id == "$1" + assert lines == ("x",) + + +# --------------------------------------------------------------------------- # +# Live geometry conveniences +# --------------------------------------------------------------------------- # +def test_resolve_relative_pane_live(session: Session) -> None: + """resolve_relative_pane finds the adjacent pane in a real split layout.""" + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="reltest") + try: + origin = created.first_pane_id + assert origin is not None + other = split_pane_id(engine, origin) + # Exactly one of left/right of the origin is the new pane. + found = { + resolve_relative_pane(engine, "left", origin).pane_id, + resolve_relative_pane(engine, "right", origin).pane_id, + } + assert other in found + finally: + kill(engine, created.session_id) + + +def test_find_pane_by_position_live(session: Session) -> None: + """find_pane_by_position returns a real pane occupying the corner.""" + from libtmux.experimental.mcp.vocabulary import find_pane_by_position, list_panes + + engine = SubprocessEngine.for_server(session.server) + created = create_session(engine, name="corner") + try: + origin = created.first_pane_id + assert origin is not None + split_pane_id(engine, origin) + ids = { + row["pane_id"] + for row in list_panes(engine).rows + if row.get("session_id") == created.session_id + } + corner = find_pane_by_position(engine, "top-left", origin).pane_id + assert corner in ids + finally: + kill(engine, created.session_id) + + +def split_pane_id(engine: SubprocessEngine, target: str) -> str: + """Split *target* horizontally and return the new pane id (test helper).""" + from libtmux.experimental.mcp.vocabulary import split_pane + + return split_pane(engine, target, horizontal=True).pane_id + + +def kill(engine: SubprocessEngine, session_id: str) -> None: + """Kill a test session (helper).""" + from libtmux.experimental.mcp.vocabulary import kill_session + + kill_session(engine, session_id) From 0d87e33c1c93fc13f4b108f0a85cd11a9d0d77b1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:39:56 -0500 Subject: [PATCH 068/154] Mcp(feat): Caller discovery + self-kill guards why: The round-2 caller-awareness read only the MCP's own environment, which real launchers (agent -> uv -> python child) strip -- so the caller pane was undiscoverable and the whole surface went inert. what: - Add CallerContext.discover(): process-env -> explicit override (LIBTMUX_MCP_CALLER_PANE/TMUX) -> a bounded, same-uid Linux /proc parent-process env walk (vocabulary/_proc.py). Fail-closed (never raises), env-minimised to TMUX/TMUX_PANE, depth-capped, and injectable so it is unit-testable without /proc; records the discovery source. - Bind the default engine to the discovered caller's -S socket when no explicit override (--socket-path/--socket-name/--no-caller-socket, $LIBTMUX_SOCKET*), so a stripped-env MCP still drives the user's own tmux server instead of a fresh default one. - Add a conservative socket comparator (socket_could_match / is_conservative_caller) alongside the strict one: the strict stays on the is_caller annotation and origin resolution; the conservative, fail-safe one guards destructive ops. - Refuse self-kill: kill_pane / respawn_pane / kill_window / kill_session (and the others=True siblings) decline the pane, window, or session running this MCP, with a hint to act manually. - Thread the discovered context to the tool bodies by stashing it on the engine (read by caller_of); SyncToAsyncEngine delegates server_args / _caller_context so the sync surface sees the same identity. - Cover with /proc parser, discovery precedence, comparator, and self-kill tests (no pytest-asyncio); fix the stale resolve_relative_pane active-pane-fallback docstring. --- src/libtmux/experimental/mcp/__init__.py | 83 +++++- .../experimental/mcp/fastmcp_adapter.py | 22 +- .../experimental/mcp/vocabulary/_bridge.py | 10 + .../experimental/mcp/vocabulary/_caller.py | 240 ++++++++++++++++-- .../experimental/mcp/vocabulary/_proc.py | 87 +++++++ .../experimental/mcp/vocabulary/_resolve.py | 86 ++++++- .../experimental/mcp/vocabulary/pane.py | 45 +++- .../experimental/mcp/vocabulary/session.py | 3 + .../experimental/mcp/vocabulary/window.py | 49 +++- tests/experimental/mcp/test_caller.py | 163 +++++++++++- tests/experimental/mcp/test_proc.py | 43 ++++ 11 files changed, 791 insertions(+), 40 deletions(-) create mode 100644 src/libtmux/experimental/mcp/vocabulary/_proc.py create mode 100644 tests/experimental/mcp/test_proc.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 2861957b7..0b3f89033 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -68,6 +68,40 @@ from fastmcp import FastMCP + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + +def _socket_args( + caller: CallerContext, + *, + socket_path: str | None = None, + socket_name: str | None = None, + no_caller_socket: bool = False, +) -> tuple[str, ...]: + """Resolve tmux connection flags: explicit overrides, else the caller's socket. + + Precedence: ``--socket-path`` > ``--socket-name`` > ``$LIBTMUX_SOCKET_PATH`` > + ``$LIBTMUX_SOCKET`` > the discovered caller's socket (unless suppressed) > + none (ambient/default server). + """ + import os + + from libtmux.experimental.mcp.vocabulary._caller import caller_server_args + + if socket_path: + return ("-S", socket_path) + if socket_name: + return ("-L", socket_name) + env_path = os.environ.get("LIBTMUX_SOCKET_PATH") + if env_path: + return ("-S", env_path) + env_name = os.environ.get("LIBTMUX_SOCKET") + if env_name: + return ("-L", env_name) + if no_caller_socket: + return () + return caller_server_args(caller, explicit=False) + def default_server(*, expose_operations: bool = False) -> FastMCP: """Build a synchronous FastMCP server over a :class:`~..engines.SubprocessEngine`. @@ -79,8 +113,11 @@ def default_server(*, expose_operations: bool = False) -> FastMCP: """ from libtmux.experimental.engines import SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server + from libtmux.experimental.mcp.vocabulary._caller import CallerContext - return build_server(SubprocessEngine(), expose_operations=expose_operations) + ctx = CallerContext.discover() + engine = SubprocessEngine(server_args=_socket_args(ctx)) + return build_server(engine, expose_operations=expose_operations, caller=ctx) def default_async_server( @@ -100,9 +137,13 @@ def default_async_server( from libtmux.experimental.engines import AsyncControlModeEngine from libtmux.experimental.mcp.events import EventMode, EventSource from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + ctx = CallerContext.discover() + engine = AsyncControlModeEngine(server_args=_socket_args(ctx)) return build_async_server( - AsyncControlModeEngine(), + engine, + caller=ctx, expose_operations=expose_operations, events=t.cast("EventMode", events), event_source=t.cast("EventSource", event_source), @@ -149,23 +190,53 @@ def main(argv: Sequence[str] | None = None) -> None: default=os.environ.get("LIBTMUX_MCP_EVENT_SOURCE", "subscription"), help="event substrate (async server only)", ) + parser.add_argument( + "--socket-path", + help="tmux -S socket path (overrides caller-socket discovery)", + ) + parser.add_argument( + "--socket-name", + help="tmux -L socket name (overrides caller-socket discovery)", + ) + parser.add_argument( + "--no-caller-socket", + action="store_true", + help="do not auto-bind to the discovered caller's tmux socket", + ) args = parser.parse_args(argv) try: + from libtmux.experimental.mcp.vocabulary._caller import CallerContext + + ctx = CallerContext.discover() + srv_args = _socket_args( + ctx, + socket_path=args.socket_path, + socket_name=args.socket_name, + no_caller_socket=args.no_caller_socket, + ) if args.sync: from libtmux.experimental.engines import SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server server = build_server( - SubprocessEngine(), + SubprocessEngine(server_args=srv_args), name=args.name, expose_operations=args.operations, + caller=ctx, ) else: - server = default_async_server( + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.events import EventMode, EventSource + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + AsyncControlModeEngine(server_args=srv_args), + name=args.name, expose_operations=args.operations, - events=args.events, - event_source=args.event_source, + events=t.cast("EventMode", args.events), + event_source=t.cast("EventSource", args.event_source), + caller=ctx, ) except ImportError: sys.stderr.write( diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 363b5c380..7d7cd7079 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -509,6 +509,16 @@ def build_workspace( mcp.add_tool(tool) +def _stash_caller(engine: t.Any, ctx: CallerContext) -> None: + """Stash the discovered caller on the engine so the tool bodies can read it. + + The curated tool bodies bind only ``engine``; stashing the once-discovered + context here (read by :func:`~.vocabulary._resolve.caller_of`) threads caller + identity to them without changing every tool signature. + """ + engine._caller_context = ctx + + def build_server( engine: TmuxEngine, *, @@ -517,16 +527,19 @@ def build_server( include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, + caller: CallerContext | None = None, ) -> FastMCP: """Build a synchronous FastMCP server over a sync *engine*. The sync wrapper: the curated tools are the derived sync twins, which FastMCP offloads to a worker thread. Prefer :func:`build_async_server` for the - async-first surface and the event stream. + async-first surface and the event stream. *caller* defaults to + :meth:`CallerContext.discover`. """ from fastmcp import FastMCP - ctx = CallerContext.from_env() + ctx = caller if caller is not None else CallerContext.discover() + _stash_caller(engine, ctx) mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=False) @@ -554,6 +567,7 @@ def build_async_server( include_plan_tools: bool = True, events: EventMode = "push", event_source: EventSource = "subscription", + caller: CallerContext | None = None, ) -> FastMCP: """Build the async-first FastMCP server over an async *engine*. @@ -561,12 +575,14 @@ def build_async_server( awaited directly on FastMCP's event loop. When *engine* supports a notification stream (a control-mode engine), the live event tools are registered per *events* (``"push"``/``"pull"``/``"both"``/``"off"``). + *caller* defaults to :meth:`CallerContext.discover`. """ from fastmcp import FastMCP from libtmux.experimental.mcp.events import register_events - ctx = CallerContext.from_env() + ctx = caller if caller is not None else CallerContext.discover() + _stash_caller(engine, ctx) mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) diff --git a/src/libtmux/experimental/mcp/vocabulary/_bridge.py b/src/libtmux/experimental/mcp/vocabulary/_bridge.py index a473bbe97..db78380d7 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_bridge.py +++ b/src/libtmux/experimental/mcp/vocabulary/_bridge.py @@ -50,6 +50,16 @@ class SyncToAsyncEngine: def __init__(self, engine: TmuxEngine) -> None: self._engine = engine + def __getattr__(self, name: str) -> t.Any: + """Delegate unknown attributes to the wrapped engine. + + Keeps the wrapper transparent for the attributes the vocabulary reads off + an engine -- ``server_args`` (socket scoping) and the stashed + ``_caller_context`` -- so the sync surface sees the same identity the + async surface does. + """ + return getattr(self._engine, name) + async def run(self, request: CommandRequest) -> CommandResult: """Run one command on the wrapped sync engine (resolves inline).""" return self._engine.run(request) diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py index 7cd6c75c9..30942c2af 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_caller.py +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -1,28 +1,39 @@ -"""Caller context: who launched this MCP server, read from its own environment. +"""Caller context: who launched this MCP server, discovered from the environment. A tmux ``-C`` control client resolves a no-target or relative target against its *own* cursor pane, never the pane that launched the controlling process -- so the -caller pane is knowable only from the server process's environment. A process -spawned inside a tmux pane inherits ``TMUX_PANE`` (its ``%N`` id) and ``TMUX`` -(``socket-path,server-pid,session-id``); those are fixed for the process -lifetime, so the curated tools can read them at call time and the adapter can -read them once for the server instructions -- both see the same launching pane. - -Everything here is pure (no tmux call, no fastmcp): the whole point is to *avoid* -asking tmux, which would answer for the control client instead of the caller. A -pane id is unique only within one tmux server, so :func:`is_strict_caller` -socket-scopes the comparison rather than trusting a bare ``%N``. +caller pane is knowable only from the server process's environment, not by asking +tmux. A process spawned inside a tmux pane inherits ``TMUX_PANE`` (its ``%N``) and +``TMUX`` (``socket-path,server-pid,session-id``). + +But real launchers strip that env: an agent harness may hold ``TMUX``/ +``TMUX_PANE`` while the ``uv run`` child that became this server does not. So +:meth:`CallerContext.discover` layers the server's own env, an explicit override, +and a bounded same-uid ``/proc`` parent walk (:mod:`._proc`) to recover the pane. + +Everything here is pure (no tmux call, no fastmcp). A pane id is unique only +within one tmux server, so identity is socket-scoped: :func:`is_strict_caller` +(realpath-only, for the ``is_caller`` annotation) and the fail-safe +:func:`is_conservative_caller` (true-when-uncertain, for destructive guards). """ from __future__ import annotations +import dataclasses import os import os.path +import pathlib import typing as t from dataclasses import dataclass +from libtmux.experimental.mcp.vocabulary._proc import ( + read_proc_environ, + read_proc_ppid, + read_proc_uid, +) + if t.TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Callable, Mapping, Sequence @dataclass(frozen=True) @@ -33,8 +44,8 @@ class CallerContext: -------- >>> env = {"TMUX_PANE": "%3", "TMUX": "/tmp/tmux-1000/default,42,2"} >>> c = CallerContext.from_env(env) - >>> (c.pane_id, c.socket_path, c.session_id, c.in_tmux) - ('%3', '/tmp/tmux-1000/default', '2', True) + >>> (c.pane_id, c.socket_path, c.session_id, c.in_tmux, c.source) + ('%3', '/tmp/tmux-1000/default', '2', True, 'process-env') >>> CallerContext.from_env({}).in_tmux False >>> CallerContext.from_env({"TMUX": "garbage"}).socket_path is None @@ -48,6 +59,7 @@ class CallerContext: server_pid: str | None = None session_id: str | None = None in_tmux: bool = False + source: str = "none" @classmethod def from_env(cls, environ: Mapping[str, str] | None = None) -> CallerContext: @@ -73,8 +85,114 @@ def from_env(cls, environ: Mapping[str, str] | None = None) -> CallerContext: server_pid=server_pid, session_id=session_id, in_tmux=pane is not None, + source="process-env" if pane is not None else "none", ) + @classmethod + def discover( + cls, + *, + environ: Mapping[str, str] | None = None, + read_env: Callable[[int], Mapping[str, str] | None] = read_proc_environ, + read_ppid: Callable[[int], int | None] = read_proc_ppid, + read_uid: Callable[[int], int | None] = read_proc_uid, + self_pid: int | None = None, + self_uid: int | None = None, + is_linux: bool | None = None, + max_depth: int = 32, + ) -> CallerContext: + """Discover the caller pane: own env, then an override, then a /proc walk. + + Recovers the launching pane even when the MCP's own environment was + stripped. Precedence (first source that is inside tmux wins, recorded in + :attr:`source`): + + 1. ``process-env`` -- the server's own ``TMUX``/``TMUX_PANE``. + 2. ``explicit-override`` -- ``LIBTMUX_MCP_CALLER_PANE`` (+ optional + ``LIBTMUX_MCP_CALLER_TMUX``); the trusted escape hatch. + 3. ``parent-walk`` -- a bounded, same-uid Linux ``/proc`` ancestor climb + (disabled by ``LIBTMUX_MCP_DISCOVER=0`` or off Linux). + + Never raises: a reader failure, uid mismatch, or missing ``/proc`` + degrades to the next source and ultimately ``source="none"``. The reader + callables are injectable so the walk is unit-testable without ``/proc``. + + Examples + -------- + >>> env = {10: {}, 20: {}, 30: {"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}} + >>> ppid = {10: 20, 20: 30, 30: 1} + >>> c = CallerContext.discover( + ... environ={}, + ... read_env=env.get, + ... read_ppid=ppid.get, + ... read_uid=lambda _pid: 1000, + ... self_pid=10, + ... self_uid=1000, + ... is_linux=True, + ... ) + >>> (c.pane_id, c.socket_path, c.source) + ('%3', '/tmp/s', 'parent-walk') + """ + env = os.environ if environ is None else environ + own = cls.from_env(env) + if own.in_tmux: + return own + override_pane = env.get("LIBTMUX_MCP_CALLER_PANE") + if override_pane: + override: dict[str, str] = {"TMUX_PANE": override_pane} + override_tmux = env.get("LIBTMUX_MCP_CALLER_TMUX") + if override_tmux: + override["TMUX"] = override_tmux + return dataclasses.replace( + cls.from_env(override), + source="explicit-override", + ) + if env.get("LIBTMUX_MCP_DISCOVER") == "0": + return cls(source="none") + linux = pathlib.Path("/proc").is_dir() if is_linux is None else is_linux + if linux: + walked = cls._parent_walk( + read_env, + read_ppid, + read_uid, + self_pid, + self_uid, + max_depth, + ) + if walked is not None: + return walked + return cls(source="none") + + @classmethod + def _parent_walk( + cls, + read_env: Callable[[int], Mapping[str, str] | None], + read_ppid: Callable[[int], int | None], + read_uid: Callable[[int], int | None], + self_pid: int | None, + self_uid: int | None, + max_depth: int, + ) -> CallerContext | None: + """Climb the parent chain for the first same-uid ancestor inside tmux.""" + pid = os.getpid() if self_pid is None else self_pid + uid = os.getuid() if self_uid is None else self_uid + seen: set[int] = set() + for _ in range(max_depth): + ppid = read_ppid(pid) + if ppid is None or ppid in (0, 1) or ppid in seen: + return None + if read_uid(ppid) != uid: + return None # never read a foreign or setuid parent's env + seen.add(ppid) + env = read_env(ppid) + if env is None: + return None + ctx = cls.from_env(env) + if ctx.in_tmux: + return dataclasses.replace(ctx, source="parent-walk") + pid = ppid + return None + def _scan_flag(args: Sequence[str], flag: str) -> str | None: """Read a tmux connection flag's value (joined ``-Sx`` or separated ``-S x``).""" @@ -110,6 +228,28 @@ def engine_socket(engine: t.Any) -> str | None: return _scan_flag(args, "-L") +def caller_server_args(caller: CallerContext, *, explicit: bool) -> tuple[str, ...]: + """Return ``("-S", socket)`` to bind the caller's server, or ``()``. + + Binds only when the caller's socket was discovered and no explicit socket + override was supplied -- so a stripped-env MCP still drives the user's own + tmux server instead of spawning a fresh default one. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> caller_server_args(caller, explicit=False) + ('-S', '/tmp/s') + >>> caller_server_args(caller, explicit=True) + () + >>> caller_server_args(CallerContext.from_env({}), explicit=False) + () + """ + if explicit or not caller.in_tmux or caller.socket_path is None: + return () + return ("-S", caller.socket_path) + + def socket_matches(socket: str | None, caller: CallerContext) -> bool: """Whether an engine *socket* selector denotes the caller's tmux server. @@ -140,6 +280,48 @@ def socket_matches(socket: str | None, caller: CallerContext) -> bool: return os.path.realpath(expected) == os.path.realpath(caller.socket_path) +def socket_could_match(socket: str | None, caller: CallerContext) -> bool: + """Conservative socket comparator: True unless the caller is provably elsewhere. + + The fail-safe counterpart to :func:`socket_matches`, for destructive guards: + it blocks (returns ``True``) whenever it cannot *disprove* that *socket* is + the caller's server -- an unknown caller socket, an ambient default engine, + or a last-chance basename match all count, so a self-kill is refused under + uncertainty (e.g. a ``$TMUX_TMPDIR`` divergence). + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/tmp/s,1,2"}) + >>> socket_could_match(None, caller) + True + >>> socket_could_match("/tmp/s", caller) + True + >>> socket_could_match("/tmp/other", caller) + False + >>> socket_could_match(None, CallerContext.from_env({})) + False + """ + if not caller.in_tmux: + return False + if caller.socket_path is None: + return True + if socket is None: + return True + if "/" in socket: + try: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + except OSError: + return socket == caller.socket_path + tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" + expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" + try: + if os.path.realpath(expected) == os.path.realpath(caller.socket_path): + return True + except OSError: + pass + return caller.socket_path.rsplit("/", 1)[-1] == socket + + def is_strict_caller( pane_id: str | None, socket: str | None, @@ -149,7 +331,8 @@ def is_strict_caller( Strict: requires pane-id equality *and* a confirmed socket match, since a pane id is unique only within one tmux server. Bare pane-id equality is - rejected to avoid a cross-server false positive. + rejected to avoid a cross-server false positive. Used for the ``is_caller`` + annotation and the caller-default origin -- both must demand a positive match. Examples -------- @@ -166,3 +349,30 @@ def is_strict_caller( if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: return False return socket_matches(socket, caller) + + +def is_conservative_caller( + pane_id: str | None, + socket: str | None, + caller: CallerContext, +) -> bool: + """Whether *pane_id* could be the caller's pane (conservative, for guards). + + Scoped to a matching pane id but biased to block under socket uncertainty -- + the comparator the self-kill guards use, so better discovery never makes the + destructive surface fail open. A different pane, or the same ``%N`` on a + provably different socket, is not the caller. + + Examples + -------- + >>> caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}) + >>> is_conservative_caller("%3", None, caller) + True + >>> is_conservative_caller("%9", None, caller) + False + >>> is_conservative_caller("%3", "/tmp/other", caller) + False + """ + if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: + return False + return socket_could_match(socket, caller) diff --git a/src/libtmux/experimental/mcp/vocabulary/_proc.py b/src/libtmux/experimental/mcp/vocabulary/_proc.py new file mode 100644 index 000000000..a8fc35c5c --- /dev/null +++ b/src/libtmux/experimental/mcp/vocabulary/_proc.py @@ -0,0 +1,87 @@ +"""Linux ``/proc`` readers for the caller-discovery parent walk (pure, fail-closed). + +Recovering the launching pane when the MCP's own environment is stripped means +walking the process tree: a launcher (e.g. an agent harness) may hold ``TMUX`` / +``TMUX_PANE`` while the ``uv run`` child that became this server does not. Each +reader returns ``None`` on any failure (missing ``/proc``, permission, a dead +pid) so discovery degrades to "not in tmux" rather than raising -- matching the +lenient list-accessor contract. Only ``TMUX`` / ``TMUX_PANE`` are ever read from +another process's environment (env-minimisation -- never materialise secrets). +""" + +from __future__ import annotations + +import pathlib +import typing as t + +if t.TYPE_CHECKING: + from collections.abc import Mapping + +#: The only environment keys ever read from another process. +_WANTED = ("TMUX", "TMUX_PANE") + + +def read_proc_environ(pid: int) -> Mapping[str, str] | None: + """Return a process's ``TMUX``/``TMUX_PANE`` env only, or ``None`` on failure. + + ``/proc//environ`` is ``KEY=VAL`` pairs joined by NUL bytes. Any read + error (missing, permission, dead pid -- all ``OSError`` subclasses) yields + ``None``. + """ + try: + raw = pathlib.Path(f"/proc/{pid}/environ").read_bytes() + except OSError: + return None + out: dict[str, str] = {} + for item in raw.split(b"\x00"): + if b"=" not in item: + continue + key, value = item.split(b"=", 1) + name = key.decode(errors="replace") + if name in _WANTED: + out[name] = value.decode(errors="replace") + return out + + +def _ppid_from_stat(data: bytes) -> int | None: + """Parse the parent pid out of ``/proc//stat`` bytes. + + The ``comm`` field (2nd) is paren-wrapped and may itself contain spaces or + parens, so the parse anchors on the *last* ``)``; the fields after it are + space-separated, with state at index 0 and ppid at index 1. + + Examples + -------- + >>> _ppid_from_stat(b"1234 (we ird (name)) S 99 1234 1234 0 -1") + 99 + >>> _ppid_from_stat(b"garbage") is None + True + """ + try: + return int(data[data.rindex(b")") + 1 :].split()[1]) + except (ValueError, IndexError): + return None + + +def read_proc_ppid(pid: int) -> int | None: + """Return a process's parent pid from ``/proc//stat``, or ``None``.""" + try: + data = pathlib.Path(f"/proc/{pid}/stat").read_bytes() + except OSError: + return None + return _ppid_from_stat(data) + + +def read_proc_uid(pid: int) -> int | None: + """Return a process's real uid from ``/proc//status``, or ``None``. + + The ``Uid:`` line is ``real effective saved-set filesystem``; the first + field (the real uid) is what :func:`os.getuid` returns. + """ + try: + for line in pathlib.Path(f"/proc/{pid}/status").read_bytes().splitlines(): + if line.startswith(b"Uid:"): + return int(line.split()[1]) + except (OSError, ValueError, IndexError): + return None + return None diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py index 87872daba..b61336eeb 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_resolve.py +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -15,6 +15,7 @@ from libtmux.experimental.mcp.vocabulary._caller import ( CallerContext, engine_socket, + socket_could_match, socket_matches, ) from libtmux.experimental.ops import DisplayMessage, ListPanes, SelectPane, arun @@ -145,16 +146,17 @@ async def resolve_origin( """Resolve a caller-relative origin to a concrete pane id. An explicit *origin* is resolved as a target; ``origin=None`` means the - caller's own pane (from the server's environment), socket-scoped exactly - like :func:`~._caller.is_strict_caller` because a ``%N`` is unique only - within one server. When there is no trustworthy caller pane -- the server is - not inside tmux, or its pane belongs to a different server than this engine - targets -- this raises rather than guessing the active pane, so the caller - must pass an explicit origin. + caller's own pane (from the discovered caller context -- own env, an explicit + override, or a ``/proc`` parent walk), socket-scoped exactly like + :func:`~._caller.is_strict_caller` because a ``%N`` is unique only within one + server. When there is no trustworthy caller pane -- the server is not inside + tmux, or its pane belongs to a different server than this engine targets -- + this raises rather than guessing the active pane, so the caller must pass an + explicit origin. """ if origin is not None: return await pane_id(engine, origin, version) - caller = CallerContext.from_env() + caller = caller_of(engine) if caller.pane_id and socket_matches(engine_socket(engine), caller): return caller.pane_id raise_target_hint( @@ -196,3 +198,73 @@ def reject_relative_special(resolved: Target | None) -> None: "composed capture_relative_pane / grep_relative_pane (origin defaults to " "your pane)", ) + + +def caller_of(engine: AsyncTmuxEngine) -> CallerContext: + """Return the caller context discovered at build time (stashed on the engine). + + Falls back to a fresh :meth:`~._caller.CallerContext.from_env` read when no + context was stashed (the engine was built outside the adapter). + """ + stashed = getattr(engine, "_caller_context", None) + if isinstance(stashed, CallerContext): + return stashed + return CallerContext.from_env() + + +async def session_id_of( + engine: AsyncTmuxEngine, + target: str | Target, + version: str | None, +) -> str: + """Return the session id (``$N``) containing *target* (a pane/window/session).""" + result = await arun( + DisplayMessage(target=resolve_target(target), message="#{session_id}"), + engine, + version=version, + ) + result.raise_for_status() + return result.text.strip() + + +async def guard_self_kill( + engine: AsyncTmuxEngine, + *, + pane: str | None = None, + window: str | None = None, + session: str | None = None, + version: str | None = None, +) -> None: + """Refuse a destructive op aimed at the caller's own pane/window/session. + + Socket-scoped first (``%N``/``@N``/``$N`` are per-server counters that + collide across servers), then the caller's pane is mapped to its window / + session *via the engine* (``$TMUX`` carries no window id). Uses the + conservative comparator so a self-kill fails safe under socket uncertainty. + Raises a refusal hint; a different pane/window/session, or a cross-socket + target with the same id, is not refused. + """ + caller = caller_of(engine) + if not caller.in_tmux or caller.pane_id is None: + return + if not socket_could_match(engine_socket(engine), caller): + return + if pane is not None and caller.pane_id == pane: + raise_target_hint( + f"refusing to kill pane {pane}: it runs this MCP server. Target a " + "different pane, or run the tmux command manually if intended.", + ) + if window is not None: + caller_window = await window_id(engine, PaneId(caller.pane_id), version) + if caller_window == window: + raise_target_hint( + f"refusing to kill window {window}: it holds this MCP server's " + "pane. Use a manual tmux command if intended.", + ) + if session is not None: + caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) + if caller_session == session: + raise_target_hint( + f"refusing to kill session {session}: it holds this MCP server's " + "pane. Use a manual tmux command if intended.", + ) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 69bca9321..51da8afcb 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -22,9 +22,9 @@ from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced from libtmux.experimental.mcp.vocabulary._caller import ( - CallerContext, engine_socket, is_strict_caller, + socket_could_match, ) from libtmux.experimental.mcp.vocabulary._geometry import ( Corner, @@ -36,7 +36,10 @@ from libtmux.experimental.mcp.vocabulary._resolve import ( DIR_FLAG, active_pane_id, + caller_of, + guard_self_kill, opt_target, + pane_id, raise_target_hint, reject_relative_special, resolve_origin, @@ -246,7 +249,7 @@ async def asearch_panes( matcher = _compile(pattern, ignore_case=ignore_case) listing = await arun(ListPanes(all_panes=True), engine, version=version) listing.raise_for_status() - caller = CallerContext.from_env() + caller = caller_of(engine) socket = engine_socket(engine) matches: list[PaneMatch] = [] for row in listing.rows[:max_panes]: @@ -373,6 +376,9 @@ async def arespawn_pane( """Restart a pane's process in place (``respawn-pane``).""" resolved = resolve_target(target) reject_relative_special(resolved) + await guard_self_kill( + engine, pane=await pane_id(engine, target, version), version=version + ) ( await arun( RespawnPane( @@ -397,6 +403,11 @@ async def akill_pane( """Kill a pane (or all others in its window with ``others=True``).""" resolved = resolve_target(target) reject_relative_special(resolved) + target_pane = await pane_id(engine, target, version) + if others: + await _guard_kill_others(engine, target_pane, version) + else: + await guard_self_kill(engine, pane=target_pane, version=version) ( await arun( KillPane(target=resolved, others=others), @@ -406,6 +417,30 @@ async def akill_pane( ).raise_for_status() +async def _guard_kill_others( + engine: AsyncTmuxEngine, + target_pane: str, + version: str | None, +) -> None: + """Refuse ``kill_pane(others=True)`` when the caller is a sibling of the target. + + ``others=True`` keeps the target and kills every other pane in its window, so + the danger is the caller pane being one of those siblings (not the target). + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id or caller.pane_id == target_pane: + return + if not socket_could_match(engine_socket(engine), caller): + return + target_window = await window_id(engine, PaneId(target_pane), version) + caller_window = await window_id(engine, PaneId(caller.pane_id), version) + if caller_window == target_window: + raise_target_hint( + f"refusing to kill the other panes of window {target_window}: one of " + "them runs this MCP server. Use a manual tmux command if intended.", + ) + + async def alist_panes( engine: AsyncTmuxEngine, target: str | Target | None = None, @@ -421,7 +456,7 @@ async def alist_panes( """ result = await arun(ListPanes(all_panes=all_panes), engine, version=version) result.raise_for_status() - caller = CallerContext.from_env() + caller = caller_of(engine) socket = engine_socket(engine) rows = tuple( { @@ -482,7 +517,9 @@ async def aresolve_relative_pane( Resolved from layout geometry (the ``pane_left/top/right/bottom`` the list template already carries) -- robust across tmux versions, and without moving the active pane. ``origin=None`` means the caller's own pane (the pane that - launched this MCP), falling back to the active pane outside tmux. + launched this MCP), resolved only when this engine targets the caller's tmux + server; otherwise an explicit ``origin`` is required. This never falls back to + tmux's active pane (the control client's cursor, not the caller). """ origin_id = await resolve_origin(engine, origin, version) if not origin_id: diff --git a/src/libtmux/experimental/mcp/vocabulary/session.py b/src/libtmux/experimental/mcp/vocabulary/session.py index 419233265..e5e0e65e1 100644 --- a/src/libtmux/experimental/mcp/vocabulary/session.py +++ b/src/libtmux/experimental/mcp/vocabulary/session.py @@ -12,6 +12,7 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._resolve import guard_self_kill, session_id_of from libtmux.experimental.mcp.vocabulary._results import Listing, SessionResult from libtmux.experimental.ops import ( HasSession, @@ -88,6 +89,8 @@ async def akill_session( version: str | None = None, ) -> None: """Kill a session (mirrors ``session.kill``).""" + target_session = await session_id_of(engine, target, version) + await guard_self_kill(engine, session=target_session, version=version) ( await arun(KillSession(target=resolve_target(target)), engine, version=version) ).raise_for_status() diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index c9d856b53..5a69982ee 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -5,6 +5,17 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced +from libtmux.experimental.mcp.vocabulary._caller import ( + engine_socket, + socket_could_match, +) +from libtmux.experimental.mcp.vocabulary._resolve import ( + caller_of, + guard_self_kill, + raise_target_hint, + session_id_of, + window_id, +) from libtmux.experimental.mcp.vocabulary._results import Listing, WindowResult from libtmux.experimental.ops import ( KillWindow, @@ -17,7 +28,7 @@ SwapWindow, arun, ) -from libtmux.experimental.ops._types import Target +from libtmux.experimental.ops._types import PaneId, Target async def acreate_window( @@ -126,15 +137,49 @@ async def akill_window( version: str | None = None, ) -> None: """Kill a window (or all others in its session with ``others=True``).""" + resolved = resolve_target(target) + target_window = await window_id(engine, resolved, version) + if others: + await _guard_kill_other_windows(engine, target, target_window, version) + else: + await guard_self_kill(engine, window=target_window, version=version) ( await arun( - KillWindow(target=resolve_target(target), others=others), + KillWindow(target=resolved, others=others), engine, version=version, ) ).raise_for_status() +async def _guard_kill_other_windows( + engine: AsyncTmuxEngine, + target: str | Target, + target_window: str, + version: str | None, +) -> None: + """Refuse ``kill_window(others=True)`` when the caller is a same-session sibling. + + ``others=True`` keeps the target window and kills every other window in its + session, so the danger is the caller's window being one of those siblings. + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id: + return + if not socket_could_match(engine_socket(engine), caller): + return + caller_window = await window_id(engine, PaneId(caller.pane_id), version) + if caller_window == target_window: + return # the kept target window + target_session = await session_id_of(engine, target, version) + caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) + if caller_session == target_session: + raise_target_hint( + f"refusing to kill the other windows of session {caller_session}: one " + "holds this MCP server's pane. Use a manual tmux command if intended.", + ) + + async def alist_windows( engine: AsyncTmuxEngine, target: str | Target | None = None, diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index ce208774c..cc67aa18a 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -14,10 +14,18 @@ import pytest from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine -from libtmux.experimental.mcp.vocabulary import create_session, kill_session, list_panes +from libtmux.experimental.mcp.vocabulary import ( + create_session, + kill_pane, + kill_session, + kill_window, + list_panes, + respawn_pane, +) from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine from libtmux.experimental.mcp.vocabulary._caller import ( CallerContext, + caller_server_args, engine_socket, is_strict_caller, ) @@ -129,6 +137,8 @@ def test_instructions_outside_tmux(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TMUX", raising=False) monkeypatch.delenv("TMUX_PANE", raising=False) + # Disable the /proc parent walk so a test runner inside tmux is not discovered. + monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") assert "not running inside a tmux pane" in (server.instructions or "") @@ -143,9 +153,11 @@ def test_is_caller_row_flag_live( try: pane = created.first_pane_id assert pane is not None - socket = engine_socket(engine) or "" + real_socket = session.server.cmd( + "display-message", "-p", "#{socket_path}" + ).stdout[0] monkeypatch.setenv("TMUX_PANE", pane) - monkeypatch.setenv("TMUX", f"{socket},0,0") + monkeypatch.setenv("TMUX", f"{real_socket},0,0") flagged = [ row["pane_id"] for row in list_panes(engine).rows @@ -153,6 +165,10 @@ def test_is_caller_row_flag_live( ] assert pane in flagged finally: + # Clear the simulated caller env so the self-kill guard does not refuse + # to tear down the session we pointed it at. + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) kill_session(engine, created.session_id) @@ -191,3 +207,144 @@ async def main() -> str: # Cross-server: the env %3 is refused; the caller must name an origin. with pytest.raises(ToolError, match="explicit origin"): asyncio.run(main()) + + +# --------------------------------------------------------------------------- # +# CallerContext.discover -- precedence + injectable /proc parent walk +# --------------------------------------------------------------------------- # +def test_discover_process_env_wins() -> None: + """The server's own env beats every other source.""" + ctx = CallerContext.discover( + environ={"TMUX_PANE": "%1", "TMUX": "/s,1,2"}, is_linux=True + ) + assert ctx.source == "process-env" + assert ctx.pane_id == "%1" + + +def test_discover_override_beats_walk() -> None: + """LIBTMUX_MCP_CALLER_PANE is the trusted override (no /proc walk).""" + ctx = CallerContext.discover( + environ={"LIBTMUX_MCP_CALLER_PANE": "%5", "LIBTMUX_MCP_CALLER_TMUX": "/s,1,2"}, + is_linux=True, + ) + assert ctx.source == "explicit-override" + assert (ctx.pane_id, ctx.socket_path) == ("%5", "/s") + + +def test_discover_parent_walk_recovers_stripped_env() -> None: + """A stripped child recovers TMUX from a same-uid ancestor.""" + fake_env = {10: {}, 20: {"TMUX_PANE": "%9", "TMUX": "/tmp/sock,7,3"}} + fake_ppid = {10: 20, 20: 1} + ctx = CallerContext.discover( + environ={}, + read_env=fake_env.get, + read_ppid=fake_ppid.get, + read_uid=lambda _pid: 1000, + self_pid=10, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "parent-walk" + assert (ctx.pane_id, ctx.socket_path) == ("%9", "/tmp/sock") + + +def test_discover_refuses_foreign_uid() -> None: + """The walk stops at a differently-owned ancestor (no env read).""" + fake_env = {10: {}, 20: {"TMUX_PANE": "%9", "TMUX": "/s,1,2"}} + fake_ppid = {10: 20, 20: 1} + ctx = CallerContext.discover( + environ={}, + read_env=fake_env.get, + read_ppid=fake_ppid.get, + read_uid=lambda _pid: 99999, + self_pid=10, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "none" + + +def test_discover_off_linux() -> None: + """No /proc means no walk (fail closed to source='none').""" + assert CallerContext.discover(environ={}, is_linux=False).source == "none" + + +def test_discover_disabled_by_env() -> None: + """LIBTMUX_MCP_DISCOVER=0 disables the parent walk.""" + ctx = CallerContext.discover(environ={"LIBTMUX_MCP_DISCOVER": "0"}, is_linux=True) + assert ctx.source == "none" + + +def test_discover_fails_closed_on_reader_failure() -> None: + """A reader returning None mid-walk degrades to source='none'.""" + ctx = CallerContext.discover( + environ={}, + read_env=lambda _pid: None, + read_ppid=lambda _pid: 2, + read_uid=lambda _pid: 1000, + self_pid=1, + self_uid=1000, + is_linux=True, + ) + assert ctx.source == "none" + + +def test_caller_server_args_binds_caller_socket() -> None: + """The binding decision yields -S only for a discovered, non-overridden socket.""" + ctx = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/sock,1,2"}) + assert caller_server_args(ctx, explicit=False) == ("-S", "/sock") + assert caller_server_args(ctx, explicit=True) == () + assert caller_server_args(CallerContext.from_env({}), explicit=False) == () + + +# --------------------------------------------------------------------------- # +# Self-kill guards +# --------------------------------------------------------------------------- # +def test_kill_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """kill_pane refuses the pane running this MCP server.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + with pytest.raises(ToolError, match="this MCP server"): + kill_pane(ConcreteEngine(), "%9") + + +def test_respawn_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """respawn_pane (which destroys the process) refuses the caller's pane.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + with pytest.raises(ToolError, match="this MCP server"): + respawn_pane(ConcreteEngine(), "%9") + + +def test_kill_pane_allows_other_pane(monkeypatch: pytest.MonkeyPatch) -> None: + """A different pane is not the caller, so it is not refused.""" + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + assert kill_pane(ConcreteEngine(), "%1") is None + + +def test_self_kill_refusals_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Killing the caller's own pane/window/session is refused on a real server.""" + engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(engine, name="selfkill") + try: + pane = created.first_pane_id + assert pane is not None + monkeypatch.setenv("TMUX_PANE", pane) + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + with pytest.raises(ToolError, match="this MCP server"): + kill_pane(engine, pane) + with pytest.raises(ToolError, match="this MCP server"): + kill_window(engine, created.first_window_id or "") + with pytest.raises(ToolError, match="this MCP server"): + kill_session(engine, created.session_id) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(engine, created.session_id) diff --git a/tests/experimental/mcp/test_proc.py b/tests/experimental/mcp/test_proc.py new file mode 100644 index 000000000..8a0d73e71 --- /dev/null +++ b/tests/experimental/mcp/test_proc.py @@ -0,0 +1,43 @@ +"""The Linux ``/proc`` readers for caller discovery: byte parsing + fail-closed.""" + +from __future__ import annotations + +import os + +from libtmux.experimental.mcp.vocabulary._proc import ( + _ppid_from_stat, + read_proc_environ, + read_proc_ppid, + read_proc_uid, +) + + +def test_ppid_from_stat_survives_parens_in_comm() -> None: + """The ppid parse anchors on the last ')', so a paren-laden comm is fine.""" + assert _ppid_from_stat(b"1234 (tmux: serv (x)) S 99 1234 1234 0 -1") == 99 + + +def test_ppid_from_stat_garbage_is_none() -> None: + """Unparseable stat bytes yield None, never an exception.""" + assert _ppid_from_stat(b"nonsense") is None + + +def test_real_readers_match_os() -> None: + """The real /proc readers agree with os.getppid()/os.getuid() for self.""" + assert read_proc_ppid(os.getpid()) == os.getppid() + assert read_proc_uid(os.getpid()) == os.getuid() + + +def test_environ_reader_minimises_keys() -> None: + """The environ reader exposes only TMUX/TMUX_PANE (never other secrets).""" + env = read_proc_environ(os.getpid()) + assert env is not None + assert set(env) <= {"TMUX", "TMUX_PANE"} + + +def test_readers_fail_closed_on_bad_pid() -> None: + """An unreadable pid yields None from every reader (never raises).""" + bad = -1 + assert read_proc_environ(bad) is None + assert read_proc_ppid(bad) is None + assert read_proc_uid(bad) is None From a3eb23483b2cc5528bcff894342053b625bd8b1f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:47:25 -0500 Subject: [PATCH 069/154] Mcp(fix): Harden self-kill guards + socket scoping why: An adversarial review of the caller-discovery work surfaced fail-unsafe and over-broad edges in the new guards. what: - guard_self_kill and the others=True sibling guards now resolve the caller's own pane to its window/session through a fail-safe helper: a caller pane absent from the engine's server is not a self-kill, so a kill on an unrelated target no longer raises a raw tmux error. - Scope the ambient (socket=None) branch of both comparators to a process-env caller, so a parent-walked caller is not matched to an unbound default engine (which would mis-target resolve_origin reads and over-refuse kills under --no-caller-socket). - Wrap socket_matches' realpath comparisons (a $TMUX-controlled path can raise) so the read-only tools degrade instead of crashing. - Guard the op_* per-operation kill/respawn surface too, closing the bypass when --operations is enabled. - Name the caller pane in the others=True refusal hints; correct the get_caller_context docstring; document the socket/caller precedence in --help; make the adapter tests hermetic (no host /proc walk). --- src/libtmux/experimental/mcp/__init__.py | 8 +++ .../experimental/mcp/fastmcp_adapter.py | 14 +++- .../experimental/mcp/vocabulary/_caller.py | 23 ++++-- .../experimental/mcp/vocabulary/_resolve.py | 71 ++++++++++++++++--- .../experimental/mcp/vocabulary/pane.py | 10 ++- .../experimental/mcp/vocabulary/window.py | 24 ++++--- tests/experimental/mcp/conftest.py | 18 +++++ tests/experimental/mcp/test_caller.py | 34 +++++++++ 8 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 tests/experimental/mcp/conftest.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 0b3f89033..1c1d96ade 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -166,6 +166,14 @@ def main(argv: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser( prog="libtmux-engine-mcp", description="Run the experimental libtmux typed-ops MCP server (stdio).", + epilog=( + "socket precedence: --socket-path > --socket-name > " + "$LIBTMUX_SOCKET_PATH > $LIBTMUX_SOCKET > discovered caller socket > " + "default; --no-caller-socket drops the caller socket. " + "caller identity: $TMUX/$TMUX_PANE > $LIBTMUX_MCP_CALLER_PANE " + "(+$LIBTMUX_MCP_CALLER_TMUX) > /proc parent walk " + "($LIBTMUX_MCP_DISCOVER=0 disables)." + ), ) parser.add_argument("--name", default="tmux", help="server name") parser.add_argument( diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 7d7cd7079..40ce25acb 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -246,7 +246,11 @@ def register_caller_context(mcp: FastMCP, ctx: CallerContext) -> None: from mcp.types import ToolAnnotations def get_caller_context() -> CallerContext: - """Return the tmux pane/server that launched this MCP (from its env).""" + """Return the tmux pane/server discovered for this MCP. + + From the server's own env, an explicit override, or a bounded ``/proc`` + parent walk -- inspect the ``source`` field for which. + """ return ctx tool = FunctionTool.from_function( @@ -314,6 +318,8 @@ def register_operations( from mcp.types import ToolAnnotations from pydantic import PrivateAttr + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + from libtmux.experimental.mcp.vocabulary._resolve import guard_destructive_op from libtmux.experimental.ops import arun as arun_op, run as run_op from libtmux.experimental.ops.serialize import result_to_dict @@ -326,6 +332,12 @@ class _OperationTool(Tool): async def run(self, arguments: dict[str, t.Any]) -> ToolResult: operation = self._descriptor.build(**arguments) + # The per-op surface dispatches around the curated guard, so apply the + # self-kill guard here too (a sync engine is wrapped to async for it). + guard_engine = ( + self._engine if self._is_async else SyncToAsyncEngine(self._engine) + ) + await guard_destructive_op(guard_engine, operation) if self._is_async: result = await arun_op(operation, self._engine) else: diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py index 30942c2af..ae080749f 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_caller.py +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -270,14 +270,27 @@ def socket_matches(socket: str | None, caller: CallerContext) -> bool: False """ if socket is None: - return caller.in_tmux and caller.socket_path is not None + # An unbound engine talks to the ambient $TMUX server, which is the + # caller's only when the MCP itself inherited it (process-env). A + # parent-walked caller's socket is NOT the ambient default. + return ( + caller.in_tmux + and caller.socket_path is not None + and caller.source == "process-env" + ) if caller.socket_path is None: return False if "/" in socket: - return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + try: + return os.path.realpath(socket) == os.path.realpath(caller.socket_path) + except OSError: + return socket == caller.socket_path tmpdir = os.environ.get("TMUX_TMPDIR") or "/tmp" expected = f"{tmpdir}/tmux-{os.getuid()}/{socket}" - return os.path.realpath(expected) == os.path.realpath(caller.socket_path) + try: + return os.path.realpath(expected) == os.path.realpath(caller.socket_path) + except OSError: + return expected == caller.socket_path def socket_could_match(socket: str | None, caller: CallerContext) -> bool: @@ -306,7 +319,9 @@ def socket_could_match(socket: str | None, caller: CallerContext) -> bool: if caller.socket_path is None: return True if socket is None: - return True + # Ambient default engine: the caller's server only when the MCP inherited + # $TMUX (process-env), not when its pane was parent-walked or overridden. + return caller.source == "process-env" if "/" in socket: try: return os.path.realpath(socket) == os.path.realpath(caller.socket_path) diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py index b61336eeb..2de5c57e9 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_resolve.py +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -18,7 +18,13 @@ socket_could_match, socket_matches, ) -from libtmux.experimental.ops import DisplayMessage, ListPanes, SelectPane, arun +from libtmux.experimental.ops import ( + DisplayMessage, + ListPanes, + SelectPane, + TmuxCommandError, + arun, +) from libtmux.experimental.ops._types import PaneId, Special, Target #: Relative directional special tokens that resolve against the control client, @@ -227,6 +233,35 @@ async def session_id_of( return result.text.strip() +async def caller_window_or_none( + engine: AsyncTmuxEngine, + caller_pane: str, + version: str | None, +) -> str | None: + """Map the caller's pane to its window, or ``None`` if not on this server. + + Fails safe: a caller pane that does not exist on the engine's server (the + cross-server case the conservative gate tolerates) is *not* a self-kill, so + the lookup returns ``None`` rather than surfacing a raw tmux error. + """ + try: + return await window_id(engine, PaneId(caller_pane), version) + except TmuxCommandError: + return None + + +async def caller_session_or_none( + engine: AsyncTmuxEngine, + caller_pane: str, + version: str | None, +) -> str | None: + """Map the caller's pane to its session, or ``None`` if not on this server.""" + try: + return await session_id_of(engine, PaneId(caller_pane), version) + except TmuxCommandError: + return None + + async def guard_self_kill( engine: AsyncTmuxEngine, *, @@ -240,9 +275,10 @@ async def guard_self_kill( Socket-scoped first (``%N``/``@N``/``$N`` are per-server counters that collide across servers), then the caller's pane is mapped to its window / session *via the engine* (``$TMUX`` carries no window id). Uses the - conservative comparator so a self-kill fails safe under socket uncertainty. - Raises a refusal hint; a different pane/window/session, or a cross-socket - target with the same id, is not refused. + conservative comparator so a self-kill fails safe under socket uncertainty; + a caller pane absent from this engine's server fails safe to *allow* (it is + not a self-kill). Raises a refusal hint; a different pane/window/session, or a + cross-socket target with the same id, is not refused. """ caller = caller_of(engine) if not caller.in_tmux or caller.pane_id is None: @@ -255,16 +291,35 @@ async def guard_self_kill( "different pane, or run the tmux command manually if intended.", ) if window is not None: - caller_window = await window_id(engine, PaneId(caller.pane_id), version) - if caller_window == window: + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is not None and caller_window == window: raise_target_hint( f"refusing to kill window {window}: it holds this MCP server's " "pane. Use a manual tmux command if intended.", ) if session is not None: - caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) - if caller_session == session: + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is not None and caller_session == session: raise_target_hint( f"refusing to kill session {session}: it holds this MCP server's " "pane. Use a manual tmux command if intended.", ) + + +async def guard_destructive_op(engine: AsyncTmuxEngine, operation: t.Any) -> None: + """Apply the self-kill guard to a per-op kill/respawn operation, by kind. + + Closes the gap where the ``op_*`` per-operation surface dispatches around the + curated guard. The ``others=True`` sibling case is not fully covered here -- + prefer the curated kill tools when killing "all others". + """ + target = operation.target + if target is None: + return + kind = operation.kind + if kind in ("kill_pane", "respawn_pane"): + await guard_self_kill(engine, pane=await pane_id(engine, target, None)) + elif kind == "kill_window": + await guard_self_kill(engine, window=await window_id(engine, target, None)) + elif kind == "kill_session": + await guard_self_kill(engine, session=await session_id_of(engine, target, None)) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 51da8afcb..083922f2f 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -37,6 +37,7 @@ DIR_FLAG, active_pane_id, caller_of, + caller_window_or_none, guard_self_kill, opt_target, pane_id, @@ -432,12 +433,15 @@ async def _guard_kill_others( return if not socket_could_match(engine_socket(engine), caller): return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None: + return target_window = await window_id(engine, PaneId(target_pane), version) - caller_window = await window_id(engine, PaneId(caller.pane_id), version) if caller_window == target_window: raise_target_hint( - f"refusing to kill the other panes of window {target_window}: one of " - "them runs this MCP server. Use a manual tmux command if intended.", + f"refusing to kill the other panes of window {target_window}: pane " + f"{caller.pane_id} runs this MCP server. Kill panes individually " + f"(excluding {caller.pane_id}), or run the tmux command manually.", ) diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index 5a69982ee..978b7c421 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -11,6 +11,8 @@ ) from libtmux.experimental.mcp.vocabulary._resolve import ( caller_of, + caller_session_or_none, + caller_window_or_none, guard_self_kill, raise_target_hint, session_id_of, @@ -28,7 +30,7 @@ SwapWindow, arun, ) -from libtmux.experimental.ops._types import PaneId, Target +from libtmux.experimental.ops._types import Target async def acreate_window( @@ -168,16 +170,18 @@ async def _guard_kill_other_windows( return if not socket_could_match(engine_socket(engine), caller): return - caller_window = await window_id(engine, PaneId(caller.pane_id), version) - if caller_window == target_window: - return # the kept target window + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None or caller_window == target_window: + return # caller not on this server, or it is the kept target window target_session = await session_id_of(engine, target, version) - caller_session = await session_id_of(engine, PaneId(caller.pane_id), version) - if caller_session == target_session: - raise_target_hint( - f"refusing to kill the other windows of session {caller_session}: one " - "holds this MCP server's pane. Use a manual tmux command if intended.", - ) + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is None or caller_session != target_session: + return + raise_target_hint( + f"refusing to kill the other windows of session {caller_session}: window " + f"{caller_window} holds this MCP server's pane {caller.pane_id}. Exclude " + "it, or run the tmux command manually.", + ) async def alist_windows( diff --git a/tests/experimental/mcp/conftest.py b/tests/experimental/mcp/conftest.py new file mode 100644 index 000000000..e076c1fef --- /dev/null +++ b/tests/experimental/mcp/conftest.py @@ -0,0 +1,18 @@ +"""Shared fixtures for the experimental MCP tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _hermetic_caller_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + """Disable the ``/proc`` parent walk by default so server builds are hermetic. + + ``build_*_server`` defaults its caller to ``CallerContext.discover()``, which + would otherwise walk the test host's process tree (host-dependent). Tests that + exercise discovery pass explicit readers/environ to ``discover`` and are + unaffected; tests that want a caller monkeypatch ``TMUX_PANE`` (the + process-env source, which wins before the walk). + """ + monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index cc67aa18a..c676e013b 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import dataclasses import typing as t import pytest @@ -28,6 +29,8 @@ caller_server_args, engine_socket, is_strict_caller, + socket_could_match, + socket_matches, ) from libtmux.experimental.mcp.vocabulary._resolve import resolve_origin @@ -348,3 +351,34 @@ def test_self_kill_refusals_live( monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) kill_session(engine, created.session_id) + + +# --------------------------------------------------------------------------- # +# Review fixes: ambient-engine scoping (S1) + per-op guard (S2) +# --------------------------------------------------------------------------- # +def test_ambient_engine_matches_only_process_env_caller() -> None: + """An unbound engine is the caller's server only for a process-env caller.""" + proc = CallerContext.from_env({"TMUX_PANE": "%1", "TMUX": "/s,1,2"}) + walked = dataclasses.replace(proc, source="parent-walk") + assert socket_could_match(None, proc) is True + assert socket_could_match(None, walked) is False + assert socket_matches(None, proc) is True + assert socket_matches(None, walked) is False + + +def test_op_kill_pane_is_guarded(monkeypatch: pytest.MonkeyPatch) -> None: + """The per-op kill surface is self-kill-guarded too (no bypass).""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + monkeypatch.setenv("TMUX_PANE", "%9") + monkeypatch.setenv("TMUX", "/s,1,2") + server = build_async_server( + SyncToAsyncEngine(ConcreteEngine()), events="off", expose_operations=True + ) + + async def main() -> None: + async with fastmcp.Client(server) as client: + await client.call_tool("op_kill_pane", {"target": "%9"}) + + with pytest.raises(ToolError, match="this MCP server"): + asyncio.run(main()) From 8792574a654b37f11d62cd02ff444513de4b3fa8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 18:52:57 -0500 Subject: [PATCH 070/154] Mcp(feat): Needle-free pane-output monitor why: Agents need to know when a command in a pane finishes without hard-coding a needle (regex/sentinel) the tool must guess, and without blocking the server. tmux stops emitting %output the instant a pane goes quiet, so idle-since-last-%output is a structural signal the agent interprets via the captured chunk plus pane metadata. what: - Add _settle.py pure core: decode_output (tmux octal), output_payload (per-pane filter, split not join so inner whitespace survives), and accumulate_until_settle (settle/byte/time/end fold over an injected async stream + clock). All doctested; no I/O, no fastmcp. - Add wait_for_output edge tool in events.py: folds decoded %output to a frozen MonitorResult, reads DoneMetadata (pane_dead/status, pane_current_command) so the agent disambiguates finished vs blocked. ctx.info for live partials; aclosing for cancellation safety; each call runs in its own task. - _ensure_attached: a bare tmux -C client emits no %output until attach-session, so attach (sticky per engine) before folding; raise on a failed attach so a stale session never yields a silent capture. - Tests: pure settle unit + cancellation (test_settle.py); offline integration + attach/dropped/done coverage (test_events.py); live end-to-end against real tmux (test_monitor_live.py). --- src/libtmux/experimental/mcp/_settle.py | 280 ++++++++++++++++++++ src/libtmux/experimental/mcp/events.py | 228 +++++++++++++++- tests/experimental/mcp/test_events.py | 224 ++++++++++++++++ tests/experimental/mcp/test_monitor_live.py | 73 +++++ tests/experimental/mcp/test_settle.py | 162 +++++++++++ 5 files changed, 966 insertions(+), 1 deletion(-) create mode 100644 src/libtmux/experimental/mcp/_settle.py create mode 100644 tests/experimental/mcp/test_monitor_live.py create mode 100644 tests/experimental/mcp/test_settle.py diff --git a/src/libtmux/experimental/mcp/_settle.py b/src/libtmux/experimental/mcp/_settle.py new file mode 100644 index 000000000..71d1d926d --- /dev/null +++ b/src/libtmux/experimental/mcp/_settle.py @@ -0,0 +1,280 @@ +r"""Needle-free settle accumulator for the pane-output monitor. + +A tmux pane stops emitting ``%output`` the instant it stops producing bytes, so +"no ``%output`` for ``settle_ms``" is a direct I/O-layer *quiet* signal -- no +regex, no sentinel injection, no assumed output format. This module is the pure, +framework-free core of that idea: a decoder for tmux's octal ``%output`` +escaping, a per-pane payload filter, and a fold over an injected async stream +that returns the moment the stream goes quiet (or a byte/time cap fires). + +It imports no MCP framework and touches no tmux connection, so every function +here carries an executable doctest driven by literal strings or a fake async +generator with an injected clock. The :mod:`~.events` edge maps a control-mode +engine's ``subscribe()`` stream onto these helpers. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + +SettleReason = t.Literal["settled", "time_cap", "byte_cap", "stream_end"] + + +@dataclass(frozen=True) +class SettleOutcome: + """Result of folding a decoded ``%output`` stream until the pane settles. + + Parameters + ---------- + text : str + The decoded bytes the pane produced during the watch (tail-preserving + prefix when ``truncated``). + reason : {"settled", "time_cap", "byte_cap", "stream_end"} + Why the fold stopped. ``settled`` means *stopped producing output*, not + *succeeded* -- the caller interprets the text. + byte_count : int + Size of ``text`` in bytes (capped at ``max_bytes``). + frame_count : int + Number of stream chunks folded in. + idle_ms_observed : int + Only meaningful when ``reason == "settled"``: the idle gap (``settle_ms``) + that triggered the stop. For other reasons it is the most recent + inter-chunk gap, or ``0`` if no chunk arrived -- do not read it as the + cause of the stop. + truncated : bool + Whether ``max_bytes`` clipped the text (tail kept). + """ + + text: str + reason: SettleReason + byte_count: int + frame_count: int + idle_ms_observed: int + truncated: bool + + +def decode_output(payload: str) -> str: + r"""Decode tmux's backslash-octal ``%output`` escaping. + + tmux escapes any byte below ``0x20`` and a literal backslash as ``\ooo`` (one + to three octal digits). A backslash not followed by an octal digit -- or a + lone trailing backslash -- passes through verbatim rather than raising. + + Parameters + ---------- + payload : str + The raw ``%output`` data body, after the ``%output %N `` prefix. + + Returns + ------- + str + The decoded text. + + Examples + -------- + A newline and a tab decode from their octal escapes: + + >>> decode_output('a\\012b') + 'a\nb' + >>> decode_output('tab\\011x') + 'tab\tx' + + An escaped backslash collapses to one, and plain text is untouched: + + >>> decode_output('a\\134b') + 'a\\b' + >>> decode_output('plain text, spaces kept') + 'plain text, spaces kept' + + A lone trailing backslash passes through: + + >>> decode_output('trailing\\') + 'trailing\\' + """ + out: list[str] = [] + i, n = 0, len(payload) + while i < n: + ch = payload[i] + if ch == "\\" and i + 1 < n and payload[i + 1] in "01234567": + j = i + 1 + while j < n and j - i <= 3 and payload[j] in "01234567": + j += 1 + out.append(chr(int(payload[i + 1 : j], 8))) + i = j + else: + out.append(ch) + i += 1 + return "".join(out) + + +def output_payload(raw: str, pane_id: str) -> str | None: + r"""Return the decoded ``%output`` payload for *pane_id*, else ``None``. + + Slices the data body with ``raw.split(" ", 2)[2]`` -- **not** + ``" ".join(args[1:])``, which would collapse runs of internal whitespace + because the notification parser split the whole line on single spaces. + + Parameters + ---------- + raw : str + A ``ControlNotification.raw`` line. + pane_id : str + The concrete pane id (``%N``) to match. + + Returns + ------- + str or None + The decoded payload, or ``None`` when *raw* is not an ``%output`` frame + for *pane_id*. + + Examples + -------- + Internal whitespace is preserved exactly: + + >>> output_payload('%output %1 a b', '%1') + 'a b' + + A frame for another pane, or a non-output frame, is ignored: + + >>> output_payload('%output %2 x', '%1') is None + True + >>> output_payload('%window-add @3', '%1') is None + True + """ + parts = raw.split(" ", 2) + if len(parts) < 3 or parts[0] != "%output" or parts[1] != pane_id: + return None + return decode_output(parts[2]) + + +async def accumulate_until_settle( + frames: AsyncGenerator[str, None], + *, + settle_ms: int, + timeout_ms: int, + max_bytes: int, + now: Callable[[], float] = time.monotonic, +) -> SettleOutcome: + r"""Fold a stream of decoded chunks until the pane settles. + + Resets an idle window on each chunk and returns ``reason='settled'`` when no + chunk arrives for ``settle_ms``; ``'byte_cap'`` at ``max_bytes`` (tail + preserved); ``'time_cap'`` when the overall ``timeout_ms`` budget is spent; + ``'stream_end'`` when *frames* is exhausted. The wall-clock budget reads + *now* (inject a scripted clock for deterministic ``time_cap`` tests); the idle + window uses a real :func:`asyncio.wait_for`, so a fake stream that simply + suspends settles deterministically with no scripted sleeps. The stream is + closed via :func:`contextlib.aclosing` on every exit, including cancellation. + + Parameters + ---------- + frames : AsyncGenerator[str, None] + The decoded per-pane output chunks. + settle_ms : int + Idle gap that counts as "settled". + timeout_ms : int + Overall wall-clock budget. + max_bytes : int + Byte cap; the returned text keeps the tail. + now : Callable[[], float] + Monotonic clock source, injectable for tests. + + Returns + ------- + SettleOutcome + The folded text plus the stop reason and counters. + + Examples + -------- + A pane that emits two chunks then goes quiet settles on the idle window: + + >>> import asyncio + >>> async def quiet_after_two(): + ... yield "hello " + ... yield "world" + ... await asyncio.Event().wait() # never another chunk -> idle fires + >>> out = asyncio.run( + ... accumulate_until_settle( + ... quiet_after_two(), settle_ms=10, timeout_ms=1000, max_bytes=4096 + ... ) + ... ) + >>> out.text, out.reason, out.byte_count + ('hello world', 'settled', 11) + + A flood past the byte cap truncates (tail-preserving) and stops: + + >>> async def flood(): + ... for _ in range(100): + ... yield "abcde" + >>> out = asyncio.run( + ... accumulate_until_settle( + ... flood(), settle_ms=50, timeout_ms=1000, max_bytes=8 + ... ) + ... ) + >>> out.reason, out.byte_count, out.truncated + ('byte_cap', 8, True) + + An exhausted stream ends cleanly: + + >>> async def two_then_done(): + ... yield "a" + ... yield "b" + >>> asyncio.run( + ... accumulate_until_settle( + ... two_then_done(), settle_ms=50, timeout_ms=1000, max_bytes=64 + ... ) + ... ).reason + 'stream_end' + """ + buf: list[str] = [] + byte_count = frame_count = 0 + idle_ms_observed = 0 + reason: SettleReason = "stream_end" + settle_s = settle_ms / 1000.0 + deadline = now() + timeout_ms / 1000.0 + async with contextlib.aclosing(frames): + while True: + remaining = deadline - now() + if remaining <= 0: + reason = "time_cap" + break + wait_s = min(settle_s, remaining) + start = now() + try: + chunk = await asyncio.wait_for(frames.__anext__(), timeout=wait_s) + except asyncio.TimeoutError: + if now() - deadline >= 0: # the wall-clock cap, not the idle gap + reason = "time_cap" + else: # idle window elapsed -> the pane went quiet + idle_ms_observed = int(settle_s * 1000) + reason = "settled" + break + except StopAsyncIteration: + reason = "stream_end" + break + idle_ms_observed = int((now() - start) * 1000) + buf.append(chunk) + frame_count += 1 + byte_count += len(chunk.encode()) + if byte_count >= max_bytes: + reason = "byte_cap" + break + text = "".join(buf) + truncated = reason == "byte_cap" + if truncated: # keep the tail -- "did it finish" lives at the end + text = text.encode()[-max_bytes:].decode(errors="replace") + return SettleOutcome( + text=text, + reason=reason, + byte_count=min(byte_count, max_bytes), + frame_count=frame_count, + idle_ms_observed=idle_ms_observed, + truncated=truncated, + ) diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index 2869884ce..c62f5a118 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -25,14 +25,21 @@ import asyncio import collections import contextlib +import time import typing as t +from dataclasses import dataclass from fastmcp import Context from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.mcp._settle import ( + SettleReason, + accumulate_until_settle, + output_payload, +) if t.TYPE_CHECKING: - from collections.abc import AsyncIterator, Sequence + from collections.abc import AsyncGenerator, AsyncIterator, Sequence from fastmcp import FastMCP @@ -43,6 +50,62 @@ _RING_SIZE = 1024 +# tmux format read once at settle to fill DoneMetadata (tab-joined, one round-trip). +_DONE_FORMAT = "\t".join( + ( + "#{pane_dead}", + "#{pane_dead_status}", + "#{pane_dead_signal}", + "#{pane_current_command}", + "#{cursor_y}", + "#{history_size}", + "#{pane_in_mode}", + ), +) + + +@dataclass(frozen=True) +class DoneMetadata: + """Needle-free done-heuristics, read once at settle for the agent to interpret. + + A ``pane_dead`` pane with a ``pane_dead_status`` is a *hard* "process exited" + signal; ``pane_current_command`` reverting to a shell is a *soft* "command + finished" signal. The screen-state fields add context without claiming intent. + """ + + pane_dead: bool + pane_dead_status: int | None + pane_dead_signal: str | None + pane_current_command: str | None + cursor_y: int | None + history_size: int | None + pane_in_mode: bool + + +@dataclass(frozen=True) +class MonitorResult: + """What ``wait_for_output`` returns; auto-serialized to structured content. + + ``reason`` is itself a signal: ``settled`` (the pane went quiet -- finished + *or* blocked on input; ``done`` disambiguates), ``time_cap`` (still producing + when the budget ran out; a partial chunk is returned), ``byte_cap`` (flooded, + ``truncated``), ``stream_end`` (the notification stream ended). + ``idle_ms_observed`` is only meaningful when ``reason == "settled"``; + ``snapshot_lines`` is ``None`` when the call passed ``snapshot=False``. + """ + + pane_id: str + reason: SettleReason + captured_text: str + byte_count: int + frame_count: int + idle_ms_observed: int + elapsed_ms: int + truncated: bool + dropped: int + done: DoneMetadata + snapshot_lines: tuple[str, ...] | None + class _StreamEngine(t.Protocol): """An async engine that also exposes a ``subscribe()`` notification stream. @@ -145,6 +208,7 @@ def register_events( _register_push(mcp, stream, source=source) if mode in ("pull", "both"): _register_pull(mcp, stream) + _register_monitor(mcp, stream) def _register_push( @@ -231,3 +295,165 @@ async def poll_events(since: int = 0) -> dict[str, t.Any]: annotations=ToolAnnotations(title="poll_events", readOnlyHint=True), ) mcp.add_tool(tool) + + +async def _read_done(engine: _StreamEngine, pane_id: str) -> DoneMetadata: + """Fill :class:`DoneMetadata` for *pane_id* in one ``display-message`` read.""" + from libtmux.experimental.mcp.vocabulary.server import adisplay_message + + text = (await adisplay_message(engine, pane_id, _DONE_FORMAT)).text + fields = (text.split("\t") + [""] * 7)[:7] + + def _as_int(value: str) -> int | None: + value = value.strip() + if not value: + return None + try: + return int(value) + except ValueError: + return None + + def _as_str(value: str) -> str | None: + return value.strip() or None + + return DoneMetadata( + pane_dead=fields[0].strip() == "1", + pane_dead_status=_as_int(fields[1]), + pane_dead_signal=_as_str(fields[2]), + pane_current_command=_as_str(fields[3]), + cursor_y=_as_int(fields[4]), + history_size=_as_int(fields[5]), + pane_in_mode=fields[6].strip() == "1", + ) + + +async def _ensure_attached(engine: _StreamEngine, session_id: str) -> None: + """Attach the control client to *session_id* so its panes emit ``%output``. + + A bare ``tmux -C`` control client receives **no** ``%output`` until it + attaches to a session (a server-global notification like ``%window-add`` + arrives without attaching, but per-pane output does not). Attaching also + triggers a one-time screen redraw, so a *successful* attachment is tracked + per engine: re-watching the same session does not re-attach or redraw again. + + Raises on a failed attach (stale or killed session) instead of caching, so + the caller gets a clear error rather than a silently empty capture and a + later call can retry. + """ + if getattr(engine, "_attached_session", None) == session_id: + return + result = await engine.run( + CommandRequest.from_args("attach-session", "-t", session_id), + ) + if result.returncode != 0: + detail = " ".join(result.stderr) or "attach-session failed" + msg = f"cannot watch {session_id}: {detail}" + raise RuntimeError(msg) + engine._attached_session = session_id # type: ignore[attr-defined] + + +def _register_monitor(mcp: FastMCP, engine: _StreamEngine) -> None: + """Register the ``wait_for_output`` needle-free settle monitor tool.""" + from fastmcp.tools import FunctionTool + from mcp.types import ToolAnnotations + + async def wait_for_output( + ctx: Context, + target: str, + settle_ms: int = 750, + timeout: float = 30.0, + max_bytes: int = 131072, + stream_partials: bool = False, + snapshot: bool = True, + ) -> MonitorResult: + """Watch one pane's live output and return when it goes quiet (settles). + + Needle-free: accumulates the bytes the pane *produces* and returns the + instant it stays idle for ``settle_ms`` -- or the wall-clock ``timeout`` + (seconds) or ``max_bytes`` cap fires, or the stream ends. No regex, no + sentinel injection: read ``captured_text`` + ``done`` and decide what + happened. + + ``reason='settled'`` means *stopped producing output*, NOT *succeeded* -- + it cannot tell "finished, back to the shell" from "blocked on stdin". Use + ``done.pane_dead`` (+ ``done.pane_dead_status``) and + ``done.pane_current_command`` (a shell name) to disambiguate; + ``dropped``/``truncated`` warn the captured chunk may be incomplete. + + Set ``stream_partials=True`` to also push each chunk live as an MCP log + message. ``snapshot`` defaults to ``True``: at settle the rendered grid is + captured into ``snapshot_lines``; pass ``snapshot=False`` to skip that + capture, leaving ``snapshot_lines`` ``None``. While it runs it shares + tmux's single ``%output`` stream with ``watch_events`` / ``poll_events``; + it is bounded by the caps and short-lived, and each call runs in its own + task so it does not block other tools. The first watch on a session + attaches the control client (so its panes emit ``%output``), which draws + the current screen once into ``captured_text`` -- the clean rendered grid, + when requested, is in ``snapshot_lines``. + """ + from libtmux.experimental.mcp.target_resolver import resolve_target + from libtmux.experimental.mcp.vocabulary._resolve import ( + pane_id as resolve_pane_id, + reject_relative_special, + session_id_of, + ) + from libtmux.experimental.mcp.vocabulary.pane import acapture_pane + + reject_relative_special(resolve_target(target)) + pane = await resolve_pane_id(engine, target, None) + await _ensure_attached(engine, await session_id_of(engine, target, None)) + + dropped_before = getattr(engine, "dropped_notifications", 0) + started = time.monotonic() + + async def _frames() -> AsyncGenerator[str, None]: + async for notification in engine.subscribe(): + payload = output_payload(notification.raw, pane) + if payload is None: + continue + if stream_partials: + await ctx.info(payload) + yield payload + + outcome = await accumulate_until_settle( + _frames(), + settle_ms=settle_ms, + timeout_ms=int(timeout * 1000), + max_bytes=max_bytes, + ) + elapsed_ms = int((time.monotonic() - started) * 1000) + dropped = getattr(engine, "dropped_notifications", 0) - dropped_before + + done = await _read_done(engine, pane) + snapshot_lines: tuple[str, ...] | None = None + if snapshot: + captured = await acapture_pane( + engine, + pane, + join_wrapped=True, + trim_trailing=True, + ) + snapshot_lines = tuple(captured.lines) + + return MonitorResult( + pane_id=pane, + reason=outcome.reason, + captured_text=outcome.text, + byte_count=outcome.byte_count, + frame_count=outcome.frame_count, + idle_ms_observed=outcome.idle_ms_observed, + elapsed_ms=elapsed_ms, + truncated=outcome.truncated, + dropped=dropped, + done=done, + snapshot_lines=snapshot_lines, + ) + + tool = FunctionTool.from_function( + wait_for_output, + name="wait_for_output", + description="Watch a pane's output and return when it settles (needle-free)", + tags={"readonly", "events", "monitor"}, + annotations=ToolAnnotations(title="wait_for_output", readOnlyHint=True), + ) + mcp.add_tool(tool) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 4f8a7ef0e..92f4711fe 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -46,6 +46,69 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: _STREAM = (b"%window-add @3", b"%output %1 hi", b"%window-close @3") +_MON_STREAM = (b"%output %1 a b", b"%output %1 c", b"%window-add @9") + + +class InstrumentedEngine: + """A fake stream engine that records commands and scripts a few responses. + + Records every ``run`` argv in ``calls`` (so attach idempotence is assertable), + exposes a settable ``dropped_notifications`` counter, can inject an + ``attach-session`` failure, and can return a canned ``display-message`` line + for the done-heuristics format. + """ + + def __init__( + self, + raw: tuple[bytes, ...] = (), + *, + attach_returncode: int = 0, + done_line: str | None = None, + dropped_after: int = 0, + ) -> None: + self._raw = raw + self.calls: list[tuple[str, ...]] = [] + self.dropped_notifications = 0 + self._attached_session: str | None = None + self._attach_returncode = attach_returncode + self._done_line = done_line + self._dropped_after = dropped_after + + async def run(self, request: CommandRequest) -> CommandResult: + """Record the command and return a scripted result.""" + args = tuple(request.args) + self.calls.append(args) + if args and args[0] == "attach-session": + stderr = () if self._attach_returncode == 0 else ("can't find session",) + return CommandResult( + cmd=("tmux", *args), + returncode=self._attach_returncode, + stderr=stderr, + ) + if args and args[0] == "display-message": + fmt = args[-1] + if "pane_dead" in fmt and self._done_line is not None: + return CommandResult( + cmd=("tmux", *args), + returncode=0, + stdout=(self._done_line,), + ) + if "session_id" in fmt: + return CommandResult(cmd=("tmux", *args), returncode=0, stdout=("$1",)) + return CommandResult(cmd=("tmux", *args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield the fixed notification sequence, then bump the drop counter.""" + for raw in self._raw: + yield ControlNotification.parse(raw) + self.dropped_notifications += self._dropped_after def _tool_names(server: t.Any) -> set[str]: @@ -123,6 +186,166 @@ def test_both_registers_push_and_pull() -> None: assert {"watch_events", "poll_events"} <= names +def test_monitor_registered_when_streaming() -> None: + """wait_for_output is exposed whenever the engine streams, in any event mode.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="pull", + include_operations=False, + include_plan_tools=False, + ) + assert "wait_for_output" in _tool_names(server) + + +def test_monitor_settles_on_stream_end() -> None: + """wait_for_output folds per-pane output and returns when the stream ends. + + The decoded chunks preserve internal whitespace (``a b`` + ``c`` -> ``a bc``), + locking out the ``" ".join`` reconstruction bug; the non-output frame is + filtered. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool("wait_for_output", {"target": "%1"}) + return result.data + + data = asyncio.run(main()) + assert data.pane_id == "%1" + assert data.reason == "stream_end" + assert data.captured_text == "a bc" + assert data.frame_count == 2 + assert data.truncated is False + assert data.snapshot_lines == [] + assert data.done.pane_dead is False + + +def test_monitor_snapshot_false_omits_grid() -> None: + """snapshot=False leaves snapshot_lines None and skips the capture.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + InstrumentedEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool( + "wait_for_output", + {"target": "%1", "snapshot": False}, + ) + return result.data + + data = asyncio.run(main()) + assert data.snapshot_lines is None + assert data.reason == "stream_end" + + +def test_monitor_reports_dropped_delta() -> None: + """The dropped field is the engine's overflow-counter delta during the watch.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + InstrumentedEngine(_MON_STREAM, dropped_after=5), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + result = await client.call_tool("wait_for_output", {"target": "%1"}) + return result.data + + data = asyncio.run(main()) + assert data.dropped == 5 + + +def test_monitor_stream_partials_pushes_each_chunk() -> None: + """stream_partials=True pushes each decoded chunk as an MCP log message.""" + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + logged: list[t.Any] = [] + + async def log_handler(message: t.Any) -> None: + # ctx.info wraps the payload as {"msg": ..., "extra": ...}. + data = message.data + logged.append(data["msg"] if isinstance(data, dict) else data) + + server = build_async_server( + InstrumentedEngine(_MON_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> None: + async with fastmcp.Client(server, log_handler=log_handler) as client: + await client.call_tool( + "wait_for_output", + {"target": "%1", "stream_partials": True}, + ) + + asyncio.run(main()) + assert "a b" in logged + assert "c" in logged + + +def test_ensure_attached_raises_on_failed_attach() -> None: + """A failed attach raises and does not poison the per-engine cache.""" + from libtmux.experimental.mcp.events import _ensure_attached + + engine = InstrumentedEngine(attach_returncode=1) + with pytest.raises(RuntimeError, match="cannot watch"): + asyncio.run(_ensure_attached(engine, "$dead")) + assert getattr(engine, "_attached_session", None) is None + + +def test_ensure_attached_is_idempotent_per_session() -> None: + """Re-watching the same session attaches exactly once (no repeated redraw).""" + from libtmux.experimental.mcp.events import _ensure_attached + + engine = InstrumentedEngine() + + async def main() -> None: + await _ensure_attached(engine, "$1") + await _ensure_attached(engine, "$1") + + asyncio.run(main()) + attaches = [c for c in engine.calls if c and c[0] == "attach-session"] + assert len(attaches) == 1 + assert engine._attached_session == "$1" + + +def test_read_done_parses_display_message_fields() -> None: + """_read_done maps the tab-joined display-message into DoneMetadata.""" + from libtmux.experimental.mcp.events import _read_done + + done_line = "1\t137\tHUP\tbash\t3\t50\t1" + engine = InstrumentedEngine(done_line=done_line) + done = asyncio.run(_read_done(engine, "%1")) + assert done.pane_dead is True + assert done.pane_dead_status == 137 + assert done.pane_dead_signal == "HUP" + assert done.pane_current_command == "bash" + assert done.cursor_y == 3 + assert done.history_size == 50 + assert done.pane_in_mode is True + + def test_no_event_tools_without_a_stream() -> None: """A non-streaming engine registers no event tools, even when asked.""" from libtmux.experimental.engines import ConcreteEngine @@ -138,3 +361,4 @@ def test_no_event_tools_without_a_stream() -> None: names = _tool_names(server) assert "watch_events" not in names assert "poll_events" not in names + assert "wait_for_output" not in names diff --git a/tests/experimental/mcp/test_monitor_live.py b/tests/experimental/mcp/test_monitor_live.py new file mode 100644 index 000000000..4f55d5805 --- /dev/null +++ b/tests/experimental/mcp/test_monitor_live.py @@ -0,0 +1,73 @@ +"""End-to-end ``wait_for_output`` against a real tmux control-mode engine. + +Unlike the offline event tests, this drives the monitor through an in-process +FastMCP client over a live ``tmux -C`` connection: a real pane produces output, +and the tool folds the genuine ``%output`` firehose (octal-decoded) until the +pane goes quiet. Proves the needle-free settle path works against real tmux, not +just a scripted stream. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines.base import CommandRequest + +fastmcp = pytest.importorskip("fastmcp") + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_wait_for_output_captures_real_output(session: Session) -> None: + """The monitor folds a real pane's output and settles when it goes quiet.""" + from libtmux.experimental.engines import AsyncControlModeEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = session.server + pane = session.active_window.active_pane + assert pane is not None + pane_id = pane.pane_id + assert pane_id is not None + + async def main() -> t.Any: + async with AsyncControlModeEngine.for_server(server) as engine: + mcp = build_async_server( + engine, + events="push", + include_operations=False, + include_plan_tools=False, + ) + async with fastmcp.Client(mcp) as client: + + async def produce() -> None: + # Let the monitor subscribe first, then make the pane emit. + await asyncio.sleep(0.3) + await engine.run( + CommandRequest.from_args( + "send-keys", + "-t", + pane_id, + "echo MONITOR_OK", + "Enter", + ), + ) + + producer = asyncio.ensure_future(produce()) + try: + result = await client.call_tool( + "wait_for_output", + {"target": pane_id, "settle_ms": 400, "timeout": 10.0}, + ) + finally: + await producer + return result.data + + data = asyncio.run(main()) + assert data.pane_id == pane_id + assert data.reason in ("settled", "byte_cap") + assert "MONITOR_OK" in data.captured_text + assert data.frame_count >= 1 diff --git a/tests/experimental/mcp/test_settle.py b/tests/experimental/mcp/test_settle.py new file mode 100644 index 000000000..9c93907fb --- /dev/null +++ b/tests/experimental/mcp/test_settle.py @@ -0,0 +1,162 @@ +"""The pure settle accumulator -- decoder, per-pane filter, and the fold. + +Driven offline with literal strings and fake async generators plus an injected +clock, so every stop reason (settled, byte_cap, time_cap, stream_end) and the +cancellation teardown are exercised deterministically without a real tmux ``-C`` +connection or ``pytest-asyncio``. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.mcp._settle import ( + accumulate_until_settle, + decode_output, + output_payload, +) + +if t.TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable + + +def test_decode_output_octal_and_passthrough() -> None: + """Octal escapes decode; an escaped backslash collapses; plain text is kept.""" + assert decode_output("a\\012b") == "a\nb" + assert decode_output("tab\\011x") == "tab\tx" + assert decode_output("a\\134b") == "a\\b" + assert decode_output("plain, spaces kept") == "plain, spaces kept" + assert decode_output("trailing\\") == "trailing\\" + + +def test_output_payload_preserves_internal_whitespace() -> None: + """The per-pane filter slices the data body without collapsing inner spaces.""" + assert output_payload("%output %1 a b", "%1") == "a b" + assert output_payload("%output %2 x", "%1") is None + assert output_payload("%window-add @3", "%1") is None + + +def test_accumulate_settles_on_idle() -> None: + """A pane that emits then goes quiet returns reason='settled'.""" + + async def quiet_after_two() -> AsyncGenerator[str, None]: + yield "hello " + yield "world" + await asyncio.Event().wait() + + out = asyncio.run( + accumulate_until_settle( + quiet_after_two(), + settle_ms=10, + timeout_ms=2000, + max_bytes=4096, + ), + ) + assert out.reason == "settled" + assert out.text == "hello world" + assert out.byte_count == 11 + assert out.frame_count == 2 + assert out.truncated is False + # On 'settled', idle_ms_observed is exactly the settle_ms threshold. + assert out.idle_ms_observed == 10 + + +def test_accumulate_byte_cap_keeps_tail() -> None: + """A flood past max_bytes truncates, preserving the tail.""" + + async def flood() -> AsyncGenerator[str, None]: + for _ in range(100): + yield "abcde" + + out = asyncio.run( + accumulate_until_settle( + flood(), + settle_ms=50, + timeout_ms=2000, + max_bytes=8, + ), + ) + assert out.reason == "byte_cap" + assert out.byte_count == 8 + assert out.truncated is True + assert out.text == "cdeabcde" # last 8 bytes of "abcdeabcde" + + +def test_accumulate_stream_end() -> None: + """An exhausted stream returns reason='stream_end'.""" + + async def two_then_done() -> AsyncGenerator[str, None]: + yield "a" + yield "b" + + out = asyncio.run( + accumulate_until_settle( + two_then_done(), + settle_ms=50, + timeout_ms=2000, + max_bytes=64, + ), + ) + assert out.reason == "stream_end" + assert out.text == "ab" + + +def test_accumulate_time_cap_with_scripted_clock() -> None: + """A slow-but-never-idle pane hits the wall-clock cap via the injected clock.""" + + def make_clock(step: float = 0.5) -> Callable[[], float]: + state = {"t": -step} + + def clock() -> float: + state["t"] += step + return state["t"] + + return clock + + async def forever() -> AsyncGenerator[str, None]: + while True: + yield "x" + + out = asyncio.run( + accumulate_until_settle( + forever(), + settle_ms=50, + timeout_ms=1000, + max_bytes=100000, + now=make_clock(), + ), + ) + assert out.reason == "time_cap" + assert out.frame_count >= 1 + + +def test_accumulate_closes_stream_on_cancel() -> None: + """Cancelling the fold closes the stream, so no consumer is leaked.""" + closed = {"value": False} + + async def blocking() -> AsyncGenerator[str, None]: + try: + yield "first" + await asyncio.Event().wait() + finally: + closed["value"] = True + + async def main() -> bool: + task = asyncio.ensure_future( + accumulate_until_settle( + blocking(), + settle_ms=10000, + timeout_ms=10000, + max_bytes=4096, + ), + ) + await asyncio.sleep(0.05) # let the fold park on the blocking stream + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + return closed["value"] + + assert asyncio.run(main()) is True From 3e9a9cf78432cbc3954e0e1877c6127fab8eb7f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 19:06:59 -0500 Subject: [PATCH 071/154] Mcp(feat): Make wait_for_output discoverable why: A capable agent asked to run tests in a pane and wait fell back to sleep + capture_pane polling -- it never found wait_for_output because the tool's surface said "watch pane output / settles / needle-free", not the agent's intent "run a command and wait for it to finish". The capability shipped in round 4; this surfaces it. what: - _instructions(): add a run-a-command-and-wait paragraph naming the split_pane/send_input -> wait_for_output workflow, the test/build use case, the "prefer over sleep + capture_pane polling" steer, and the "settled is not success" caveat. Gate it (events_enabled) so the sync server never names a tool it does not register. - wait_for_output: enrich description= with discovery vocabulary (completion, exit/return code, success/failed) and add a NumPy Parameters section -- FastMCP parses it into per-param schema descriptions even when description= is overridden. - docs: rename the colliding sync polling helper wait_for_output -> wait_for_text and point agents at the event-backed tool. - tests: lock the discoverable wording -- instructions name the tool + workflow + anti-polling steer; tool metadata carries the vocab + per param descriptions; events=off omits the live-output guidance. --- docs/topics/automation_patterns.md | 9 +- src/libtmux/experimental/mcp/events.py | 82 +++++++++----- .../experimental/mcp/fastmcp_adapter.py | 101 ++++++++++++------ tests/experimental/mcp/test_events.py | 75 +++++++++++++ 4 files changed, 206 insertions(+), 61 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba9641108..df0aa294f 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -118,13 +118,18 @@ command that never finishes can't hang your script forever. The `poll_interval` the latency/work trade in one knob: poll faster to react sooner, slower to spare tmux the round-trips. +> **Note:** This polls with `capture_pane` + `sleep` — correct for the +> synchronous library. If you drive tmux through the libtmux MCP server, prefer +> the event-backed `wait_for_output` tool instead: it folds live `%output` and +> returns when the pane settles, with no polling. + ```python >>> import time >>> monitor_window = session.new_window(window_name='monitor', attach=False) >>> monitor_pane = monitor_window.active_pane ->>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): +>>> def wait_for_text(pane, text, timeout=5.0, poll_interval=0.1): ... """Wait for specific text to appear in pane output.""" ... start = time.time() ... while time.time() - start < timeout: @@ -135,7 +140,7 @@ the round-trips. ... return False >>> monitor_pane.send_keys('sleep 0.2; echo "READY"') ->>> wait_for_output(monitor_pane, 'READY', timeout=2.0) +>>> wait_for_text(monitor_pane, 'READY', timeout=2.0) True >>> # Clean up diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index c62f5a118..ed8fa2c68 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -366,30 +366,54 @@ async def wait_for_output( stream_partials: bool = False, snapshot: bool = True, ) -> MonitorResult: - """Watch one pane's live output and return when it goes quiet (settles). - - Needle-free: accumulates the bytes the pane *produces* and returns the - instant it stays idle for ``settle_ms`` -- or the wall-clock ``timeout`` - (seconds) or ``max_bytes`` cap fires, or the stream ends. No regex, no - sentinel injection: read ``captured_text`` + ``done`` and decide what - happened. - - ``reason='settled'`` means *stopped producing output*, NOT *succeeded* -- - it cannot tell "finished, back to the shell" from "blocked on stdin". Use - ``done.pane_dead`` (+ ``done.pane_dead_status``) and - ``done.pane_current_command`` (a shell name) to disambiguate; - ``dropped``/``truncated`` warn the captured chunk may be incomplete. - - Set ``stream_partials=True`` to also push each chunk live as an MCP log - message. ``snapshot`` defaults to ``True``: at settle the rendered grid is - captured into ``snapshot_lines``; pass ``snapshot=False`` to skip that - capture, leaving ``snapshot_lines`` ``None``. While it runs it shares - tmux's single ``%output`` stream with ``watch_events`` / ``poll_events``; - it is bounded by the caps and short-lived, and each call runs in its own - task so it does not block other tools. The first watch on a session - attaches the control client (so its panes emit ``%output``), which draws - the current screen once into ``captured_text`` -- the clean rendered grid, - when requested, is in ``snapshot_lines``. + """Run a command and wait for it to finish; watch a pane until it settles. + + Use this to run a long-running command -- a test run (``uv run pytest``), a + build, an install, a server coming up -- and wait for the result instead of + polling with sleep + capture_pane. Typical flow: ``send_input`` the command + to a pane (``enter=True``), then call ``wait_for_output`` on that same pane. + It folds the bytes the pane *produces* and returns the instant it stays idle + for ``settle_ms`` -- or ``timeout`` / ``max_bytes`` fires, or the stream + ends. Needle-free: no regex, no sentinel injection. + + **Settled is not success.** ``reason='settled'`` means the pane stopped + producing output -- it cannot, on its own, tell "finished, back to the + shell" from "blocked waiting on stdin". To confirm the command exited, read + ``done.pane_dead`` with ``done.pane_dead_status`` (the process exit code; 0 + is success) and ``done.pane_current_command`` (a shell name means idle). + ``dropped`` / ``truncated`` warn the captured chunk may be incomplete. + + While it runs it shares tmux's single ``%output`` stream with + ``watch_events`` / ``poll_events``; it is bounded by the caps and + short-lived, and each call runs in its own task so it does not block other + tools. The first watch on a session attaches the control client (so its + panes emit ``%output``), which draws the current screen once into + ``captured_text`` -- the clean rendered grid, when requested, is in + ``snapshot_lines``. + + Parameters + ---------- + target : str + The pane to watch: a tmux id (``%pane``, ``@window``, ``$session``), a + name, or ``session:window.pane``. Resolve directional specials to a + concrete ``%N`` first. + settle_ms : int + Idle time in milliseconds with no new output before the pane is treated + as done (default 750). Lower returns sooner but risks a false settle + while a command pauses; raise it for chatty or bursty commands. + timeout : float + Wall-clock seconds to wait before giving up (default 30.0). Raise it for + slow test suites or heavy builds; on expiry ``reason`` reports the cap. + max_bytes : int + Cap on captured output bytes (default 131072). On overflow the watch + returns early with ``truncated`` set; raise it to keep more output. + stream_partials : bool + When ``True``, also push each output chunk live as an MCP log message + for real-time progress on long runs (default ``False``). + snapshot : bool + When ``True`` (default), capture the rendered pane grid into + ``snapshot_lines`` at settle; ``False`` skips that extra capture and + leaves ``snapshot_lines`` ``None``. """ from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._resolve import ( @@ -452,7 +476,15 @@ async def _frames() -> AsyncGenerator[str, None]: tool = FunctionTool.from_function( wait_for_output, name="wait_for_output", - description="Watch a pane's output and return when it settles (needle-free)", + description=( + "Run a command and wait for it to finish (command completion): watch " + "one pane's live output and return when it goes quiet (settles). Use " + "after send_input to wait for long-running tests/builds/installs " + "instead of sleep + capture_pane polling. Needle-free (no " + "regex/sentinel); read captured_text and the done metadata (pane_dead, " + "pane_dead_status = exit / return code, 0 is success) to tell whether " + "it finished or failed." + ), tags={"readonly", "events", "monitor"}, annotations=ToolAnnotations(title="wait_for_output", readOnlyHint=True), ) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 40ce25acb..915f25511 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -129,39 +129,68 @@ def _agent_context_segment(ctx: CallerContext) -> str: ) -def _instructions(ctx: CallerContext) -> str: - """Compose the server instructions, woven with the live caller context.""" - return "\n\n".join( - ( - "This MCP drives a real tmux server through typed tools: sessions, " - "windows, panes, terminal scrollback, send-keys, copy-mode buffers. " - "Targets accept tmux ids (%pane, @window, $session), names, or " - "'session:window.pane'.", - "When to invoke: managing tmux panes/windows/sessions; reading " - "terminal scrollback (capture_pane/grep_pane/search_panes); sending " - "keystrokes to a running shell or REPL (send_input); copy-mode and " - "paste-buffer work; operating on a pane relative to another or to you " - "(capture_relative_pane/grep_relative_pane).", - "Do NOT invoke for: editor panes you edit via file tools; browser tabs " - "or web content; GUI application windows; notebook cells; any non-tmux " - "terminal surface. tmux only sees terminal panes -- it cannot read a " - "browser or GUI app.", - "Prefer a concrete %N pane id; resolve relative or caller-relative " - "targets to a concrete %N before capture/send. Never hand a directional " - "special target ({up-of}/{down-of}/{left-of}/{right-of}) to " - "capture_pane/grep_pane/send_input -- those resolve against THIS MCP's " - "control client, not your pane; use capture_relative_pane / " - "grep_relative_pane / resolve_relative_pane instead.", - _agent_context_segment(ctx), - "list_panes/list_windows/show_options query tmux metadata (format " - "fields); grep_pane (one pane) and search_panes (across panes) search " - "terminal text (scrollback). Pick the right one for 'which pane shows X'.", - "The curated tools cover most needs; the per-operation surface (op_*) " - "and the plan tools (preview_plan/execute_plan/result_schema/" - "build_workspace) are power-use; watch_events streams live notifications " - "on a control-mode engine.", - ), +def _instructions(ctx: CallerContext, *, events_enabled: bool = False) -> str: + """Compose the server instructions, woven with the live caller context. + + *events_enabled* gates the live-output guidance (``wait_for_output`` / + ``watch_events``), which is registered only on a streaming control-mode + server -- the sync server omits it so it never names a tool it lacks. + """ + closer = ( + "The curated tools cover most needs; the per-operation surface (op_*) " + "and the plan tools (preview_plan/execute_plan/result_schema/" + "build_workspace) are power-use." ) + if events_enabled: + closer += ( + " For live output: wait_for_output waits for one pane to settle " + "(run-a-command-and-wait); watch_events/poll_events stream/buffer raw " + "control-mode notifications across the server." + ) + segments = [ + "This MCP drives a real tmux server through typed tools: sessions, " + "windows, panes, terminal scrollback, send-keys, copy-mode buffers. " + "Targets accept tmux ids (%pane, @window, $session), names, or " + "'session:window.pane'.", + "When to invoke: managing tmux panes/windows/sessions; reading " + "terminal scrollback (capture_pane/grep_pane/search_panes); sending " + "keystrokes to a running shell or REPL (send_input); copy-mode and " + "paste-buffer work; operating on a pane relative to another or to you " + "(capture_relative_pane/grep_relative_pane).", + "Do NOT invoke for: editor panes you edit via file tools; browser tabs " + "or web content; GUI application windows; notebook cells; any non-tmux " + "terminal surface. tmux only sees terminal panes -- it cannot read a " + "browser or GUI app.", + "Prefer a concrete %N pane id; resolve relative or caller-relative " + "targets to a concrete %N before capture/send. Never hand a directional " + "special target ({up-of}/{down-of}/{left-of}/{right-of}) to " + "capture_pane/grep_pane/send_input -- those resolve against THIS MCP's " + "control client, not your pane; use capture_relative_pane / " + "grep_relative_pane / resolve_relative_pane instead.", + _agent_context_segment(ctx), + ] + if events_enabled: + segments.append( + "Run a command and wait for it to finish / for completion " + "(long-running builds, test runs like `uv run pytest`, installs, a " + "server reaching ready): split_pane or pick a pane, send_input the " + "command (enter=True), then call wait_for_output on that same pane -- " + "it folds the live output and returns when the pane goes quiet " + "(settles), needle-free (no regex, no sentinel). Prefer this over " + "polling with sleep + capture_pane: wait_for_output is event-backed, " + "returns the captured_text, and reports done.pane_dead / " + "done.pane_dead_status (process exit / return code) plus " + "done.pane_current_command so you can tell finished from " + "blocked-on-input. Settled means output stopped, not that the command " + "succeeded or failed -- read the done metadata to confirm exit status.", + ) + segments.append( + "list_panes/list_windows/show_options query tmux metadata (format " + "fields); grep_pane (one pane) and search_panes (across panes) search " + "terminal text (scrollback). Pick the right one for 'which pane shows X'.", + ) + segments.append(closer) + return "\n\n".join(segments) def _summary(doc: str | None) -> str | None: @@ -591,11 +620,15 @@ def build_async_server( """ from fastmcp import FastMCP - from libtmux.experimental.mcp.events import register_events + from libtmux.experimental.mcp.events import _supports_stream, register_events ctx = caller if caller is not None else CallerContext.discover() _stash_caller(engine, ctx) - mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) + events_enabled = events != "off" and _supports_stream(engine) + mcp: FastMCP = FastMCP( + name=name, + instructions=instructions or _instructions(ctx, events_enabled=events_enabled), + ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) register_caller_context(mcp, ctx) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 92f4711fe..bfc69b313 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -346,6 +346,81 @@ def test_read_done_parses_display_message_fields() -> None: assert done.pane_in_mode is True +def test_instructions_surface_wait_for_output() -> None: + """The server instructions name the run-a-command-and-wait workflow. + + If this fails, the discoverable wording drifted -- update BOTH the instruction + text in fastmcp_adapter.py AND these assertions intentionally. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + text = server.instructions or "" + assert "wait_for_output" in text # the tool by name + assert "send_input" in text # the workflow pair + assert "completion" in text # the run-a-command-and-wait intent + assert "pytest" in text # the long-running / test use case + assert "sleep + capture_pane" in text # the anti-polling steer + assert "Settled" in text # settled-is-not-success caveat + + +def test_instructions_omit_wait_for_output_without_streaming() -> None: + """events='off' (no wait_for_output tool) drops the live-output guidance. + + The instructions must not name a tool the server did not register. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="off", + include_operations=False, + include_plan_tools=False, + ) + text = server.instructions or "" + assert "wait_for_output" not in text + assert "watch_events" not in text + + +def test_wait_for_output_metadata_is_discoverable() -> None: + """wait_for_output's description + per-param schema carry the search vocabulary. + + If this fails, the discoverable wording drifted -- update BOTH the description / + docstring in events.py AND these assertions intentionally. The per-param + descriptions also prove FastMCP parsed the NumPy ``Parameters`` section. + """ + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + server = build_async_server( + FakeStreamEngine(_STREAM), + events="push", + include_operations=False, + include_plan_tools=False, + ) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return {tool.name: tool for tool in await client.list_tools()} + + by_name = asyncio.run(main()) + tool = by_name["wait_for_output"] + description = tool.description or "" + assert "wait" in description + assert "finish" in description + assert "completion" in description + assert "sleep + capture_pane" in description + + props = tool.inputSchema["properties"] + for param in ("target", "settle_ms", "timeout", "max_bytes", "stream_partials"): + assert props[param].get("description"), f"{param} missing param description" + assert "idle" in props["settle_ms"]["description"].lower() + + def test_no_event_tools_without_a_stream() -> None: """A non-streaming engine registers no event tools, even when asked.""" from libtmux.experimental.engines import ConcreteEngine From 8e7fac7ea59d26cb9f5512bfd216deb64fb38e89 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 19:10:14 -0500 Subject: [PATCH 072/154] Mcp(fix): Close self-kill guard deferrals why: The self-kill guards left two known gaps: the per-op (op_*) kill surface skipped the others=True sibling case, and the conservative socket match relied on path reconstruction that diverges on macOS. what: - Add an authoritative conservative_socket() that queries the engine's #{socket_path} for a -L name or ambient socket, so the guard's socket scoping survives a macOS $TMUX_TMPDIR divergence; an explicit -S path is used as-is. - Lift the others=True guards (guard_kill_other_panes / _windows) into _resolve so the curated tools and guard_destructive_op share them; the per-op op_kill_pane/op_kill_window now refuse killing the caller's sibling pane/window too. - Cover with offline (conservative_socket) and live (curated + per-op others=True) regression tests. --- .../experimental/mcp/vocabulary/_resolve.py | 105 +++++++++++++++++- .../experimental/mcp/vocabulary/pane.py | 32 +----- .../experimental/mcp/vocabulary/window.py | 42 +------ tests/experimental/mcp/test_caller.py | 80 +++++++++++++ 4 files changed, 183 insertions(+), 76 deletions(-) diff --git a/src/libtmux/experimental/mcp/vocabulary/_resolve.py b/src/libtmux/experimental/mcp/vocabulary/_resolve.py index 2de5c57e9..dbbcf29db 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_resolve.py +++ b/src/libtmux/experimental/mcp/vocabulary/_resolve.py @@ -262,6 +262,32 @@ async def caller_session_or_none( return None +async def conservative_socket( + engine: AsyncTmuxEngine, + version: str | None, +) -> str | None: + """Resolve the engine's socket for a conservative caller comparison. + + An explicit ``-S`` path is authoritative as-is. For a ``-L`` name or the + ambient socket, asks tmux for ``#{socket_path}`` -- the path tmux actually + uses -- so a macOS ``$TMUX_TMPDIR`` divergence under launchd cannot fool the + reconstruction; falls back to the static selector when the query fails. + """ + static = engine_socket(engine) + if static is not None and "/" in static: + return static + try: + result = await arun( + DisplayMessage(message="#{socket_path}"), + engine, + version=version, + ) + result.raise_for_status() + except TmuxCommandError: + return static + return result.text.strip() or static + + async def guard_self_kill( engine: AsyncTmuxEngine, *, @@ -283,7 +309,7 @@ async def guard_self_kill( caller = caller_of(engine) if not caller.in_tmux or caller.pane_id is None: return - if not socket_could_match(engine_socket(engine), caller): + if not socket_could_match(await conservative_socket(engine, version), caller): return if pane is not None and caller.pane_id == pane: raise_target_hint( @@ -306,20 +332,87 @@ async def guard_self_kill( ) +async def guard_kill_other_panes( + engine: AsyncTmuxEngine, + target_pane: str, + version: str | None, +) -> None: + """Refuse ``kill_pane(others=True)`` when the caller is a sibling of the target. + + ``others=True`` keeps the target and kills every other pane in its window, so + the danger is the caller pane being one of those siblings (not the target). + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id or caller.pane_id == target_pane: + return + if not socket_could_match(await conservative_socket(engine, version), caller): + return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None: + return + target_window = await window_id(engine, PaneId(target_pane), version) + if caller_window == target_window: + raise_target_hint( + f"refusing to kill the other panes of window {target_window}: pane " + f"{caller.pane_id} runs this MCP server. Kill panes individually " + f"(excluding {caller.pane_id}), or run the tmux command manually.", + ) + + +async def guard_kill_other_windows( + engine: AsyncTmuxEngine, + target: str | Target, + target_window: str, + version: str | None, +) -> None: + """Refuse ``kill_window(others=True)`` when the caller is a same-session sibling. + + ``others=True`` keeps the target window and kills every other window in its + session, so the danger is the caller's window being one of those siblings. + """ + caller = caller_of(engine) + if not caller.in_tmux or not caller.pane_id: + return + if not socket_could_match(await conservative_socket(engine, version), caller): + return + caller_window = await caller_window_or_none(engine, caller.pane_id, version) + if caller_window is None or caller_window == target_window: + return # caller not on this server, or it is the kept target window + target_session = await session_id_of(engine, target, version) + caller_session = await caller_session_or_none(engine, caller.pane_id, version) + if caller_session is None or caller_session != target_session: + return + raise_target_hint( + f"refusing to kill the other windows of session {caller_session}: window " + f"{caller_window} holds this MCP server's pane {caller.pane_id}. Exclude " + "it, or run the tmux command manually.", + ) + + async def guard_destructive_op(engine: AsyncTmuxEngine, operation: t.Any) -> None: """Apply the self-kill guard to a per-op kill/respawn operation, by kind. - Closes the gap where the ``op_*`` per-operation surface dispatches around the - curated guard. The ``others=True`` sibling case is not fully covered here -- - prefer the curated kill tools when killing "all others". + Covers the ``op_*`` per-operation surface, including the ``others=True`` + sibling case (which keeps the target and kills its neighbours). """ target = operation.target if target is None: return kind = operation.kind - if kind in ("kill_pane", "respawn_pane"): + others = bool(getattr(operation, "others", False)) + if kind == "respawn_pane": await guard_self_kill(engine, pane=await pane_id(engine, target, None)) + elif kind == "kill_pane": + target_pane = await pane_id(engine, target, None) + if others: + await guard_kill_other_panes(engine, target_pane, None) + else: + await guard_self_kill(engine, pane=target_pane) elif kind == "kill_window": - await guard_self_kill(engine, window=await window_id(engine, target, None)) + target_window = await window_id(engine, target, None) + if others: + await guard_kill_other_windows(engine, target, target_window, None) + else: + await guard_self_kill(engine, window=target_window) elif kind == "kill_session": await guard_self_kill(engine, session=await session_id_of(engine, target, None)) diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 083922f2f..032312835 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -24,7 +24,6 @@ from libtmux.experimental.mcp.vocabulary._caller import ( engine_socket, is_strict_caller, - socket_could_match, ) from libtmux.experimental.mcp.vocabulary._geometry import ( Corner, @@ -37,7 +36,7 @@ DIR_FLAG, active_pane_id, caller_of, - caller_window_or_none, + guard_kill_other_panes, guard_self_kill, opt_target, pane_id, @@ -406,7 +405,7 @@ async def akill_pane( reject_relative_special(resolved) target_pane = await pane_id(engine, target, version) if others: - await _guard_kill_others(engine, target_pane, version) + await guard_kill_other_panes(engine, target_pane, version) else: await guard_self_kill(engine, pane=target_pane, version=version) ( @@ -418,33 +417,6 @@ async def akill_pane( ).raise_for_status() -async def _guard_kill_others( - engine: AsyncTmuxEngine, - target_pane: str, - version: str | None, -) -> None: - """Refuse ``kill_pane(others=True)`` when the caller is a sibling of the target. - - ``others=True`` keeps the target and kills every other pane in its window, so - the danger is the caller pane being one of those siblings (not the target). - """ - caller = caller_of(engine) - if not caller.in_tmux or not caller.pane_id or caller.pane_id == target_pane: - return - if not socket_could_match(engine_socket(engine), caller): - return - caller_window = await caller_window_or_none(engine, caller.pane_id, version) - if caller_window is None: - return - target_window = await window_id(engine, PaneId(target_pane), version) - if caller_window == target_window: - raise_target_hint( - f"refusing to kill the other panes of window {target_window}: pane " - f"{caller.pane_id} runs this MCP server. Kill panes individually " - f"(excluding {caller.pane_id}), or run the tmux command manually.", - ) - - async def alist_panes( engine: AsyncTmuxEngine, target: str | Target | None = None, diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index 978b7c421..dc10b2fc5 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -5,17 +5,9 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine from libtmux.experimental.mcp.target_resolver import resolve_target from libtmux.experimental.mcp.vocabulary._bridge import synced -from libtmux.experimental.mcp.vocabulary._caller import ( - engine_socket, - socket_could_match, -) from libtmux.experimental.mcp.vocabulary._resolve import ( - caller_of, - caller_session_or_none, - caller_window_or_none, + guard_kill_other_windows, guard_self_kill, - raise_target_hint, - session_id_of, window_id, ) from libtmux.experimental.mcp.vocabulary._results import Listing, WindowResult @@ -142,7 +134,7 @@ async def akill_window( resolved = resolve_target(target) target_window = await window_id(engine, resolved, version) if others: - await _guard_kill_other_windows(engine, target, target_window, version) + await guard_kill_other_windows(engine, target, target_window, version) else: await guard_self_kill(engine, window=target_window, version=version) ( @@ -154,36 +146,6 @@ async def akill_window( ).raise_for_status() -async def _guard_kill_other_windows( - engine: AsyncTmuxEngine, - target: str | Target, - target_window: str, - version: str | None, -) -> None: - """Refuse ``kill_window(others=True)`` when the caller is a same-session sibling. - - ``others=True`` keeps the target window and kills every other window in its - session, so the danger is the caller's window being one of those siblings. - """ - caller = caller_of(engine) - if not caller.in_tmux or not caller.pane_id: - return - if not socket_could_match(engine_socket(engine), caller): - return - caller_window = await caller_window_or_none(engine, caller.pane_id, version) - if caller_window is None or caller_window == target_window: - return # caller not on this server, or it is the kept target window - target_session = await session_id_of(engine, target, version) - caller_session = await caller_session_or_none(engine, caller.pane_id, version) - if caller_session is None or caller_session != target_session: - return - raise_target_hint( - f"refusing to kill the other windows of session {caller_session}: window " - f"{caller_window} holds this MCP server's pane {caller.pane_id}. Exclude " - "it, or run the tmux command manually.", - ) - - async def alist_windows( engine: AsyncTmuxEngine, target: str | Target | None = None, diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index c676e013b..802b9b77a 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -22,6 +22,7 @@ kill_window, list_panes, respawn_pane, + split_pane, ) from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine from libtmux.experimental.mcp.vocabulary._caller import ( @@ -382,3 +383,82 @@ async def main() -> None: with pytest.raises(ToolError, match="this MCP server"): asyncio.run(main()) + + +# --------------------------------------------------------------------------- # +# Deferrals: authoritative socket (1) + others=True per-op guard (2) +# --------------------------------------------------------------------------- # +def test_conservative_socket_prefers_explicit_path() -> None: + """An explicit -S path is authoritative as-is; no tmux query is issued.""" + from libtmux.experimental.mcp.vocabulary._resolve import conservative_socket + + class Pathed(SyncToAsyncEngine): + server_args = ("-S", "/tmp/explicit-socket") + + async def main() -> str | None: + return await conservative_socket(Pathed(ConcreteEngine()), None) + + assert asyncio.run(main()) == "/tmp/explicit-socket" + + +def test_kill_pane_others_refuses_caller_sibling_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """kill_pane(others=True) refuses when the caller is a sibling of the target.""" + engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(engine, name="killothers") + try: + pane_a = created.first_pane_id + assert pane_a is not None + pane_b = split_pane(engine, pane_a, horizontal=True).pane_id + monkeypatch.setenv("TMUX_PANE", pane_b) # caller is sibling B + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + with pytest.raises(ToolError, match="other panes"): + kill_pane(engine, pane_a, others=True) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(engine, created.session_id) + + +def test_op_kill_pane_others_is_guarded_live( + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The per-op kill surface routes others=True to the sibling guard too.""" + from libtmux.experimental.engines import AsyncSubprocessEngine + from libtmux.experimental.mcp.fastmcp_adapter import build_async_server + + sync_engine = SubprocessEngine.for_server(session.server) + real_socket = session.server.cmd("display-message", "-p", "#{socket_path}").stdout[ + 0 + ] + created = create_session(sync_engine, name="opkillothers") + try: + pane_a = created.first_pane_id + assert pane_a is not None + pane_b = split_pane(sync_engine, pane_a, horizontal=True).pane_id + monkeypatch.setenv("TMUX_PANE", pane_b) + monkeypatch.setenv("TMUX", f"{real_socket},0,0") + server = build_async_server( + AsyncSubprocessEngine.for_server(session.server), + events="off", + expose_operations=True, + ) + + async def main() -> None: + async with fastmcp.Client(server) as client: + await client.call_tool( + "op_kill_pane", {"target": pane_a, "others": True} + ) + + with pytest.raises(ToolError, match="other panes"): + asyncio.run(main()) + finally: + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + kill_session(sync_engine, created.session_id) From 79f5b39458fcb6ee5255f436902f8d761f09b895 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 23 Jun 2026 19:14:11 -0500 Subject: [PATCH 073/154] Mcp(fix): Harden wait_for_output monitor why: An adversarial review found the needle-free monitor could falsely report a command "settled" and could raise or mislead when the watched pane died -- the exact cases the design targets. what: - Make AsyncControlModeEngine.subscribe() a true broadcast: each subscriber gets its own queue, so wait_for_output, watch_events, and poll_events no longer steal each other's %output frames (which caused a premature settle with truncated text). - Make wait_for_output fail-safe when the watched pane is gone: _read_done no longer raises or fabricates pane_dead=False on a blank or fallback-pane probe (pane_dead becomes Optional/unknown, keyed on #{pane_id}), and the settle snapshot capture is guarded; the result is preserved. - Add a derived exit_code to MonitorResult. - Supervise the pull ring drainer (aclosing + recorded error surfaced via poll_events) so a reader failure cannot silently freeze it. - Regression test: two concurrent subscribers each see every event. --- .../engines/async_control_mode.py | 58 ++++++++--- src/libtmux/experimental/mcp/events.py | 95 ++++++++++++++----- tests/experimental/mcp/test_events.py | 32 ++++++- 3 files changed, 144 insertions(+), 41 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index c9939c52d..fbd0fd1a5 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -87,6 +87,26 @@ class _PendingCommand: blocks: list[ControlModeBlock] = field(default_factory=list) +def _offer( + queue: asyncio.Queue[ControlNotification], + notification: ControlNotification, +) -> int: + """Put *notification* on *queue*, dropping the oldest on overflow. + + Returns ``1`` when a notification was dropped, else ``0`` (so a broadcast can + tally drops without a ``try``/``except`` in its hot loop). + """ + try: + queue.put_nowait(notification) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(notification) + return 1 + return 0 + + class AsyncControlModeEngine: """Execute tmux commands over one persistent async ``tmux -C`` connection. @@ -120,9 +140,8 @@ def __init__( self.timeout = timeout self._parser = ControlModeParser() self._pending: collections.deque[_PendingCommand] = collections.deque() - self._events: asyncio.Queue[ControlNotification] = asyncio.Queue( - maxsize=event_queue_size, - ) + self._event_queue_size = event_queue_size + self._subscribers: set[asyncio.Queue[ControlNotification]] = set() self._dropped_notifications = 0 self._proc: asyncio.subprocess.Process | None = None self._reader_task: asyncio.Task[None] | None = None @@ -249,10 +268,21 @@ async def run_batch( async def subscribe(self) -> AsyncIterator[ControlNotification]: """Yield asynchronous tmux notifications as they arrive. - The iterator runs until the engine is closed or cancelled by the caller. + Each subscriber gets its own queue, so concurrent subscribers (the event + push tool, the pull ring, the output monitor) each see *every* + notification rather than competing for one shared stream. The iterator + runs until the engine is closed or the caller stops iterating; its queue + is unregistered on exit. """ - while True: - yield await self._events.get() + queue: asyncio.Queue[ControlNotification] = asyncio.Queue( + maxsize=self._event_queue_size, + ) + self._subscribers.add(queue) + try: + while True: + yield await queue.get() + finally: + self._subscribers.discard(queue) @property def dropped_notifications(self) -> int: @@ -339,16 +369,14 @@ def _dispatch_block(self, block: ControlModeBlock) -> None: pending.future.set_result(_merge_blocks(pending.blocks, pending.argv)) def _publish(self, line: bytes) -> None: - """Enqueue a notification, dropping the oldest on overflow.""" + """Broadcast a notification to every subscriber (drop-oldest per queue). + + Runs synchronously from the single reader task, so the subscriber set is + never mutated mid-iteration. + """ notification = ControlNotification.parse(line) - try: - self._events.put_nowait(notification) - except asyncio.QueueFull: - self._dropped_notifications += 1 - with contextlib.suppress(asyncio.QueueEmpty): - self._events.get_nowait() - with contextlib.suppress(asyncio.QueueFull): - self._events.put_nowait(notification) + for queue in self._subscribers: + self._dropped_notifications += _offer(queue, notification) def _mark_dead(self, error: BaseException) -> None: """Record the engine as dead and fail all pending commands.""" diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index ed8fa2c68..3f290e54f 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -37,6 +37,7 @@ accumulate_until_settle, output_payload, ) +from libtmux.experimental.ops import TmuxCommandError if t.TYPE_CHECKING: from collections.abc import AsyncGenerator, AsyncIterator, Sequence @@ -53,6 +54,7 @@ # tmux format read once at settle to fill DoneMetadata (tab-joined, one round-trip). _DONE_FORMAT = "\t".join( ( + "#{pane_id}", "#{pane_dead}", "#{pane_dead_status}", "#{pane_dead_signal}", @@ -71,9 +73,11 @@ class DoneMetadata: A ``pane_dead`` pane with a ``pane_dead_status`` is a *hard* "process exited" signal; ``pane_current_command`` reverting to a shell is a *soft* "command finished" signal. The screen-state fields add context without claiming intent. + ``pane_dead`` is ``None`` when the pane is gone or its liveness could not be + read (the command exited and took the pane with it). """ - pane_dead: bool + pane_dead: bool | None pane_dead_status: int | None pane_dead_signal: str | None pane_current_command: str | None @@ -92,6 +96,8 @@ class MonitorResult: ``truncated``), ``stream_end`` (the notification stream ended). ``idle_ms_observed`` is only meaningful when ``reason == "settled"``; ``snapshot_lines`` is ``None`` when the call passed ``snapshot=False``. + ``exit_code`` is the process exit code when the watched process is known to + have exited (``done.pane_dead`` is true), else ``None``. """ pane_id: str @@ -104,6 +110,7 @@ class MonitorResult: truncated: bool dropped: int done: DoneMetadata + exit_code: int | None snapshot_lines: tuple[str, ...] | None @@ -168,6 +175,7 @@ def __init__(self, engine: _StreamEngine, maxlen: int = _RING_SIZE) -> None: ) self._seq = 0 self._task: asyncio.Task[None] | None = None + self._error: str | None = None def _ensure_started(self) -> None: """Start the drainer task once, lazily, on the running loop.""" @@ -175,17 +183,29 @@ def _ensure_started(self) -> None: self._task = asyncio.create_task(self._drain(), name="libtmux-mcp-events") async def _drain(self) -> None: - """Copy every notification into the ring buffer.""" - stream: AsyncIterator[t.Any] = self._engine.subscribe() - async for notification in stream: - self._seq += 1 - self._buffer.append((self._seq, _event_dict(notification))) + """Copy every notification into the ring buffer (supervised, fail-safe). + + A reader failure is recorded and surfaced on the next :meth:`since` call, + rather than silently freezing the cursor or raising at garbage-collection + time. The subscription is closed deterministically via ``aclosing``. + """ + stream = t.cast("AsyncGenerator[t.Any, None]", self._engine.subscribe()) + try: + async with contextlib.aclosing(stream) as managed: + async for notification in managed: + self._seq += 1 + self._buffer.append((self._seq, _event_dict(notification))) + except Exception as error: # capture: the drainer must never crash + self._error = repr(error) def since(self, seq: int) -> dict[str, t.Any]: """Return buffered events with sequence number greater than *seq*.""" self._ensure_started() events = [event for n, event in self._buffer if n > seq] - return {"events": events, "cursor": self._seq} + out: dict[str, t.Any] = {"events": events, "cursor": self._seq} + if self._error is not None: + out["error"] = self._error + return out def register_events( @@ -297,12 +317,36 @@ async def poll_events(since: int = 0) -> dict[str, t.Any]: mcp.add_tool(tool) +def _gone_done() -> DoneMetadata: + """``DoneMetadata`` for a pane that is gone or whose liveness can't be read.""" + return DoneMetadata( + pane_dead=None, + pane_dead_status=None, + pane_dead_signal=None, + pane_current_command=None, + cursor_y=None, + history_size=None, + pane_in_mode=False, + ) + + async def _read_done(engine: _StreamEngine, pane_id: str) -> DoneMetadata: - """Fill :class:`DoneMetadata` for *pane_id* in one ``display-message`` read.""" + """Fill :class:`DoneMetadata` for *pane_id* in one ``display-message`` read. + + Fail-safe: when the pane is gone -- the probe errors, returns blank, or tmux + resolves a *different* (fallback) pane -- liveness is reported as unknown + (``pane_dead=None``) rather than raising or fabricating ``pane_dead=False``, + so a command that exited and took its pane still yields a result. + """ from libtmux.experimental.mcp.vocabulary.server import adisplay_message - text = (await adisplay_message(engine, pane_id, _DONE_FORMAT)).text - fields = (text.split("\t") + [""] * 7)[:7] + try: + text = (await adisplay_message(engine, pane_id, _DONE_FORMAT)).text + except TmuxCommandError: + return _gone_done() + fields = (text.split("\t") + [""] * 8)[:8] + if not fields[0].strip() or fields[0].strip() != pane_id: + return _gone_done() # blank probe, or tmux resolved a fallback pane def _as_int(value: str) -> int | None: value = value.strip() @@ -317,13 +361,13 @@ def _as_str(value: str) -> str | None: return value.strip() or None return DoneMetadata( - pane_dead=fields[0].strip() == "1", - pane_dead_status=_as_int(fields[1]), - pane_dead_signal=_as_str(fields[2]), - pane_current_command=_as_str(fields[3]), - cursor_y=_as_int(fields[4]), - history_size=_as_int(fields[5]), - pane_in_mode=fields[6].strip() == "1", + pane_dead=fields[1].strip() == "1", + pane_dead_status=_as_int(fields[2]), + pane_dead_signal=_as_str(fields[3]), + pane_current_command=_as_str(fields[4]), + cursor_y=_as_int(fields[5]), + history_size=_as_int(fields[6]), + pane_in_mode=fields[7].strip() == "1", ) @@ -451,13 +495,15 @@ async def _frames() -> AsyncGenerator[str, None]: done = await _read_done(engine, pane) snapshot_lines: tuple[str, ...] | None = None if snapshot: - captured = await acapture_pane( - engine, - pane, - join_wrapped=True, - trim_trailing=True, - ) - snapshot_lines = tuple(captured.lines) + # A pane that died at settle cannot be captured -- keep the result. + with contextlib.suppress(TmuxCommandError): + captured = await acapture_pane( + engine, + pane, + join_wrapped=True, + trim_trailing=True, + ) + snapshot_lines = tuple(captured.lines) return MonitorResult( pane_id=pane, @@ -470,6 +516,7 @@ async def _frames() -> AsyncGenerator[str, None]: truncated=outcome.truncated, dropped=dropped, done=done, + exit_code=done.pane_dead_status if done.pane_dead else None, snapshot_lines=snapshot_lines, ) diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index bfc69b313..079fbe9c9 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -227,7 +227,8 @@ async def main() -> t.Any: assert data.frame_count == 2 assert data.truncated is False assert data.snapshot_lines == [] - assert data.done.pane_dead is False + # The fake's pane id is unverifiable post-settle, so liveness reads unknown. + assert data.done.pane_dead is None def test_monitor_snapshot_false_omits_grid() -> None: @@ -334,7 +335,7 @@ def test_read_done_parses_display_message_fields() -> None: """_read_done maps the tab-joined display-message into DoneMetadata.""" from libtmux.experimental.mcp.events import _read_done - done_line = "1\t137\tHUP\tbash\t3\t50\t1" + done_line = "%1\t1\t137\tHUP\tbash\t3\t50\t1" # pane_id first engine = InstrumentedEngine(done_line=done_line) done = asyncio.run(_read_done(engine, "%1")) assert done.pane_dead is True @@ -346,6 +347,33 @@ def test_read_done_parses_display_message_fields() -> None: assert done.pane_in_mode is True +def test_subscribe_broadcasts_to_every_consumer() -> None: + """Concurrent subscribers each receive every notification (no frame stealing). + + A regression for the competing-consumer bug: a single shared queue handed each + item to exactly one waiter, so wait_for_output and watch_events/poll_events + stole each other's %output and the monitor could falsely report 'settled'. + """ + from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + engine = AsyncControlModeEngine() + + async def main() -> tuple[str, str]: + stream_a, stream_b = engine.subscribe(), engine.subscribe() + first = asyncio.ensure_future(stream_a.__anext__()) + second = asyncio.ensure_future(stream_b.__anext__()) + await asyncio.sleep(0) # let both register their queues at the first await + await asyncio.sleep(0) + engine._publish(b"%window-add @3") + notif_a, notif_b = await first, await second + # asyncio.run finalizes the suspended generators via shutdown_asyncgens. + return notif_a.raw, notif_b.raw + + raw_a, raw_b = asyncio.run(main()) + assert raw_a == "%window-add @3" + assert raw_b == "%window-add @3" # both, not split across consumers + + def test_instructions_surface_wait_for_output() -> None: """The server instructions name the run-a-command-and-wait workflow. From 1f2048cc57a74749e478a6716b226a8a8ae5fd2f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:04:55 -0500 Subject: [PATCH 074/154] Workspace(feat): Thread env/shell/options through declarative tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmuxp parity — per-pane/window environment, shell, global options, and options_after were silently dropped by the declarative tier even though the Core ops already accept every one of them. what: - IR: Pane.environment/shell; Window.environment/window_shell/ options_after; Workspace.global_options - analyzer: read the new tmuxp keys (lenient, empty-default) - compiler: thread env/shell into split-window/new-window; fold window-0 + its first pane's env into new-session -e so the reused implicit pane keeps its env with no extra dispatch; global_options -> set-option -g; options_after emitted after select-layout/focus - reconcile the stale Pane.focus docstring (first-pane focus is valid now that the creator captures the first pane's id) - tests: global_options, first-pane-env fold, window/pane shell precedence, options_after ordering --- .../experimental/workspace/analyzer.py | 6 ++ .../experimental/workspace/compiler.py | 26 ++++++ src/libtmux/experimental/workspace/ir.py | 39 +++++++- ..._async_control_engine_workspace_builder.py | 92 +++++++++++++++++++ 4 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 55118204f..8e7a07a73 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -44,6 +44,7 @@ def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: start_directory=data.get("start_directory"), environment=dict(data.get("environment", {}) or {}), options=dict(data.get("options", {}) or {}), + global_options=dict(data.get("global_options", {}) or {}), windows=windows, before_script=data.get("before_script"), on_exists=data.get("on_exists", "error"), @@ -83,6 +84,9 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: start_directory=raw.get("start_directory"), focus=bool(raw.get("focus", False)), options=dict(raw.get("options", {}) or {}), + options_after=dict(raw.get("options_after", {}) or {}), + environment=dict(raw.get("environment", {}) or {}), + window_shell=raw.get("window_shell"), panes=[_pane(p) for p in raw.get("panes", []) or []], ) @@ -100,6 +104,8 @@ def _pane(raw: t.Any) -> Pane: start_directory=raw.get("start_directory"), sleep_before=raw.get("sleep_before"), sleep_after=raw.get("sleep_after"), + environment=dict(raw.get("environment", {}) or {}), + shell=raw.get("shell"), ) msg = f"unsupported pane config: {raw!r}" raise TypeError(msg) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 8dd9e3efc..610aa65bc 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -142,6 +142,10 @@ def _emit_window( or window.start_directory or ws.start_directory ), + environment=( + dict(pane.environment) or dict(window.environment) or None + ), + shell=pane.shell or window.window_shell, ), ) if pane.commands: @@ -173,6 +177,23 @@ def _emit_window( plan.add(SelectLayout(target=window_ref, layout=window.layout)) for target in focus_targets: plan.add(SelectPane(target=target)) + for key, value in window.options_after.items(): + plan.add(SetWindowOption(target=window_ref, option=key, value=value)) + + +def _creator_environment(window: Window) -> dict[str, str]: + """Env for a window's *first* (implicit) pane, applied via its creator's ``-e``. + + The first pane reuses the window's implicit pane rather than splitting, so its + process environment cannot ride a ``split-window -e``. Instead the window's + ``environment`` (and its first pane's own ``environment``) fold into the + creator -- ``new-session -e`` for window 0, ``new-window -e`` for the rest -- + applying it without an extra dispatch. + """ + env = dict(window.environment) + if window.panes: + env.update(window.panes[0].environment) + return env def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: @@ -195,6 +216,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: start_directory=ws.start_directory, width=width, height=height, + environment=_creator_environment(ws.windows[0]) or None, capture_panes=True, ), ) @@ -202,6 +224,8 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: plan.add(SetEnvironment(target=session, name=key, value=value)) for key, value in ws.options.items(): plan.add(SetOption(target=session, option=key, value=value)) + for key, value in ws.global_options.items(): + plan.add(SetOption(option=key, value=value, global_=True)) window_refs: list[SlotRef] = [] for index, window in enumerate(ws.windows): @@ -217,6 +241,8 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: target=session, name=window.name, start_directory=window.start_directory or ws.start_directory, + environment=_creator_environment(window) or None, + window_shell=window.window_shell, capture_pane=True, ), ) diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 85e34edca..ff2ab1655 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -49,15 +49,24 @@ class Pane: Command(s) to send after the pane is created (a bare string is one command). focus : bool - Select this pane once its window's panes are built. Focusing the *first* - pane of a multi-pane window is rejected at compile time (the implicit - first pane has no captured id after the window is split). + Select this pane once its window's panes are built. Every pane -- the + first one included -- has a concrete captured id (the window's creator + captures its first pane), so focusing any pane is valid. start_directory : str or None Working directory (inherited from window/session when unset). suppress_history : bool Keep sent commands out of shell history (leading-space trick). sleep_before, sleep_after : float or None Host-side delays around this pane's commands (orchestration, not tmux). + environment : Mapping[str, str] + Process environment for a *split* pane (``split-window -e``). For the + window's first pane -- which reuses the window's implicit pane rather than + splitting -- this env is folded into the window's creator instead, so it + applies without an extra dispatch. + shell : str or None + A shell command to launch in the pane instead of the default shell + (``split-window`` trailing command); falls back to the window's + ``window_shell``. """ run: str | Sequence[str] | None = None @@ -66,6 +75,8 @@ class Pane: suppress_history: bool = True sleep_before: float | None = None sleep_after: float | None = None + environment: Mapping[str, str] = field(default_factory=dict) + shell: str | None = None @property def commands(self) -> tuple[str, ...]: @@ -92,7 +103,19 @@ class Window: focus : bool Select this window at the end of the build. options : Mapping[str, str] - ``set-window-option`` key/values. + ``set-window-option`` key/values applied *before* the panes are built. + options_after : Mapping[str, str] + ``set-window-option`` key/values applied *after* the layout and pane + focus -- for options (e.g. ``main-pane-width``) that only take effect + once the panes and layout exist. + environment : Mapping[str, str] + Process environment for the window (``new-window -e``), inherited by its + panes. For window 0 (which reuses the session's implicit window) this is + folded into ``new-session -e`` instead. + window_shell : str or None + A shell command to launch in the window's first pane instead of the + default shell (``new-window`` trailing command); also the fallback + ``shell`` for the window's split panes. panes : Sequence[Pane] The window's panes (the first reuses the window's implicit pane). """ @@ -102,6 +125,9 @@ class Window: start_directory: str | None = None focus: bool = False options: Mapping[str, str] = field(default_factory=dict) + options_after: Mapping[str, str] = field(default_factory=dict) + environment: Mapping[str, str] = field(default_factory=dict) + window_shell: str | None = None panes: Sequence[Pane] = () @@ -118,9 +144,11 @@ class Workspace: start_directory : str or None Working directory for the session. environment : Mapping[str, str] - ``set-environment`` key/values. + ``set-environment`` key/values (session-scoped tmux environment). options : Mapping[str, str] ``set-option`` (session) key/values. + global_options : Mapping[str, str] + ``set-option -g`` key/values (server-global options). windows : Sequence[Window] The session's windows. before_script : str or None @@ -134,6 +162,7 @@ class Workspace: start_directory: str | None = None environment: Mapping[str, str] = field(default_factory=dict) options: Mapping[str, str] = field(default_factory=dict) + global_options: Mapping[str, str] = field(default_factory=dict) windows: Sequence[Window] = () before_script: str | None = None on_exists: t.Literal["error", "replace", "reuse"] = "error" diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 826bb4033..f4d8c9cd6 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -28,10 +28,13 @@ LazyPlan, MarkedPlanner, NewSession, + NewWindow, + SelectLayout, SequentialPlanner, SetEnvironment, SetOption, SetWindowOption, + SplitWindow, ) from libtmux.experimental.workspace import ( HostStep, @@ -452,6 +455,95 @@ def test_compile_emits_environment_and_options() -> None: assert (set_wopt.option, set_wopt.value) == ("main-pane-height", "10") +def test_compile_emits_global_options() -> None: + """Workspace.global_options compile to ``set-option -g`` (no target).""" + ws = Workspace( + name="ws-global", + global_options={"status-position": "top"}, + windows=[Window("w", panes=[Pane(run="echo a")])], + ) + ops = compile_full(ws).plan.operations + global_opt = next(op for op in ops if isinstance(op, SetOption) and op.global_) + assert (global_opt.option, global_opt.value) == ("status-position", "top") + assert global_opt.target is None # -g options carry no target + + +def test_compile_folds_first_pane_env_into_creator() -> None: + """Window/first-pane env rides the creator's ``-e`` (no extra dispatch). + + Window 0 reuses the session's implicit pane, so its env -- and its first + pane's -- folds into ``new-session -e``; window 2..N fold into ``new-window + -e``. A *split* pane carries its own ``-e``. + """ + ws = Workspace( + name="ws-env", + windows=[ + Window( + "editor", + environment={"WIN_ENV": "w"}, + panes=[ + Pane(run="vim", environment={"PANE_ENV": "p"}), + Pane(run="htop", environment={"SPLIT_ENV": "s"}), + ], + ), + Window("logs", environment={"W2": "x"}, panes=[Pane(run="tail")]), + ], + ) + ops = compile_full(ws).plan.operations + new_session = next(op for op in ops if isinstance(op, NewSession)) + new_window = next(op for op in ops if isinstance(op, NewWindow)) + split = next(op for op in ops if isinstance(op, SplitWindow)) + # window 0 + its first pane fold into new-session -e + assert new_session.environment == {"WIN_ENV": "w", "PANE_ENV": "p"} + # window 1 folds into new-window -e + assert new_window.environment == {"W2": "x"} + # a split pane carries its own env (falling back to the window's) + assert split.environment == {"SPLIT_ENV": "s"} + + +def test_compile_threads_window_and_pane_shell() -> None: + """window_shell rides new-window; pane.shell (then window_shell) rides split.""" + ws = Workspace( + name="ws-shell", + windows=[ + Window("a", panes=[Pane(run="x")]), + Window( + "b", + window_shell="fish", + panes=[Pane(run="x"), Pane(run="y", shell="zsh")], + ), + ], + ) + ops = compile_full(ws).plan.operations + new_window = next(op for op in ops if isinstance(op, NewWindow)) + split = next(op for op in ops if isinstance(op, SplitWindow)) + assert new_window.window_shell == "fish" + assert split.shell == "zsh" # pane.shell wins over window_shell + + +def test_compile_emits_options_after_following_layout() -> None: + """options_after compile to set-window-option *after* the layout op.""" + ws = Workspace( + name="ws-after", + windows=[ + Window( + "w", + layout="main-vertical", + options_after={"main-pane-width": "120"}, + panes=[Pane(run="a"), Pane(run="b")], + ), + ], + ) + ops = compile_full(ws).plan.operations + layout_at = next(i for i, op in enumerate(ops) if isinstance(op, SelectLayout)) + after_at = next( + i + for i, op in enumerate(ops) + if isinstance(op, SetWindowOption) and op.option == "main-pane-width" + ) + assert after_at > layout_at # options_after runs once the layout exists + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From d606bf6272ff71a9cadcc7cfe272353f86c586da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:07:30 -0500 Subject: [PATCH 075/154] Workspace(feat): Add per-command Command (enter + sleeps) why: tmuxp lets a shell_command item set enter=False (type without submitting) and per-command sleep_before/sleep_after; the flat string model dropped that fidelity. what: - IR: Command(cmd, enter=True, sleep_before, sleep_after); widen Pane.run to str | Command | Sequence[str | Command]; Pane.commands now normalizes to tuple[Command, ...] (bare str -> Command) - analyzer: preserve per-item enter/sleep from {cmd, ...} maps, else keep a plain string - compiler: emit SendKeys(enter=cmd.enter) + per-command sleep host steps, nested inside the pane-level sleeps - export Command from the workspace package - tests: per-command enter/sleep compile + run-form normalization --- .../experimental/workspace/__init__.py | 3 +- .../experimental/workspace/analyzer.py | 37 +++++++++--- .../experimental/workspace/compiler.py | 20 +++++-- src/libtmux/experimental/workspace/ir.py | 56 ++++++++++++++++--- ..._async_control_engine_workspace_builder.py | 55 +++++++++++++++--- 5 files changed, 141 insertions(+), 30 deletions(-) diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 9b1f34bfe..07796f8b4 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -31,10 +31,11 @@ compile_workspace, ) from libtmux.experimental.workspace.confirm import ConfirmReport, confirm -from libtmux.experimental.workspace.ir import Pane, Window, Workspace +from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace __all__ = ( + "Command", "Compiled", "ConfirmReport", "HostStep", diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 8e7a07a73..24e19de7a 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -20,10 +20,10 @@ 'dev' >>> [w.name for w in ws.windows] ['editor', 'logs'] ->>> ws.windows[0].panes[0].commands -('vim',) ->>> ws.windows[0].panes[1].commands -('cd src', 'pytest -q') +>>> [c.cmd for c in ws.windows[0].panes[0].commands] +['vim'] +>>> [c.cmd for c in ws.windows[0].panes[1].commands] +['cd src', 'pytest -q'] """ from __future__ import annotations @@ -31,7 +31,7 @@ import collections.abc import typing as t -from libtmux.experimental.workspace.ir import Pane, Window, Workspace +from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: @@ -111,16 +111,35 @@ def _pane(raw: t.Any) -> Pane: raise TypeError(msg) -def _shell_commands(value: t.Any) -> tuple[str, ...]: - """Normalize a ``shell_command`` (None / string / list of str|{cmd}).""" +def _shell_commands(value: t.Any) -> tuple[str | Command, ...]: + """Normalize a ``shell_command`` (None / string / list of str|{cmd}). + + A ``{cmd}`` mapping that carries ``enter``/``sleep_before``/``sleep_after`` + becomes a :class:`~.ir.Command` preserving that orchestration; a plain + command stays a bare string. + """ if value is None: return () if isinstance(value, str): return (value,) - out: list[str] = [] + out: list[str | Command] = [] for item in t.cast("collections.abc.Sequence[t.Any]", value): if isinstance(item, str): out.append(item) elif isinstance(item, collections.abc.Mapping): - out.append(str(item["cmd"])) + out.append(_command(item)) return tuple(out) + + +def _command(item: collections.abc.Mapping[str, t.Any]) -> str | Command: + """Build a Command from a ``{cmd, enter?, sleep_before?, sleep_after?}`` map. + + Stays a bare string when no per-command overrides are present. + """ + cmd = str(item["cmd"]) + enter = bool(item.get("enter", True)) + sleep_before = item.get("sleep_before") + sleep_after = item.get("sleep_after") + if enter and sleep_before is None and sleep_after is None: + return cmd + return Command(cmd, enter=enter, sleep_before=sleep_before, sleep_after=sleep_after) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 610aa65bc..2ddcd6861 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -148,7 +148,8 @@ def _emit_window( shell=pane.shell or window.window_shell, ), ) - if pane.commands: + commands = pane.commands + if commands: if pane.sleep_before is not None: _schedule_before( host_after, @@ -156,15 +157,26 @@ def _emit_window( len(plan), HostStep("sleep", seconds=pane.sleep_before), ) - for command in pane.commands: + for command in commands: + if command.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=command.sleep_before), + ) plan.add( SendKeys( target=target, - keys=command, - enter=True, + keys=command.cmd, + enter=command.enter, suppress_history=pane.suppress_history, ), ) + if command.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=command.sleep_after), + ) if pane.sleep_after is not None: host_after.setdefault(len(plan) - 1, []).append( HostStep("sleep", seconds=pane.sleep_after), diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index ff2ab1655..9984908b6 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -39,15 +39,44 @@ from libtmux.experimental.ops.plan import LazyPlan, PlanResult +@dataclass(frozen=True) +class Command: + """One command sent to a pane, with per-command orchestration. + + Parameters + ---------- + cmd : str + The command line sent via ``send-keys``. + enter : bool + Submit the command with ``Enter`` (``False`` types it without running -- + e.g. to pre-fill a prompt). + sleep_before, sleep_after : float or None + Host-side delays around this individual command (orchestration, not tmux). + + Examples + -------- + >>> Command("pytest -q").enter + True + >>> Command("git commit", enter=False).cmd + 'git commit' + """ + + cmd: str + enter: bool = True + sleep_before: float | None = None + sleep_after: float | None = None + + @dataclass(frozen=True) class Pane: """A pane in the declared workspace. Parameters ---------- - run : str or Sequence[str] or None - Command(s) to send after the pane is created (a bare string is one - command). + run : str or Command or Sequence[str or Command] or None + Command(s) to send after the pane is created. A bare string is one + command (submitted with Enter); a :class:`Command` carries per-command + ``enter`` and sleep overrides. focus : bool Select this pane once its window's panes are built. Every pane -- the first one included -- has a concrete captured id (the window's creator @@ -69,7 +98,7 @@ class Pane: ``window_shell``. """ - run: str | Sequence[str] | None = None + run: str | Command | Sequence[str | Command] | None = None focus: bool = False start_directory: str | None = None suppress_history: bool = True @@ -79,13 +108,22 @@ class Pane: shell: str | None = None @property - def commands(self) -> tuple[str, ...]: - """The pane's commands as a tuple (a bare string becomes one command).""" + def commands(self) -> tuple[Command, ...]: + """The pane's commands as :class:`Command` values (a bare string -> Command). + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Pane, Command + >>> Pane(run="vim").commands + (Command(cmd='vim', enter=True, sleep_before=None, sleep_after=None),) + >>> [c.cmd for c in Pane(run=["a", Command("b", enter=False)]).commands] + ['a', 'b'] + """ if self.run is None: return () - if isinstance(self.run, str): - return (self.run,) - return tuple(self.run) + items: Sequence[str | Command] + items = (self.run,) if isinstance(self.run, (str, Command)) else self.run + return tuple(c if isinstance(c, Command) else Command(c) for c in items) @dataclass(frozen=True) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index f4d8c9cd6..864c1fe73 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -30,6 +30,7 @@ NewSession, NewWindow, SelectLayout, + SendKeys, SequentialPlanner, SetEnvironment, SetOption, @@ -37,6 +38,7 @@ SplitWindow, ) from libtmux.experimental.workspace import ( + Command, HostStep, Pane, Window, @@ -102,8 +104,11 @@ def test_workspace_analyze_normalizes_shorthand() -> None: ws = analyze(_YAML) assert ws.name == "ws-offline" assert [w.name for w in ws.windows] == ["editor", "logs"] - assert ws.windows[0].panes[0].commands == ("echo top",) - assert ws.windows[0].panes[1].commands == ("echo bottom-1", "echo bottom-2") + assert [c.cmd for c in ws.windows[0].panes[0].commands] == ["echo top"] + assert [c.cmd for c in ws.windows[0].panes[1].commands] == [ + "echo bottom-1", + "echo bottom-2", + ] assert ws.windows[0].panes[1].focus is True @@ -324,10 +329,46 @@ async def main() -> PlanResult: def test_pane_commands_normalizes_run_forms() -> None: - """Pane.commands turns run (None / str / sequence) into a command tuple.""" + """Pane.commands turns run (None / str / Command / sequence) into Commands.""" assert Pane().commands == () - assert Pane(run="vim").commands == ("vim",) - assert Pane(run=["cd src", "pytest -q"]).commands == ("cd src", "pytest -q") + assert [c.cmd for c in Pane(run="vim").commands] == ["vim"] + assert [c.cmd for c in Pane(run=["cd src", "pytest -q"]).commands] == [ + "cd src", + "pytest -q", + ] + # bare strings default to enter=True; a Command carries its own overrides + mixed = Pane(run=["plain", Command("typed", enter=False, sleep_after=0.2)]).commands + assert (mixed[0].cmd, mixed[0].enter) == ("plain", True) + assert (mixed[1].cmd, mixed[1].enter, mixed[1].sleep_after) == ("typed", False, 0.2) + + +def test_compile_per_command_enter_and_sleeps() -> None: + """Command(enter=False) emits send-keys without Enter; per-cmd sleeps host-step.""" + ws = Workspace( + name="ws-cmd", + windows=[ + Window( + "w", + panes=[ + Pane( + run=[ + Command("git add -p", enter=False), + Command("echo done", sleep_before=0.3, sleep_after=0.4), + ], + ), + ], + ), + ], + ) + compiled = compile_full(ws) + sends = [op for op in compiled.plan.operations if isinstance(op, SendKeys)] + assert (sends[0].keys, sends[0].enter) == ("git add -p", False) + assert (sends[1].keys, sends[1].enter) == ("echo done", True) + send_at = [ + i for i, op in enumerate(compiled.plan.operations) if isinstance(op, SendKeys) + ] + assert HostStep("sleep", seconds=0.3) in compiled.host_after[send_at[1] - 1] + assert HostStep("sleep", seconds=0.4) in compiled.host_after[send_at[1]] def test_analyze_dimensions_list_and_mapping() -> None: @@ -360,8 +401,8 @@ def test_analyze_shell_command_shorthand_forms() -> None: }, ) panes = ws.windows[0].panes - assert panes[0].commands == ("echo solo",) - assert panes[1].commands == ("echo a", "echo b") + assert [c.cmd for c in panes[0].commands] == ["echo solo"] + assert [c.cmd for c in panes[1].commands] == ["echo a", "echo b"] assert panes[2].commands == () # a None pane is an empty (implicit) pane From afe253e99cd97cc53c00b6896b6fc8123137c620 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:11:02 -0500 Subject: [PATCH 076/154] Ops(feat): Add ForwardCaptureError + ShowOptionsResult.get_int MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: a plan step targeting a slot that captured no id failed with a generic OperationError pointing at the consumer; and integer options (base-index, pane-base-index) had to be hand-parsed from strings. what: - exc: ForwardCaptureError(OperationError) names the unbound slot/part and explains only a capturing creator can be targeted; _resolve_slot raises it (still an OperationError, so broad handlers keep working) - results: ShowOptionsResult.get_int(name, default) -> int - export ForwardCaptureError from the ops package - tests: assert ForwardCaptureError.slot + OperationError subclassing; get_int doctested note: PlanResult live-object accessors (pane/window/session) are deferred — speculative DX better suited to the future object-graph tier, not a tmuxp-parity requirement. --- src/libtmux/experimental/ops/__init__.py | 2 ++ src/libtmux/experimental/ops/exc.py | 31 ++++++++++++++++++++++++ src/libtmux/experimental/ops/plan.py | 8 ++---- src/libtmux/experimental/ops/results.py | 22 +++++++++++++++++ tests/experimental/ops/test_plan.py | 9 ++++--- 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index e0d1a3e37..7950c3edd 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -98,6 +98,7 @@ from libtmux.experimental.ops.catalog import CatalogEntry, catalog from libtmux.experimental.ops.exc import ( DuplicateOperation, + ForwardCaptureError, OperationError, TmuxCommandError, UnknownOperation, @@ -160,6 +161,7 @@ "DuplicateOperation", "Effects", "FoldingPlanner", + "ForwardCaptureError", "HasSession", "HasSessionResult", "IndexRef", diff --git a/src/libtmux/experimental/ops/exc.py b/src/libtmux/experimental/ops/exc.py index e9a87ee2e..b780ff5ce 100644 --- a/src/libtmux/experimental/ops/exc.py +++ b/src/libtmux/experimental/ops/exc.py @@ -55,6 +55,37 @@ def __init__(self, kind: str) -> None: super().__init__(msg) +class ForwardCaptureError(OperationError): + """A plan step references a slot that captured no id. + + Raised when an operation targets the result of an earlier step (via a + :class:`~._types.SlotRef`) but that step captured no id -- either it has not + run yet (a forward or self reference), or it is not a capturing creator (a + mutating op, or a creator built with ``capture=False``). Failing here points + at the unbound reference rather than letting a later tmux command fail with an + opaque target error. + + Examples + -------- + >>> raise ForwardCaptureError(2, "self") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.ForwardCaptureError: plan step references slot 2 + (its own id) but that step captured no id; only an earlier capturing creator + can be targeted + """ + + def __init__(self, slot: int, part: str) -> None: + self.slot = slot + self.part = part + target = "its own id" if part == "self" else f"its {part!r} child" + msg = ( + f"plan step references slot {slot} ({target}) but that step captured " + f"no id; only an earlier capturing creator can be targeted" + ) + super().__init__(msg) + + class VersionUnsupported(OperationError): """An operation cannot render against the given tmux version. diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index aad70b331..c97d17598 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -32,7 +32,7 @@ Special, WindowId, ) -from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.exc import ForwardCaptureError from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.planner import Planner, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict @@ -89,11 +89,7 @@ def _resolve_slot( try: concrete = bindings[key] + ref.suffix except KeyError as error: - msg = ( - f"slot {ref.slot} (part {ref.part!r}) has no captured id yet; a plan " - f"step can only reference an earlier step that creates that object" - ) - raise OperationError(msg) from error + raise ForwardCaptureError(ref.slot, ref.part) from error return _target_from_id(concrete) diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index ae34b9463..ebd41b19e 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -345,6 +345,28 @@ class ShowOptionsResult(Result): options: Mapping[str, str] = field(default_factory=dict) + def get_int(self, name: str, default: int) -> int: + """Return option *name* parsed as an int, or *default* when absent. + + A small typed accessor for the integer options a builder reads back + (``base-index`` / ``pane-base-index`` / ``main-pane-width``), so callers + do not hand-parse :attr:`options` strings. + + Examples + -------- + >>> from libtmux.experimental.ops import ShowOptions + >>> result = ShowOptions().build_result( + ... returncode=0, + ... stdout=("base-index 1", "history-limit 5000"), + ... ) + >>> result.get_int("base-index", 0) + 1 + >>> result.get_int("status-interval", 15) + 15 + """ + value = self.options.get(name) + return int(value) if value is not None else default + @dataclass(frozen=True) class ShowBufferResult(Result): diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 812d20665..22caaf3a8 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -19,7 +19,7 @@ SwapPane, ) from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId -from libtmux.experimental.ops.exc import OperationError +from libtmux.experimental.ops.exc import ForwardCaptureError, OperationError if t.TYPE_CHECKING: from libtmux.experimental.ops.operation import Operation @@ -134,9 +134,12 @@ def test_plan_serialization_round_trip() -> None: def test_plan_unresolvable_ref_fails_closed() -> None: - """Targeting a step that creates nothing raises a clear error.""" + """Targeting a step that creates nothing raises a clear ForwardCaptureError.""" plan = LazyPlan() typed = plan.add(SendKeys(target=PaneId("%1"), keys="x")) # creates no id plan.add(SendKeys(target=typed, keys="y")) - with pytest.raises(OperationError, match="no captured id"): + with pytest.raises(ForwardCaptureError, match="captured no id") as exc_info: plan.execute(ConcreteEngine()) + assert exc_info.value.slot == 0 # points at the non-capturing creator + # ForwardCaptureError stays an OperationError, so broad handlers keep working + assert isinstance(exc_info.value, OperationError) From f5d82903ba30ba69b3c58549421eb75acb5a875e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:12:55 -0500 Subject: [PATCH 077/154] Workspace(feat): Add Workspace.to_dict + read suppress_history why: analyze (dict/YAML -> spec) had no inverse, so a spec could not round-trip back to a canonical tmuxp config; and the analyzer silently dropped pane suppress_history. what: - ir: Command.to_config + Pane/Window/Workspace.to_dict emit canonical (non-shorthand) tmuxp keys, omitting defaults; bare commands collapse back to strings - analyzer: read pane suppress_history (so to_dict round-trips it) - reconcile the package docstring: "WorkspaceBuilder" -> declarative workspace, note to_dict is analyze's inverse - test: analyze(ws.to_dict()) yields an identical compiled plan over a rich spec (env/options/global_options/options_after/Command/shell) --- .../experimental/workspace/__init__.py | 10 +- .../experimental/workspace/analyzer.py | 1 + src/libtmux/experimental/workspace/ir.py | 96 +++++++++++++++++++ ..._async_control_engine_workspace_builder.py | 28 ++++++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 07796f8b4..2b4c176a4 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -1,11 +1,11 @@ -"""Declarative WorkspaceBuilder: a structural object language over the Core ops. +"""Declarative workspace: a structural object language over the Core ops. The *Declarative* tier (à la SQLAlchemy Declarative on Core). Declare a workspace shape with :class:`~.ir.Workspace` / :class:`~.ir.Window` / :class:`~.ir.Pane`; -:func:`~.analyzer.analyze` builds that tree from a tmuxp-style YAML/dict; the -compiler lowers it to a Core :class:`~libtmux.experimental.ops.plan.LazyPlan`; the -runner executes it over any engine, sync or async; :func:`~.confirm.confirm` -verifies the live result. +:func:`~.analyzer.analyze` builds that tree from a tmuxp-style YAML/dict (and +:meth:`~.ir.Workspace.to_dict` is its inverse); the compiler lowers it to a Core +:class:`~libtmux.experimental.ops.plan.LazyPlan`; the runner executes it over any +engine, sync or async; :func:`~.confirm.confirm` verifies the live result. Everything here is experimental and outside the versioning policy. diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 24e19de7a..574793194 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -102,6 +102,7 @@ def _pane(raw: t.Any) -> Pane: run=_shell_commands(raw.get("shell_command")), focus=bool(raw.get("focus", False)), start_directory=raw.get("start_directory"), + suppress_history=bool(raw.get("suppress_history", True)), sleep_before=raw.get("sleep_before"), sleep_after=raw.get("sleep_after"), environment=dict(raw.get("environment", {}) or {}), diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 9984908b6..0f942ca48 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -66,6 +66,23 @@ class Command: sleep_before: float | None = None sleep_after: float | None = None + def to_config(self) -> str | dict[str, t.Any]: + """Serialize to a tmuxp ``shell_command`` item. + + A plain command (``enter=True``, no sleeps) collapses back to a bare + string; otherwise a ``{cmd, ...}`` mapping carrying only the overrides. + """ + if self.enter and self.sleep_before is None and self.sleep_after is None: + return self.cmd + out: dict[str, t.Any] = {"cmd": self.cmd} + if not self.enter: + out["enter"] = False + if self.sleep_before is not None: + out["sleep_before"] = self.sleep_before + if self.sleep_after is not None: + out["sleep_after"] = self.sleep_after + return out + @dataclass(frozen=True) class Pane: @@ -125,6 +142,27 @@ def commands(self) -> tuple[Command, ...]: items = (self.run,) if isinstance(self.run, (str, Command)) else self.run return tuple(c if isinstance(c, Command) else Command(c) for c in items) + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical tmuxp pane config (inverse of the analyzer).""" + out: dict[str, t.Any] = {} + if self.run is not None: + out["shell_command"] = [c.to_config() for c in self.commands] + if self.focus: + out["focus"] = True + if self.start_directory is not None: + out["start_directory"] = self.start_directory + if not self.suppress_history: + out["suppress_history"] = False + if self.sleep_before is not None: + out["sleep_before"] = self.sleep_before + if self.sleep_after is not None: + out["sleep_after"] = self.sleep_after + if self.environment: + out["environment"] = dict(self.environment) + if self.shell is not None: + out["shell"] = self.shell + return out + @dataclass(frozen=True) class Window: @@ -168,6 +206,28 @@ class Window: window_shell: str | None = None panes: Sequence[Pane] = () + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical tmuxp window config (inverse of the analyzer).""" + out: dict[str, t.Any] = {} + if self.name is not None: + out["window_name"] = self.name + if self.layout is not None: + out["layout"] = self.layout + if self.start_directory is not None: + out["start_directory"] = self.start_directory + if self.focus: + out["focus"] = True + if self.options: + out["options"] = dict(self.options) + if self.options_after: + out["options_after"] = dict(self.options_after) + if self.environment: + out["environment"] = dict(self.environment) + if self.window_shell is not None: + out["window_shell"] = self.window_shell + out["panes"] = [pane.to_dict() for pane in self.panes] + return out + @dataclass(frozen=True) class Workspace: @@ -205,6 +265,42 @@ class Workspace: before_script: str | None = None on_exists: t.Literal["error", "replace", "reuse"] = "error" + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical tmuxp workspace dict (the inverse of analyze). + + ``analyze(ws.to_dict())`` reconstructs an equivalent workspace; the output + uses canonical (non-shorthand) keys and omits fields left at their default. + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane + >>> ws = Workspace( + ... name="dev", + ... windows=[Window("editor", panes=[Pane(run="vim")])], + ... ) + >>> ws.to_dict()["session_name"] + 'dev' + >>> ws.to_dict()["windows"][0]["window_name"] + 'editor' + """ + out: dict[str, t.Any] = {"session_name": self.name} + if self.dimensions is not None: + out["dimensions"] = list(self.dimensions) + if self.start_directory is not None: + out["start_directory"] = self.start_directory + if self.environment: + out["environment"] = dict(self.environment) + if self.options: + out["options"] = dict(self.options) + if self.global_options: + out["global_options"] = dict(self.global_options) + if self.before_script is not None: + out["before_script"] = self.before_script + if self.on_exists != "error": + out["on_exists"] = self.on_exists + out["windows"] = [window.to_dict() for window in self.windows] + return out + def compile(self, *, version: str | None = None) -> LazyPlan: """Lower this declared workspace into a Core ``LazyPlan`` (ops only). diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 864c1fe73..879fa0a6b 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -585,6 +585,34 @@ def test_compile_emits_options_after_following_layout() -> None: assert after_at > layout_at # options_after runs once the layout exists +def test_workspace_to_dict_round_trips_through_analyze() -> None: + """Workspace.to_dict() is the inverse of analyze: an identical compiled plan.""" + ws = Workspace( + name="rt", + dimensions=(120, 40), + environment={"E": "1"}, + options={"history-limit": "5000"}, + global_options={"status-position": "top"}, + on_exists="replace", + windows=[ + Window( + "editor", + layout="main-vertical", + options={"automatic-rename": "off"}, + options_after={"main-pane-width": "120"}, + environment={"WE": "w"}, + panes=[ + Pane(run="vim", environment={"PE": "p"}, suppress_history=False), + Pane(run=[Command("htop", enter=False)], shell="bash"), + ], + ), + Window("logs", window_shell="journalctl -f", panes=[Pane(run="tail")]), + ], + ) + revived = analyze(ws.to_dict()) + assert revived.compile().to_list() == ws.compile().to_list() + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From dd779123e31d3661d1dd06853370b595968b5072 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:18:37 -0500 Subject: [PATCH 078/154] Workspace(feat): Add BuildEvent stream + on_event observer why: a faithful replica of tmuxp's on_build_event hooks needs a structural progress stream (session -> windows -> panes -> built) so a caller can drive an incremental UI without polling tmux. what: - events: SessionCreated/WindowCreated/PaneCreated/WorkspaceBuilt union + events_for(op, result) deriving events from bound ids (incl. a creator's implicit children); lives in the Declarative tier, Core plan stays observer-free - runner: build_workspace gains on_event (Callable[[BuildEvent], None]); abuild_workspace gains an awaited async observer; both emit per-op events then a terminal WorkspaceBuilt - ir: Workspace.build/abuild thread on_event through - export the event types from the workspace package - test: identical event stream over sync ConcreteEngine and async AsyncConcreteEngine (sync/async neutrality) --- .../experimental/workspace/__init__.py | 12 +++ src/libtmux/experimental/workspace/events.py | 82 +++++++++++++++++++ src/libtmux/experimental/workspace/ir.py | 22 ++++- src/libtmux/experimental/workspace/runner.py | 25 +++++- ..._async_control_engine_workspace_builder.py | 38 +++++++++ 5 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 src/libtmux/experimental/workspace/events.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 2b4c176a4..aff78a4fa 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -31,17 +31,29 @@ compile_workspace, ) from libtmux.experimental.workspace.confirm import ConfirmReport, confirm +from libtmux.experimental.workspace.events import ( + BuildEvent, + PaneCreated, + SessionCreated, + WindowCreated, + WorkspaceBuilt, +) from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace __all__ = ( + "BuildEvent", "Command", "Compiled", "ConfirmReport", "HostStep", "Pane", + "PaneCreated", + "SessionCreated", "Window", + "WindowCreated", "Workspace", + "WorkspaceBuilt", "WorkspaceCompileError", "abuild_workspace", "analyze", diff --git a/src/libtmux/experimental/workspace/events.py b/src/libtmux/experimental/workspace/events.py new file mode 100644 index 000000000..0899c45c4 --- /dev/null +++ b/src/libtmux/experimental/workspace/events.py @@ -0,0 +1,82 @@ +"""Build events emitted by the workspace runner as it constructs a session. + +These mirror tmuxp's ``on_build_event`` hooks: a structural stream (session -> +windows -> panes -> built) derived from the bound ids as the compiled plan runs, +so a caller can drive an incremental UI without polling tmux. The events live in +the *Declarative* tier -- the runner emits them; the Core +:class:`~libtmux.experimental.ops.plan.LazyPlan` stays observer-free. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.results import Result + + +@dataclass(frozen=True) +class SessionCreated: + """The session was created.""" + + session_id: str + + +@dataclass(frozen=True) +class WindowCreated: + """A window was created (a fresh window, or the reused first window bound).""" + + window_id: str + + +@dataclass(frozen=True) +class PaneCreated: + """A pane was created (a window's first pane, or a split).""" + + pane_id: str + + +@dataclass(frozen=True) +class WorkspaceBuilt: + """The whole workspace finished building.""" + + session_id: str + + +#: A build event in the order the runner emits them. +BuildEvent = SessionCreated | WindowCreated | PaneCreated | WorkspaceBuilt + + +def events_for(op: Operation[t.Any], result: Result) -> list[BuildEvent]: + """Derive the build events from one executed operation and its result. + + A creator binds its own id plus any implicit children (a new session's first + window/pane, a new window's first pane), so one op can yield several events. + + Examples + -------- + >>> from libtmux.experimental.ops import NewWindow + >>> op = NewWindow(capture_pane=True) + >>> result = op.build_result(returncode=0, stdout=("@5 %6",)) + >>> events_for(op, result) + [WindowCreated(window_id='@5'), PaneCreated(pane_id='%6')] + """ + events: list[BuildEvent] = [] + if op.kind == "new_session": + if result.created_id is not None: + events.append(SessionCreated(result.created_id)) + subids = result.created_subids + if "window" in subids: + events.append(WindowCreated(subids["window"])) + if "pane" in subids: + events.append(PaneCreated(subids["pane"])) + elif op.kind == "new_window": + if result.created_id is not None: + events.append(WindowCreated(result.created_id)) + if "pane" in result.created_subids: + events.append(PaneCreated(result.created_subids["pane"])) + elif op.kind == "split_window" and result.created_id is not None: + events.append(PaneCreated(result.created_id)) + return events diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 0f942ca48..08b74565b 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -33,10 +33,11 @@ from dataclasses import dataclass, field if t.TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Awaitable, Callable, Mapping, Sequence from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops.plan import LazyPlan, PlanResult + from libtmux.experimental.workspace.events import BuildEvent @dataclass(frozen=True) @@ -319,16 +320,24 @@ def build( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, ) -> PlanResult: """Compile and execute this workspace synchronously over *engine*. Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check (e.g. against the stateless ``ConcreteEngine``, which has no real - sessions to detect). + sessions to detect). Pass *on_event* to observe the structural build + stream (see :mod:`~.events`). """ from libtmux.experimental.workspace.runner import build_workspace - return build_workspace(self, engine, version=version, preflight=preflight) + return build_workspace( + self, + engine, + version=version, + preflight=preflight, + on_event=on_event, + ) async def abuild( self, @@ -336,8 +345,12 @@ async def abuild( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, ) -> PlanResult: - """Compile and execute this workspace asynchronously over *engine*.""" + """Compile and execute this workspace asynchronously over *engine*. + + *on_event* is awaited for each build event (see :mod:`~.events`). + """ from libtmux.experimental.workspace.runner import abuild_workspace return await abuild_workspace( @@ -345,4 +358,5 @@ async def abuild( engine, version=version, preflight=preflight, + on_event=on_event, ) diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index e99e4aac3..b800497a6 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -26,11 +26,15 @@ from libtmux.experimental.ops._types import NameRef from libtmux.experimental.ops.plan import PlanResult, _resolve from libtmux.experimental.workspace.compiler import compile_full +from libtmux.experimental.workspace.events import WorkspaceBuilt, events_for if t.TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops.results import Result from libtmux.experimental.workspace.compiler import HostStep + from libtmux.experimental.workspace.events import BuildEvent from libtmux.experimental.workspace.ir import Workspace @@ -89,9 +93,13 @@ def build_workspace( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, ) -> PlanResult: """Compile and execute *ws* synchronously over *engine*. + Pass *on_event* to observe the structural build stream (session -> windows -> + panes -> built) as each operation binds its id. + Examples -------- >>> from libtmux.experimental.engines import ConcreteEngine @@ -114,8 +122,13 @@ def build_workspace( bindings[index] = result.created_id for part, sub in result.created_subids.items(): bindings[index, part] = sub + if on_event is not None: + for event in events_for(op, result): + on_event(event) for step in compiled.host_after.get(index, ()): _run_host_sync(step) + if on_event is not None: + on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) @@ -125,8 +138,13 @@ async def abuild_workspace( *, version: str | None = None, preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, ) -> PlanResult: - """Compile and execute *ws* asynchronously over *engine* (same resolution).""" + """Compile and execute *ws* asynchronously over *engine* (same resolution). + + *on_event* is awaited for each build event, so an async observer can stream + the structural progress (e.g. through a fastmcp Context). + """ if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) @@ -141,6 +159,11 @@ async def abuild_workspace( bindings[index] = result.created_id for part, sub in result.created_subids.items(): bindings[index, part] = sub + if on_event is not None: + for event in events_for(op, result): + await on_event(event) for step in compiled.host_after.get(index, ()): await _run_host_async(step) + if on_event is not None: + await on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 879fa0a6b..c33829b03 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -19,6 +19,7 @@ import pytest from libtmux.experimental.engines import ( + AsyncConcreteEngine, AsyncControlModeEngine, ConcreteEngine, SubprocessEngine, @@ -38,11 +39,14 @@ SplitWindow, ) from libtmux.experimental.workspace import ( + BuildEvent, Command, HostStep, Pane, + SessionCreated, Window, Workspace, + WorkspaceBuilt, WorkspaceCompileError, analyze, compile_full, @@ -613,6 +617,40 @@ def test_workspace_to_dict_round_trips_through_analyze() -> None: assert revived.compile().to_list() == ws.compile().to_list() +def test_build_emits_event_stream_sync_and_async() -> None: + """on_event streams session -> windows -> panes -> built, sync and async.""" + ws = analyze( + { + "session_name": "ev", + "windows": [ + {"window_name": "a", "panes": ["echo x", "echo y"]}, + {"window_name": "b", "panes": ["echo z"]}, + ], + }, + ) + + sync_events: list[BuildEvent] = [] + ws.build(ConcreteEngine(), preflight=False, on_event=sync_events.append) + + async_events: list[BuildEvent] = [] + + async def collect(event: BuildEvent) -> None: + async_events.append(event) + + async def main() -> None: + await ws.abuild(AsyncConcreteEngine(), preflight=False, on_event=collect) + + asyncio.run(main()) + + # the same stream regardless of sync vs async engine (neutrality) + for events in (sync_events, async_events): + kinds = [type(e).__name__ for e in events] + assert isinstance(events[0], SessionCreated) + assert isinstance(events[-1], WorkspaceBuilt) + assert kinds.count("WindowCreated") == 2 # reused window a + created window b + assert kinds.count("PaneCreated") == 3 # a: first + split; b: first + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From efebd5d04c48de9e9df9f72e26fa098871e17524 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:19:45 -0500 Subject: [PATCH 079/154] Workspace(feat): Add opt-in wait_pane readiness (anti-race) why: tmuxp waits for a pane's shell prompt before sending keys so the first keystrokes are not eaten by an un-initialized shell; without it a slow shell races the first send. Kept opt-in so the fast path stays the default (the few-calls north star). what: - ir: Workspace.wait_pane flag (default False), threaded through analyze/to_dict - compiler: HostStep gains "wait_pane" + a pane SlotRef; emitted before a command pane's first send when wait_pane is on and the pane has no custom shell (mirrors tmuxp's `if pane_shell is None`) - runner: host-step executors resolve the pane ref and poll display-message '#{cursor_x},#{cursor_y}' until the cursor leaves the origin (~2s budget, 50ms cadence), sync and async - tests: wait step emitted per command pane (custom-shell skipped, off by default) + a live subprocess build with wait_pane on --- .../experimental/workspace/analyzer.py | 1 + .../experimental/workspace/compiler.py | 20 ++++++- src/libtmux/experimental/workspace/ir.py | 8 +++ src/libtmux/experimental/workspace/runner.py | 58 ++++++++++++++++--- ..._async_control_engine_workspace_builder.py | 50 ++++++++++++++++ 5 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 574793194..4a70db723 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -48,6 +48,7 @@ def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: windows=windows, before_script=data.get("before_script"), on_exists=data.get("on_exists", "error"), + wait_pane=bool(data.get("wait_pane", False)), ) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 2ddcd6861..39459fabc 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -56,12 +56,18 @@ class WorkspaceCompileError(ValueError): @dataclass(frozen=True) class HostStep: - """A host-side step interleaved by the runner (not a tmux operation).""" + """A host-side step interleaved by the runner (not a tmux operation). - kind: t.Literal["sleep", "script"] + ``"sleep"`` waits *seconds*; ``"script"`` runs *command* in *cwd*; + ``"wait_pane"`` polls *pane* (a :class:`~..ops._types.SlotRef`) until its + shell is ready (the runner resolves the ref and queries the live cursor). + """ + + kind: t.Literal["sleep", "script", "wait_pane"] seconds: float | None = None command: str | None = None cwd: str | None = None + pane: SlotRef | None = None @dataclass(frozen=True) @@ -150,6 +156,16 @@ def _emit_window( ) commands = pane.commands if commands: + if ws.wait_pane and (pane.shell or window.window_shell) is None: + # Wait for the pane's shell prompt before sending keys (anti-race); + # a pane launching a custom shell/command does not get this wait, + # mirroring tmuxp's `if pane_shell is None`. + _schedule_before( + host_after, + pre, + len(plan), + HostStep("wait_pane", pane=target), + ) if pane.sleep_before is not None: _schedule_before( host_after, diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 08b74565b..a59fa1b9c 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -254,6 +254,11 @@ class Workspace: A host shell command run once before building (orchestration). on_exists : {"error", "replace", "reuse"} What to do if a session of this name already exists. + wait_pane : bool + When ``True``, wait for each command-bearing pane's shell to be ready + (its cursor to leave the origin) before sending keys -- the tmuxp + anti-race. Off by default (the fast path); panes with a custom ``shell`` + skip the wait. See :mod:`~.events` and the runner. """ name: str @@ -265,6 +270,7 @@ class Workspace: windows: Sequence[Window] = () before_script: str | None = None on_exists: t.Literal["error", "replace", "reuse"] = "error" + wait_pane: bool = False def to_dict(self) -> dict[str, t.Any]: """Serialize to a canonical tmuxp workspace dict (the inverse of analyze). @@ -299,6 +305,8 @@ def to_dict(self) -> dict[str, t.Any]: out["before_script"] = self.before_script if self.on_exists != "error": out["on_exists"] = self.on_exists + if self.wait_pane: + out["wait_pane"] = True out["windows"] = [window.to_dict() for window in self.windows] return out diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index b800497a6..5513259e0 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -22,7 +22,13 @@ import time import typing as t -from libtmux.experimental.ops import HasSession, KillSession, arun, run +from libtmux.experimental.ops import ( + DisplayMessage, + HasSession, + KillSession, + arun, + run, +) from libtmux.experimental.ops._types import NameRef from libtmux.experimental.ops.plan import PlanResult, _resolve from libtmux.experimental.workspace.compiler import compile_full @@ -38,21 +44,55 @@ from libtmux.experimental.workspace.ir import Workspace -def _run_host_sync(step: HostStep) -> None: +#: Pane-readiness poll budget: ~2s at a 50ms cadence (matches tmuxp's timeout). +_WAIT_PANE_POLLS = 40 +_WAIT_PANE_INTERVAL = 0.05 +_CURSOR_FMT = "#{cursor_x},#{cursor_y}" + + +def _pane_ready(cursor: str) -> bool: + """Whether the pane's cursor has left the origin (its shell prompt drew).""" + return bool(cursor) and cursor != "0,0" + + +def _run_host_sync( + step: HostStep, + engine: TmuxEngine, + bindings: dict[int | tuple[int, str], str], + version: str | None, +) -> None: """Execute one host step synchronously.""" if step.kind == "sleep" and step.seconds is not None: time.sleep(step.seconds) elif step.kind == "script" and step.command is not None: subprocess.run(step.command, shell=True, cwd=step.cwd, check=False) + elif step.kind == "wait_pane" and step.pane is not None: + op = _resolve(DisplayMessage(target=step.pane, message=_CURSOR_FMT), bindings) + for _ in range(_WAIT_PANE_POLLS): + if _pane_ready(run(op, engine, version=version).text): + return + time.sleep(_WAIT_PANE_INTERVAL) -async def _run_host_async(step: HostStep) -> None: +async def _run_host_async( + step: HostStep, + engine: AsyncTmuxEngine, + bindings: dict[int | tuple[int, str], str], + version: str | None, +) -> None: """Execute one host step asynchronously.""" if step.kind == "sleep" and step.seconds is not None: await asyncio.sleep(step.seconds) elif step.kind == "script" and step.command is not None: proc = await asyncio.create_subprocess_shell(step.command, cwd=step.cwd) await proc.wait() + elif step.kind == "wait_pane" and step.pane is not None: + op = _resolve(DisplayMessage(target=step.pane, message=_CURSOR_FMT), bindings) + for _ in range(_WAIT_PANE_POLLS): + result = await arun(op, engine, version=version) + if _pane_ready(result.text): + return + await asyncio.sleep(_WAIT_PANE_INTERVAL) def _preflight_sync(ws: Workspace, engine: TmuxEngine, version: str | None) -> bool: @@ -111,10 +151,10 @@ def build_workspace( if preflight and _preflight_sync(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - for step in compiled.pre: - _run_host_sync(step) bindings: dict[int | tuple[int, str], str] = {} results: list[Result] = [] + for step in compiled.pre: + _run_host_sync(step, engine, bindings, version) for index, op in enumerate(compiled.plan.operations): result = run(_resolve(op, bindings), engine, version=version) results.append(result) @@ -126,7 +166,7 @@ def build_workspace( for event in events_for(op, result): on_event(event) for step in compiled.host_after.get(index, ()): - _run_host_sync(step) + _run_host_sync(step, engine, bindings, version) if on_event is not None: on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) @@ -148,10 +188,10 @@ async def abuild_workspace( if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - for step in compiled.pre: - await _run_host_async(step) bindings: dict[int | tuple[int, str], str] = {} results: list[Result] = [] + for step in compiled.pre: + await _run_host_async(step, engine, bindings, version) for index, op in enumerate(compiled.plan.operations): result = await arun(_resolve(op, bindings), engine, version=version) results.append(result) @@ -163,7 +203,7 @@ async def abuild_workspace( for event in events_for(op, result): await on_event(event) for step in compiled.host_after.get(index, ()): - await _run_host_async(step) + await _run_host_async(step, engine, bindings, version) if on_event is not None: await on_event(WorkspaceBuilt(bindings.get(0, ""))) return PlanResult(tuple(results), bindings) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index c33829b03..12317a3f8 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -651,6 +651,56 @@ async def main() -> None: assert kinds.count("PaneCreated") == 3 # a: first + split; b: first +def test_compile_emits_wait_pane_when_enabled() -> None: + """wait_pane=True schedules a wait host step per command pane (skipping shells).""" + ws = Workspace( + name="ws-wait", + wait_pane=True, + windows=[ + Window( + "w", + panes=[ + Pane(run="echo a"), # plain shell -> gets a readiness wait + Pane(run="echo b", shell="bash"), # custom shell -> no wait + ], + ), + ], + ) + compiled = compile_full(ws) + waits = [ + step + for steps in (*compiled.host_after.values(), compiled.pre) + for step in steps + if step.kind == "wait_pane" + ] + assert len(waits) == 1 # the custom-shell pane is skipped + assert waits[0].pane is not None # carries the pane SlotRef to poll + + # off by default: no wait steps emitted + off = Workspace(name="off", windows=[Window("w", panes=[Pane(run="echo a")])]) + assert not any( + step.kind == "wait_pane" + for steps in compile_full(off).host_after.values() + for step in steps + ) + + +def test_workspace_wait_pane_builds_live(session: Session, tmp_path: Path) -> None: + """A wait_pane build polls readiness and still completes over real tmux.""" + spec = analyze( + { + "session_name": "ws-wait-live", + "start_directory": str(tmp_path), + "on_exists": "replace", + "wait_pane": True, + "windows": [{"window_name": "w", "panes": ["echo ready", "echo two"]}], + }, + ) + result = spec.build(SubprocessEngine.for_server(session.server)) + assert result.ok + assert confirm(spec, session.server).ok + + def test_compile_schedules_host_steps_off_the_op_spine() -> None: """before_script and pane sleeps become host steps, not recorded operations.""" ws = Workspace( From 860bae584ac5cecf381f399eba919068c31b2b48 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:22:19 -0500 Subject: [PATCH 080/154] Workspace(feat): Honor explicit Window.window_index placement why: tmuxp's window_index config key places a window at a chosen session index; the builder always appended, ignoring it. what: - ir: Window.window_index (threaded through analyze/to_dict) - compiler: a created window (2..N) with window_index targets new-window at `session:N` by suffixing the session SlotRef (":N"), so the captured window-id binding is preserved -- zero Core change - test: window_index renders new-window -t $1:5 and still binds the id note: window 0 reuses the session's implicit window and keeps the base index; append-into-existing-session mode (tmuxp load -a) is deferred as a follow-up -- it restructures the build flow (no new-session, all windows created) and the fresh-session reuse model is faithful for the common case. --- src/libtmux/experimental/workspace/analyzer.py | 1 + src/libtmux/experimental/workspace/compiler.py | 11 +++++++++-- src/libtmux/experimental/workspace/ir.py | 7 +++++++ ...test_async_control_engine_workspace_builder.py | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 4a70db723..6d227b3c3 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -88,6 +88,7 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: options_after=dict(raw.get("options_after", {}) or {}), environment=dict(raw.get("environment", {}) or {}), window_shell=raw.get("window_shell"), + window_index=raw.get("window_index"), panes=[_pane(p) for p in raw.get("panes", []) or []], ) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 39459fabc..4d9065901 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -25,7 +25,7 @@ from __future__ import annotations import typing as t -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from libtmux.experimental.ops import ( LazyPlan, @@ -264,9 +264,16 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: if window.name is not None: plan.add(RenameWindow(target=window_ref, name=window.name)) else: + # An explicit window_index targets new-window at `session:N`; the + # SlotRef suffix appends ":N" to the captured session id at run time. + create_target = ( + replace(session, suffix=f":{window.window_index}") + if window.window_index is not None + else session + ) slot = plan.add( NewWindow( - target=session, + target=create_target, name=window.name, start_directory=window.start_directory or ws.start_directory, environment=_creator_environment(window) or None, diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index a59fa1b9c..4b16a8125 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -193,6 +193,10 @@ class Window: A shell command to launch in the window's first pane instead of the default shell (``new-window`` trailing command); also the fallback ``shell`` for the window's split panes. + window_index : int or None + Place this window at an explicit session index (``new-window -t + session:N``). Honored only for created windows (2..N); window 0 reuses + the session's implicit window and keeps the session base index. panes : Sequence[Pane] The window's panes (the first reuses the window's implicit pane). """ @@ -205,6 +209,7 @@ class Window: options_after: Mapping[str, str] = field(default_factory=dict) environment: Mapping[str, str] = field(default_factory=dict) window_shell: str | None = None + window_index: int | None = None panes: Sequence[Pane] = () def to_dict(self) -> dict[str, t.Any]: @@ -226,6 +231,8 @@ def to_dict(self) -> dict[str, t.Any]: out["environment"] = dict(self.environment) if self.window_shell is not None: out["window_shell"] = self.window_shell + if self.window_index is not None: + out["window_index"] = self.window_index out["panes"] = [pane.to_dict() for pane in self.panes] return out diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 12317a3f8..51142c7dd 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -685,6 +685,21 @@ def test_compile_emits_wait_pane_when_enabled() -> None: ) +def test_compile_window_index_targets_session_index() -> None: + """window_index places a created window at session:N (capture preserved).""" + ws = Workspace( + name="wi", + windows=[ + Window("a", panes=[Pane(run="echo x")]), + Window("b", window_index=5, panes=[Pane(run="echo y")]), + ], + ) + results = ws.build(ConcreteEngine(), preflight=False).results + new_window = next(r for r in results if r.argv[0] == "new-window") + assert "$1:5" in new_window.argv # targeted at the explicit session index + assert new_window.ok # the -P -F capture still binds the new window id + + def test_workspace_wait_pane_builds_live(session: Session, tmp_path: Path) -> None: """A wait_pane build polls readiness and still completes over real tmux.""" spec = analyze( From bcb34dc208ff6a9a605b84e32be9125247899eae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:23:29 -0500 Subject: [PATCH 081/154] Mcp(feat): Expose build_workspace on the async server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the async-first control-mode server lacked the Declarative tier — build_workspace was sync-only — so an agent on an async engine could not build a whole workspace in one call (a documented asymmetry). what: - plan_tools: abuild_workspace, the async sibling over analyze(spec).abuild(engine) - fastmcp_adapter: register an async build_workspace on the async server, backed by abuild_workspace (mirrors execute_plan's conditional-variant type:ignore) - export abuild_workspace from the mcp package - test: the async server lists + calls build_workspace offline --- src/libtmux/experimental/mcp/__init__.py | 2 ++ .../experimental/mcp/fastmcp_adapter.py | 21 +++++++++++++++++- src/libtmux/experimental/mcp/plan_tools.py | 22 +++++++++++++++++++ tests/experimental/mcp/test_adapter_async.py | 21 ++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 1c1d96ade..b31bb5d6a 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -31,6 +31,7 @@ PlanOutcome, PlanPreview, ResultSchema, + abuild_workspace, aexecute_plan, build_workspace, execute_plan, @@ -267,6 +268,7 @@ def main(argv: Sequence[str] | None = None) -> None: "SessionResult", "ToolDescriptor", "WindowResult", + "abuild_workspace", "aexecute_plan", "build_workspace", "capture_pane", diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 915f25511..ce5fd752e 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -492,7 +492,26 @@ async def execute_plan( "bindings": outcome.bindings, } + async def build_workspace( + spec: dict[str, t.Any], + preflight: bool = True, + version: str | None = None, + ) -> dict[str, t.Any]: + """Build a declarative workspace (the Declarative tier) in one call.""" + outcome = await _plan.abuild_workspace( + spec, + t.cast("AsyncTmuxEngine", engine), + version=version, + preflight=preflight, + ) + return { + "ok": outcome.ok, + "results": outcome.results, + "bindings": outcome.bindings, + } + tools.append((execute_plan, "mutating")) + tools.append((build_workspace, "mutating")) else: def execute_plan( # type: ignore[misc] @@ -513,7 +532,7 @@ def execute_plan( # type: ignore[misc] "bindings": outcome.bindings, } - def build_workspace( + def build_workspace( # type: ignore[misc] spec: dict[str, t.Any], preflight: bool = True, version: str | None = None, diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py index bc5bc7101..49da2edee 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -142,3 +142,25 @@ def build_workspace( results=[result_to_dict(item) for item in result.results], bindings=bindings_to_dict(result.bindings), ) + + +async def abuild_workspace( + spec: t.Mapping[str, t.Any] | str, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, +) -> PlanOutcome: + """Async sibling of :func:`build_workspace` (builds over an async engine). + + Lets the async-first server expose the Declarative tier too, so an agent on a + control-mode engine can build a whole workspace in one call. + """ + from libtmux.experimental.workspace import analyze + + result = await analyze(spec).abuild(engine, version=version, preflight=preflight) + return PlanOutcome( + ok=result.ok, + results=[result_to_dict(item) for item in result.results], + bindings=bindings_to_dict(result.bindings), + ) diff --git a/tests/experimental/mcp/test_adapter_async.py b/tests/experimental/mcp/test_adapter_async.py index 08a7b6904..e34890b3c 100644 --- a/tests/experimental/mcp/test_adapter_async.py +++ b/tests/experimental/mcp/test_adapter_async.py @@ -91,3 +91,24 @@ async def main() -> t.Any: data = asyncio.run(main()) assert data["ok"] is True + + +def test_async_build_workspace_offline() -> None: + """The async server exposes build_workspace, backed by abuild_workspace.""" + spec = { + "session_name": "ws", + "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], + } + + async def main() -> tuple[set[str], t.Any]: + async with fastmcp.Client(_async_server()) as client: + names = {tool.name for tool in await client.list_tools()} + payload = await client.call_tool( + "build_workspace", + {"spec": spec, "preflight": False}, + ) + return names, payload + + names, payload = asyncio.run(main()) + assert "build_workspace" in names # Declarative tier reaches the async server + assert (payload.structured_content or {})["ok"] is True From 731e6136bf38a171126eef63988ee95ce9d47821 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:24:31 -0500 Subject: [PATCH 082/154] Mcp(feat[safety]): Add tier constants, resolver, ExpectedToolError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: porting libtmux-mcp's safety surface into the core adapter needs a single source of truth for the safety tiers and the agent-correctable error type, ahead of the middleware and the tag-gate. what: - _safety.py: TAG_readonly/mutating/destructive, VALID_SAFETY_LEVELS, _TIER_LEVELS, resolve_safety_level (None->mutating, valid->verbatim, invalid->warn+readonly fail-safe), ExpectedToolError(ToolError) (log_level=WARNING default + suggestion) — fastmcp+logging deps only, off the framework-agnostic import path - tests: resolver defaults/fail-safe-with-warning + ExpectedToolError --- src/libtmux/experimental/mcp/_safety.py | 95 +++++++++++++++++++++++++ tests/experimental/mcp/test_safety.py | 52 ++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 src/libtmux/experimental/mcp/_safety.py create mode 100644 tests/experimental/mcp/test_safety.py diff --git a/src/libtmux/experimental/mcp/_safety.py b/src/libtmux/experimental/mcp/_safety.py new file mode 100644 index 000000000..cae9479fd --- /dev/null +++ b/src/libtmux/experimental/mcp/_safety.py @@ -0,0 +1,95 @@ +"""Safety tiers for the MCP tool surface. + +A small, dependency-light core: the three safety-tier tags every tool is +registered under, the ``LIBTMUX_SAFETY`` resolver, and the expected-error type +the middleware demotes to ``WARNING``. Imported only from the fastmcp edge (the +adapter wiring and the middleware), so it stays cycle-free -- fastmcp + logging +only, never the framework-agnostic core. +""" + +from __future__ import annotations + +import logging + +from fastmcp.exceptions import ToolError + +logger = logging.getLogger(__name__) + +#: Safety-tier tags -- the string every tool is registered under. +TAG_READONLY = "readonly" +TAG_MUTATING = "mutating" +TAG_DESTRUCTIVE = "destructive" + +#: The recognized ``LIBTMUX_SAFETY`` values. +VALID_SAFETY_LEVELS: frozenset[str] = frozenset( + {TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE}, +) + +#: Tier ordering: a tool at level N is allowed when ``N <= the server's max``. +_TIER_LEVELS: dict[str, int] = { + TAG_READONLY: 0, + TAG_MUTATING: 1, + TAG_DESTRUCTIVE: 2, +} + + +def resolve_safety_level(value: str | None) -> str: + """Return the effective safety tier for a ``LIBTMUX_SAFETY`` value. + + Unset defaults to ``"mutating"`` (mutating tools visible, destructive + hidden); a recognized value is honored verbatim; anything else fails *safe* + to ``"readonly"`` with a warning. + + Examples + -------- + >>> resolve_safety_level(None) + 'mutating' + >>> resolve_safety_level("destructive") + 'destructive' + >>> resolve_safety_level("bogus") + 'readonly' + """ + if value is None: + return TAG_MUTATING + if value in VALID_SAFETY_LEVELS: + return value + logger.warning( + "invalid LIBTMUX_SAFETY=%r, falling back to %s", + value, + TAG_READONLY, + ) + return TAG_READONLY + + +class ExpectedToolError(ToolError): + """A ``ToolError`` for expected, agent-correctable failures. + + Defaults ``log_level`` to ``WARNING`` (honored by fastmcp when logging tool + failures) so routine validation errors, missing objects, and tier denials do + not surface as ``ERROR`` records. Carries an optional agent-facing + ``suggestion`` the error-result middleware appends to the result text and + mirrors into the result ``meta``. + + Examples + -------- + >>> import logging + >>> ExpectedToolError("Pane not found: %5").log_level == logging.WARNING + True + >>> ExpectedToolError("noisy", log_level=logging.INFO).log_level == logging.INFO + True + >>> isinstance(ExpectedToolError("x"), ToolError) + True + >>> ExpectedToolError("x", suggestion="Call list_panes.").suggestion + 'Call list_panes.' + >>> ExpectedToolError("no hint").suggestion is None + True + """ + + def __init__( + self, + *args: object, + log_level: int = logging.WARNING, + suggestion: str | None = None, + ) -> None: + super().__init__(*args, log_level=log_level) + self.suggestion = suggestion diff --git a/tests/experimental/mcp/test_safety.py b/tests/experimental/mcp/test_safety.py new file mode 100644 index 000000000..e580d4bc1 --- /dev/null +++ b/tests/experimental/mcp/test_safety.py @@ -0,0 +1,52 @@ +"""Tests for the MCP safety-tier core (resolver + ExpectedToolError).""" + +from __future__ import annotations + +import logging + +import pytest + +pytest.importorskip("fastmcp") + + +def test_resolve_safety_level_default_is_mutating() -> None: + """An unset LIBTMUX_SAFETY defaults to the mutating tier.""" + from libtmux.experimental.mcp._safety import TAG_MUTATING, resolve_safety_level + + assert resolve_safety_level(None) == TAG_MUTATING + + +def test_resolve_safety_level_honors_valid_values() -> None: + """Each recognized tier resolves to itself.""" + from libtmux.experimental.mcp._safety import ( + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + resolve_safety_level, + ) + + for tier in (TAG_READONLY, TAG_MUTATING, TAG_DESTRUCTIVE): + assert resolve_safety_level(tier) == tier + + +def test_resolve_safety_level_invalid_fails_safe_with_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """An invalid value falls back to readonly and warns (fail-safe).""" + from libtmux.experimental.mcp._safety import TAG_READONLY, resolve_safety_level + + with caplog.at_level(logging.WARNING, logger="libtmux.experimental.mcp._safety"): + assert resolve_safety_level("bogus") == TAG_READONLY + assert any( + record.levelno == logging.WARNING and "LIBTMUX_SAFETY" in record.getMessage() + for record in caplog.records + ) + + +def test_expected_tool_error_carries_suggestion() -> None: + """ExpectedToolError defaults to WARNING and stores a suggestion.""" + from libtmux.experimental.mcp._safety import ExpectedToolError + + err = ExpectedToolError("Pane not found", suggestion="Call list_panes.") + assert err.log_level == logging.WARNING + assert err.suggestion == "Call list_panes." From faea0f0c1dbbe7b96fbca070bfe25cd374a9367b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:30:10 -0500 Subject: [PATCH 083/154] Mcp(feat[middleware]): Port error-result + tail-preserving limiter why: fastmcp's stock transform funnels every expected failure through a -32603 "Internal error:" catch-all, and its response limiter drops the tail (terminal scrollback's useful output is at the bottom). what: - new mcp/middleware.py with the real fastmcp base-class imports - ToolErrorResultMiddleware: tool failures -> ToolResult(is_error) with the clean message + typed meta (error_type/expected/suggestion); _log_error demotes ExpectedToolError + schema-validation to WARNING - TailPreservingResponseLimitingMiddleware: keeps the tail, prefixes a truncation header, re-attaches is_error the base path drops - the schema-validation + suggestion helpers (no raw input echoed), _RESPONSE_LIMITED_TOOLS (engine-ops scrollback tools) - dropped libtmux-mcp's global fastmcp-log-filter side effect - tests: tail-keep, error-result meta/suggestion, schema redaction --- src/libtmux/experimental/mcp/middleware.py | 348 ++++++++++++++++++ .../mcp/test_middleware_errors.py | 82 +++++ 2 files changed, 430 insertions(+) create mode 100644 src/libtmux/experimental/mcp/middleware.py create mode 100644 tests/experimental/mcp/test_middleware_errors.py diff --git a/src/libtmux/experimental/mcp/middleware.py b/src/libtmux/experimental/mcp/middleware.py new file mode 100644 index 000000000..0d598df12 --- /dev/null +++ b/src/libtmux/experimental/mcp/middleware.py @@ -0,0 +1,348 @@ +"""fastmcp middleware for the engine-ops MCP server. + +Ported from libtmux-mcp, adapted to the engine-ops adapter (the only +``libtmux_mcp`` dependency was the safety core, now :mod:`._safety`). In +outer-to-inner stack order: + +* :class:`SafetyMiddleware` gates tools by safety tier (see :mod:`._safety`). +* :class:`ToolErrorResultMiddleware` converts tool-call failures into + ``ToolResult(is_error=True)`` carrying the clean message + a structured + ``meta`` payload, instead of fastmcp's ``-32603`` "Internal error: " catch-all. +* :class:`AuditMiddleware` emits one structured log record per tool call, with + payload-bearing arguments redacted to a length + SHA-256 prefix. +* :class:`ReadonlyRetryMiddleware` retries transient libtmux failures, but only + for readonly tools (re-running a mutating tool would double side effects). +* :class:`TailPreservingResponseLimitingMiddleware` caps oversized output while + keeping the **tail** -- terminal scrollback's useful output is at the bottom. + +This module is imported only from the fastmcp edge (the adapter builders), so it +imports the real fastmcp base classes at module top. +""" + +from __future__ import annotations + +import logging +import typing as t + +from fastmcp.server.middleware import MiddlewareContext +from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware +from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware +from fastmcp.tools.base import ToolResult +from mcp.types import CallToolRequestParams, TextContent +from pydantic import ValidationError as PydanticValidationError + +from libtmux.experimental.mcp._safety import ExpectedToolError + +#: Curated scrollback tools whose output the tail-preserving limiter backstops. +#: Only terminal-text tools benefit; structured list/get responses stay under the +#: cap naturally. +_RESPONSE_LIMITED_TOOLS: tuple[str, ...] = ( + "capture_pane", + "capture_active_pane", + "grep_pane", + "search_panes", + "show_buffer", + "capture_relative_pane", + "grep_relative_pane", +) + +#: Default byte ceiling -- matches fastmcp's stock 1 MB so normal schema-bearing +#: responses stay below this global backstop. +DEFAULT_RESPONSE_LIMIT_BYTES = 1_000_000 + + +# --------------------------------------------------------------------------- +# Tool-error result conversion +# --------------------------------------------------------------------------- + + +def _schema_validation_error( + error: BaseException, +) -> PydanticValidationError | None: + """Return the Pydantic validation error behind a schema failure.""" + if isinstance(error, PydanticValidationError): + return error + cause = error.__cause__ + if isinstance(cause, PydanticValidationError): + return cause + return None + + +def _is_schema_validation_error(error: BaseException) -> bool: + """Return True for fastmcp argument-schema validation failures. + + fastmcp validates tool arguments against the input schema *before* tool code + runs, raising a bare :exc:`pydantic.ValidationError`. Bad arguments are + agent-correctable, so they get the same expected/WARNING treatment as + :class:`~._safety.ExpectedToolError`. + """ + return _schema_validation_error(error) is not None + + +def _validation_errors_without_inputs( + error: PydanticValidationError, +) -> list[dict[str, t.Any]]: + """Return validation errors without rejected input values.""" + return t.cast( + "list[dict[str, t.Any]]", + error.errors( + include_url=False, + include_context=False, + include_input=False, + ), + ) + + +def _format_schema_validation_error(error: BaseException) -> str: + """Format a Pydantic validation error without raw input values.""" + err = _schema_validation_error(error) + if err is None: + return str(error) + count = err.error_count() + noun = "validation error" if count == 1 else "validation errors" + lines = [f"{count} {noun} for {err.title}"] + for item in _validation_errors_without_inputs(err): + loc = ".".join(str(part) for part in item.get("loc", ())) or "__root__" + msg = str(item.get("msg", "Input validation failed")) + error_type = str(item.get("type", "unknown")) + lines.extend((loc, f" {msg} [type={error_type}]")) + return "\n".join(lines) + + +#: Scheduling flag some MCP clients (notably Gemini CLI batching tool calls) +#: merge into a tool's arguments. Recognized only to *word the rejection +#: helpfully* -- the argument is still rejected, never silently stripped. +_CLIENT_SCHEDULING_FLAG = "wait_for_previous" + + +def _unexpected_kwargs(error: BaseException) -> list[str]: + """Argument names rejected as unexpected by schema validation.""" + err = _schema_validation_error(error) + if err is None: + return [] + return [ + str(item["loc"][-1]) + for item in err.errors() + if item.get("type") == "unexpected_keyword_argument" and item.get("loc") + ] + + +def _client_label(context: MiddlewareContext | None) -> str | None: + """``"name version"`` of the connected client, when the handshake exposed it. + + Every hop can be absent (unit-test contexts, background tasks, clients that + omit ``clientInfo``), so any failure resolves to ``None``. Used only to word + error suggestions; never gates behavior. + """ + if context is None: + return None + try: + fastmcp_ctx = context.fastmcp_context + if fastmcp_ctx is None: + return None + params = fastmcp_ctx.session.client_params + if params is None: + return None + info = params.clientInfo + return f"{info.name} {info.version}".strip() + except (AttributeError, RuntimeError): + return None + + +def _error_tool_result( + error: Exception, + context: MiddlewareContext | None = None, +) -> ToolResult: + """Build a rich ``ToolResult(is_error=True)`` from a tool failure. + + The text carries the error message exactly as raised -- no transform-layer + prefix -- with the recovery ``suggestion`` appended when available. ``meta`` + mirrors the details: ``error_type`` (the ``__cause__`` class when chained, so + agents see ``PaneNotFound`` not the wrapper), ``expected`` (True for + agent-correctable failures), and ``suggestion`` (carried by the error or + synthesized for rejected unexpected arguments). ``structured_content`` is + left unset so an error-shaped payload never fails a strict client's + output-schema validation. + """ + cause = error.__cause__ + origin = cause if cause is not None else error + meta: dict[str, t.Any] = { + "error_type": type(origin).__name__, + "expected": isinstance(error, ExpectedToolError) + or _is_schema_validation_error(error), + } + text = ( + _format_schema_validation_error(error) + if _is_schema_validation_error(error) + else str(error) + ) + suggestion = getattr(error, "suggestion", None) + if suggestion is None: + unknown = _unexpected_kwargs(error) + if unknown: + suggestion = ( + f"Remove or correct the unrecognized argument(s): {', '.join(unknown)}." + ) + if _CLIENT_SCHEDULING_FLAG in unknown: + client = _client_label(context) + who = ( + f"your client ({client})" + if client + else "some clients (e.g. Gemini CLI)" + ) + suggestion += ( + f" {_CLIENT_SCHEDULING_FLAG} is a scheduling flag {who} can " + f"leak into batched tool calls; retry the call without it." + ) + if suggestion: + meta["suggestion"] = suggestion + text = f"{text}\n{suggestion}" + return ToolResult( + content=[TextContent(type="text", text=text)], + meta=meta, + is_error=True, + ) + + +class ToolErrorResultMiddleware(ErrorHandlingMiddleware): + """Convert tool-call failures into rich ``ToolResult`` errors. + + Replaces the stock ``transform_errors`` ``-32603`` catch-all (which mangled + every expected failure message into ``"Internal error: ..."``) for + ``tools/call`` only; non-tool messages fall through to the inherited + ``on_message`` (preserving the MCP ``-32002`` resource-not-found transform). + + Ordering invariant: must sit **outside** ``AuditMiddleware``, + ``ReadonlyRetryMiddleware``, and ``SafetyMiddleware`` -- all three depend on + the failure still being an exception, so converting it to a result deeper in + the stack would silently break them. + """ + + def _log_error(self, error: Exception, context: MiddlewareContext) -> None: + """Log at the error's own ``log_level`` instead of a flat ERROR. + + Expected failures (``ExpectedToolError``, argument-schema validation) log + at WARNING; everything else at ERROR. + """ + level: int | None = getattr(error, "log_level", None) + if level is None: + level = ( + logging.WARNING if _is_schema_validation_error(error) else logging.ERROR + ) + + error_type = type(error).__name__ + method = context.method or "unknown" + + error_key = f"{error_type}:{method}" + self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 + + error_text = ( + _format_schema_validation_error(error) + if _is_schema_validation_error(error) + else str(error) + ) + self.logger.log( + level, + "Error in %s: %s: %s", + method, + error_type, + error_text, + exc_info=self.include_traceback, + ) + + if self.error_callback: + try: + self.error_callback(error, context) + except Exception: + self.logger.exception("Error in error callback") + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Convert tool-call exceptions into ``is_error`` results.""" + try: + return await call_next(context) + except Exception as error: + self._log_error(error, context) + return _error_tool_result(error, context) + + +# --------------------------------------------------------------------------- +# Tail-preserving response limiter +# --------------------------------------------------------------------------- + +#: Header prefixed to a truncated response. +_TRUNCATION_HEADER_TEMPLATE = "[... truncated {dropped} bytes ...]\n" + + +class TailPreservingResponseLimitingMiddleware(ResponseLimitingMiddleware): + """Response-limiter that keeps the tail of oversized output. + + fastmcp's stock :class:`ResponseLimitingMiddleware` keeps the *head*; that is + exactly wrong for terminal scrollback, where the active prompt and most + recent output live at the **bottom**. This subclass keeps the tail and + prefixes a single truncation-header line. Error results keep their + ``is_error`` flag through truncation (the base path drops it). + """ + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Apply the size cap without dropping ``is_error``.""" + inner: t.Any = None + + async def _capture( + context: MiddlewareContext[CallToolRequestParams], + ) -> ToolResult: + # ``context`` (not ``ctx``): fastmcp's CallNext protocol matches the + # parameter *name*, not just the shape -- renaming breaks dispatch. + nonlocal inner + inner = await call_next(context) + return t.cast("ToolResult", inner) + + result = await super().on_call_tool(context, _capture) + if result is not inner and isinstance(inner, ToolResult) and inner.is_error: + return ToolResult( + content=result.content, + meta=result.meta, + is_error=True, + ) + return result + + def _truncate_to_result( + self, + text: str, + meta: dict[str, t.Any] | None = None, + ) -> ToolResult: + """Keep the last ``max_size`` bytes of ``text`` and prefix a header.""" + encoded = text.encode("utf-8") + if len(encoded) <= self.max_size: + return ToolResult( + content=[TextContent(type="text", text=text)], + meta=meta if meta is not None else {}, + ) + + header = _TRUNCATION_HEADER_TEMPLATE.format(dropped=len(encoded)) + header_bytes = len(header.encode("utf-8")) + overhead = 50 # JSON-wrapper accounting, mirrors the base class + target_size = self.max_size - header_bytes - overhead + if target_size <= 0: + return ToolResult( + content=[TextContent(type="text", text=header.rstrip("\n"))], + meta=meta if meta is not None else {}, + ) + + # errors="ignore" so a split UTF-8 sequence at the boundary is dropped + # rather than corrupting the output. + tail = encoded[-target_size:].decode("utf-8", errors="ignore") + dropped = len(encoded) - len(tail.encode("utf-8")) + final_header = _TRUNCATION_HEADER_TEMPLATE.format(dropped=dropped) + truncated = final_header + tail + return ToolResult( + content=[TextContent(type="text", text=truncated)], + meta=meta if meta is not None else {}, + ) diff --git a/tests/experimental/mcp/test_middleware_errors.py b/tests/experimental/mcp/test_middleware_errors.py new file mode 100644 index 000000000..251f57d3e --- /dev/null +++ b/tests/experimental/mcp/test_middleware_errors.py @@ -0,0 +1,82 @@ +"""Tests for the error-result + tail-preserving response middleware.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("fastmcp") + + +def test_truncate_keeps_tail_with_header() -> None: + """The limiter drops the head and keeps the tail behind a header.""" + from mcp.types import TextContent + + from libtmux.experimental.mcp.middleware import ( + TailPreservingResponseLimitingMiddleware, + ) + + mw = TailPreservingResponseLimitingMiddleware(max_size=200) + result = mw._truncate_to_result("HEAD" + "x" * 1000 + "TAILEND") + block = result.content[0] + assert isinstance(block, TextContent) + assert block.text.startswith("[... truncated") + assert block.text.endswith("TAILEND") # tail preserved, head dropped + assert len(block.text.encode("utf-8")) <= 200 + + +def test_error_result_carries_message_and_meta() -> None: + """A plain failure becomes an is_error result with typed meta, no prefix.""" + from mcp.types import TextContent + + from libtmux.experimental.mcp.middleware import _error_tool_result + + result = _error_tool_result(ValueError("boom")) + block = result.content[0] + assert result.is_error is True + assert result.meta is not None + assert result.meta["error_type"] == "ValueError" + assert result.meta["expected"] is False + assert isinstance(block, TextContent) + assert block.text == "boom" + + +def test_error_result_appends_suggestion() -> None: + """An ExpectedToolError's suggestion lands in text + meta, marked expected.""" + from mcp.types import TextContent + + from libtmux.experimental.mcp._safety import ExpectedToolError + from libtmux.experimental.mcp.middleware import _error_tool_result + + result = _error_tool_result( + ExpectedToolError("Pane not found", suggestion="Call list_panes."), + ) + block = result.content[0] + assert result.meta is not None + assert result.meta["expected"] is True + assert result.meta["suggestion"] == "Call list_panes." + assert isinstance(block, TextContent) + assert block.text.endswith("Call list_panes.") + + +def test_schema_validation_error_formats_without_raw_input() -> None: + """Schema-validation failures are expected and never echo the raw input.""" + import pydantic + + from libtmux.experimental.mcp.middleware import ( + _format_schema_validation_error, + _is_schema_validation_error, + ) + + class Model(pydantic.BaseModel): + x: int + + try: + Model(x="not-an-int") # type: ignore[arg-type] + except pydantic.ValidationError as exc: + assert _is_schema_validation_error(exc) + formatted = _format_schema_validation_error(exc) + assert "x" in formatted + assert "[type=" in formatted + assert "not-an-int" not in formatted # raw input is redacted + else: + pytest.fail("expected a ValidationError") From f8423fc6e8b46fbc0280e3d6bcb67abb4b77bdea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:38:09 -0500 Subject: [PATCH 084/154] Mcp(feat[middleware]): Port safety, audit, readonly-retry middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: complete the middleware stack — the runtime safety gate (defense in depth behind the static tag-gate), a structured audit trail, and retries scoped to readonly tools so a transient socket error never double-runs a mutating tool. what: - SafetyMiddleware: fail-closed tier gate on list + call (untagged tool denied); raises ExpectedToolError on an over-tier call - AuditMiddleware: one INFO record per call, restructured to the project logging standard (static message + structured extra: tmux_subcommand/ outcome/duration_ms/tmux_args), payload args digested (len+sha256) - ReadonlyRetryMiddleware: composes fastmcp RetryMiddleware, delegates only for readonly-tagged tools; trigger LibTmuxException - loggers namespaced libtmux.experimental.mcp.audit/.retry - single tier source: _TIER_LEVELS/TAG_* imported from _safety - tests: audit redaction, fail-closed _is_allowed, retry pass-through --- src/libtmux/experimental/mcp/middleware.py | 327 +++++++++++++++++- .../experimental/mcp/test_middleware_audit.py | 85 +++++ 2 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 tests/experimental/mcp/test_middleware_audit.py diff --git a/src/libtmux/experimental/mcp/middleware.py b/src/libtmux/experimental/mcp/middleware.py index 0d598df12..d7510e03c 100644 --- a/src/libtmux/experimental/mcp/middleware.py +++ b/src/libtmux/experimental/mcp/middleware.py @@ -21,17 +21,27 @@ from __future__ import annotations +import hashlib import logging +import time import typing as t -from fastmcp.server.middleware import MiddlewareContext -from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware +from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.middleware.error_handling import ( + ErrorHandlingMiddleware, + RetryMiddleware, +) from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware from fastmcp.tools.base import ToolResult from mcp.types import CallToolRequestParams, TextContent from pydantic import ValidationError as PydanticValidationError -from libtmux.experimental.mcp._safety import ExpectedToolError +from libtmux import exc as libtmux_exc +from libtmux.experimental.mcp._safety import ( + _TIER_LEVELS, + TAG_READONLY, + ExpectedToolError, +) #: Curated scrollback tools whose output the tail-preserving limiter backstops. #: Only terminal-text tools benefit; structured list/get responses stay under the @@ -346,3 +356,314 @@ def _truncate_to_result( content=[TextContent(type="text", text=truncated)], meta=meta if meta is not None else {}, ) + + +# --------------------------------------------------------------------------- +# Safety tier gate (runtime) +# --------------------------------------------------------------------------- + + +class SafetyMiddleware(Middleware): + """Gate tools by safety tier at runtime (defense in depth). + + The adapter hides over-tier tools from listings statically; this middleware + blocks *execution* of anything that slips through (e.g. a per-op tool exposed + via ``expose_operations=True``). Fail-closed: a tool with no recognized tier + tag is denied. + + Parameters + ---------- + max_tier : str + Maximum allowed tier (one of the ``TAG_*`` values in :mod:`._safety`). + """ + + def __init__(self, max_tier: str) -> None: + self.max_level = _TIER_LEVELS.get(max_tier, 0) + + def _is_allowed(self, tags: set[str]) -> bool: + """Whether the tool's tags fall within the allowed tier (fail-closed).""" + found_tier = False + for tier, level in _TIER_LEVELS.items(): + if tier in tags: + found_tier = True + if level > self.max_level: + return False + return found_tier + + async def on_list_tools( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Filter tools above the safety tier from the listing.""" + tools = await call_next(context) + return [tool for tool in tools if self._is_allowed(tool.tags)] + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Block execution of tools above the safety tier.""" + if context.fastmcp_context: + tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) + if tool and not self._is_allowed(tool.tags): + msg = ( + f"Tool '{context.message.name}' is not available at the current " + f"safety level. Set LIBTMUX_SAFETY=destructive to enable " + f"destructive tools." + ) + raise ExpectedToolError(msg) + return await call_next(context) + + +# --------------------------------------------------------------------------- +# Audit middleware +# --------------------------------------------------------------------------- + +#: Argument names that carry user payloads we never want in logs (commands, +#: secrets, arbitrary large strings). Matched by exact name, case-sensitive. +#: ``environment`` is dict-shaped: its values are digested while its keys (env +#: var names) stay visible. +_SENSITIVE_ARG_NAMES: frozenset[str] = frozenset( + {"keys", "text", "command", "value", "content", "shell", "environment"}, +) + +#: Nested argument containers that may contain sensitive argument names. +_NESTED_ARG_LIST_NAMES: frozenset[str] = frozenset({"operations"}) + +_NONE_TYPE = type(None) + +_SEND_KEYS_OPERATION_ARG_TYPES: dict[str, tuple[type[t.Any], ...]] = { + "keys": (str,), + "pane_id": (str, _NONE_TYPE), + "session_name": (str, _NONE_TYPE), + "session_id": (str, _NONE_TYPE), + "window_id": (str, _NONE_TYPE), + "enter": (bool,), + "literal": (bool,), + "suppress_history": (bool,), +} + +#: Non-sensitive strings longer than this get truncated in the log summary. +_MAX_LOGGED_STR_LEN: int = 200 + + +def _redact_digest(value: str) -> dict[str, t.Any]: + """Return a length + SHA-256 prefix summary of ``value``. + + Stable and deterministic, so operators correlate the same payload across log + lines without ever recording the payload itself. + + Examples + -------- + >>> _redact_digest("hello") + {'len': 5, 'sha256_prefix': '2cf24dba5fb0'} + >>> _redact_digest("") + {'len': 0, 'sha256_prefix': 'e3b0c44298fc'} + """ + return { + "len": len(value), + "sha256_prefix": hashlib.sha256(value.encode("utf-8")).hexdigest()[:12], + } + + +def _redacted_value_shape(value: t.Any) -> dict[str, t.Any]: + """Return non-payload metadata for a value that cannot be logged.""" + return {"type": type(value).__name__, "redacted": True} + + +def _summarize_send_keys_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize one send-keys batch operation for audit logging.""" + summary: dict[str, t.Any] = {} + for key, value in args.items(): + expected_types = _SEND_KEYS_OPERATION_ARG_TYPES.get(key) + if expected_types is None or not isinstance(value, expected_types): + summary[key] = _redacted_value_shape(value) + else: + summary[key] = _summarize_args({key: value})[key] + return summary + + +def _summarize_tool_batch_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize one generic tool-batch operation for audit logging.""" + summary: dict[str, t.Any] = {} + for key, value in args.items(): + if key == "tool" and isinstance(value, str): + summary[key] = value + elif key == "arguments" and isinstance(value, dict): + summary[key] = _summarize_args(value) + else: + summary[key] = _redacted_value_shape(value) + return summary + + +def _summarize_nested_operation_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize a known nested operation shape.""" + if "tool" in args or "arguments" in args: + return _summarize_tool_batch_operation_args(args) + return _summarize_send_keys_operation_args(args) + + +def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: + """Summarize tool arguments for audit logging. + + Sensitive keys are replaced by a digest; over-long strings truncated; + everything else passes through. Dict-shaped sensitive values keep their keys + but digest each value; known nested operation lists are summarized + recursively. + + Examples + -------- + >>> _summarize_args({"pane_id": "%1", "bracket": True}) + {'pane_id': '%1', 'bracket': True} + >>> _summarize_args({"keys": "rm -rf /"})["keys"]["len"] + 8 + >>> redacted = _summarize_args({"environment": {"FOO": "bar"}}) + >>> redacted["environment"]["FOO"]["len"] + 3 + >>> "bar" in str(redacted) + False + """ + summary: dict[str, t.Any] = {} + for key, value in args.items(): + if key in _SENSITIVE_ARG_NAMES and isinstance(value, str): + summary[key] = _redact_digest(value) + elif key in _SENSITIVE_ARG_NAMES and isinstance(value, dict): + summary[key] = {k: _redact_digest(str(v)) for k, v in value.items()} + elif key in _NESTED_ARG_LIST_NAMES: + if isinstance(value, list): + summary[key] = [ + _summarize_nested_operation_args(item) + if isinstance(item, dict) + else _redacted_value_shape(item) + for item in value + ] + else: + summary[key] = _redacted_value_shape(value) + elif isinstance(value, str) and len(value) > _MAX_LOGGED_STR_LEN: + summary[key] = value[:_MAX_LOGGED_STR_LEN] + "..." + else: + summary[key] = value + return summary + + +class AuditMiddleware(Middleware): + """Emit a structured log record per tool invocation. + + One ``INFO`` record per call carries the tool name, outcome, duration, error + type on failure, the fastmcp client/request ids when available, and a + redacted argument summary -- all in the record's ``extra`` (the message is a + static template, per the project logging standard), so payload-bearing + arguments never reach the log as raw text. + + Parameters + ---------- + logger_name : str + Name of the :mod:`logging` logger used for audit records. + """ + + def __init__( + self, + logger_name: str = "libtmux.experimental.mcp.audit", + ) -> None: + self._logger = logging.getLogger(logger_name) + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Wrap the tool call with a timer and emit one audit record.""" + start = time.monotonic() + tool_name = getattr(context.message, "name", "") + raw_args = getattr(context.message, "arguments", None) or {} + args_summary = _summarize_args(raw_args) + + client_id: str | None = None + request_id: str | None = None + if context.fastmcp_context is not None: + client_id = getattr(context.fastmcp_context, "client_id", None) + request_id = getattr(context.fastmcp_context, "request_id", None) + + try: + result = await call_next(context) + except Exception as exc: + self._logger.info( + "tool call failed", + extra={ + "tmux_subcommand": tool_name, + "outcome": "error", + "error_type": type(exc).__name__, + "duration_ms": round((time.monotonic() - start) * 1000.0, 2), + "client_id": client_id, + "request_id": request_id, + "tmux_args": args_summary, + }, + ) + raise + + self._logger.info( + "tool call completed", + extra={ + "tmux_subcommand": tool_name, + "outcome": "ok", + "duration_ms": round((time.monotonic() - start) * 1000.0, 2), + "client_id": client_id, + "request_id": request_id, + "tmux_args": args_summary, + }, + ) + return result + + +# --------------------------------------------------------------------------- +# Readonly retry +# --------------------------------------------------------------------------- + + +class ReadonlyRetryMiddleware(Middleware): + """Retry transient libtmux failures, but only for readonly tools. + + Composes fastmcp's :class:`RetryMiddleware`. Mutating and destructive tools + pass straight through -- re-running them on a transient socket error would + silently double side effects. Readonly tools are safe to retry. The default + trigger is :class:`libtmux.exc.LibTmuxException` (libtmux wraps the transient + subprocess failures); fastmcp's stock ``(ConnectionError, TimeoutError)`` does + not match these, so the upstream default would be a silent no-op. + + Place this **inside** ``AuditMiddleware`` (so retried calls are audited once) + and **outside** ``SafetyMiddleware`` (so tier-denied tools never reach retry). + """ + + def __init__( + self, + max_retries: int = 1, + base_delay: float = 0.1, + max_delay: float = 1.0, + backoff_multiplier: float = 2.0, + retry_exceptions: tuple[type[Exception], ...] = (libtmux_exc.LibTmuxException,), + logger_: logging.Logger | None = None, + ) -> None: + if logger_ is None: + logger_ = logging.getLogger("libtmux.experimental.mcp.retry") + self._retry = RetryMiddleware( + max_retries=max_retries, + base_delay=base_delay, + max_delay=max_delay, + backoff_multiplier=backoff_multiplier, + retry_exceptions=retry_exceptions, + logger=logger_, + ) + + async def on_call_tool( + self, + context: MiddlewareContext, + call_next: t.Any, + ) -> t.Any: + """Delegate to the upstream retry only for tools tagged readonly.""" + if context.fastmcp_context: + tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) + if tool and TAG_READONLY in tool.tags: + return await self._retry.on_request(context, call_next) + return await call_next(context) diff --git a/tests/experimental/mcp/test_middleware_audit.py b/tests/experimental/mcp/test_middleware_audit.py new file mode 100644 index 000000000..b503f8b97 --- /dev/null +++ b/tests/experimental/mcp/test_middleware_audit.py @@ -0,0 +1,85 @@ +"""Tests for the audit, safety-tier, and readonly-retry middleware.""" + +from __future__ import annotations + +import asyncio +import logging +import typing as t +from types import SimpleNamespace + +import pytest + +pytest.importorskip("fastmcp") + + +def test_audit_emits_one_redacted_record(caplog: pytest.LogCaptureFixture) -> None: + """One structured INFO record per call, with sensitive args digested.""" + from libtmux.experimental.mcp.middleware import AuditMiddleware + + mw = AuditMiddleware() + ctx: t.Any = SimpleNamespace( + message=SimpleNamespace( + name="send_input", + arguments={"keys": "secret-cmd", "pane_id": "%1"}, + ), + fastmcp_context=SimpleNamespace(client_id="c1", request_id="r1"), + ) + + async def call_next(_context: t.Any) -> str: + return "ok" + + async def main() -> t.Any: + return await mw.on_call_tool(ctx, call_next) + + with caplog.at_level(logging.INFO, logger="libtmux.experimental.mcp.audit"): + result = asyncio.run(main()) + + assert result == "ok" + records = [ + r for r in caplog.records if getattr(r, "tmux_subcommand", None) == "send_input" + ] + assert len(records) == 1 + record = records[0] + assert record.outcome == "ok" # type: ignore[attr-defined] + assert isinstance(record.duration_ms, float) # type: ignore[attr-defined] + # the sensitive `keys` payload is digested, never logged raw + summary = record.tmux_args # type: ignore[attr-defined] + assert "secret-cmd" not in str(summary) + assert summary["keys"]["sha256_prefix"] + assert summary["pane_id"] == "%1" # non-sensitive args pass through + + +def test_safety_is_allowed_is_fail_closed() -> None: + """A tool at or below the tier is allowed; an untagged tool is denied.""" + from libtmux.experimental.mcp._safety import ( + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + ) + from libtmux.experimental.mcp.middleware import SafetyMiddleware + + mw = SafetyMiddleware(TAG_MUTATING) + assert mw._is_allowed({TAG_READONLY}) + assert mw._is_allowed({TAG_MUTATING}) + assert not mw._is_allowed({TAG_DESTRUCTIVE}) # over tier + assert not mw._is_allowed(set()) # no recognized tier -> denied + + +def test_readonly_retry_passes_through_without_context() -> None: + """With no fastmcp context the retry wrapper is a transparent pass-through.""" + from libtmux.experimental.mcp.middleware import ReadonlyRetryMiddleware + + mw = ReadonlyRetryMiddleware() + ctx: t.Any = SimpleNamespace(fastmcp_context=None) + calls = 0 + + async def call_next(_context: t.Any) -> str: + nonlocal calls + calls += 1 + return "value" + + async def main() -> t.Any: + return await mw.on_call_tool(ctx, call_next) + + assert asyncio.run(main()) == "value" + assert calls == 1 # not retried, just forwarded From f7c9946b4e28ba324eb0cb15f016c5e619d86353 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:44:42 -0500 Subject: [PATCH 085/154] Mcp(feat[safety]): Wire safety gate + middleware into the builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: replacing libtmux-mcp needs the safety tier-gate and the middleware stack on the engine-ops servers — gating destructive tools by LIBTMUX_SAFETY (default mutating) and adding the timing/limit/error/ audit/retry/safety chain. what: - _apply_safety_gate (Option A, subtractive): disable only the over-tier tiers AFTER register_operations, so the per-op hide is never undone — destructive op_* stay hidden at every tier (regression-tested) - _make_middleware builds the outer->inner stack (Safety innermost, fail-closed); passed at FastMCP(middleware=...) construction - build_server/build_async_server grow safety_level + include_middleware; level resolved in-body (env read deferred -> monkeypatchable) - main() gains --safety; default_server/main forward it - tests: static visibility per tier, the per-op re-exposure regression, destructive-call blocked at readonly, plan-tool tier - existing kill_*/op_kill_* tests opt into safety_level="destructive" (the new default tier hides destructive tools, as intended) --- src/libtmux/experimental/mcp/__init__.py | 8 ++ .../experimental/mcp/fastmcp_adapter.py | 88 ++++++++++++++++++- tests/experimental/mcp/test_caller.py | 6 +- .../experimental/mcp/test_fastmcp_adapter.py | 13 ++- tests/experimental/mcp/test_safety_gate.py | 82 +++++++++++++++++ 5 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 tests/experimental/mcp/test_safety_gate.py diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index b31bb5d6a..4a66441ca 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -182,6 +182,12 @@ def main(argv: Sequence[str] | None = None) -> None: action="store_true", help="expose the full per-operation tool surface (op_*)", ) + parser.add_argument( + "--safety", + choices=("readonly", "mutating", "destructive"), + default=None, + help="max tool safety tier (default: $LIBTMUX_SAFETY or mutating)", + ) parser.add_argument( "--sync", action="store_true", @@ -232,6 +238,7 @@ def main(argv: Sequence[str] | None = None) -> None: SubprocessEngine(server_args=srv_args), name=args.name, expose_operations=args.operations, + safety_level=args.safety, caller=ctx, ) else: @@ -243,6 +250,7 @@ def main(argv: Sequence[str] | None = None) -> None: AsyncControlModeEngine(server_args=srv_args), name=args.name, expose_operations=args.operations, + safety_level=args.safety, events=t.cast("EventMode", args.events), event_source=t.cast("EventSource", args.event_source), caller=ctx, diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index ce5fd752e..1d5f11350 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -23,6 +23,7 @@ import dataclasses import inspect +import os import typing as t from libtmux.experimental.mcp import vocabulary @@ -579,6 +580,70 @@ def _stash_caller(engine: t.Any, ctx: CallerContext) -> None: engine._caller_context = ctx +def _make_middleware(level: str) -> list[t.Any]: + """Build the middleware stack (outer-to-inner; Safety innermost, fail-closed). + + Timing observes; the tail-preserving limiter caps oversized scrollback; + ToolErrorResult converts failures to typed error results; Audit records each + call; ReadonlyRetry retries readonly tools; Safety gates execution by tier. + """ + from fastmcp.server.middleware.timing import TimingMiddleware + + from libtmux.experimental.mcp.middleware import ( + _RESPONSE_LIMITED_TOOLS, + DEFAULT_RESPONSE_LIMIT_BYTES, + AuditMiddleware, + ReadonlyRetryMiddleware, + SafetyMiddleware, + TailPreservingResponseLimitingMiddleware, + ToolErrorResultMiddleware, + ) + + return [ + TimingMiddleware(), + TailPreservingResponseLimitingMiddleware( + max_size=DEFAULT_RESPONSE_LIMIT_BYTES, + tools=list(_RESPONSE_LIMITED_TOOLS), + ), + ToolErrorResultMiddleware(transform_errors=True), + AuditMiddleware(), + ReadonlyRetryMiddleware(), + SafetyMiddleware(max_tier=level), + ] + + +def _apply_safety_gate(mcp: FastMCP, max_tier: str) -> None: + """Hide tools above *max_tier* without re-exposing hidden per-op tools. + + Subtractive (never calls ``enable``): disable only the over-tier tiers, so the + per-op hide (``mcp.disable(tags={'per-op'})`` in :func:`register_operations`) + and any individually-disabled tool survive. ``readonly`` is always allowed. + """ + from libtmux.experimental.mcp._safety import ( + TAG_DESTRUCTIVE, + TAG_MUTATING, + TAG_READONLY, + ) + + allowed = {TAG_READONLY} + if max_tier in {TAG_MUTATING, TAG_DESTRUCTIVE}: + allowed.add(TAG_MUTATING) + if max_tier == TAG_DESTRUCTIVE: + allowed.add(TAG_DESTRUCTIVE) + for tier in (TAG_MUTATING, TAG_DESTRUCTIVE): + if tier not in allowed: + mcp.disable(tags={tier}) + + +def _resolve_level(safety_level: str | None) -> str: + """Resolve the effective tier from an explicit arg or ``LIBTMUX_SAFETY``.""" + from libtmux.experimental.mcp._safety import resolve_safety_level + + return resolve_safety_level( + safety_level if safety_level is not None else os.environ.get("LIBTMUX_SAFETY"), + ) + + def build_server( engine: TmuxEngine, *, @@ -587,6 +652,8 @@ def build_server( include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, + include_middleware: bool = True, + safety_level: str | None = None, caller: CallerContext | None = None, ) -> FastMCP: """Build a synchronous FastMCP server over a sync *engine*. @@ -595,12 +662,22 @@ def build_server( offloads to a worker thread. Prefer :func:`build_async_server` for the async-first surface and the event stream. *caller* defaults to :meth:`CallerContext.discover`. + + *safety_level* (or ``LIBTMUX_SAFETY``) gates the tool surface by tier + (``readonly``/``mutating``/``destructive``, default ``mutating``); over-tier + tools are hidden and blocked. *include_middleware* adds the full stack + (timing, response cap, error results, audit, readonly retry, safety). """ from fastmcp import FastMCP + level = _resolve_level(safety_level) ctx = caller if caller is not None else CallerContext.discover() _stash_caller(engine, ctx) - mcp: FastMCP = FastMCP(name=name, instructions=instructions or _instructions(ctx)) + mcp: FastMCP = FastMCP( + name=name, + instructions=instructions or _instructions(ctx), + middleware=_make_middleware(level) if include_middleware else None, + ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=False) register_caller_context(mcp, ctx) @@ -614,6 +691,7 @@ def build_server( ) if include_plan_tools: register_plan_tools(mcp, engine, is_async=False, registry=registry) + _apply_safety_gate(mcp, level) return mcp @@ -625,6 +703,8 @@ def build_async_server( include_operations: bool = True, expose_operations: bool = False, include_plan_tools: bool = True, + include_middleware: bool = True, + safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", caller: CallerContext | None = None, @@ -636,17 +716,22 @@ def build_async_server( notification stream (a control-mode engine), the live event tools are registered per *events* (``"push"``/``"pull"``/``"both"``/``"off"``). *caller* defaults to :meth:`CallerContext.discover`. + + *safety_level* (or ``LIBTMUX_SAFETY``) and *include_middleware* behave as in + :func:`build_server`. """ from fastmcp import FastMCP from libtmux.experimental.mcp.events import _supports_stream, register_events + level = _resolve_level(safety_level) ctx = caller if caller is not None else CallerContext.discover() _stash_caller(engine, ctx) events_enabled = events != "off" and _supports_stream(engine) mcp: FastMCP = FastMCP( name=name, instructions=instructions or _instructions(ctx, events_enabled=events_enabled), + middleware=_make_middleware(level) if include_middleware else None, ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) @@ -662,4 +747,5 @@ def build_async_server( if include_plan_tools: register_plan_tools(mcp, engine, is_async=True, registry=registry) register_events(mcp, engine, mode=events, source=event_source) + _apply_safety_gate(mcp, level) return mcp diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index 802b9b77a..317b0c342 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -374,7 +374,10 @@ def test_op_kill_pane_is_guarded(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") server = build_async_server( - SyncToAsyncEngine(ConcreteEngine()), events="off", expose_operations=True + SyncToAsyncEngine(ConcreteEngine()), + events="off", + expose_operations=True, + safety_level="destructive", # op_kill_* is destructive-tier ) async def main() -> None: @@ -448,6 +451,7 @@ def test_op_kill_pane_others_is_guarded_live( AsyncSubprocessEngine.for_server(session.server), events="off", expose_operations=True, + safety_level="destructive", # op_kill_* is destructive-tier ) async def main() -> None: diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py index 80dc91278..2fd5ffd43 100644 --- a/tests/experimental/mcp/test_fastmcp_adapter.py +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -27,7 +27,8 @@ def test_adapter_registers_typed_tools() -> None: """The curated vocabulary appears as typed tools with safety annotations.""" - server = build_server(ConcreteEngine()) + # destructive tier so the kill_* tools this asserts on are visible + server = build_server(ConcreteEngine(), safety_level="destructive") async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -73,7 +74,8 @@ async def main() -> t.Any: def test_adapter_live(session: Session) -> None: """Drive a real tmux server through fastmcp tools end to end.""" server = session.server - mcp = build_server(SubprocessEngine.for_server(server)) + # destructive tier so the live kill_session call below is permitted + mcp = build_server(SubprocessEngine.for_server(server), safety_level="destructive") async def main() -> str | None: async with fastmcp.Client(mcp) as client: @@ -112,7 +114,12 @@ async def main() -> t.Any: def test_adapter_exposes_per_op_tools() -> None: """``expose_operations`` reveals one typed ``op_`` per operation.""" - server = build_server(ConcreteEngine(), expose_operations=True) + # destructive tier so the destructive op_kill_* tools are visible too + server = build_server( + ConcreteEngine(), + expose_operations=True, + safety_level="destructive", + ) async def main() -> t.Any: async with fastmcp.Client(server) as client: diff --git a/tests/experimental/mcp/test_safety_gate.py b/tests/experimental/mcp/test_safety_gate.py new file mode 100644 index 000000000..5de29f5f0 --- /dev/null +++ b/tests/experimental/mcp/test_safety_gate.py @@ -0,0 +1,82 @@ +"""Tests for the safety tier-gate wired into the fastmcp adapter.""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine + +fastmcp = pytest.importorskip("fastmcp") + +from libtmux.experimental.mcp.fastmcp_adapter import build_server # noqa: E402 + + +def _names_at(level: str, **kwargs: t.Any) -> set[str]: + """Return the visible tool names from a server built at *level*.""" + + async def main() -> set[str]: + server = build_server(ConcreteEngine(), safety_level=level, **kwargs) + async with fastmcp.Client(server) as client: + return {tool.name for tool in await client.list_tools()} + + return asyncio.run(main()) + + +def test_safety_gate_static_visibility() -> None: + """Each tier hides the tools above it from the listing.""" + readonly = _names_at("readonly") + mutating = _names_at("mutating") + destructive = _names_at("destructive") + + assert "list_sessions" in readonly # readonly always visible + assert "create_session" not in readonly # mutating hidden at readonly + assert "create_session" in mutating # ... visible at mutating + assert "kill_session" not in readonly # destructive hidden ... + assert "kill_session" not in mutating # ... and at mutating + assert "kill_session" in destructive # visible only at destructive + + +def test_safety_gate_keeps_per_op_hidden_at_every_tier() -> None: + """Regression: the subtractive gate never re-exposes hidden per-op tools.""" + for level in ("readonly", "mutating", "destructive"): + names = _names_at(level, include_operations=True, expose_operations=False) + assert not any(name.startswith("op_") for name in names), ( + f"per-op tools leaked into the listing at safety_level={level!r}" + ) + # expose_operations=True surfaces them (and they still respect the tier) + exposed = _names_at( + "destructive", + include_operations=True, + expose_operations=True, + ) + assert any(name.startswith("op_") for name in exposed) + + +def test_safety_gate_blocks_destructive_call_at_readonly() -> None: + """A destructive tool cannot be successfully called at the readonly tier.""" + + async def main() -> tuple[str, t.Any]: + server = build_server(ConcreteEngine(), safety_level="readonly") + async with fastmcp.Client(server) as client: + try: + result = await client.call_tool("kill_session", {"target": "$1"}) + except Exception as error: + return ("raised", type(error).__name__) + return ("result", result.is_error) + + kind, value = asyncio.run(main()) + # Either the hidden tool is rejected (raised) or surfaced as an error result; + # never a clean success. + assert kind == "raised" or value is True + + +def test_safety_gate_plan_tool_tier() -> None: + """Plan tools obey the tier too (build_workspace is mutating).""" + readonly = _names_at("readonly") + mutating = _names_at("mutating") + assert "preview_plan" in readonly # readonly plan tool always visible + assert "build_workspace" not in readonly # mutating plan tool hidden ... + assert "build_workspace" in mutating # ... visible at mutating From f3bdf23984557fcae0de74392205dad52235a7ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:50:01 -0500 Subject: [PATCH 086/154] Mcp(feat[prompts]): Add recipe prompts in engine-ops vocabulary why: libtmux-mcp ships workflow prompts (run-and-wait, diagnose, build-workspace, interrupt) that package operator-discovered best practices; the engine-ops server should offer the same, in its own vocabulary. what: - prompts.py: the four recipes rewritten over the engine-ops verbs (send_input/wait_for_output/capture_pane/create_session/split_pane), not libtmux-mcp's run_command/snapshot_pane/send_keys/split_window - register_prompts(mcp) via Prompt.from_function; pure string builders, identical on the sync and async servers - both builders gain include_prompts (default True); registered after the caller context - tests: the four prompts register; rendered bodies name only engine-ops tools (guards prompt tool-name drift) --- .../experimental/mcp/fastmcp_adapter.py | 10 +++ src/libtmux/experimental/mcp/prompts.py | 89 +++++++++++++++++++ tests/experimental/mcp/test_prompts.py | 64 +++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 src/libtmux/experimental/mcp/prompts.py create mode 100644 tests/experimental/mcp/test_prompts.py diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 1d5f11350..9e900fd2f 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -653,6 +653,7 @@ def build_server( expose_operations: bool = False, include_plan_tools: bool = True, include_middleware: bool = True, + include_prompts: bool = True, safety_level: str | None = None, caller: CallerContext | None = None, ) -> FastMCP: @@ -681,6 +682,10 @@ def build_server( registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=False) register_caller_context(mcp, ctx) + if include_prompts: + from libtmux.experimental.mcp.prompts import register_prompts + + register_prompts(mcp) if include_operations: register_operations( mcp, @@ -704,6 +709,7 @@ def build_async_server( expose_operations: bool = False, include_plan_tools: bool = True, include_middleware: bool = True, + include_prompts: bool = True, safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", @@ -736,6 +742,10 @@ def build_async_server( registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) register_caller_context(mcp, ctx) + if include_prompts: + from libtmux.experimental.mcp.prompts import register_prompts + + register_prompts(mcp) if include_operations: register_operations( mcp, diff --git a/src/libtmux/experimental/mcp/prompts.py b/src/libtmux/experimental/mcp/prompts.py new file mode 100644 index 000000000..48c922169 --- /dev/null +++ b/src/libtmux/experimental/mcp/prompts.py @@ -0,0 +1,89 @@ +"""Recipe prompts for the engine-ops MCP server. + +Each function is a FastMCP prompt -- a template returning the text an MCP client +sends to its model, packaging a few common workflows over the engine-ops +vocabulary (``send_input`` / ``wait_for_output`` / ``capture_pane`` / +``create_session`` / ``split_pane``). Pure string builders, engine-agnostic, so +the same set registers on both the sync and async servers. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + +def run_and_wait(command: str, pane_id: str, timeout: float = 60.0) -> str: + """Run a shell command in a pane and wait for it to settle.""" + return f"""Run this shell command in tmux pane {pane_id}, then wait for the +pane to settle and inspect the result: + +1. send_input(target={pane_id!r}, keys={command!r}, enter=True) +2. wait_for_output(pane={pane_id!r}, timeout={timeout}) -- folds the live output + and returns when the pane goes quiet (needle-free: no regex, no sentinel). +3. Read done.pane_dead / done.pane_dead_status (exit code) and captured_text. + "Settled" means output stopped, not that the command succeeded -- check the + done metadata for the exit status. + +Prefer this over a send_input + capture_pane retry loop: wait_for_output is +event-backed and reports the process exit.""" + + +def diagnose_failing_pane(pane_id: str) -> str: + """Gather pane context and propose a root-cause hypothesis.""" + return f"""Something went wrong in tmux pane {pane_id}. Diagnose it: + +1. capture_pane(target={pane_id!r}) to read the scrollback (the active prompt and + most recent output are at the bottom). +2. If the pane is still producing output, wait_for_output(pane={pane_id!r}) until + it settles, then capture again. +3. Identify the last command that ran (the prompt line and the line above it) and + the last non-empty output line. +4. Propose a root-cause hypothesis and a minimal command to verify it -- produce + the plan first; do NOT execute anything yet.""" + + +def build_dev_workspace(session_name: str, log_command: str = "watch -n 1 date") -> str: + """Construct a simple 3-pane development session.""" + return f"""Set up a 3-pane development workspace named {session_name!r} +(editor on top, a shell bottom-left, a logs tail bottom-right): + +1. create_session(name={session_name!r}) -- creates the session with one pane + (the editor). Capture its first_pane_id as %A. +2. split_pane(target="%A") -- splits off the bottom half (the terminal, %B). +3. split_pane(target="%B", horizontal=True) -- splits %B (the logs pane, %C). +4. send_input(target="%A", keys="vim", enter=True) and + send_input(target="%C", keys={log_command!r}, enter=True). Leave %B at its + fresh shell prompt. No pre-launch wait is required -- tmux buffers keystrokes + into the PTY whether or not the shell has finished drawing. + +Use pane ids (%N) for all targeting -- they are stable across layout changes; +window renames are not.""" + + +def interrupt_gracefully(pane_id: str) -> str: + r"""Interrupt a running command and verify the prompt returned.""" + return rf"""Interrupt whatever is running in pane {pane_id} and verify that +control returns to the shell: + +1. send_input(target={pane_id!r}, keys="C-c", enter=False) -- tmux interprets + C-c as SIGINT. +2. wait_for_output(pane={pane_id!r}, timeout=5.0) -- wait for the pane to settle + back at a shell prompt. +3. If it does not settle, the process is ignoring SIGINT. Stop and ask the caller + how to proceed -- do NOT escalate automatically to C-\ (SIGQUIT) or kill.""" + + +def register_prompts(mcp: FastMCP) -> None: + """Register the recipe prompts on *mcp*.""" + from fastmcp.prompts import Prompt + + for fn in ( + run_and_wait, + diagnose_failing_pane, + build_dev_workspace, + interrupt_gracefully, + ): + mcp.add_prompt(Prompt.from_function(fn, name=fn.__name__)) diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py new file mode 100644 index 000000000..2d097c621 --- /dev/null +++ b/tests/experimental/mcp/test_prompts.py @@ -0,0 +1,64 @@ +"""Tests for the recipe prompts on the engine-ops MCP server.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from libtmux.experimental.engines import ConcreteEngine + +fastmcp = pytest.importorskip("fastmcp") + +from libtmux.experimental.mcp.fastmcp_adapter import build_server # noqa: E402 + +_NAMES = { + "run_and_wait", + "diagnose_failing_pane", + "build_dev_workspace", + "interrupt_gracefully", +} +#: Tool names from libtmux-mcp that do NOT exist in the engine-ops vocabulary. +_FOREIGN_TOOLS = ( + "run_command", + "snapshot_pane", + "send_keys", + "split_window", + "wait_for_text", + "capture_since", +) + + +def test_prompts_registered() -> None: + """The four recipe prompts are registered on the server.""" + server = build_server(ConcreteEngine()) + + async def main() -> set[str]: + async with fastmcp.Client(server) as client: + return {prompt.name for prompt in await client.list_prompts()} + + assert asyncio.run(main()) >= _NAMES + + +def test_prompt_bodies_use_engine_ops_vocabulary() -> None: + """Rendered prompt text names engine-ops verbs, never libtmux-mcp-only ones.""" + from libtmux.experimental.mcp.prompts import ( + build_dev_workspace, + diagnose_failing_pane, + interrupt_gracefully, + run_and_wait, + ) + + bodies = [ + run_and_wait("ls", "%1"), + diagnose_failing_pane("%1"), + build_dev_workspace("dev"), + interrupt_gracefully("%1"), + ] + for body in bodies: + for foreign in _FOREIGN_TOOLS: + assert foreign not in body, f"foreign tool {foreign!r} leaked" + # the canonical engine-ops verbs appear + assert "send_input" in run_and_wait("ls", "%1") + assert "split_pane" in build_dev_workspace("dev") + assert "wait_for_output" in interrupt_gracefully("%1") From 1a9e2c700681b04e8f801a726e888938e860c15b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:51:54 -0500 Subject: [PATCH 087/154] Mcp(feat[resources]): Add tmux:// hierarchy resources over the engine why: libtmux-mcp exposes the server->session->window->pane tree as MCP resources (a read interface distinct from the list_* tools); the engine-ops server should too, built on its own vocabulary. what: - resources.py: register_resources(mcp, engine, *, is_async) with six tmux:// resources (sessions, session detail, session windows, window detail, pane detail, pane content) over alist_sessions/windows/panes + acapture_pane; rows filtered by session_name/window_index/pane_id - single async body set; a sync server's engine is wrapped once (SyncToAsyncEngine) so there is no sync/async duplication - drop libtmux-mcp's {?socket_name} query var (one socket per engine) - both builders gain include_resources (default True) - tests: offline read returns JSON; live read lists the session + pane content over a real tmux server --- .../experimental/mcp/fastmcp_adapter.py | 10 ++ src/libtmux/experimental/mcp/resources.py | 154 ++++++++++++++++++ tests/experimental/mcp/test_resources.py | 54 ++++++ 3 files changed, 218 insertions(+) create mode 100644 src/libtmux/experimental/mcp/resources.py create mode 100644 tests/experimental/mcp/test_resources.py diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 9e900fd2f..0d9a6e41a 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -654,6 +654,7 @@ def build_server( include_plan_tools: bool = True, include_middleware: bool = True, include_prompts: bool = True, + include_resources: bool = True, safety_level: str | None = None, caller: CallerContext | None = None, ) -> FastMCP: @@ -696,6 +697,10 @@ def build_server( ) if include_plan_tools: register_plan_tools(mcp, engine, is_async=False, registry=registry) + if include_resources: + from libtmux.experimental.mcp.resources import register_resources + + register_resources(mcp, engine, is_async=False) _apply_safety_gate(mcp, level) return mcp @@ -710,6 +715,7 @@ def build_async_server( include_plan_tools: bool = True, include_middleware: bool = True, include_prompts: bool = True, + include_resources: bool = True, safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", @@ -756,6 +762,10 @@ def build_async_server( ) if include_plan_tools: register_plan_tools(mcp, engine, is_async=True, registry=registry) + if include_resources: + from libtmux.experimental.mcp.resources import register_resources + + register_resources(mcp, engine, is_async=True) register_events(mcp, engine, mode=events, source=event_source) _apply_safety_gate(mcp, level) return mcp diff --git a/src/libtmux/experimental/mcp/resources.py b/src/libtmux/experimental/mcp/resources.py new file mode 100644 index 000000000..a83b9e60c --- /dev/null +++ b/src/libtmux/experimental/mcp/resources.py @@ -0,0 +1,154 @@ +"""tmux:// hierarchy resources over the engine. + +Re-expose the server -> session -> window -> pane tree as MCP resources, built on +the curated vocabulary list/capture verbs. The engine binds one socket, so +libtmux-mcp's ``{?socket_name}`` query var is dropped. Every body is ``async`` +and runs over the async vocabulary -- a sync server's engine is wrapped once +(:class:`~.vocabulary._bridge.SyncToAsyncEngine`), so there is no sync/async +duplication. +""" + +from __future__ import annotations + +import json +import typing as t + +from fastmcp.exceptions import ResourceError + +from libtmux.experimental.mcp import vocabulary + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + +_JSON_MIME = "application/json" +_TEXT_MIME = "text/plain" + + +def _json(rows: Iterable[Mapping[str, str]]) -> str: + """Serialize tmux format rows to a pretty JSON array.""" + return json.dumps([dict(row) for row in rows], indent=2) + + +def register_resources( + mcp: FastMCP, + engine: TmuxEngine | AsyncTmuxEngine, + *, + is_async: bool, +) -> None: + """Register the ``tmux://`` hierarchy resources, bound to *engine*.""" + from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine + + if is_async: + aengine = t.cast("AsyncTmuxEngine", engine) + else: + aengine = t.cast( + "AsyncTmuxEngine", + SyncToAsyncEngine(t.cast("TmuxEngine", engine)), + ) + + @mcp.resource("tmux://sessions", title="All sessions", mime_type=_JSON_MIME) + async def get_sessions() -> str: + """All tmux sessions as a JSON array.""" + return _json((await vocabulary.alist_sessions(aengine)).rows) + + @mcp.resource( + "tmux://sessions/{session_name}", + title="Session detail", + mime_type=_JSON_MIME, + ) + async def get_session(session_name: str) -> str: + """One session plus its windows.""" + sessions = [ + row + for row in (await vocabulary.alist_sessions(aengine)).rows + if row.get("session_name") == session_name + ] + if not sessions: + msg = f"session not found: {session_name}" + raise ResourceError(msg) + windows = [ + row + for row in (await vocabulary.alist_windows(aengine, all_windows=True)).rows + if row.get("session_name") == session_name + ] + result: dict[str, t.Any] = dict(sessions[0]) + result["windows"] = [dict(window) for window in windows] + return json.dumps(result, indent=2) + + @mcp.resource( + "tmux://sessions/{session_name}/windows", + title="Session windows", + mime_type=_JSON_MIME, + ) + async def get_session_windows(session_name: str) -> str: + """Return the windows of a session as a JSON array.""" + windows = [ + row + for row in (await vocabulary.alist_windows(aengine, all_windows=True)).rows + if row.get("session_name") == session_name + ] + return _json(windows) + + @mcp.resource( + "tmux://sessions/{session_name}/windows/{window_index}", + title="Window detail", + mime_type=_JSON_MIME, + ) + async def get_window(session_name: str, window_index: str) -> str: + """One window plus its panes.""" + windows = [ + row + for row in (await vocabulary.alist_windows(aengine, all_windows=True)).rows + if row.get("session_name") == session_name + and row.get("window_index") == window_index + ] + if not windows: + msg = f"window not found: {session_name}:{window_index}" + raise ResourceError(msg) + panes = [ + row + for row in (await vocabulary.alist_panes(aengine, all_panes=True)).rows + if row.get("session_name") == session_name + and row.get("window_index") == window_index + ] + result: dict[str, t.Any] = dict(windows[0]) + result["panes"] = [dict(pane) for pane in panes] + return json.dumps(result, indent=2) + + @mcp.resource("tmux://panes/{pane_id}", title="Pane detail", mime_type=_JSON_MIME) + async def get_pane(pane_id: str) -> str: + """One pane's metadata.""" + panes = [ + row + for row in (await vocabulary.alist_panes(aengine, all_panes=True)).rows + if row.get("pane_id") == pane_id + ] + if not panes: + msg = f"pane not found: {pane_id}" + raise ResourceError(msg) + return json.dumps(dict(panes[0]), indent=2) + + @mcp.resource( + "tmux://panes/{pane_id}/content", + title="Pane content", + mime_type=_TEXT_MIME, + ) + async def get_pane_content(pane_id: str) -> str: + """Return a pane's captured terminal text.""" + capture = await vocabulary.acapture_pane(aengine, pane_id) + return "\n".join(capture.lines) + + # The decorator registers each resource; bind the names so linters do not + # read them as dead code. + _ = ( + get_sessions, + get_session, + get_session_windows, + get_window, + get_pane, + get_pane_content, + ) diff --git a/tests/experimental/mcp/test_resources.py b/tests/experimental/mcp/test_resources.py new file mode 100644 index 000000000..8f5303939 --- /dev/null +++ b/tests/experimental/mcp/test_resources.py @@ -0,0 +1,54 @@ +"""Tests for the tmux:// hierarchy resources on the engine-ops MCP server.""" + +from __future__ import annotations + +import asyncio +import json +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine + +fastmcp = pytest.importorskip("fastmcp") + +from libtmux.experimental.mcp.fastmcp_adapter import build_server # noqa: E402 + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _text(contents: t.Any) -> str: + """Join the text of a read_resource result (robust to content shape).""" + return "".join(getattr(item, "text", "") for item in contents) + + +def test_resources_read_offline_returns_json() -> None: + """The sessions resource is registered and returns a JSON array.""" + server = build_server(ConcreteEngine()) + + async def main() -> t.Any: + async with fastmcp.Client(server) as client: + return await client.read_resource("tmux://sessions") + + payload = json.loads(_text(asyncio.run(main()))) + assert isinstance(payload, list) + + +def test_resources_read_live_hierarchy(session: Session) -> None: + """Over a real tmux server, the resources expose the live session + panes.""" + mcp = build_server(SubprocessEngine.for_server(session.server)) + pane = session.active_window.active_pane + assert pane is not None and pane.pane_id is not None + + async def main() -> tuple[str, str]: + async with fastmcp.Client(mcp) as client: + sessions = _text(await client.read_resource("tmux://sessions")) + content = _text( + await client.read_resource(f"tmux://panes/{pane.pane_id}/content"), + ) + return sessions, content + + sessions, _content = asyncio.run(main()) + assert session.session_name is not None + assert session.session_name in sessions # the live session is listed From 1206c039ffd99709e36b4f096d70b54afc619936 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:56:38 -0500 Subject: [PATCH 088/154] Mcp(feat[lifespan]): Add engine-probe lifespan (async server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: fail fast when the engine cannot reach tmux at startup (missing binary, broken connection) instead of surfacing it on the first tool call — parity with libtmux-mcp's preflight. what: - _lifespan.py: make_lifespan(engine) runs list-sessions at startup and raises RuntimeError only on an engine-broken outcome (it raises), never on a tmux-side error (returned as a CommandResult, e.g. no server) - build_async_server gains lifespan (default True), passed at FastMCP construction; the sync server stays lifespan-less - tests: broken engine fails the preflight; a tmux-side error is tolerated note: the paste-buffer GC half of libtmux-mcp's lifespan is deferred — engine-ops does not namespace MCP-created buffers, so there is no prefix to GC (a follow-up). --- src/libtmux/experimental/mcp/_lifespan.py | 46 +++++++++++++ .../experimental/mcp/fastmcp_adapter.py | 3 + tests/experimental/mcp/test_lifespan.py | 67 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/libtmux/experimental/mcp/_lifespan.py create mode 100644 tests/experimental/mcp/test_lifespan.py diff --git a/src/libtmux/experimental/mcp/_lifespan.py b/src/libtmux/experimental/mcp/_lifespan.py new file mode 100644 index 000000000..2008fa06e --- /dev/null +++ b/src/libtmux/experimental/mcp/_lifespan.py @@ -0,0 +1,46 @@ +"""Engine-probe lifespan for the async MCP server. + +A startup preflight that fails fast if the engine cannot reach tmux at all +(missing binary, a fundamentally broken connection) -- distinct from a tmux-side +error such as "no server running", which the engine returns as data, not an +exception. Shutdown is a best-effort no-op: engine-ops does not namespace +MCP-created paste buffers, so there is no buffer GC to run (a documented +follow-up). +""" + +from __future__ import annotations + +import contextlib +import typing as t + +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + from fastmcp import FastMCP + + from libtmux.experimental.engines.base import AsyncTmuxEngine + + +def make_lifespan( + engine: AsyncTmuxEngine, +) -> Callable[[FastMCP], contextlib.AbstractAsyncContextManager[None]]: + """Return a FastMCP lifespan that probes *engine* at startup. + + The probe runs ``list-sessions`` over *engine* and raises ``RuntimeError`` + only when the engine itself is broken (it raises -- missing binary, lost + connection), never on a tmux-side failure, which comes back as a + :class:`~..engines.base.CommandResult`. + """ + + @contextlib.asynccontextmanager + async def _lifespan(_app: FastMCP) -> AsyncIterator[None]: + try: + await engine.run(CommandRequest.from_args("list-sessions")) + except Exception as error: + msg = f"tmux engine preflight failed: {error}" + raise RuntimeError(msg) from error + yield + + return _lifespan diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 0d9a6e41a..6c448550c 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -716,6 +716,7 @@ def build_async_server( include_middleware: bool = True, include_prompts: bool = True, include_resources: bool = True, + lifespan: bool = True, safety_level: str | None = None, events: EventMode = "push", event_source: EventSource = "subscription", @@ -734,6 +735,7 @@ def build_async_server( """ from fastmcp import FastMCP + from libtmux.experimental.mcp._lifespan import make_lifespan from libtmux.experimental.mcp.events import _supports_stream, register_events level = _resolve_level(safety_level) @@ -744,6 +746,7 @@ def build_async_server( name=name, instructions=instructions or _instructions(ctx, events_enabled=events_enabled), middleware=_make_middleware(level) if include_middleware else None, + lifespan=make_lifespan(engine) if lifespan else None, ) registry = OperationToolRegistry() register_vocabulary(mcp, engine, is_async=True) diff --git a/tests/experimental/mcp/test_lifespan.py b/tests/experimental/mcp/test_lifespan.py new file mode 100644 index 000000000..95dc7aaf5 --- /dev/null +++ b/tests/experimental/mcp/test_lifespan.py @@ -0,0 +1,67 @@ +"""Tests for the engine-probe lifespan.""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +pytest.importorskip("fastmcp") + + +class _BrokenEngine: + """An engine whose every command raises (a broken connection).""" + + async def run(self, _request: t.Any) -> t.Any: + msg = "connection lost" + raise ConnectionError(msg) + + async def run_batch(self, requests: t.Any) -> t.Any: + return await self.run(requests) + + +class _TmuxErrorEngine: + """An engine that returns a tmux-side failure as data (never raises).""" + + async def run(self, _request: t.Any) -> t.Any: + from libtmux.experimental.engines.base import CommandResult + + return CommandResult( + cmd=("tmux", "list-sessions"), + returncode=1, + stderr=("no server running",), + ) + + async def run_batch(self, requests: t.Any) -> t.Any: + return [await self.run(requests)] + + +def test_lifespan_raises_on_broken_engine() -> None: + """A broken engine (raises) fails the startup preflight loudly.""" + from libtmux.experimental.mcp._lifespan import make_lifespan + + lifespan = make_lifespan(t.cast("t.Any", _BrokenEngine())) + + async def main() -> None: + async with lifespan(t.cast("t.Any", None)): + pass + + with pytest.raises(RuntimeError, match="preflight failed"): + asyncio.run(main()) + + +def test_lifespan_tolerates_tmux_side_error() -> None: + """A tmux-side error (returned as data) does not fail startup.""" + from libtmux.experimental.mcp._lifespan import make_lifespan + + lifespan = make_lifespan(t.cast("t.Any", _TmuxErrorEngine())) + entered = False + + async def main() -> None: + nonlocal entered + async with lifespan(t.cast("t.Any", None)): + entered = True + + asyncio.run(main()) + assert entered From 32dd9f8cfe2e096fdfeafaf8c82de0eda8d068c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:05:11 -0500 Subject: [PATCH 089/154] Workspace(feat[cli]): Add `load` command for .tmuxp.yaml files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the declarative workspace tier had no human entry point — building a workspace meant calling analyze()+build() in Python. Mirror `tmuxp load` so a .tmuxp.yaml launches from the shell. what: - workspace/cli.py: `python -m libtmux.experimental.workspace.cli load ` resolves a workspace file (path / directory -> .tmuxp.*/ bare name under $TMUXP_WORKSPACEDIR), expands ~/$VAR/./ paths relative to the file's dir (the cwd-bound step analyze() deliberately omits), analyzes + builds over a SubprocessEngine, then attaches (switch-client when inside tmux) unless -d; -L/-S socket, -s session-name override - an already-running session is attached, not rebuilt (FileExistsError -> attach), matching tmuxp's behavior - tests: file resolution (path/dir/missing), ./-relative path expansion, arg parsing, and a live detached build whose windows/panes match the file --- src/libtmux/experimental/workspace/cli.py | 299 ++++++++++++++++++ .../contract/test_workspace_cli.py | 98 ++++++ 2 files changed, 397 insertions(+) create mode 100644 src/libtmux/experimental/workspace/cli.py create mode 100644 tests/experimental/contract/test_workspace_cli.py diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py new file mode 100644 index 000000000..7a9f8ef60 --- /dev/null +++ b/src/libtmux/experimental/workspace/cli.py @@ -0,0 +1,299 @@ +"""Command-line entry to launch a tmuxp-style workspace file. + +Run with:: + + uv run python -m libtmux.experimental.workspace.cli load + +A thin shell over the declarative tier: it resolves a workspace file (path, +directory, or a name under the workspace dir), expands ``~`` / ``$VAR`` / ``./`` +paths relative to the file's directory (the part :func:`~.analyzer.analyze` +deliberately leaves to a caller with a cwd), analyzes it into a +:class:`~.ir.Workspace`, builds it over a :class:`~..engines.SubprocessEngine`, +and -- unless ``-d`` -- attaches (or switches the client when already inside +tmux), mirroring ``tmuxp load``. + +Everything here is experimental and outside the versioning policy. +""" + +from __future__ import annotations + +import argparse +import collections.abc +import os +import pathlib +import sys +import typing as t + +from libtmux.experimental.workspace.analyzer import analyze + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.ops.plan import PlanResult + +#: Filenames searched when a directory is given (tmuxp's convention). +_WORKSPACE_FILENAMES = (".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json") +#: Extensions tried when a bare name is given (resolved under the workspace dir). +_WORKSPACE_EXTENSIONS = ("yaml", "yml", "json") + + +def _workspace_dir() -> pathlib.Path: + """Return the directory bare workspace names resolve against (``~/.tmuxp``).""" + return pathlib.Path( + os.environ.get("TMUXP_WORKSPACEDIR", "~/.tmuxp"), + ).expanduser() + + +def _find_workspace_file(arg: str) -> pathlib.Path: + """Resolve *arg* to a concrete workspace file. + + A path to a file is used as-is; a directory is searched for + ``.tmuxp.{yaml,yml,json}``; a bare name (no directory, no extension) is + resolved against the workspace dir (``$TMUXP_WORKSPACEDIR`` or ``~/.tmuxp``). + """ + path = pathlib.Path(arg).expanduser() + if path.is_file(): + return path + if path.is_dir(): + for name in _WORKSPACE_FILENAMES: + candidate = path / name + if candidate.is_file(): + return candidate + msg = f"no .tmuxp.{{yaml,yml,json}} found in {path}" + raise FileNotFoundError(msg) + if os.sep not in arg and "." not in pathlib.Path(arg).name: + workspace_dir = _workspace_dir() + for ext in _WORKSPACE_EXTENSIONS: + candidate = workspace_dir / f"{arg}.{ext}" + if candidate.is_file(): + return candidate + msg = f"workspace file not found: {arg}" + raise FileNotFoundError(msg) + + +def _expandshell(value: str, cwd: str | os.PathLike[str]) -> str: + """Expand ``~`` and ``$VAR``; resolve a ``./``-relative path against *cwd*. + + Mirrors tmuxp's ``expandshell`` + relative-path rule: only ``.``-prefixed + paths are joined to *cwd* (a bare relative path is left for tmux to resolve + against the pane's directory). + + Examples + -------- + >>> _expandshell("/abs/path", "/tmp") + '/abs/path' + >>> _expandshell("./src", "/home/me/proj") + '/home/me/proj/src' + >>> _expandshell("../sibling", "/home/me/proj") + '/home/me/sibling' + """ + raw = os.path.expandvars(value) + if raw in {".", ".."} or raw.startswith(("./", "../")): + # Check the relative prefix on the raw value -- Path() would collapse the + # leading "./" before we could test for it. + return os.path.normpath(pathlib.Path(cwd) / raw) + return str(pathlib.Path(raw).expanduser()) + + +def _expand_env( + environment: collections.abc.Mapping[str, t.Any], +) -> dict[str, str]: + """Expand ``$VAR`` in environment *values* (values, not paths).""" + return { + key: os.path.expandvars(str(value)) for key, value in environment.items() + } + + +def _expand_pane(pane: t.Any, cwd: str | os.PathLike[str]) -> t.Any: + """Expand a pane's ``start_directory`` (mappings only; strings pass through).""" + if isinstance(pane, collections.abc.Mapping) and pane.get("start_directory"): + expanded = dict(pane) + expanded["start_directory"] = _expandshell(pane["start_directory"], cwd) + return expanded + return pane + + +def _expand_window( + window: collections.abc.Mapping[str, t.Any], + cwd: str | os.PathLike[str], +) -> dict[str, t.Any]: + """Expand a window's ``start_directory`` / ``environment`` and its panes.""" + expanded = dict(window) + if window.get("start_directory"): + expanded["start_directory"] = _expandshell(window["start_directory"], cwd) + if window.get("environment"): + expanded["environment"] = _expand_env(window["environment"]) + panes = window.get("panes") + if panes: + expanded["panes"] = [_expand_pane(pane, cwd) for pane in panes] + return expanded + + +def _expand_workspace( + raw: collections.abc.Mapping[str, t.Any], + cwd: str | os.PathLike[str], +) -> dict[str, t.Any]: + """Expand path/var-bearing fields relative to the workspace file's *cwd*. + + The analyzer is intentionally pure (no cwd), so this CLI does the + expansion ``tmuxp load`` does: ``start_directory`` (session/window/pane), + ``before_script``, and ``environment`` values. + """ + expanded = dict(raw) + if raw.get("start_directory"): + expanded["start_directory"] = _expandshell(raw["start_directory"], cwd) + if raw.get("before_script"): + expanded["before_script"] = _expandshell(raw["before_script"], cwd) + if raw.get("environment"): + expanded["environment"] = _expand_env(raw["environment"]) + expanded["windows"] = [ + _expand_window(window, cwd) for window in raw.get("windows", []) or [] + ] + return expanded + + +def _read_workspace(path: pathlib.Path) -> dict[str, t.Any]: + """Parse a YAML/JSON workspace file into a mapping (YAML parses JSON too).""" + import yaml # type: ignore[import-untyped] + + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, collections.abc.Mapping): + msg = f"workspace file {path} must contain a mapping" + raise TypeError(msg) + return dict(data) + + +def _attach(session: t.Any, *, detached: bool) -> None: + """Attach the session, or switch the client when already inside tmux.""" + if detached: + return + if "TMUX" in os.environ: + session.switch_client() + else: + session.attach() + + +def load( + workspace_file: str, + *, + socket_name: str | None = None, + socket_path: str | None = None, + new_session_name: str | None = None, + detached: bool = False, +) -> PlanResult | None: + """Build (and unless *detached*, attach) a workspace file. + + Resolves *workspace_file*, expands its paths, analyzes it, and builds it over + a subprocess engine bound to a :class:`libtmux.Server` on the given socket. An + already-running session of the same name is attached rather than rebuilt + (unless the file's ``on_exists`` opts into ``replace``/``reuse``). + + Returns + ------- + PlanResult or None + The build outcome, or ``None`` when an existing session was attached. + """ + import libtmux + from libtmux.experimental.engines import SubprocessEngine + + path = _find_workspace_file(workspace_file) + raw = _expand_workspace(_read_workspace(path), cwd=path.parent) + if new_session_name: + raw["session_name"] = new_session_name + workspace = analyze(raw) + + server = libtmux.Server(socket_name=socket_name, socket_path=socket_path) + engine = SubprocessEngine.for_server(server) + + existed = server.has_session(workspace.name) + result: PlanResult | None = None + try: + result = workspace.build(engine) + except FileExistsError: + # on_exists="error" (the default) and the session is already running; + # attach to it rather than failing, matching `tmuxp load`. + existed = True + + session = server.sessions.get(session_name=workspace.name, default=None) + if session is None: + msg = f"session {workspace.name!r} was not found after build" + raise RuntimeError(msg) + + verb = "attached existing" if (existed and result is None) else "built" + windows = len(session.windows) + panes = sum(len(window.panes) for window in session.windows) + sys.stderr.write( + f"✓ {verb} session {workspace.name!r} ({windows} windows, {panes} panes)\n", + ) + _attach(session, detached=detached) + return result + + +def _build_parser() -> argparse.ArgumentParser: + """Build the argument parser for the workspace CLI.""" + parser = argparse.ArgumentParser( + prog="python -m libtmux.experimental.workspace.cli", + description="Build and attach an experimental libtmux workspace file.", + ) + sub = parser.add_subparsers(dest="command", required=True) + load_parser = sub.add_parser( + "load", + help="build (and attach) a tmuxp-style workspace file", + description=( + "Load a .tmuxp.{yaml,yml,json} workspace: a file path, a directory " + "(searched for .tmuxp.*), or a bare name under " + "$TMUXP_WORKSPACEDIR (default ~/.tmuxp)." + ), + ) + load_parser.add_argument( + "workspace_file", + metavar="workspace-file", + help="path, directory, or name of a .tmuxp.{yaml,yml,json} file", + ) + load_parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="tmux -L socket name", + ) + load_parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="tmux -S socket path", + ) + load_parser.add_argument( + "-s", + dest="new_session_name", + metavar="session-name", + help="override the workspace's session name", + ) + load_parser.add_argument( + "-d", + "--detached", + action="store_true", + help="build the session without attaching", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> None: + """Run the workspace CLI (the ``python -m ...workspace.cli`` entry). + + Requires a tmux binary on ``PATH``. ``load`` builds the workspace and, unless + ``-d`` is given, attaches it (or switches the client when already inside + tmux). + """ + args = _build_parser().parse_args(argv) + if args.command == "load": + load( + args.workspace_file, + socket_name=args.socket_name, + socket_path=args.socket_path, + new_session_name=args.new_session_name, + detached=args.detached, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/experimental/contract/test_workspace_cli.py b/tests/experimental/contract/test_workspace_cli.py new file mode 100644 index 000000000..6d5c84dde --- /dev/null +++ b/tests/experimental/contract/test_workspace_cli.py @@ -0,0 +1,98 @@ +"""Tests for the workspace CLI (``python -m ...workspace.cli load``).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.workspace import cli + +if t.TYPE_CHECKING: + from pathlib import Path + + +def test_find_workspace_file_direct_path(tmp_path: Path) -> None: + """A direct file path is returned as-is.""" + target = tmp_path / "ws.yaml" + target.write_text("session_name: x\n") + assert cli._find_workspace_file(str(target)) == target + + +def test_find_workspace_file_in_directory(tmp_path: Path) -> None: + """A directory is searched for .tmuxp.{yaml,yml,json}.""" + target = tmp_path / ".tmuxp.yaml" + target.write_text("session_name: x\n") + assert cli._find_workspace_file(str(tmp_path)) == target + + +def test_find_workspace_file_missing_raises(tmp_path: Path) -> None: + """A missing file fails closed.""" + with pytest.raises(FileNotFoundError): + cli._find_workspace_file(str(tmp_path / "nope.yaml")) + + +def test_expand_workspace_resolves_relative_paths(tmp_path: Path) -> None: + """start_directory is expanded relative to the workspace file's directory.""" + raw = { + "session_name": "x", + "start_directory": "./root", + "windows": [ + { + "window_name": "w", + "start_directory": "./win", + "panes": [{"shell_command": ["echo hi"], "start_directory": "./pane"}], + }, + ], + } + expanded = cli._expand_workspace(raw, cwd=tmp_path) + assert expanded["start_directory"] == str(tmp_path / "root") + assert expanded["windows"][0]["start_directory"] == str(tmp_path / "win") + pane = expanded["windows"][0]["panes"][0] + assert pane["start_directory"] == str(tmp_path / "pane") + + +def test_parser_load_arguments() -> None: + """The load subparser captures the workspace file and the flags.""" + args = cli._build_parser().parse_args( + ["load", "ws.yaml", "-d", "-L", "sock", "-s", "newname"], + ) + assert args.command == "load" + assert args.workspace_file == "ws.yaml" + assert args.detached is True + assert args.socket_name == "sock" + assert args.new_session_name == "newname" + + +def test_load_builds_and_reattaches(tmp_path: Path) -> None: + """load() builds a real detached session; a second load attaches it (no rebuild).""" + import libtmux + + socket = "libtmux_wscli_test" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: clitest\n" + f"start_directory: {tmp_path}\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - echo two\n" + " - window_name: logs\n" + " panes:\n" + " - echo log\n", + ) + server = libtmux.Server(socket_name=socket) + try: + result = cli.load(str(tmp_path), socket_name=socket, detached=True) + assert result is not None + assert result.ok + assert server.has_session("clitest") + session = server.sessions.get(session_name="clitest") + assert session is not None + assert [window.window_name for window in session.windows] == ["editor", "logs"] + + # A second load finds the running session and attaches it (no rebuild). + again = cli.load(str(tmp_path), socket_name=socket, detached=True) + assert again is None + finally: + server.kill() From 53f6ec989ea0d6738331757caf5100fd220ac390 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:05:38 -0500 Subject: [PATCH 090/154] Workspace(feat): blank/pane empty-pane parity + cli --dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: real .tmuxp.yaml files use `- blank` / `- pane` / `- ` to mean "an empty pane" (no command) — the analyzer was sending those as literal commands. And launching a file blind is risky; a dry run lets you see the tmux commands first. what: - analyzer: a pane whose sole content is None / "blank" / "pane" / "" (a bare string or a single-element shell_command) is now an empty pane, matching tmuxp's expand_cmd; a blank mixed with real commands is left alone - cli: `load --dry-run` prints the tmux command lines (resolved against the in-memory ConcreteEngine so ids render) with host steps as comments, executing nothing - tests: blank/pane/empty shorthands -> empty panes; dry-run prints the commands (blank pane creates a split but sends no keys) and starts no tmux server --- .../experimental/workspace/analyzer.py | 27 ++++++- src/libtmux/experimental/workspace/cli.py | 73 +++++++++++++++++-- ..._async_control_engine_workspace_builder.py | 28 +++++++ .../contract/test_workspace_cli.py | 29 ++++++++ 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 6d227b3c3..0cb3d9c44 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -93,9 +93,23 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: ) +#: Pane shorthands that mean "an empty pane" (no command sent), per tmuxp. +_BLANK_PANE = frozenset({"", "blank", "pane"}) + + +def _is_blank_command(item: t.Any) -> bool: + """Whether a pane/command shorthand means an empty pane (None/blank/pane).""" + return item is None or (isinstance(item, str) and item.strip() in _BLANK_PANE) + + def _pane(raw: t.Any) -> Pane: - """Normalize one pane config (None / bare string / mapping).""" - if raw is None: + """Normalize one pane config (None / bare string / mapping). + + The bare-string shorthands ``blank`` / ``pane`` (and an empty/``None`` entry) + mean "create an empty pane" -- they are markers, not commands, matching + tmuxp. + """ + if raw is None or (isinstance(raw, str) and raw.strip() in _BLANK_PANE): return Pane() if isinstance(raw, str): return Pane(run=raw) @@ -124,9 +138,14 @@ def _shell_commands(value: t.Any) -> tuple[str | Command, ...]: if value is None: return () if isinstance(value, str): - return (value,) + return () if value.strip() in _BLANK_PANE else (value,) + items = list(t.cast("collections.abc.Sequence[t.Any]", value)) + # A sole blank/pane/None element means "an empty pane" (tmuxp parity); a + # blank mixed with real commands is left alone. + if len(items) == 1 and _is_blank_command(items[0]): + return () out: list[str | Command] = [] - for item in t.cast("collections.abc.Sequence[t.Any]", value): + for item in items: if isinstance(item, str): out.append(item) elif isinstance(item, collections.abc.Mapping): diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py index 7a9f8ef60..c461c976d 100644 --- a/src/libtmux/experimental/workspace/cli.py +++ b/src/libtmux/experimental/workspace/cli.py @@ -30,6 +30,7 @@ from collections.abc import Sequence from libtmux.experimental.ops.plan import PlanResult + from libtmux.experimental.workspace.ir import Workspace #: Filenames searched when a directory is given (tmuxp's convention). _WORKSPACE_FILENAMES = (".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json") @@ -99,9 +100,7 @@ def _expand_env( environment: collections.abc.Mapping[str, t.Any], ) -> dict[str, str]: """Expand ``$VAR`` in environment *values* (values, not paths).""" - return { - key: os.path.expandvars(str(value)) for key, value in environment.items() - } + return {key: os.path.expandvars(str(value)) for key, value in environment.items()} def _expand_pane(pane: t.Any, cwd: str | os.PathLike[str]) -> t.Any: @@ -173,6 +172,50 @@ def _attach(session: t.Any, *, detached: bool) -> None: session.attach() +def _print_dry_run( + workspace: Workspace, + *, + socket_name: str | None, + socket_path: str | None, +) -> None: + """Print the tmux commands a build would run, without touching tmux. + + The plan is resolved against the in-memory ``ConcreteEngine`` (which + fabricates ids) so every line renders fully; host steps (sleep / + before_script / pane-readiness) print as comments in execution order. + """ + import shlex + + from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.workspace.compiler import HostStep, compile_full + + compiled = compile_full(workspace) + outcome = compiled.plan.execute(ConcreteEngine()) + + prefix: list[str] = ["tmux"] + if socket_name: + prefix += ["-L", socket_name] + if socket_path: + prefix += ["-S", socket_path] + + def _emit_host(step: HostStep) -> None: + if step.kind == "sleep": + print(f"# sleep {step.seconds}") + elif step.kind == "script": + where = f" (cwd {step.cwd})" if step.cwd else "" + print(f"# before_script{where}: {step.command}") + elif step.kind == "wait_pane": + print("# wait for the pane's shell to be ready") + + print(f"# build plan for session {workspace.name!r} (dry run, ids fabricated)") + for step in compiled.pre: + _emit_host(step) + for index, result in enumerate(outcome.results): + print(shlex.join([*prefix, *result.argv])) + for step in compiled.host_after.get(index, ()): + _emit_host(step) + + def load( workspace_file: str, *, @@ -180,28 +223,35 @@ def load( socket_path: str | None = None, new_session_name: str | None = None, detached: bool = False, + dry_run: bool = False, ) -> PlanResult | None: """Build (and unless *detached*, attach) a workspace file. Resolves *workspace_file*, expands its paths, analyzes it, and builds it over a subprocess engine bound to a :class:`libtmux.Server` on the given socket. An already-running session of the same name is attached rather than rebuilt - (unless the file's ``on_exists`` opts into ``replace``/``reuse``). + (unless the file's ``on_exists`` opts into ``replace``/``reuse``). With + *dry_run*, the tmux commands are printed and nothing is executed. Returns ------- PlanResult or None - The build outcome, or ``None`` when an existing session was attached. + The build outcome, or ``None`` when an existing session was attached or a + dry run was requested. """ - import libtmux - from libtmux.experimental.engines import SubprocessEngine - path = _find_workspace_file(workspace_file) raw = _expand_workspace(_read_workspace(path), cwd=path.parent) if new_session_name: raw["session_name"] = new_session_name workspace = analyze(raw) + if dry_run: + _print_dry_run(workspace, socket_name=socket_name, socket_path=socket_path) + return None + + import libtmux + from libtmux.experimental.engines import SubprocessEngine + server = libtmux.Server(socket_name=socket_name, socket_path=socket_path) engine = SubprocessEngine.for_server(server) @@ -274,6 +324,12 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="build the session without attaching", ) + load_parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="print the tmux commands that would run, without executing them", + ) return parser @@ -292,6 +348,7 @@ def main(argv: Sequence[str] | None = None) -> None: socket_path=args.socket_path, new_session_name=args.new_session_name, detached=args.detached, + dry_run=args.dry_run, ) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 51142c7dd..1e1716c6f 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -466,6 +466,34 @@ def test_analyze_rejects_unsupported_pane() -> None: analyze({"session_name": "s", "windows": [{"panes": [123]}]}) +def test_analyze_blank_pane_shorthands() -> None: + """Blank / pane / empty shorthands make an empty pane (no command), tmuxp parity.""" + ws = analyze( + { + "session_name": "s", + "windows": [ + { + "panes": [ + "blank", + "pane", + None, + "", + {"shell_command": ["blank"]}, + "echo real", + ], + }, + ], + }, + ) + commands = [pane.commands for pane in ws.windows[0].panes] + assert commands[0] == () # "blank" marker + assert commands[1] == () # "pane" marker + assert commands[2] == () # None + assert commands[3] == () # empty string + assert commands[4] == () # single-element [blank] shell_command + assert [c.cmd for c in commands[5]] == ["echo real"] # a real command is kept + + # --- Compiler: op emission + host-step schedule (offline, no tmux) --- diff --git a/tests/experimental/contract/test_workspace_cli.py b/tests/experimental/contract/test_workspace_cli.py index 6d5c84dde..83dbcee59 100644 --- a/tests/experimental/contract/test_workspace_cli.py +++ b/tests/experimental/contract/test_workspace_cli.py @@ -96,3 +96,32 @@ def test_load_builds_and_reattaches(tmp_path: Path) -> None: assert again is None finally: server.kill() + + +def test_dry_run_prints_commands_without_touching_tmux( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """--dry-run renders the tmux commands (blank pane = no send) and runs nothing.""" + import libtmux + + socket = "libtmux_wscli_dryrun" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: dry\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - blank\n", + ) + result = cli.load(str(tmp_path), socket_name=socket, dry_run=True) + assert result is None + + out = capsys.readouterr().out + assert f"tmux -L {socket} new-session" in out # socket prefix + create + assert "split-window" in out # the blank pane is still created + assert "echo one" in out + # the blank pane sends no command, so exactly one send-keys line is rendered + assert out.count("send-keys") == 1 + # nothing was executed: no tmux server exists on the dry-run socket + assert not libtmux.Server(socket_name=socket).is_alive() From c565cf1c7c8764df43623f8a7c4af15cc91eb207 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:09:05 -0500 Subject: [PATCH 091/154] Workspace(fix): First window's start_directory for its first pane why: window 0 reuses the session's implicit window/pane, so its first pane inherited the *session* start_directory (-c on new-session) instead of the window's. A per-project tmuxp config (each window cd'd into its repo) opened window 1's first pane in the session root, not the repo. what: - compiler: _creator_start_directory folds the window's (and its first pane's) start_directory into the creator's -c with pane -> window -> session precedence; used for both new-session (window 0) and new-window (windows 2..N). A window without start_directory still falls back to the session's, so existing behavior is unchanged. - test: window 0's start_directory drives new-session -c; fallback to the session dir; a first pane's own start_directory wins --- .../experimental/workspace/compiler.py | 18 ++++++- ..._async_control_engine_workspace_builder.py | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 4d9065901..5a4577118 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -224,6 +224,20 @@ def _creator_environment(window: Window) -> dict[str, str]: return env +def _creator_start_directory(window: Window, ws: Workspace) -> str | None: + """Working directory for a window's *first* (implicit) pane. + + The first pane is created by the window's creator (``new-session`` for + window 0, ``new-window`` for the rest), not a split, so its directory must + ride the creator's ``-c`` with the full pane -> window -> session precedence. + Without this, window 0's first pane would inherit the *session* + ``start_directory`` instead of the window's. + """ + if window.panes and window.panes[0].start_directory: + return window.panes[0].start_directory + return window.start_directory or ws.start_directory + + def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: """Lower a workspace into a Core plan plus its host-step schedule.""" if not ws.windows: @@ -241,7 +255,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: session = plan.add( NewSession( session_name=ws.name, - start_directory=ws.start_directory, + start_directory=_creator_start_directory(ws.windows[0], ws), width=width, height=height, environment=_creator_environment(ws.windows[0]) or None, @@ -275,7 +289,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: NewWindow( target=create_target, name=window.name, - start_directory=window.start_directory or ws.start_directory, + start_directory=_creator_start_directory(window, ws), environment=_creator_environment(window) or None, window_shell=window.window_shell, capture_pane=True, diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 1e1716c6f..3715faa01 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -574,6 +574,58 @@ def test_compile_folds_first_pane_env_into_creator() -> None: assert split.environment == {"SPLIT_ENV": "s"} +def test_compile_first_window_start_directory_drives_new_session() -> None: + """Window 0's start_directory rides new-session -c (its first pane reuses it). + + Window 0 reuses the session's implicit pane, so without folding the window's + directory into ``new-session -c`` the first pane would land in the *session* + start_directory instead of the window's. + """ + ws = Workspace( + name="s", + start_directory="/session", + windows=[ + Window("a", start_directory="/win-a", panes=[Pane(run="x")]), + Window("b", start_directory="/win-b", panes=[Pane(run="y")]), + ], + ) + ops = compile_full(ws).plan.operations + new_session = next(op for op in ops if isinstance(op, NewSession)) + new_window = next(op for op in ops if isinstance(op, NewWindow)) + assert new_session.start_directory == "/win-a" # window 0's dir, not /session + assert new_window.start_directory == "/win-b" + + # a window with no start_directory still falls back to the session's + plain = Workspace( + name="s", + start_directory="/session", + windows=[Window("a", panes=[Pane(run="x")])], + ) + session_op = next( + op for op in compile_full(plain).plan.operations if isinstance(op, NewSession) + ) + assert session_op.start_directory == "/session" + + # a first pane's own start_directory wins for the creator + pane_dir = Workspace( + name="s", + start_directory="/session", + windows=[ + Window( + "a", + start_directory="/win", + panes=[Pane(run="x", start_directory="/pane")], + ), + ], + ) + pane_session_op = next( + op + for op in compile_full(pane_dir).plan.operations + if isinstance(op, NewSession) + ) + assert pane_session_op.start_directory == "/pane" + + def test_compile_threads_window_and_pane_shell() -> None: """window_shell rides new-window; pane.shell (then window_shell) rides split.""" ws = Workspace( From 3f733b1e3c4f7e4730ed4b50e77eb306d45a52b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:13:13 -0500 Subject: [PATCH 092/154] Ops(feat): Per-step host hook and bounded planner why: The declarative runner needs to fold tmux dispatches yet still interleave host-side steps (sleeps, pane-ready waits) between them. These additive Core primitives let any driver reuse the plan trampoline for that without putting host I/O in the sans-I/O core. what: - Add StepReport + _Host sentinel; _drive yields it after each step binds its results (the sched.delayfunc(0) seam), performing no I/O - Add an on_step hook to execute/aexecute; extract _adispatch as the async twin of _dispatch so both pumps share one dispatch seam - Add BoundedPlanner: run an inner planner over the full op list, then split its steps wherever a host-step boundary falls (a marked fold demotes to plain ; chains past the boundary) - Export BoundedPlanner and StepReport from the ops package - Test the hook stream, sync/async parity, and bounded splitting --- src/libtmux/experimental/ops/__init__.py | 5 +- src/libtmux/experimental/ops/plan.py | 83 +++++++++++++++++++++--- src/libtmux/experimental/ops/planner.py | 61 +++++++++++++++++ tests/experimental/ops/test_plan.py | 47 ++++++++++++++ tests/experimental/ops/test_planner.py | 67 ++++++++++++++++++- 5 files changed, 251 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 7950c3edd..28616dd2d 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -106,8 +106,9 @@ ) from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation -from libtmux.experimental.ops.plan import LazyPlan, PlanResult +from libtmux.experimental.ops.plan import LazyPlan, PlanResult, StepReport from libtmux.experimental.ops.planner import ( + BoundedPlanner, FoldingPlanner, MarkedPlanner, Planner, @@ -147,6 +148,7 @@ __all__ = ( "AckResult", + "BoundedPlanner", "BreakPane", "CapturePane", "CapturePaneResult", @@ -237,6 +239,7 @@ "SplitWindowResult", "StartServer", "Status", + "StepReport", "SuspendClient", "SwapPane", "SwapWindow", diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index c97d17598..84d085f7e 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -34,7 +34,7 @@ ) from libtmux.experimental.ops.exc import ForwardCaptureError from libtmux.experimental.ops.execute import arun, run -from libtmux.experimental.ops.planner import Planner, SequentialPlanner +from libtmux.experimental.ops.planner import Planner, PlanStep, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict if t.TYPE_CHECKING: @@ -67,6 +67,38 @@ class _Chain: argv: tuple[str, ...] +@dataclass(frozen=True) +class StepReport: + """One executed :class:`~.planner.PlanStep`, reported to a per-step callback. + + Passed to the ``on_step`` hook of :meth:`LazyPlan.execute` / + :meth:`LazyPlan.aexecute` after each step's results bind, letting a caller + interleave host-side work (e.g. the workspace runner's sleeps and pane-ready + waits) *between* dispatches without forking the resolution core. + + Parameters + ---------- + step : PlanStep + The dispatch unit that just ran. + results : tuple[Result, ...] + The step's per-op results, in ``step.indices`` order. + bindings : dict[int | tuple[int, str], str] + The live binding map (same reference the driver mutates), so the callback + can resolve a :class:`~._types.SlotRef` against already-captured ids. + """ + + step: PlanStep + results: tuple[Result, ...] + bindings: dict[int | tuple[int, str], str] + + +@dataclass(frozen=True) +class _Host: + """Drive request: fire the per-step host hook; the driver returns ``None``.""" + + report: StepReport + + def _target_from_id(value: str) -> Target: """Map a captured concrete id back to its typed target.""" if value.startswith("%"): @@ -247,12 +279,15 @@ def _drive( self, version: str | None, planner: Planner, - ) -> Generator[_Single | _Chain, t.Any, PlanResult]: + ) -> Generator[_Single | _Chain | _Host, t.Any, PlanResult]: """Sans-I/O resolution core driven by a :class:`~.planner.Planner`. Yields a :class:`_Single` (driver runs one op, returns its - :class:`~.results.Result`) or a :class:`_Chain` (driver returns the - merged :class:`~..engines.base.CommandResult`, attributed per op here). + :class:`~.results.Result`), a :class:`_Chain` (driver returns the merged + :class:`~..engines.base.CommandResult`, attributed per op here), or a + :class:`_Host` once per step *after* its results bind (driver fires the + ``on_step`` hook, returns ``None``). The generator performs no host I/O + itself -- the host hook is the single colored leaf the drivers fork on. The sync and async drivers differ only in ``run`` vs ``await arun`` and ``engine.run`` vs ``await engine.run``. """ @@ -292,6 +327,8 @@ def _drive( results.update( zip(step.indices, attribute(group, merged, version), strict=True), ) + ordered_step = tuple(results[i] for i in step.indices) + yield _Host(StepReport(step, ordered_step, bindings)) ordered = tuple(results[slot] for slot in range(len(self._operations))) return PlanResult(ordered, bindings) @@ -301,6 +338,7 @@ def execute( *, version: str | None = None, planner: Planner | None = None, + on_step: t.Callable[[StepReport], None] | None = None, ) -> PlanResult: """Resolve and execute the plan synchronously. @@ -309,12 +347,21 @@ def execute( :class:`~.planner.FoldingPlanner` or :class:`~.planner.MarkedPlanner` to fold dispatches -- the :class:`PlanResult` is identical, only the dispatch count changes. + + *on_step* is called with a :class:`StepReport` after each step's results + bind, so a caller can interleave host-side work between dispatches; it is + a no-op trampoline hop when ``None``. """ gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: - request = gen.send(self._dispatch(request, engine, version)) + if isinstance(request, _Host): + if on_step is not None: + on_step(request.report) + request = gen.send(None) + else: + request = gen.send(self._dispatch(request, engine, version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) @@ -335,16 +382,32 @@ async def aexecute( *, version: str | None = None, planner: Planner | None = None, + on_step: t.Callable[[StepReport], t.Awaitable[None]] | None = None, ) -> PlanResult: - """Resolve and execute the plan asynchronously (same resolution core).""" + """Resolve and execute the plan asynchronously (same resolution core). + + Mirrors :meth:`execute`; *on_step* is awaited per step. + """ gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) while True: - if isinstance(request, _Chain): - raw = await engine.run(CommandRequest.from_args(*request.argv)) - request = gen.send(raw) + if isinstance(request, _Host): + if on_step is not None: + await on_step(request.report) + request = gen.send(None) else: - request = gen.send(await arun(request.op, engine, version=version)) + request = gen.send(await self._adispatch(request, engine, version)) except StopIteration as stop: return t.cast("PlanResult", stop.value) + + async def _adispatch( + self, + request: _Single | _Chain, + engine: AsyncTmuxEngine, + version: str | None, + ) -> t.Any: + """Run one drive request asynchronously (async twin of :meth:`_dispatch`).""" + if isinstance(request, _Chain): + return await engine.run(CommandRequest.from_args(*request.argv)) + return await arun(request.op, engine, version=version) diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py index 53f5f23aa..393f1f8a9 100644 --- a/src/libtmux/experimental/ops/planner.py +++ b/src/libtmux/experimental/ops/planner.py @@ -141,6 +141,67 @@ def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: return steps +def _split_at_boundaries( + step: PlanStep, + boundaries: frozenset[int], +) -> list[PlanStep]: + """Break *step* wherever a boundary falls between two of its indices. + + A boundary at index ``i`` means a host step runs after op ``i``, so no fold + may span ``i -> i+1``. Splitting only ever breaks a step into contiguous + sub-runs (never merges), so it cannot change the result -- only the dispatch + grouping. A ``marked`` step keeps ``marked=True`` on its first sub-run iff the + creator still keeps at least one decorate; later sub-runs become plain + ``;``-chains that resolve the creator's now-bound id instead of ``{marked}``. + """ + indices = step.indices + cuts = [k + 1 for k in range(len(indices) - 1) if indices[k] in boundaries] + if not cuts: + return [step] + starts, ends = [0, *cuts], [*cuts, len(indices)] + runs = [indices[lo:hi] for lo, hi in zip(starts, ends, strict=True)] + return [ + PlanStep(run, marked=step.marked and pos == 0 and len(run) > 1) + for pos, run in enumerate(runs) + ] + + +@dataclass(frozen=True) +class BoundedPlanner: + """Wrap a planner so no fold crosses a host-step boundary. + + *boundaries* are operation indices after which a host step runs (for the + workspace runner, exactly ``frozenset(compiled.host_after)``). The *inner* + planner runs over the full operation list -- so its global + :class:`~._types.SlotRef` matching is unaffected -- and every resulting + :class:`PlanStep` is then split at any boundary it spans. + """ + + inner: Planner + boundaries: frozenset[int] + + def plan(self, operations: Sequence[Operation[t.Any]]) -> list[PlanStep]: + """Plan with *inner*, then split each step at host-step boundaries. + + Examples + -------- + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> ops = [ + ... SendKeys(target=PaneId("%1"), keys="a"), + ... SendKeys(target=PaneId("%1"), keys="b"), + ... ] + >>> BoundedPlanner(FoldingPlanner(), frozenset({0})).plan(ops) + [PlanStep(indices=(0,), marked=False), PlanStep(indices=(1,), marked=False)] + >>> BoundedPlanner(FoldingPlanner(), frozenset()).plan(ops) + [PlanStep(indices=(0, 1), marked=False)] + """ + steps: list[PlanStep] = [] + for step in self.inner.plan(operations): + steps.extend(_split_at_boundaries(step, self.boundaries)) + return steps + + def _marked_decorates( operations: Sequence[Operation[t.Any]], index: int, diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 22caaf3a8..0737c7961 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -15,7 +15,9 @@ MarkedPlanner, MovePane, SendKeys, + SequentialPlanner, SplitWindow, + StepReport, SwapPane, ) from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId @@ -122,6 +124,51 @@ def test_plan_aexecute_matches_execute() -> None: assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") +def test_execute_on_step_reports_each_step() -> None: + """on_step fires once per dispatched step, carrying its per-op results + ids.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + reports: list[StepReport] = [] + outcome = plan.execute( + ConcreteEngine(), + planner=SequentialPlanner(), + on_step=reports.append, + ) + + # one report per op (sequential), in dispatch order + assert [report.step.indices for report in reports] == [(0,), (1,)] + # the creator's report already sees its freshly-bound pane id + assert reports[0].bindings == {0: "%1"} + assert reports[0].results[0].created_id == "%1" + # the decorate report carries the resolved send-keys argv + assert reports[1].results[0].argv == ("send-keys", "-t", "%1", "vim", "Enter") + # the reported results are the same objects the PlanResult collects + assert tuple(report.results[0] for report in reports) == outcome.results + + +def test_aexecute_on_step_matches_execute() -> None: + """The async hook fires identically to the sync one (one report per step).""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + + sync_steps: list[tuple[int, ...]] = [] + plan.execute( + ConcreteEngine(), + on_step=lambda report: sync_steps.append(report.step.indices), + ) + + async_steps: list[tuple[int, ...]] = [] + + async def collect(report: StepReport) -> None: + async_steps.append(report.step.indices) + + asyncio.run(plan.aexecute(AsyncConcreteEngine(), on_step=collect)) + assert async_steps == sync_steps == [(0,), (1,)] + + def test_plan_serialization_round_trip() -> None: """A plan (including its SlotRef targets) survives a list round-trip.""" plan = LazyPlan() diff --git a/tests/experimental/ops/test_planner.py b/tests/experimental/ops/test_planner.py index 8595982a2..cc6261e69 100644 --- a/tests/experimental/ops/test_planner.py +++ b/tests/experimental/ops/test_planner.py @@ -11,19 +11,22 @@ import pytest from libtmux.experimental.ops import ( + BoundedPlanner, FoldingPlanner, LazyPlan, MarkedPlanner, + PlanStep, SendKeys, SequentialPlanner, SplitWindow, ) -from libtmux.experimental.ops._types import PaneId, WindowId +from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId if t.TYPE_CHECKING: from collections.abc import Sequence from libtmux.experimental.engines.base import CommandRequest, CommandResult + from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.planner import Planner from libtmux.session import Session @@ -125,6 +128,68 @@ def test_marked_falls_back_without_pattern() -> None: assert len(engine.calls) == 1 # folded as a plain ; chain +def _split_decorate_plan() -> list[Operation[t.Any]]: + """Return the {marked}-foldable shape: split @1, then two pane decorates.""" + return [ + SplitWindow(target=WindowId("@1")), + SendKeys(target=SlotRef(0), keys="a", enter=True), + SendKeys(target=SlotRef(0), keys="b", enter=True), + ] + + +def test_bounded_planner_no_boundaries_is_identity() -> None: + """With no boundaries, BoundedPlanner reproduces the inner planner exactly.""" + ops = _split_decorate_plan() + inner = MarkedPlanner() + assert BoundedPlanner(inner, frozenset()).plan(ops) == inner.plan(ops) + + +def test_bounded_planner_splits_chain_at_boundary() -> None: + """A boundary breaks a folded chain between the two ops it separates.""" + ops = [ + SendKeys(target=PaneId("%1"), keys="a"), + SendKeys(target=PaneId("%1"), keys="b"), + SendKeys(target=PaneId("%1"), keys="c"), + ] + steps = BoundedPlanner(FoldingPlanner(), frozenset({1})).plan(ops) + assert steps == [PlanStep((0, 1)), PlanStep((2,))] + + +def test_bounded_planner_demotes_marked_at_creator_boundary() -> None: + """A host step after the creator forbids {marked}; the creator dispatches alone.""" + ops = _split_decorate_plan() + steps = BoundedPlanner(MarkedPlanner(), frozenset({0})).plan(ops) + # creator alone, then the decorates as a plain ; chain -- no marked fold spans + # the boundary (the pane id is bound before the host step runs). + assert steps == [PlanStep((0,)), PlanStep((1, 2))] + assert not any(step.marked for step in steps) + + +def test_bounded_planner_keeps_marked_first_run_between_decorates() -> None: + """A boundary between decorates keeps creator+first marked; the rest plain.""" + ops = _split_decorate_plan() + steps = BoundedPlanner(MarkedPlanner(), frozenset({1})).plan(ops) + assert steps == [PlanStep((0, 1), marked=True), PlanStep((2,))] + + +def test_bounded_planner_preserves_result() -> None: + """Bounding a planner changes only dispatch grouping, never the result.""" + plan = _build_plan() + plain = plan.execute(_CountingEngine(), planner=MarkedPlanner()) + bounded = plan.execute( + _CountingEngine(), + planner=BoundedPlanner(MarkedPlanner(), frozenset({0})), + ) + assert [r.argv for r in plain.results] == [r.argv for r in bounded.results] + assert plain.bindings == bounded.bindings + # the boundary forced an extra dispatch without changing the outcome + plain_calls = _CountingEngine() + bounded_calls = _CountingEngine() + plan.execute(plain_calls, planner=MarkedPlanner()) + plan.execute(bounded_calls, planner=BoundedPlanner(MarkedPlanner(), frozenset({0}))) + assert len(bounded_calls.calls) > len(plain_calls.calls) + + def test_marked_fold_live(session: Session) -> None: """The {marked} fold creates and decorates a real pane in one dispatch.""" from libtmux.experimental.engines import SubprocessEngine From 7746e6a8a8f6302bbc4d47ca575480159f638224 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:16:43 -0500 Subject: [PATCH 093/154] Workspace(feat): Fold build dispatches by default why: A declarative build paid one tmux dispatch per operation because the runner forked its own per-op loop to interleave host steps, bypassing the Core planner. A multi-pane window now renders in a few round-trips instead of dozens, with the same result. what: - Drive build_workspace/abuild_workspace through LazyPlan.execute with BoundedPlanner(MarkedPlanner, frozenset(host_after)) and an on_step hook that replays each index's host steps and build events, deleting the hand-rolled per-op loop - Default the build to folding; add planner= to the runner functions and Workspace.build/abuild so a caller can override (e.g. SequentialPlanner for one legible tmux call per op) - host_after keys are the fold boundaries, so sleeps, the wait_pane anti-race, and before_script keep a fold from ever crossing a pause; the PlanResult is identical, only the dispatch count drops - Add folding contract tests (dispatch reduction, planner equivalence, boundary rules, live subprocess) and a CHANGES deliverable --- CHANGES | 19 +++ src/libtmux/experimental/workspace/ir.py | 11 +- src/libtmux/experimental/workspace/runner.py | 112 ++++++++----- .../contract/test_workspace_folding.py | 148 ++++++++++++++++++ 4 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 tests/experimental/contract/test_workspace_folding.py diff --git a/CHANGES b/CHANGES index 66285b197..6d66b008e 100644 --- a/CHANGES +++ b/CHANGES @@ -73,6 +73,25 @@ dispatches is a pluggable {class}`~libtmux.experimental.ops.planner.Planner` (sequential, ``;``-folding, or ``{marked}``-folding), so dispatch strategies can be A/B tested against the same plan with identical results. +#### Declarative workspace builds fold to a few tmux calls (#690) + +A {class}`~libtmux.experimental.workspace.ir.Workspace` declares a session as a +tree of windows and panes and lowers to a Core +{class}`~libtmux.experimental.ops.plan.LazyPlan`, so a tmuxp-style spec can be +analyzed, inspected, and built over any engine. +{meth}`~libtmux.experimental.workspace.ir.Workspace.build` and its async twin +{meth}`~libtmux.experimental.workspace.ir.Workspace.abuild` fold the build's +dispatches by default: a multi-pane window collapses from one tmux call per +operation into a handful of ``;``-chained and ``{marked}`` dispatches, so a +session renders in a few round-trips instead of dozens. + +The resulting {class}`~libtmux.experimental.ops.plan.PlanResult` is identical to +an unfolded build -- only the dispatch count changes -- because host-side steps +(per-command sleeps, the ``wait_pane`` anti-race, ``before_script``) stay hard +fold boundaries that a fold never crosses. Pass a +{class}`~libtmux.experimental.ops.planner.SequentialPlanner` to ``build`` for one +legible tmux call per operation when debugging. + ## libtmux 0.61.0 (2026-07-04) libtmux 0.61.0 hardens support for the tmux 3.7 patch line. It fixes diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 4b16a8125..552e2cca3 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -37,6 +37,7 @@ from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops.plan import LazyPlan, PlanResult + from libtmux.experimental.ops.planner import Planner from libtmux.experimental.workspace.events import BuildEvent @@ -336,13 +337,15 @@ def build( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute this workspace synchronously over *engine*. Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check (e.g. against the stateless ``ConcreteEngine``, which has no real sessions to detect). Pass *on_event* to observe the structural build - stream (see :mod:`~.events`). + stream (see :mod:`~.events`). The build folds dispatches by default; pass + *planner* (e.g. :class:`~..ops.planner.SequentialPlanner`) to override. """ from libtmux.experimental.workspace.runner import build_workspace @@ -352,6 +355,7 @@ def build( version=version, preflight=preflight, on_event=on_event, + planner=planner, ) async def abuild( @@ -361,10 +365,12 @@ async def abuild( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute this workspace asynchronously over *engine*. - *on_event* is awaited for each build event (see :mod:`~.events`). + *on_event* is awaited for each build event (see :mod:`~.events`). Folds by + default; pass *planner* to override. """ from libtmux.experimental.workspace.runner import abuild_workspace @@ -374,4 +380,5 @@ async def abuild( version=version, preflight=preflight, on_event=on_event, + planner=planner, ) diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index 5513259e0..2a73d1222 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -1,11 +1,16 @@ """Execute a compiled workspace over any engine, sync or async. -The runner is the Declarative tier's *bound* layer. It keeps the Core operation -spine pure: it drives the compiled plan one operation at a time (reusing Core's -:func:`~libtmux.experimental.ops.plan._resolve` forward-ref resolution) and -interleaves host-side steps (sleep / before_script) *between* operations rather -than weaving them into Core's ``_drive`` generator. Idempotent replace is handled -*around* the build via a ``has-session`` pre-check. +The runner is the Declarative tier's *bound* layer. It drives the compiled plan +through Core's :meth:`~libtmux.experimental.ops.plan.LazyPlan.execute` so the +build reuses the same sans-I/O resolution trampoline as any other plan -- and so +folds dispatches via a :class:`~..ops.planner.Planner`. Host-side steps (sleep / +before_script / pane-readiness waits) must run *between* tmux dispatches, so the +compiler records them as a separate schedule keyed by operation index +(:attr:`~..compiler.Compiled.host_after`). The runner turns those keys into fold +boundaries via a :class:`~..ops.planner.BoundedPlanner` (no fold may cross a host +step) and replays each index's host steps from the ``on_step`` hook +:meth:`~..ops.plan.LazyPlan.execute` fires after every step binds. Idempotent +replace is handled *around* the build via a ``has-session`` pre-check. The same compiled plan runs identically through any engine and through either the sync (:func:`build_workspace`) or async (:func:`abuild_workspace`) driver -- the @@ -30,7 +35,8 @@ run, ) from libtmux.experimental.ops._types import NameRef -from libtmux.experimental.ops.plan import PlanResult, _resolve +from libtmux.experimental.ops.plan import PlanResult, StepReport, _resolve +from libtmux.experimental.ops.planner import BoundedPlanner, MarkedPlanner from libtmux.experimental.workspace.compiler import compile_full from libtmux.experimental.workspace.events import WorkspaceBuilt, events_for @@ -38,7 +44,7 @@ from collections.abc import Awaitable, Callable from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine - from libtmux.experimental.ops.results import Result + from libtmux.experimental.ops.planner import Planner from libtmux.experimental.workspace.compiler import HostStep from libtmux.experimental.workspace.events import BuildEvent from libtmux.experimental.workspace.ir import Workspace @@ -134,12 +140,20 @@ def build_workspace( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute *ws* synchronously over *engine*. Pass *on_event* to observe the structural build stream (session -> windows -> panes -> built) as each operation binds its id. + The build folds dispatches by default (a :class:`~..ops.planner.MarkedPlanner` + wrapped so no fold crosses a host step), so a multi-pane window costs a few + tmux calls instead of one per op. Pass *planner* to override -- e.g. + :class:`~..ops.planner.SequentialPlanner` for one legible call per op. The + :class:`~..ops.plan.PlanResult` is identical either way; only the dispatch + count changes. + Examples -------- >>> from libtmux.experimental.engines import ConcreteEngine @@ -151,25 +165,30 @@ def build_workspace( if preflight and _preflight_sync(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - bindings: dict[int | tuple[int, str], str] = {} - results: list[Result] = [] + ops = compiled.plan.operations for step in compiled.pre: - _run_host_sync(step, engine, bindings, version) - for index, op in enumerate(compiled.plan.operations): - result = run(_resolve(op, bindings), engine, version=version) - results.append(result) - if result.created_id is not None: - bindings[index] = result.created_id - for part, sub in result.created_subids.items(): - bindings[index, part] = sub - if on_event is not None: - for event in events_for(op, result): - on_event(event) - for step in compiled.host_after.get(index, ()): - _run_host_sync(step, engine, bindings, version) + _run_host_sync(step, engine, {}, version) + + def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + on_event(event) + for host_step in compiled.host_after.get(index, ()): + _run_host_sync(host_step, engine, report.bindings, version) + + outcome = compiled.plan.execute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) if on_event is not None: - on_event(WorkspaceBuilt(bindings.get(0, ""))) - return PlanResult(tuple(results), bindings) + on_event(WorkspaceBuilt(outcome.bindings.get(0, ""))) + return outcome async def abuild_workspace( @@ -179,31 +198,38 @@ async def abuild_workspace( version: str | None = None, preflight: bool = True, on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, ) -> PlanResult: """Compile and execute *ws* asynchronously over *engine* (same resolution). *on_event* is awaited for each build event, so an async observer can stream - the structural progress (e.g. through a fastmcp Context). + the structural progress (e.g. through a fastmcp Context). Folds by default; + see :func:`build_workspace` for the *planner* knob. """ if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) compiled = compile_full(ws, version=version) - bindings: dict[int | tuple[int, str], str] = {} - results: list[Result] = [] + ops = compiled.plan.operations for step in compiled.pre: - await _run_host_async(step, engine, bindings, version) - for index, op in enumerate(compiled.plan.operations): - result = await arun(_resolve(op, bindings), engine, version=version) - results.append(result) - if result.created_id is not None: - bindings[index] = result.created_id - for part, sub in result.created_subids.items(): - bindings[index, part] = sub - if on_event is not None: - for event in events_for(op, result): - await on_event(event) - for step in compiled.host_after.get(index, ()): - await _run_host_async(step, engine, bindings, version) + await _run_host_async(step, engine, {}, version) + + async def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + await on_event(event) + for host_step in compiled.host_after.get(index, ()): + await _run_host_async(host_step, engine, report.bindings, version) + + outcome = await compiled.plan.aexecute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) if on_event is not None: - await on_event(WorkspaceBuilt(bindings.get(0, ""))) - return PlanResult(tuple(results), bindings) + await on_event(WorkspaceBuilt(outcome.bindings.get(0, ""))) + return outcome diff --git a/tests/experimental/contract/test_workspace_folding.py b/tests/experimental/contract/test_workspace_folding.py new file mode 100644 index 000000000..78ca64b47 --- /dev/null +++ b/tests/experimental/contract/test_workspace_folding.py @@ -0,0 +1,148 @@ +"""The workspace build folds dispatches through the Core planner. + +A declarative build drives Core's ``LazyPlan.execute`` with a +:class:`~libtmux.experimental.ops.planner.BoundedPlanner`, so a multi-pane window +collapses to a few tmux calls instead of one per op -- while host steps (sleeps, +pane-ready waits) stay hard fold boundaries. These tests pin the dispatch-count +reduction, the planner-equivalence (same ``PlanResult``), and the boundary rules, +offline and live. +""" + +from __future__ import annotations + +import dataclasses +import typing as t + +from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.ops import SequentialPlanner +from libtmux.experimental.workspace import Command, Pane, Window, Workspace + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, TmuxEngine + from libtmux.session import Session + + +@dataclasses.dataclass +class _RecordingEngine: + """Record every dispatch's argv; answer a wait_pane cursor as ready. + + A first-class engine arm (not a monkeypatch): it forwards to a real inner + engine but reports a non-origin cursor for ``display-message`` so the + runner's pane-readiness poll returns on the first try, keeping the tests + fast. + """ + + inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and forward (faking a ready cursor for waits).""" + self.calls.append(request.args) + if "display-message" in request.args: + return CommandResult(cmd=("tmux", *request.args), stdout=("1,1",)) + return self.inner.run(request) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def _spec(*, wait_pane: bool = False) -> Workspace: + """Return a 2-window workspace; the editor window has three command panes.""" + return Workspace( + name="fold", + start_directory="/tmp", + windows=[ + Window( + "editor", + panes=[ + Pane(run="echo a"), + Pane(run=["echo b", "echo c"]), + Pane(run="echo d"), + ], + ), + Window("logs", panes=[Pane(run="echo log")]), + ], + wait_pane=wait_pane, + ) + + +def test_build_folds_by_default() -> None: + """A build folds dispatches without the caller choosing a planner.""" + default = _RecordingEngine() + _spec().build(default, preflight=False) + sequential = _RecordingEngine() + _spec().build(sequential, preflight=False, planner=SequentialPlanner()) + + assert len(default.calls) < len(sequential.calls) + assert any(";" in argv for argv in default.calls) # at least one folded chain + + +def test_build_planner_equivalence() -> None: + """The default (folding) build yields the same PlanResult as the sequential one.""" + folded = _spec().build(ConcreteEngine(), preflight=False) + sequential = _spec().build( + ConcreteEngine(), + preflight=False, + planner=SequentialPlanner(), + ) + + assert [r.argv for r in folded.results] == [r.argv for r in sequential.results] + assert folded.bindings == sequential.bindings + + +def test_build_wait_pane_never_folds_create_into_send() -> None: + """wait_pane keeps the split a fold boundary: no dispatch carries split+send.""" + engine = _RecordingEngine() + _spec(wait_pane=True).build(engine, preflight=False) + + crossed = [ + argv for argv in engine.calls if "split-window" in argv and "send-keys" in argv + ] + assert not crossed + + +def test_build_sleep_after_forces_boundary() -> None: + """A sleep between two sends keeps them in separate dispatches.""" + ws = Workspace( + name="s", + windows=[ + Window("w", panes=[Pane(run=[Command("a", sleep_after=0.0), "b"])]), + ], + ) + engine = _RecordingEngine() + ws.build(engine, preflight=False) + + folded_both = [argv for argv in engine.calls if argv.count("send-keys") == 2] + assert not folded_both + + +def test_build_folds_live_subprocess(session: Session) -> None: + """A folded build creates the real structure with at least one ; chain.""" + server = session.server + engine = _RecordingEngine(SubprocessEngine.for_server(server)) + spec = Workspace( + name="fold-live", + start_directory="/tmp", + windows=[ + Window( + "editor", + panes=[Pane(run="echo a"), Pane(run="echo b"), Pane(run="echo c")], + ), + Window("logs", panes=[Pane(run="echo log")]), + ], + ) + + result = spec.build(engine) + + assert result.ok + built = server.sessions.get(session_name="fold-live") + assert built is not None + assert [w.window_name for w in built.windows] == ["editor", "logs"] + assert [len(w.panes) for w in built.windows] == [3, 1] + # the build folded: at least one ; chain, fewer dispatches than operations + assert any(";" in argv for argv in engine.calls) + assert len(engine.calls) < len(spec.compile().operations) From e66ad29b652ce330fcf8c77baf8f2f1e862ac7f8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:23:18 -0500 Subject: [PATCH 094/154] Workspace(feat): Fold --dry-run output why: The dry run rendered the unfolded sequential plan, but the build folds by default -- so the preview misrepresented the dispatches that would actually run (one tmux line per op instead of the ; chains). what: - Drive the dry run through the same BoundedPlanner(MarkedPlanner) the build uses, via a recording engine, so the printed lines are the real folded dispatches; a standalone ; renders as \; (copy-pasteable) and the header reports the dispatch count and shape - Add --no-fold to load (and a fold= param) that controls BOTH the dry run rendering and the real build planner, keeping them consistent - Cover the folded/{marked} dry run, --no-fold, and flag parsing --- src/libtmux/experimental/workspace/cli.py | 100 +++++++++++++++--- .../contract/test_workspace_cli.py | 54 ++++++++++ 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py index c461c976d..4489d4d10 100644 --- a/src/libtmux/experimental/workspace/cli.py +++ b/src/libtmux/experimental/workspace/cli.py @@ -29,7 +29,12 @@ if t.TYPE_CHECKING: from collections.abc import Sequence - from libtmux.experimental.ops.plan import PlanResult + from libtmux.experimental.engines.base import ( + CommandRequest, + CommandResult, + TmuxEngine, + ) + from libtmux.experimental.ops.plan import PlanResult, StepReport from libtmux.experimental.workspace.ir import Workspace #: Filenames searched when a directory is given (tmuxp's convention). @@ -172,32 +177,77 @@ def _attach(session: t.Any, *, detached: bool) -> None: session.attach() +class _RecordingEngine: + """Wrap an engine, capturing every dispatched argv for the dry run. + + Each recorded entry is one tmux dispatch -- a folded ``;`` chain renders as a + single argv with bare ``;`` separators, exactly as the real build sends it. + """ + + def __init__(self, inner: TmuxEngine) -> None: + self.inner = inner + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv, then forward to the wrapped engine.""" + self.calls.append(request.args) + return self.inner.run(request) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Forward each request in order, recording as it goes.""" + return [self.run(req) for req in requests] + + def _print_dry_run( workspace: Workspace, *, socket_name: str | None, socket_path: str | None, + fold: bool = True, ) -> None: - """Print the tmux commands a build would run, without touching tmux. + r"""Print the tmux commands a build would run, without touching tmux. The plan is resolved against the in-memory ``ConcreteEngine`` (which - fabricates ids) so every line renders fully; host steps (sleep / - before_script / pane-readiness) print as comments in execution order. + fabricates ids) through the *same* planner the real build uses, so the + printed lines are the folded ``;`` dispatches that would actually run -- not + an unfolded op-per-line view. Pass ``fold=False`` for one tmux call per + operation. Host steps (sleep / before_script / pane-readiness) print as + comments in execution order, and a standalone ``;`` renders as ``\;`` so a + line stays copy-pasteable into a shell. """ import shlex from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.ops import ( + BoundedPlanner, + MarkedPlanner, + SequentialPlanner, + ) from libtmux.experimental.workspace.compiler import HostStep, compile_full compiled = compile_full(workspace) - outcome = compiled.plan.execute(ConcreteEngine()) - prefix: list[str] = ["tmux"] if socket_name: prefix += ["-L", socket_name] if socket_path: prefix += ["-S", socket_path] + planner = ( + BoundedPlanner(MarkedPlanner(), frozenset(compiled.host_after)) + if fold + else SequentialPlanner() + ) + engine = _RecordingEngine(ConcreteEngine()) + hosts_per_dispatch: list[tuple[HostStep, ...]] = [] + + def on_step(report: StepReport) -> None: + steps: list[HostStep] = [] + for index in report.step.indices: + steps.extend(compiled.host_after.get(index, ())) + hosts_per_dispatch.append(tuple(steps)) + + compiled.plan.execute(engine, planner=planner, on_step=on_step) + def _emit_host(step: HostStep) -> None: if step.kind == "sleep": print(f"# sleep {step.seconds}") @@ -207,12 +257,21 @@ def _emit_host(step: HostStep) -> None: elif step.kind == "wait_pane": print("# wait for the pane's shell to be ready") - print(f"# build plan for session {workspace.name!r} (dry run, ids fabricated)") + def _render(argv: tuple[str, ...]) -> str: + return " ".join( + "\\;" if token == ";" else shlex.quote(token) for token in (*prefix, *argv) + ) + + shape = "folded" if fold else "sequential" + print( + f"# build plan for session {workspace.name!r} " + f"({len(engine.calls)} dispatches, {shape}, ids fabricated)", + ) for step in compiled.pre: _emit_host(step) - for index, result in enumerate(outcome.results): - print(shlex.join([*prefix, *result.argv])) - for step in compiled.host_after.get(index, ()): + for argv, hosts in zip(engine.calls, hosts_per_dispatch, strict=True): + print(_render(argv)) + for step in hosts: _emit_host(step) @@ -224,6 +283,7 @@ def load( new_session_name: str | None = None, detached: bool = False, dry_run: bool = False, + fold: bool = True, ) -> PlanResult | None: """Build (and unless *detached*, attach) a workspace file. @@ -233,6 +293,9 @@ def load( (unless the file's ``on_exists`` opts into ``replace``/``reuse``). With *dry_run*, the tmux commands are printed and nothing is executed. + The build folds tmux dispatches by default (``fold=True``); ``fold=False`` + issues one tmux call per operation, for both the dry run and the real build. + Returns ------- PlanResult or None @@ -246,11 +309,17 @@ def load( workspace = analyze(raw) if dry_run: - _print_dry_run(workspace, socket_name=socket_name, socket_path=socket_path) + _print_dry_run( + workspace, + socket_name=socket_name, + socket_path=socket_path, + fold=fold, + ) return None import libtmux from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import SequentialPlanner server = libtmux.Server(socket_name=socket_name, socket_path=socket_path) engine = SubprocessEngine.for_server(server) @@ -258,7 +327,7 @@ def load( existed = server.has_session(workspace.name) result: PlanResult | None = None try: - result = workspace.build(engine) + result = workspace.build(engine, planner=None if fold else SequentialPlanner()) except FileExistsError: # on_exists="error" (the default) and the session is already running; # attach to it rather than failing, matching `tmuxp load`. @@ -330,6 +399,12 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="print the tmux commands that would run, without executing them", ) + load_parser.add_argument( + "--no-fold", + dest="fold", + action="store_false", + help="dispatch one tmux call per operation (no ; chaining)", + ) return parser @@ -349,6 +424,7 @@ def main(argv: Sequence[str] | None = None) -> None: new_session_name=args.new_session_name, detached=args.detached, dry_run=args.dry_run, + fold=args.fold, ) diff --git a/tests/experimental/contract/test_workspace_cli.py b/tests/experimental/contract/test_workspace_cli.py index 83dbcee59..a37fe50b1 100644 --- a/tests/experimental/contract/test_workspace_cli.py +++ b/tests/experimental/contract/test_workspace_cli.py @@ -62,6 +62,13 @@ def test_parser_load_arguments() -> None: assert args.detached is True assert args.socket_name == "sock" assert args.new_session_name == "newname" + assert args.fold is True # builds fold by default + + +def test_parser_no_fold_flag() -> None: + """``--no-fold`` opts out of dispatch chaining.""" + args = cli._build_parser().parse_args(["load", "ws.yaml", "--no-fold"]) + assert args.fold is False def test_load_builds_and_reattaches(tmp_path: Path) -> None: @@ -123,5 +130,52 @@ def test_dry_run_prints_commands_without_touching_tmux( assert "echo one" in out # the blank pane sends no command, so exactly one send-keys line is rendered assert out.count("send-keys") == 1 + # the default dry run folds: the header says so and rename + send chain + assert "folded" in out + assert "\\;" in out # nothing was executed: no tmux server exists on the dry-run socket assert not libtmux.Server(socket_name=socket).is_alive() + + +def test_dry_run_folds_split_and_send_into_one_dispatch( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """A split pane with a command renders as one {marked} dispatch by default.""" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: dryfold\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - echo two\n", + ) + cli.load(str(tmp_path), socket_name="libtmux_wscli_fold", dry_run=True) + out = capsys.readouterr().out + + # the second pane's split + send-keys collapse into a single {marked} chain + marked = [line for line in out.splitlines() if "{marked}" in line] + assert len(marked) == 1 + assert "split-window" in marked[0] and "send-keys" in marked[0] + assert "\\;" in marked[0] + + +def test_dry_run_no_fold_renders_one_call_per_op( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """``--no-fold`` prints an unchained, one-op-per-line plan.""" + (tmp_path / ".tmuxp.yaml").write_text( + "session_name: dryseq\n" + "windows:\n" + " - window_name: editor\n" + " panes:\n" + " - echo one\n" + " - echo two\n", + ) + cli.load(str(tmp_path), socket_name="libtmux_wscli_seq", dry_run=True, fold=False) + out = capsys.readouterr().out + + assert "sequential" in out + assert "\\;" not in out # nothing chained + assert "{marked}" not in out # no marked fold From 4dbca50bb571b47370f2cf69d27038ace1820fbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 14:35:14 -0500 Subject: [PATCH 095/154] Ops(feat[new_pane]): Add floating pane operation why: The engine-ops spine had 60 operations but none for tmux 3.7's new-pane (floating panes); the workspace builder, facade, and MCP had nothing to lower a floating pane into. what: - Add NewPane(Operation[SplitWindowResult]) rendering new-pane with absolute floating geometry (-x/-y size, -X/-Y position; cells or N%), -Z/-d/-E, styles, environment, and -P -F capture - Reuse SplitWindowResult so SlotRef binding, facade, and MCP keep working unchanged; first op to set min_version='3.7' (whole-command version gate) - Register + export NewPane; refresh the catalog all-kinds doctest - Cover render/round-trip/registry/version-gate plus a live floating pane test asserting pane_floating_flag on tmux 3.7+ --- src/libtmux/experimental/ops/__init__.py | 2 + src/libtmux/experimental/ops/_ops/__init__.py | 2 + src/libtmux/experimental/ops/_ops/new_pane.py | 183 ++++++++++++++++ src/libtmux/experimental/ops/catalog.py | 2 +- tests/experimental/ops/test_new_pane.py | 200 ++++++++++++++++++ 5 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/libtmux/experimental/ops/_ops/new_pane.py create mode 100644 tests/experimental/ops/test_new_pane.py diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 28616dd2d..75d58453e 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -43,6 +43,7 @@ LoadBuffer, MovePane, MoveWindow, + NewPane, NewSession, NewWindow, NextWindow, @@ -189,6 +190,7 @@ "MovePane", "MoveWindow", "NameRef", + "NewPane", "NewSession", "NewWindow", "NextWindow", diff --git a/src/libtmux/experimental/ops/_ops/__init__.py b/src/libtmux/experimental/ops/_ops/__init__.py index 17d76abab..26b90789e 100644 --- a/src/libtmux/experimental/ops/_ops/__init__.py +++ b/src/libtmux/experimental/ops/_ops/__init__.py @@ -29,6 +29,7 @@ from libtmux.experimental.ops._ops.load_buffer import LoadBuffer from libtmux.experimental.ops._ops.move_pane import MovePane from libtmux.experimental.ops._ops.move_window import MoveWindow +from libtmux.experimental.ops._ops.new_pane import NewPane from libtmux.experimental.ops._ops.new_session import NewSession from libtmux.experimental.ops._ops.new_window import NewWindow from libtmux.experimental.ops._ops.next_window import NextWindow @@ -88,6 +89,7 @@ "LoadBuffer", "MovePane", "MoveWindow", + "NewPane", "NewSession", "NewWindow", "NextWindow", diff --git a/src/libtmux/experimental/ops/_ops/new_pane.py b/src/libtmux/experimental/ops/_ops/new_pane.py new file mode 100644 index 000000000..7409a29f8 --- /dev/null +++ b/src/libtmux/experimental/ops/_ops/new_pane.py @@ -0,0 +1,183 @@ +"""The ``new-pane`` operation (tmux 3.7+ floating panes).""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from libtmux.experimental.ops._types import Effects +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import register +from libtmux.experimental.ops.results import SplitWindowResult + +if t.TYPE_CHECKING: + from collections.abc import Mapping + + from libtmux.experimental.ops._types import Status + + +@register +@dataclass(frozen=True, kw_only=True) +class NewPane(Operation[SplitWindowResult]): + """Create a floating pane (``new-pane``; tmux 3.7+). + + ``new-pane`` shares ``split-window``'s machinery but floats by default: the + pane sits above the tiled layout (popup-style, but non-modal) instead of + becoming a tiled cell. Geometry is *absolute* -- :attr:`width`/:attr:`height` + set the size (``-x``/``-y``) and :attr:`x`/:attr:`y` set the top-left offset + (``-X``/``-Y``); each accepts cells (``int``) or a percentage (``str`` like + ``"50%"``). Omitted position cascades down-right on repeated calls. + + Like :class:`~.split_window.SplitWindow` it reuses + :class:`~.results.SplitWindowResult`, capturing the new pane id via + ``-P -F '#{pane_id}'`` so plans, the facade, and MCP bind it the same way. + + This is the first operation gated by :attr:`~.operation.Operation.min_version`: + rendering against a tmux older than 3.7 raises + :exc:`~.exc.VersionUnsupported`. + + Parameters + ---------- + width : int or str or None + Floating pane width in cells or ``N%`` (``-x``). + height : int or str or None + Floating pane height in cells or ``N%`` (``-y``). + x : int or str or None + Absolute x-position (left offset) in cells or ``N%`` (``-X``). + y : int or str or None + Absolute y-position (top offset) in cells or ``N%`` (``-Y``). + zoom : bool + Zoom the new pane (``-Z``). + detach : bool + Do not focus the new pane (``-d``); defaults ``True`` for headless use. + empty : bool + Create an empty pane with no command (``-E``). + start_directory : str or None + Working directory for the new pane (``-c``). + environment : Mapping[str, str] or None + Environment variables for the new pane (``-e``). + style : str or None + Content style (``-s``). + active_border_style : str or None + Active border style (``-S``). + inactive_border_style : str or None + Inactive border style (``-R``). + message : str or None + Remain-on-exit message (``-m``). + shell_command : str or None + A shell command to run instead of the default shell. + capture : bool + Append ``-P -F '#{pane_id}'`` to capture the new pane id. + + Examples + -------- + >>> from libtmux.experimental.ops._types import PaneId + >>> NewPane(target=PaneId("%1"), width=80, height=15, x=5, y=3).render() + ('new-pane', '-t', '%1', '-x80', '-y15', '-X5', '-Y3', '-d', '-P', '-F', + '#{pane_id}') + + Percentages and zoom render verbatim: + + >>> NewPane(target=PaneId("%1"), width="50%", height="40%", zoom=True).render() + ('new-pane', '-t', '%1', '-x50%', '-y40%', '-Z', '-d', '-P', '-F', '#{pane_id}') + + Passing ``detach=False`` focuses the new pane (no ``-d``): + + >>> NewPane(target=PaneId("%1"), width=80, height=15, detach=False).render() + ('new-pane', '-t', '%1', '-x80', '-y15', '-P', '-F', '#{pane_id}') + + Floating panes need tmux 3.7+; an older tmux is refused: + + >>> NewPane(target=PaneId("%1")).render(version="3.6") + Traceback (most recent call last): + ... + libtmux.experimental.ops.exc.VersionUnsupported: operation 'new_pane' + requires tmux >= 3.7 (have 3.6) + + The created pane id is parsed into the typed result: + + >>> result = NewPane(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + >>> result.new_pane_id + '%2' + """ + + kind = "new_pane" + command = "new-pane" + scope = "window" + result_cls = SplitWindowResult + safety = "mutating" + chainable = False # captures a new pane id (-P -F); cannot fold into a ; chain + effects = Effects(creates="pane") + min_version = "3.7" + + width: int | str | None = None + height: int | str | None = None + x: int | str | None = None + y: int | str | None = None + zoom: bool = False + detach: bool = True + empty: bool = False + start_directory: str | None = None + environment: Mapping[str, str] | None = None + style: str | None = None + active_border_style: str | None = None + inactive_border_style: str | None = None + message: str | None = None + shell_command: str | None = None + capture: bool = True + + def args(self, *, version: str | None = None) -> tuple[str, ...]: + """Render ``new-pane`` geometry, style, capture, and shell flags.""" + out: list[str] = [] + if self.width is not None: + out.append(f"-x{self.width}") + if self.height is not None: + out.append(f"-y{self.height}") + if self.x is not None: + out.append(f"-X{self.x}") + if self.y is not None: + out.append(f"-Y{self.y}") + if self.zoom: + out.append("-Z") + if self.detach: + out.append("-d") + if self.start_directory is not None: + out.append(f"-c{self.start_directory}") + if self.environment: + out.extend(f"-e{key}={value}" for key, value in self.environment.items()) + if self.style is not None: + out.append(f"-s{self.style}") + if self.active_border_style is not None: + out.append(f"-S{self.active_border_style}") + if self.inactive_border_style is not None: + out.append(f"-R{self.inactive_border_style}") + if self.message is not None: + out.append(f"-m{self.message}") + if self.empty: + out.append("-E") + if self.capture: + out.extend(("-P", "-F", "#{pane_id}")) + if self.shell_command is not None: + out.append(self.shell_command) + return tuple(out) + + def _make_result( + self, + argv: tuple[str, ...], + status: Status, + returncode: int, + stdout: tuple[str, ...], + stderr: tuple[str, ...], + version: str | None = None, + ) -> SplitWindowResult: + """Parse the captured new-pane id into the typed result.""" + new_pane_id = stdout[0].strip() if status == "complete" and stdout else None + return SplitWindowResult( + operation=self, + argv=argv, + status=status, + returncode=returncode, + stdout=stdout, + stderr=stderr, + new_pane_id=new_pane_id, + ) diff --git a/src/libtmux/experimental/ops/catalog.py b/src/libtmux/experimental/ops/catalog.py index 55ed6118a..888a056c7 100644 --- a/src/libtmux/experimental/ops/catalog.py +++ b/src/libtmux/experimental/ops/catalog.py @@ -69,7 +69,7 @@ def catalog(registry: OperationRegistry | None = None) -> list[CatalogEntry]: 'display_message', 'has_session', 'join_pane', 'kill_pane', 'kill_server', 'kill_session', 'kill_window', 'last_pane', 'last_window', 'link_window', 'list_clients', 'list_panes', 'list_sessions', 'list_windows', 'load_buffer', - 'move_pane', 'move_window', 'new_session', 'new_window', 'next_window', + 'move_pane', 'move_window', 'new_pane', 'new_session', 'new_window', 'next_window', 'paste_buffer', 'pipe_pane', 'previous_window', 'refresh_client', 'rename_session', 'rename_window', 'resize_pane', 'resize_window', 'respawn_pane', 'respawn_window', 'rotate_window', 'run_shell', 'save_buffer', 'select_layout', 'select_pane', diff --git a/tests/experimental/ops/test_new_pane.py b/tests/experimental/ops/test_new_pane.py new file mode 100644 index 000000000..b936ba813 --- /dev/null +++ b/tests/experimental/ops/test_new_pane.py @@ -0,0 +1,200 @@ +"""Tests for the ``new-pane`` (floating pane) operation.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.common import has_gte_version +from libtmux.experimental.ops import ( + NewPane, + operation_from_dict, + operation_to_dict, + registry, + result_from_dict, + result_to_dict, + run, +) +from libtmux.experimental.ops._types import PaneId +from libtmux.experimental.ops.exc import VersionUnsupported + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class RenderCase(t.NamedTuple): + """A ``NewPane`` op and the exact argv it renders.""" + + test_id: str + op: NewPane + expected: tuple[str, ...] + + +RENDER_CASES = ( + RenderCase( + test_id="geometry", + op=NewPane(target=PaneId("%1"), width=80, height=15, x=5, y=3), + expected=( + "new-pane", + "-t", + "%1", + "-x80", + "-y15", + "-X5", + "-Y3", + "-d", + "-P", + "-F", + "#{pane_id}", + ), + ), + RenderCase( + test_id="percentage_zoom", + op=NewPane(target=PaneId("%1"), width="50%", height="40%", zoom=True), + expected=( + "new-pane", + "-t", + "%1", + "-x50%", + "-y40%", + "-Z", + "-d", + "-P", + "-F", + "#{pane_id}", + ), + ), + RenderCase( + test_id="attach_no_detach", + op=NewPane(target=PaneId("%1"), width=80, height=15, detach=False), + expected=( + "new-pane", + "-t", + "%1", + "-x80", + "-y15", + "-P", + "-F", + "#{pane_id}", + ), + ), + RenderCase( + test_id="styles_env_shell", + op=NewPane( + target=PaneId("%1"), + start_directory="/tmp", + environment={"E": "1"}, + style="bg=default", + active_border_style="fg=magenta", + inactive_border_style="fg=cyan", + message="done", + empty=True, + shell_command="lazygit", + ), + expected=( + "new-pane", + "-t", + "%1", + "-d", + "-c/tmp", + "-eE=1", + "-sbg=default", + "-Sfg=magenta", + "-Rfg=cyan", + "-mdone", + "-E", + "-P", + "-F", + "#{pane_id}", + "lazygit", + ), + ), +) + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_new_pane_render( + test_id: str, + op: NewPane, + expected: tuple[str, ...], +) -> None: + """Each ``NewPane`` configuration renders the exact tmux argv.""" + assert op.render() == expected + + +@pytest.mark.parametrize( + list(RenderCase._fields), + RENDER_CASES, + ids=[c.test_id for c in RENDER_CASES], +) +def test_new_pane_round_trips( + test_id: str, + op: NewPane, + expected: tuple[str, ...], +) -> None: + """The op and its result round-trip through dicts.""" + assert operation_from_dict(operation_to_dict(op)) == op + result = op.build_result(returncode=0, stdout=("%2",)) + assert result_from_dict(result_to_dict(result)) == result + + +def test_new_pane_is_registered() -> None: + """``NewPane`` is discoverable in the operation registry by kind.""" + assert "new_pane" in registry + assert registry.operation("new_pane") is NewPane + + +def test_new_pane_captures_new_pane_id() -> None: + """new-pane parses the captured pane id into the typed result.""" + result = NewPane(target=PaneId("%1")).build_result(returncode=0, stdout=("%2",)) + assert result.new_pane_id == "%2" + assert result.created_id == "%2" + + +def test_new_pane_requires_tmux_3_7() -> None: + """Rendering against tmux older than 3.7 raises (the spine's first gate).""" + op = NewPane(target=PaneId("%1"), width=80, height=15) + with pytest.raises(VersionUnsupported, match=r"requires tmux >= 3.7"): + op.render(version="3.6") + + +def test_new_pane_renders_on_supported_version() -> None: + """No version (latest) or tmux >= 3.7 renders without error.""" + op = NewPane(target=PaneId("%1"), width=80, height=15) + assert op.render()[0] == "new-pane" + assert op.render(version="3.7")[0] == "new-pane" + + +@pytest.mark.skipif( + not has_gte_version("3.7"), + reason="new-pane (floating panes) requires tmux 3.7+", +) +def test_new_pane_live(session: Session) -> None: + """new-pane creates a real floating pane against tmux 3.7+.""" + from libtmux.experimental.engines import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + pane = session.active_pane + assert pane is not None and pane.pane_id is not None + + result = run( + NewPane(target=PaneId(pane.pane_id), width=40, height=10, x=5, y=3), + engine, + ) + assert result.ok + assert result.new_pane_id is not None + assert session.server.panes.get(pane_id=result.new_pane_id) is not None + + floating = session.server.cmd( + "display-message", + "-p", + "-t", + result.new_pane_id, + "#{pane_floating_flag}", + ) + assert floating.stdout == ["1"] From 987daec865bd053ad411205cfa408b289fbff010 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 14:40:14 -0500 Subject: [PATCH 096/154] Ops(fix[break_pane]): Work around tmux 3.7 break-pane crash why: tmux 3.7 NULL-derefs the server on a nameless break-pane (fixed upstream after 3.7) and ignores -n when one is given. The experimental BreakPane op emitted no -n for nameless breaks, crashing the 3.7 server. Mirrors the fix already shipped in Pane.break_pane (#693). what: - Inject a placeholder -n on exactly tmux 3.7 when no name is requested - Gate via _normalize_tmux_version exact match; other builds render bare - Document the workaround and cover placeholder/bare/named render paths The gate fires only when a tmux version reaches args(); the engine version resolution that activates it for live runs lands next. --- .../experimental/ops/_ops/break_pane.py | 30 ++++++++++++++ tests/experimental/ops/test_pane_ops.py | 41 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/libtmux/experimental/ops/_ops/break_pane.py b/src/libtmux/experimental/ops/_ops/break_pane.py index 1034def13..9f728f8bf 100644 --- a/src/libtmux/experimental/ops/_ops/break_pane.py +++ b/src/libtmux/experimental/ops/_ops/break_pane.py @@ -9,11 +9,23 @@ from libtmux.experimental.ops.operation import Operation from libtmux.experimental.ops.registry import register from libtmux.experimental.ops.results import CreateResult +from libtmux.neo import _normalize_tmux_version if t.TYPE_CHECKING: from libtmux.experimental.ops._types import Status +def _breaks_without_name(version: str | None) -> bool: + """Whether this tmux needs a placeholder ``-n`` for a nameless break-pane. + + tmux 3.7 NULL-derefs ``break-pane`` when ``-n`` is absent (fixed upstream + after 3.7), so exactly 3.7 must be handed a placeholder name. + """ + if version is None: + return False + return _normalize_tmux_version(version) == _normalize_tmux_version("3.7") + + @register @dataclass(frozen=True, kw_only=True) class BreakPane(Operation[CreateResult]): @@ -32,11 +44,27 @@ class BreakPane(Operation[CreateResult]): capture : bool Append ``-P -F '#{window_id}'`` to capture the new window id. + Notes + ----- + tmux 3.7 crashes the server on a nameless ``break-pane`` (a NULL-deref fixed + upstream after 3.7) and ignores ``-n`` when one is given. To survive, exactly + 3.7 is handed a placeholder ``-n`` when no name was requested; a higher layer + renames the window afterward when a name is wanted. + Examples -------- >>> from libtmux.experimental.ops._types import PaneId >>> BreakPane(src_target=PaneId("%2"), name="logs").render() ('break-pane', '-d', '-n', 'logs', '-P', '-F', '#{window_id}', '-s', '%2') + + On exactly tmux 3.7 a nameless break-pane is given a placeholder name; other + builds render it bare: + + >>> BreakPane(src_target=PaneId("%2")).render(version="3.7") + ('break-pane', '-d', '-n', 'libtmux', '-P', '-F', '#{window_id}', '-s', '%2') + >>> BreakPane(src_target=PaneId("%2")).render(version="3.8") + ('break-pane', '-d', '-P', '-F', '#{window_id}', '-s', '%2') + >>> BreakPane(src_target=PaneId("%2")).build_result( ... returncode=0, stdout=("@7",) ... ).new_id @@ -62,6 +90,8 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: out.append("-d") if self.name is not None: out.extend(("-n", self.name)) + elif _breaks_without_name(version): + out.extend(("-n", "libtmux")) if self.capture: out.extend(("-P", "-F", "#{window_id}")) out.extend(self.src_args()) diff --git a/tests/experimental/ops/test_pane_ops.py b/tests/experimental/ops/test_pane_ops.py index eecad6c72..993ed1187 100644 --- a/tests/experimental/ops/test_pane_ops.py +++ b/tests/experimental/ops/test_pane_ops.py @@ -143,6 +143,47 @@ def test_pane_op_round_trips( assert result_from_dict(result_to_dict(result)) == result +def test_break_pane_placeholder_name_on_tmux_3_7() -> None: + """Tmux 3.7 gets a placeholder -n to dodge a nameless break-pane crash.""" + op = BreakPane(src_target=PaneId("%2")) + assert op.render(version="3.7") == ( + "break-pane", + "-d", + "-n", + "libtmux", + "-P", + "-F", + "#{window_id}", + "-s", + "%2", + ) + + +def test_break_pane_no_placeholder_off_3_7() -> None: + """Only exactly 3.7 gets the workaround; other versions render bare.""" + op = BreakPane(src_target=PaneId("%2")) + bare = ("break-pane", "-d", "-P", "-F", "#{window_id}", "-s", "%2") + assert op.render() == bare + assert op.render(version="3.6") == bare + assert op.render(version="3.8") == bare + + +def test_break_pane_named_unaffected_on_3_7() -> None: + """A requested name is passed through (no placeholder) on 3.7.""" + op = BreakPane(src_target=PaneId("%2"), name="logs") + assert op.render(version="3.7") == ( + "break-pane", + "-d", + "-n", + "logs", + "-P", + "-F", + "#{window_id}", + "-s", + "%2", + ) + + def test_break_pane_captures_new_window_id() -> None: """break-pane parses the captured window id into the typed result.""" result = BreakPane(src_target=PaneId("%2")).build_result( From 7d68aeff43bbfa1db7af5415e690c67c029025e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 14:48:28 -0500 Subject: [PATCH 097/154] Engines(feat): Resolve engine tmux version for gating why: Operations are version-aware, but execution defaulted to version=None, so version-gating (flag drops, whole-command gates, the break-pane 3.7 workaround) silently did nothing unless a caller threaded the version by hand. This is why test_break_and_swap_live still crashed even with the BreakPane workaround in place. what: - Add the optional SupportsTmuxVersion engine capability (base.py) and implement tmux_version() on the subprocess + asyncio engines (memoized `tmux -V`, None when unknown) - Add resolve_engine_version() and use it in run()/arun() and at the LazyPlan execute()/aexecute() entry points so the live tmux version reaches rendering when the caller passes none - Explicit version still wins; engines without the capability assume latest, so fakes and the in-memory engine are unaffected - Cover resolution + gating activation for run/arun and a folded plan; this greens test_break_and_swap_live on tmux 3.7 --- src/libtmux/experimental/engines/__init__.py | 2 + src/libtmux/experimental/engines/asyncio.py | 17 +++++ src/libtmux/experimental/engines/base.py | 18 +++++ .../experimental/engines/subprocess.py | 17 +++++ src/libtmux/experimental/ops/execute.py | 39 +++++++++- src/libtmux/experimental/ops/plan.py | 4 +- tests/experimental/ops/test_execute.py | 74 +++++++++++++++++++ tests/experimental/ops/test_plan.py | 21 ++++++ 8 files changed, 190 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 9b756af4c..3969e73f5 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -23,6 +23,7 @@ CommandResult, EngineKind, EngineSpec, + SupportsTmuxVersion, TmuxEngine, ) from libtmux.experimental.engines.concrete import AsyncConcreteEngine, ConcreteEngine @@ -55,6 +56,7 @@ "EngineSpec", "ImsgEngine", "SubprocessEngine", + "SupportsTmuxVersion", "TmuxEngine", "available_engines", "create_engine", diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py index 7ce87d0d8..e5717a6b5 100644 --- a/src/libtmux/experimental/engines/asyncio.py +++ b/src/libtmux/experimental/engines/asyncio.py @@ -16,6 +16,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult if t.TYPE_CHECKING: @@ -54,6 +55,22 @@ def __init__( self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None self.server_args = tuple(server_args) self._resolved_bin: str | None = None + self._tmux_version: str | None = None + self._version_probed = False + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Returns ``None`` when the binary is missing or its version cannot be + parsed, so version resolution degrades to "assume latest". + """ + if not self._version_probed: + self._version_probed = True + try: + self._tmux_version = str(get_version(self._resolve_bin())) + except exc.LibTmuxException: + self._tmux_version = None + return self._tmux_version def _resolve_bin(self) -> str: """Return the tmux binary path, memoized for the engine instance.""" diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 8516eb028..f5c6bfd49 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -189,3 +189,21 @@ async def run_batch( ) -> list[CommandResult]: """Execute requests in order, returning one result per request.""" ... + + +@t.runtime_checkable +class SupportsTmuxVersion(t.Protocol): + """An engine that can report the tmux version it targets. + + Optional engine capability. The executors + (:func:`~libtmux.experimental.ops.execute.run` / ``arun`` and the + :class:`~libtmux.experimental.ops.plan.LazyPlan` drivers) call + :meth:`tmux_version` to resolve the version for version-gated rendering when + the caller passes none. Engines that cannot know their version -- in-memory + or fake engines -- simply do not implement it, and resolution falls back to + "assume latest". + """ + + def tmux_version(self) -> str | None: + """Return the engine's tmux version string, or ``None`` if unknown.""" + ... diff --git a/src/libtmux/experimental/engines/subprocess.py b/src/libtmux/experimental/engines/subprocess.py index 639d829e3..8a6213331 100644 --- a/src/libtmux/experimental/engines/subprocess.py +++ b/src/libtmux/experimental/engines/subprocess.py @@ -16,6 +16,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult if t.TYPE_CHECKING: @@ -46,6 +47,22 @@ def __init__( self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None self.server_args = tuple(server_args) self._resolved_bin: str | None = None + self._tmux_version: str | None = None + self._version_probed = False + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Returns ``None`` when the binary is missing or its version cannot be + parsed, so version resolution degrades to "assume latest". + """ + if not self._version_probed: + self._version_probed = True + try: + self._tmux_version = str(get_version(self._resolve_bin())) + except exc.LibTmuxException: + self._tmux_version = None + return self._tmux_version def _resolve_bin(self) -> str: """Return the tmux binary path, memoized for the engine instance.""" diff --git a/src/libtmux/experimental/ops/execute.py b/src/libtmux/experimental/ops/execute.py index 4197dcfce..a220771d7 100644 --- a/src/libtmux/experimental/ops/execute.py +++ b/src/libtmux/experimental/ops/execute.py @@ -11,7 +11,7 @@ import typing as t -from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.engines.base import CommandRequest, SupportsTmuxVersion if t.TYPE_CHECKING: import pathlib @@ -23,6 +23,41 @@ ResultT = t.TypeVar("ResultT", bound="Result") +def resolve_engine_version( + engine: TmuxEngine | AsyncTmuxEngine, + version: str | None, +) -> str | None: + """Resolve the tmux version to render against. + + Returns *version* unchanged when the caller supplied one. Otherwise asks the + engine via the optional + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` capability, + so version-gated rendering (flag drops and whole-command gates) reflects the + live tmux at runtime instead of silently assuming latest. Engines that cannot + report a version fall back to ``None`` ("assume latest"). + + Examples + -------- + >>> from libtmux.experimental.engines import CommandResult + >>> class VersionedEngine: + ... def run(self, request): + ... return CommandResult(cmd=("tmux", *request.args), returncode=0) + ... def run_batch(self, requests): + ... return [self.run(r) for r in requests] + ... def tmux_version(self): + ... return "2.9" + >>> resolve_engine_version(VersionedEngine(), None) + '2.9' + >>> resolve_engine_version(VersionedEngine(), "3.4") + '3.4' + """ + if version is not None: + return version + if isinstance(engine, SupportsTmuxVersion): + return engine.tmux_version() + return None + + def run( operation: Operation[ResultT], engine: TmuxEngine, @@ -65,6 +100,7 @@ def run( >>> result.argv ('send-keys', '-t', '%1', 'echo hi') """ + version = resolve_engine_version(engine, version) rendered = operation.render(version=version) raw = engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) return operation.build_result( @@ -106,6 +142,7 @@ async def arun( >>> result.lines ('line-1', 'line-2') """ + version = resolve_engine_version(engine, version) rendered = operation.render(version=version) raw = await engine.run(CommandRequest.from_args(*rendered, tmux_bin=tmux_bin)) return operation.build_result( diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 84d085f7e..8b3bb52f6 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -33,7 +33,7 @@ WindowId, ) from libtmux.experimental.ops.exc import ForwardCaptureError -from libtmux.experimental.ops.execute import arun, run +from libtmux.experimental.ops.execute import arun, resolve_engine_version, run from libtmux.experimental.ops.planner import Planner, PlanStep, SequentialPlanner from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict @@ -352,6 +352,7 @@ def execute( bind, so a caller can interleave host-side work between dispatches; it is a no-op trampoline hop when ``None``. """ + version = resolve_engine_version(engine, version) gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) @@ -388,6 +389,7 @@ async def aexecute( Mirrors :meth:`execute`; *on_step* is awaited per step. """ + version = resolve_engine_version(engine, version) gen = self._drive(version, planner or SequentialPlanner()) try: request = next(gen) diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py index 017ad6ba1..8a0ef83fb 100644 --- a/tests/experimental/ops/test_execute.py +++ b/tests/experimental/ops/test_execute.py @@ -99,6 +99,80 @@ def test_run_version_threads_through() -> None: assert "-T" not in result.argv +class VersionedFakeEngine(FakeEngine): + """A sync fake engine that reports a fixed tmux version.""" + + def __init__( + self, + *, + tmux_version: str | None, + stdout: tuple[str, ...] = (), + returncode: int = 0, + ) -> None: + super().__init__(stdout=stdout, returncode=returncode) + self._tmux_version = tmux_version + + def tmux_version(self) -> str | None: + """Report the canned tmux version.""" + return self._tmux_version + + +def test_resolve_engine_version_prefers_explicit() -> None: + """An explicit version always wins over the engine's reported version.""" + from libtmux.experimental.ops.execute import resolve_engine_version + + engine = VersionedFakeEngine(tmux_version="2.9") + assert resolve_engine_version(engine, "3.4") == "3.4" + + +def test_resolve_engine_version_falls_back_to_engine() -> None: + """With no explicit version, the engine's reported version is used.""" + from libtmux.experimental.ops.execute import resolve_engine_version + + engine = VersionedFakeEngine(tmux_version="2.9") + assert resolve_engine_version(engine, None) == "2.9" + + +def test_resolve_engine_version_none_without_capability() -> None: + """A plain engine (no version capability) resolves to None (assume latest).""" + from libtmux.experimental.ops.execute import resolve_engine_version + + assert resolve_engine_version(FakeEngine(), None) is None + + +def test_run_auto_resolves_engine_version() -> None: + """run() asks the engine for its version when none is passed; gating fires.""" + from libtmux.experimental.ops import CapturePane + + engine = VersionedFakeEngine(tmux_version="3.3") + result = run(CapturePane(target=PaneId("%1"), trim_trailing=True), engine) + assert "-T" not in result.argv # -T is gated >= 3.4, dropped on resolved 3.3 + + +def test_run_without_version_capability_renders_every_flag() -> None: + """A plain engine has no version, so version-gated flags are kept.""" + from libtmux.experimental.ops import CapturePane + + engine = FakeEngine() + result = run(CapturePane(target=PaneId("%1"), trim_trailing=True), engine) + assert "-T" in result.argv + + +def test_arun_auto_resolves_engine_version() -> None: + """arun() resolves the engine version on the async path too.""" + from libtmux.experimental.ops import CapturePane + + class AsyncVersionedFakeEngine(AsyncFakeEngine): + def tmux_version(self) -> str | None: + return "3.3" + + engine = AsyncVersionedFakeEngine() + result = asyncio.run( + arun(CapturePane(target=PaneId("%1"), trim_trailing=True), engine), + ) + assert "-T" not in result.argv + + def test_arun_shares_render_and_build() -> None: """``arun`` produces the same typed result as ``run`` via the async path.""" engine = AsyncFakeEngine(stdout=("%5",)) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 0737c7961..cb5a8f24b 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -49,6 +49,27 @@ def test_plan_resolves_forward_ref() -> None: assert outcome.ok +def test_plan_execute_auto_resolves_engine_version() -> None: + """plan.execute() resolves the engine version so folded renders are gated.""" + from libtmux.experimental.ops import FoldingPlanner, RespawnPane + + class VersionedConcreteEngine(ConcreteEngine): + def tmux_version(self) -> str | None: + return "2.9" + + plan = LazyPlan() + plan.add(RespawnPane(target=PaneId("%1"), environment={"E": "1"})) + plan.add(RespawnPane(target=PaneId("%2"), environment={"E": "2"})) + + outcome = plan.execute(VersionedConcreteEngine(), planner=FoldingPlanner()) + + # -e is gated at tmux 3.0; on the engine's resolved 2.9 it is dropped even + # from the folded (rendered-in-_drive) dispatch. + assert outcome.ok + for result in outcome.results: + assert not any(arg.startswith("-e") for arg in result.argv) + + class SrcResolveCase(t.NamedTuple): """A dual-target op whose ``src_target`` is a forward :class:`SlotRef`.""" From 620d1731397aa9399d4c271a976779ed4adb35a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:04:47 -0500 Subject: [PATCH 098/154] Workspace(feat[ir]): Add floating-pane declarations why: The declarative workspace IR had no way to express tmux 3.7 floating panes; a user could not declare a floating overlay (e.g. a lazygit popup) in a spec at all. what: - Add a Float geometry value type (width/height -> -x/-y size, x/y -> -X/-Y position; cells or N%) and FloatingPane (a Pane + Float + attach_to) - Add Window.floats: Sequence[FloatingPane] overlays, kept as a plain declarative data shape like panes (NOT a live QueryList -- QueryList is the live object-query layer, not the spec) - Round-trip floats through analyze()/to_dict(); export Float + FloatingPane from the workspace package - Cover to_dict, defaults, and round-trip Inert data only; the compiler emit + events/confirm wiring lands next. --- .../experimental/workspace/__init__.py | 11 +- .../experimental/workspace/analyzer.py | 37 +++++- src/libtmux/experimental/workspace/ir.py | 121 ++++++++++++++++++ .../contract/test_workspace_floats.py | 83 ++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 tests/experimental/contract/test_workspace_floats.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index aff78a4fa..99b572142 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -38,7 +38,14 @@ WindowCreated, WorkspaceBuilt, ) -from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace +from libtmux.experimental.workspace.ir import ( + Command, + Float, + FloatingPane, + Pane, + Window, + Workspace, +) from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace __all__ = ( @@ -46,6 +53,8 @@ "Command", "Compiled", "ConfirmReport", + "Float", + "FloatingPane", "HostStep", "Pane", "PaneCreated", diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 0cb3d9c44..9eda00977 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -31,7 +31,14 @@ import collections.abc import typing as t -from libtmux.experimental.workspace.ir import Command, Pane, Window, Workspace +from libtmux.experimental.workspace.ir import ( + Command, + Float, + FloatingPane, + Pane, + Window, + Workspace, +) def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: @@ -90,6 +97,34 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: window_shell=raw.get("window_shell"), window_index=raw.get("window_index"), panes=[_pane(p) for p in raw.get("panes", []) or []], + floats=[_floating_pane(f) for f in raw.get("floats", []) or []], + ) + + +def _floating_pane(raw: collections.abc.Mapping[str, t.Any]) -> FloatingPane: + """Normalize one floating-pane config (a pane config plus ``float``).""" + return FloatingPane( + pane=_pane(raw), + geometry=_float(raw.get("float")), + attach_to=raw.get("attach_to"), + ) + + +def _float(raw: t.Any) -> Float: + """Coerce a ``float`` geometry value (mapping / None / marker) into a Float.""" + if not isinstance(raw, collections.abc.Mapping): + return Float() + return Float( + width=raw.get("width"), + height=raw.get("height"), + x=raw.get("x"), + y=raw.get("y"), + zoom=bool(raw.get("zoom", False)), + empty=bool(raw.get("empty", False)), + style=raw.get("style"), + active_border_style=raw.get("active_border_style"), + inactive_border_style=raw.get("inactive_border_style"), + message=raw.get("message"), ) diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 552e2cca3..3dd57305d 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -166,6 +166,120 @@ def to_dict(self) -> dict[str, t.Any]: return out +@dataclass(frozen=True) +class Float: + """Absolute geometry for a floating pane (tmux 3.7 ``new-pane``). + + Floating panes are popup-style overlays, not tiled cells, so their geometry + is absolute rather than split-relative. :attr:`width`/:attr:`height` set the + size (``-x``/``-y``) and :attr:`x`/:attr:`y` set the top-left offset + (``-X``/``-Y``); each is cells (``int``) or a percentage (``str`` like + ``"50%"``). The remaining fields mirror the ``new-pane`` flag vocabulary. + + Parameters + ---------- + width, height : int or str or None + Size in cells or ``N%`` (``-x`` / ``-y``). + x, y : int or str or None + Absolute position in cells or ``N%`` (``-X`` / ``-Y``). + zoom : bool + Zoom the pane (``-Z``). + empty : bool + Create an empty pane with no command (``-E``). + style, active_border_style, inactive_border_style : str or None + Content / active-border / inactive-border styles (``-s`` / ``-S`` / + ``-R``). + message : str or None + Remain-on-exit message (``-m``). + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Float + >>> Float(width=120, height=40, x="C", y="C").to_dict() + {'width': 120, 'height': 40, 'x': 'C', 'y': 'C'} + >>> Float().to_dict() + {} + """ + + width: int | str | None = None + height: int | str | None = None + x: int | str | None = None + y: int | str | None = None + zoom: bool = False + empty: bool = False + style: str | None = None + active_border_style: str | None = None + inactive_border_style: str | None = None + message: str | None = None + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a canonical float-geometry config (omitting defaults).""" + out: dict[str, t.Any] = {} + if self.width is not None: + out["width"] = self.width + if self.height is not None: + out["height"] = self.height + if self.x is not None: + out["x"] = self.x + if self.y is not None: + out["y"] = self.y + if self.zoom: + out["zoom"] = True + if self.empty: + out["empty"] = True + if self.style is not None: + out["style"] = self.style + if self.active_border_style is not None: + out["active_border_style"] = self.active_border_style + if self.inactive_border_style is not None: + out["inactive_border_style"] = self.inactive_border_style + if self.message is not None: + out["message"] = self.message + return out + + +@dataclass(frozen=True) +class FloatingPane: + """A floating-pane overlay: a :class:`Pane` plus its :class:`Float` geometry. + + Overlays are *not* tiled cells -- the compiler emits them as ``new-pane`` and + keeps them out of the tiled split chain, the ``select-layout`` call, and the + pane-count check. :attr:`attach_to` names a :class:`Window` (by name) to float + over; ``None`` floats over the window the overlay is declared on. + + Parameters + ---------- + pane : Pane + The pane's command(s), focus, env, shell, etc. + geometry : Float + The floating geometry; defaults to tmux's own (half-width, quarter-height, + cascading position). + attach_to : str or None + Name of the window to float over; ``None`` for the host window. + + Examples + -------- + >>> from libtmux.experimental.workspace.ir import Float, FloatingPane, Pane + >>> fp = FloatingPane(pane=Pane(run="lazygit"), geometry=Float(width="60%")) + >>> fp.to_dict()["shell_command"] + ['lazygit'] + >>> fp.to_dict()["float"] + {'width': '60%'} + """ + + pane: Pane = field(default_factory=Pane) + geometry: Float = field(default_factory=Float) + attach_to: str | None = None + + def to_dict(self) -> dict[str, t.Any]: + """Serialize to a pane config plus a ``float`` geometry (and ``attach_to``).""" + out = self.pane.to_dict() + out["float"] = self.geometry.to_dict() + if self.attach_to is not None: + out["attach_to"] = self.attach_to + return out + + @dataclass(frozen=True) class Window: """A window in the declared workspace. @@ -200,6 +314,10 @@ class Window: the session's implicit window and keeps the session base index. panes : Sequence[Pane] The window's panes (the first reuses the window's implicit pane). + floats : Sequence[FloatingPane] + Floating-pane overlays for this window (tmux 3.7+). Overlays are not + tiled cells: they are created with ``new-pane`` after the layout and are + excluded from the split chain and the tiled pane count. """ name: str | None = None @@ -212,6 +330,7 @@ class Window: window_shell: str | None = None window_index: int | None = None panes: Sequence[Pane] = () + floats: Sequence[FloatingPane] = () def to_dict(self) -> dict[str, t.Any]: """Serialize to a canonical tmuxp window config (inverse of the analyzer).""" @@ -235,6 +354,8 @@ def to_dict(self) -> dict[str, t.Any]: if self.window_index is not None: out["window_index"] = self.window_index out["panes"] = [pane.to_dict() for pane in self.panes] + if self.floats: + out["floats"] = [fp.to_dict() for fp in self.floats] return out diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py new file mode 100644 index 000000000..7f4cc206e --- /dev/null +++ b/tests/experimental/contract/test_workspace_floats.py @@ -0,0 +1,83 @@ +"""Tests for floating-pane declarations in the workspace IR (tmux 3.7+).""" + +from __future__ import annotations + +from libtmux.experimental.workspace import ( + Float, + FloatingPane, + Pane, + Window, + Workspace, + analyze, +) + + +def test_float_to_dict_omits_defaults() -> None: + """Float.to_dict drops fields left at their default.""" + assert Float().to_dict() == {} + assert Float(width=120, height=40, x="C", y="C", zoom=True).to_dict() == { + "width": 120, + "height": 40, + "x": "C", + "y": "C", + "zoom": True, + } + + +def test_floating_pane_to_dict() -> None: + """A FloatingPane serializes as a pane config plus float geometry + attach_to.""" + fp = FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width="60%"), + attach_to="editor", + ) + out = fp.to_dict() + assert out["shell_command"] == ["lazygit"] + assert out["float"] == {"width": "60%"} + assert out["attach_to"] == "editor" + + +def test_window_floats_default_empty() -> None: + """A window declared without floats has no overlays.""" + assert Window("editor").floats == () + + +def test_window_holds_declared_floats() -> None: + """Window.floats preserves the declared overlay specs in order.""" + window = Window( + "editor", + floats=[ + FloatingPane(pane=Pane(run="lazygit"), attach_to=None), + FloatingPane(pane=Pane(run="htop"), attach_to="logs"), + ], + ) + assert [fp.pane.run for fp in window.floats] == ["lazygit", "htop"] + assert [fp.attach_to for fp in window.floats] == [None, "logs"] + + +def test_floats_round_trip_through_to_dict() -> None: + """analyze(ws.to_dict()) reconstructs an equivalent workspace including floats.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width="60%", height="50%", zoom=True), + attach_to="editor", + ), + ], + ), + ], + ) + + revived = analyze(ws.to_dict()) + floated = revived.windows[0].floats[0] + assert [c.cmd for c in floated.pane.commands] == ["lazygit"] + assert floated.geometry.width == "60%" + assert floated.geometry.zoom is True + assert floated.attach_to == "editor" + assert revived.to_dict() == ws.to_dict() From a1fdacb8ad959f18d01d9880deadf9e3d6bcf6c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:10:57 -0500 Subject: [PATCH 099/154] Workspace(feat[compiler]): Build floating panes from specs why: Declared floating panes (Commit prior) were inert -- the compiler had no branch to lower them, so a float-bearing workspace ignored its overlays. what: - Factor per-pane command sending into _emit_pane_commands, shared by tiled panes and floats (uniform wait_pane / suppress_history / sleeps) - Emit each Window.floats overlay as a NewPane after the tiled layout, targeting the window's first pane and kept out of the split chain and select-layout; send the float's own commands and honor its focus - events: emit PaneCreated for new_pane; confirm: fold floats into the expected pane count (tiled + floats) so confirm() does not flag a spurious mismatch - Reject cross-window attach_to for now (the symbol table lands next) - Cover compile order, geometry/command emission, the attach_to guard, an offline in-memory build, and the new_pane event --- .../experimental/workspace/compiler.py | 155 +++++++++++++----- src/libtmux/experimental/workspace/confirm.py | 4 +- src/libtmux/experimental/workspace/events.py | 2 +- .../contract/test_workspace_floats.py | 102 ++++++++++++ 4 files changed, 217 insertions(+), 46 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 5a4577118..78f48d528 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -29,6 +29,7 @@ from libtmux.experimental.ops import ( LazyPlan, + NewPane, NewSession, NewWindow, RenameWindow, @@ -116,6 +117,111 @@ def _schedule_before( host_after.setdefault(after, []).append(step) +def _emit_pane_commands( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + window: Window, + pane: Pane, + target: SlotRef, +) -> None: + """Emit a pane's command sends with their host-side sleep / wait scheduling. + + Shared by tiled panes and floating-pane overlays so both honor ``wait_pane``, + ``suppress_history``, and the per-pane / per-command sleeps identically. + """ + commands = pane.commands + if not commands: + return + if ws.wait_pane and (pane.shell or window.window_shell) is None: + # Wait for the pane's shell prompt before sending keys (anti-race); a pane + # launching a custom shell/command does not get this wait, mirroring + # tmuxp's `if pane_shell is None`. + _schedule_before(host_after, pre, len(plan), HostStep("wait_pane", pane=target)) + if pane.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=pane.sleep_before), + ) + for command in commands: + if command.sleep_before is not None: + _schedule_before( + host_after, + pre, + len(plan), + HostStep("sleep", seconds=command.sleep_before), + ) + plan.add( + SendKeys( + target=target, + keys=command.cmd, + enter=command.enter, + suppress_history=pane.suppress_history, + ), + ) + if command.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=command.sleep_after), + ) + if pane.sleep_after is not None: + host_after.setdefault(len(plan) - 1, []).append( + HostStep("sleep", seconds=pane.sleep_after), + ) + + +def _emit_floats( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + window: Window, + first_pane_ref: SlotRef, +) -> None: + """Emit the window's floating-pane overlays (tmux 3.7 ``new-pane``). + + Overlays are created *after* the tiled panes and layout, target the window's + first pane, and are deliberately kept out of the split chain and the layout so + they never perturb the tiled topology. Cross-window ``attach_to`` (floating + over a *different* window) is not yet supported. + """ + for fp in window.floats: + if fp.attach_to is not None and fp.attach_to != window.name: + msg = ( + f"floating pane attach_to={fp.attach_to!r} targets another window; " + "cross-window floats are not yet supported" + ) + raise WorkspaceCompileError(msg) + geo = fp.geometry + float_ref = plan.add( + NewPane( + target=first_pane_ref, + width=geo.width, + height=geo.height, + x=geo.x, + y=geo.y, + zoom=geo.zoom, + empty=geo.empty, + style=geo.style, + active_border_style=geo.active_border_style, + inactive_border_style=geo.inactive_border_style, + message=geo.message, + start_directory=( + fp.pane.start_directory + or window.start_directory + or ws.start_directory + ), + environment=dict(fp.pane.environment) or None, + shell_command=fp.pane.shell, + ), + ) + _emit_pane_commands(plan, host_after, pre, ws, window, fp.pane, float_ref) + if fp.pane.focus: + plan.add(SelectPane(target=float_ref)) + + def _emit_window( plan: LazyPlan, host_after: dict[int, list[HostStep]], @@ -125,7 +231,7 @@ def _emit_window( window_ref: SlotRef, first_pane_ref: SlotRef, ) -> None: - """Emit a window's options, panes, sends, layout, and pane focus. + """Emit a window's options, panes, sends, layout, pane focus, and floats. *window_ref* addresses the window (rename/options/layout); *first_pane_ref* is the captured id of the window's first pane. @@ -154,49 +260,7 @@ def _emit_window( shell=pane.shell or window.window_shell, ), ) - commands = pane.commands - if commands: - if ws.wait_pane and (pane.shell or window.window_shell) is None: - # Wait for the pane's shell prompt before sending keys (anti-race); - # a pane launching a custom shell/command does not get this wait, - # mirroring tmuxp's `if pane_shell is None`. - _schedule_before( - host_after, - pre, - len(plan), - HostStep("wait_pane", pane=target), - ) - if pane.sleep_before is not None: - _schedule_before( - host_after, - pre, - len(plan), - HostStep("sleep", seconds=pane.sleep_before), - ) - for command in commands: - if command.sleep_before is not None: - _schedule_before( - host_after, - pre, - len(plan), - HostStep("sleep", seconds=command.sleep_before), - ) - plan.add( - SendKeys( - target=target, - keys=command.cmd, - enter=command.enter, - suppress_history=pane.suppress_history, - ), - ) - if command.sleep_after is not None: - host_after.setdefault(len(plan) - 1, []).append( - HostStep("sleep", seconds=command.sleep_after), - ) - if pane.sleep_after is not None: - host_after.setdefault(len(plan) - 1, []).append( - HostStep("sleep", seconds=pane.sleep_after), - ) + _emit_pane_commands(plan, host_after, pre, ws, window, pane, target) if pane.focus: focus_targets.append(target) prev = target @@ -208,6 +272,9 @@ def _emit_window( for key, value in window.options_after.items(): plan.add(SetWindowOption(target=window_ref, option=key, value=value)) + # Floating overlays come last: after the tiled layout, excluded from it. + _emit_floats(plan, host_after, pre, ws, window, first_pane_ref) + def _creator_environment(window: Window) -> dict[str, str]: """Env for a window's *first* (implicit) pane, applied via its creator's ``-e``. diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py index 020f2e992..12b958a54 100644 --- a/src/libtmux/experimental/workspace/confirm.py +++ b/src/libtmux/experimental/workspace/confirm.py @@ -44,7 +44,9 @@ def confirm(ws: Workspace, server: Server, *, timeout: float = 5.0) -> ConfirmRe f"window name {live.window_name!r} != declared {spec.name!r}" ) live_panes = list(live.panes) - expected_panes = max(1, len(spec.panes)) + # Floating overlays are real panes in the live window but not tiled cells, + # so the expected total is the tiled panes plus the declared floats. + expected_panes = max(1, len(spec.panes)) + len(spec.floats) if len(live_panes) != expected_panes: problems.append( f"window {spec.name!r} pane count " diff --git a/src/libtmux/experimental/workspace/events.py b/src/libtmux/experimental/workspace/events.py index 0899c45c4..6f6502da7 100644 --- a/src/libtmux/experimental/workspace/events.py +++ b/src/libtmux/experimental/workspace/events.py @@ -77,6 +77,6 @@ def events_for(op: Operation[t.Any], result: Result) -> list[BuildEvent]: events.append(WindowCreated(result.created_id)) if "pane" in result.created_subids: events.append(PaneCreated(result.created_subids["pane"])) - elif op.kind == "split_window" and result.created_id is not None: + elif op.kind in {"split_window", "new_pane"} and result.created_id is not None: events.append(PaneCreated(result.created_id)) return events diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py index 7f4cc206e..08486f8c5 100644 --- a/tests/experimental/contract/test_workspace_floats.py +++ b/tests/experimental/contract/test_workspace_floats.py @@ -2,13 +2,18 @@ from __future__ import annotations +import pytest + +from libtmux.experimental.ops import NewPane, SendKeys from libtmux.experimental.workspace import ( Float, FloatingPane, Pane, Window, Workspace, + WorkspaceCompileError, analyze, + compile_workspace, ) @@ -81,3 +86,100 @@ def test_floats_round_trip_through_to_dict() -> None: assert floated.geometry.zoom is True assert floated.attach_to == "editor" assert revived.to_dict() == ws.to_dict() + + +def test_compiler_emits_new_pane_after_layout() -> None: + """A declared float compiles to a new-pane op after the tiled layout.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + layout="main-vertical", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width=120, height=40), + ), + ], + ), + ], + ) + kinds = [op.kind for op in compile_workspace(ws).operations] + assert "new_pane" in kinds + assert kinds.index("new_pane") > kinds.index("select_layout") + + +def test_compiler_new_pane_geometry_and_command() -> None: + """The emitted new-pane carries the float geometry and sends its command.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width="60%", height="50%"), + ), + ], + ), + ], + ) + ops = compile_workspace(ws).operations + new_pane = next(op for op in ops if isinstance(op, NewPane)) + assert (new_pane.width, new_pane.height) == ("60%", "50%") + assert any(isinstance(op, SendKeys) and op.keys == "lazygit" for op in ops) + + +def test_cross_window_attach_to_raises() -> None: + """A float attaching to a different window is rejected (not yet supported).""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[FloatingPane(pane=Pane(run="lazygit"), attach_to="logs")], + ), + Window("logs", panes=[Pane(run="tail -f x")]), + ], + ) + with pytest.raises(WorkspaceCompileError, match="cross-window floats"): + compile_workspace(ws) + + +def test_offline_build_with_float() -> None: + """A float-bearing workspace builds over the in-memory engine.""" + from libtmux.experimental.engines import ConcreteEngine + + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[ + FloatingPane( + pane=Pane(run="lazygit"), + geometry=Float(width=120, height=40), + ), + ], + ), + ], + ) + assert ws.build(ConcreteEngine(), preflight=False).ok + + +def test_events_for_new_pane() -> None: + """events_for emits a PaneCreated for a new-pane result.""" + from libtmux.experimental.ops import NewPane + from libtmux.experimental.ops._types import PaneId + from libtmux.experimental.workspace import PaneCreated + from libtmux.experimental.workspace.events import events_for + + op = NewPane(target=PaneId("%1")) + result = op.build_result(returncode=0, stdout=("%9",)) + assert events_for(op, result) == [PaneCreated("%9")] From 683bb7cb15c16b20782d68661822c82067d42720 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:31:29 -0500 Subject: [PATCH 100/154] Workspace(feat[compiler]): Cross-window floats via symbol table why: A floating pane could only attach to its host window; the compiler rejected attach_to pointing at another window. Cross-window overlays (e.g. a status float over a different window) need name-based references resolved across the whole spec. what: - Add a Symbols registry (Django app-registry style): each declared window publishes its first-pane SlotRef by name, so a float's attach_to resolves to any window declared anywhere (forward or backward) - Add _topo_order, a graphlib.TopologicalSorter primitive that orders the reference graph (floats after the windows they attach to) and rejects cycles -- the seam for future join-pane / cross-window ops - Compile floats in a second wire phase after every window exists, so cross-window SlotRefs always resolve; lift the cross-window raise and instead raise only for an undeclared attach_to name - Cover cross-window attach (forward ref), offline build, unknown attach_to, Symbols.resolve, and _topo_order ordering + cycle detection --- .../experimental/workspace/compiler.py | 173 +++++++++++++----- .../contract/test_workspace_floats.py | 63 ++++++- 2 files changed, 188 insertions(+), 48 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 78f48d528..59cd36eb0 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -24,6 +24,7 @@ from __future__ import annotations +import graphlib import typing as t from dataclasses import dataclass, field, replace @@ -48,13 +49,55 @@ from collections.abc import Mapping from libtmux.experimental.ops._types import SlotRef - from libtmux.experimental.workspace.ir import Window, Workspace + from libtmux.experimental.workspace.ir import FloatingPane, Window, Workspace class WorkspaceCompileError(ValueError): """A declared workspace cannot be lowered to Core operations.""" +class Symbols: + """A by-name registry of window references for cross-tree resolution. + + Mirrors Django's app registry / pending-operations pattern: a declared window + publishes its first-pane :class:`~..ops._types.SlotRef` under its name, and a + later reference (a floating pane's ``attach_to``) resolves against it -- so a + float can attach to any window declared anywhere in the workspace, forward or + backward in document order. + """ + + def __init__(self) -> None: + self._refs: dict[str, SlotRef] = {} + + def define(self, name: str, ref: SlotRef) -> None: + """Publish *ref* under *name*.""" + self._refs[name] = ref + + def resolve(self, name: str) -> SlotRef: + """Return the ref registered for *name*, or raise if it is undeclared.""" + try: + return self._refs[name] + except KeyError: + msg = f"floating pane attach_to={name!r} names no declared window" + raise WorkspaceCompileError(msg) from None + + +def _topo_order(dependencies: Mapping[t.Any, set[t.Any]]) -> list[t.Any]: + """Order nodes so each follows its dependencies; raise on a cycle. + + The cross-reference ordering engine (stdlib :mod:`graphlib`): a node is + emitted only after every node it depends on. Floating panes use it to land + after the windows they attach to; the same primitive sequences future + cross-window operations (join-pane, cross-window focus) correct-by-construction + and rejects declared cycles. + """ + try: + return list(graphlib.TopologicalSorter(dependencies).static_order()) + except graphlib.CycleError as exc: + msg = f"workspace has a reference cycle: {exc.args[1]}" + raise WorkspaceCompileError(msg) from exc + + @dataclass(frozen=True) class HostStep: """A host-side step interleaved by the runner (not a tmux operation). @@ -172,54 +215,88 @@ def _emit_pane_commands( ) -def _emit_floats( +def _emit_float( plan: LazyPlan, host_after: dict[int, list[HostStep]], pre: list[HostStep], ws: Workspace, - window: Window, - first_pane_ref: SlotRef, + host_window: Window, + target_ref: SlotRef, + fp: FloatingPane, ) -> None: - """Emit the window's floating-pane overlays (tmux 3.7 ``new-pane``). + """Emit one floating-pane overlay (tmux 3.7 ``new-pane``) over *target_ref*. - Overlays are created *after* the tiled panes and layout, target the window's - first pane, and are deliberately kept out of the split chain and the layout so - they never perturb the tiled topology. Cross-window ``attach_to`` (floating - over a *different* window) is not yet supported. + *target_ref* is the captured first pane of the window the float lands on (its + host window, or the ``attach_to`` window). *host_window* provides the + ``start_directory`` fallback and command context. The overlay is created + detached, so it never steals focus during the build. """ - for fp in window.floats: - if fp.attach_to is not None and fp.attach_to != window.name: - msg = ( - f"floating pane attach_to={fp.attach_to!r} targets another window; " - "cross-window floats are not yet supported" - ) - raise WorkspaceCompileError(msg) - geo = fp.geometry - float_ref = plan.add( - NewPane( - target=first_pane_ref, - width=geo.width, - height=geo.height, - x=geo.x, - y=geo.y, - zoom=geo.zoom, - empty=geo.empty, - style=geo.style, - active_border_style=geo.active_border_style, - inactive_border_style=geo.inactive_border_style, - message=geo.message, - start_directory=( - fp.pane.start_directory - or window.start_directory - or ws.start_directory - ), - environment=dict(fp.pane.environment) or None, - shell_command=fp.pane.shell, + geo = fp.geometry + float_ref = plan.add( + NewPane( + target=target_ref, + width=geo.width, + height=geo.height, + x=geo.x, + y=geo.y, + zoom=geo.zoom, + empty=geo.empty, + style=geo.style, + active_border_style=geo.active_border_style, + inactive_border_style=geo.inactive_border_style, + message=geo.message, + start_directory=( + fp.pane.start_directory + or host_window.start_directory + or ws.start_directory ), + environment=dict(fp.pane.environment) or None, + shell_command=fp.pane.shell, + ), + ) + _emit_pane_commands(plan, host_after, pre, ws, host_window, fp.pane, float_ref) + if fp.pane.focus: + plan.add(SelectPane(target=float_ref)) + + +def _emit_pending_floats( + plan: LazyPlan, + host_after: dict[int, list[HostStep]], + pre: list[HostStep], + ws: Workspace, + symbols: Symbols, + pending: list[tuple[FloatingPane, int, SlotRef]], +) -> None: + """Emit every window's floats after all windows exist (the wire phase). + + Each float depends on the window it lands on (its host, or its ``attach_to``); + a topological sort over that reference graph orders the floats after their + target windows and rejects cycles. ``attach_to`` resolves by name through + *symbols*, so a float can attach to a window declared anywhere. + """ + if not pending: + return + deps: dict[t.Any, set[t.Any]] = {} + by_node: dict[t.Any, tuple[FloatingPane, int, SlotRef]] = {} + for index, (fp, host_index, host_ref) in enumerate(pending): + float_node = ("float", index) + target_node = ( + ("window", fp.attach_to) + if fp.attach_to is not None + else ("host", host_index) ) - _emit_pane_commands(plan, host_after, pre, ws, window, fp.pane, float_ref) - if fp.pane.focus: - plan.add(SelectPane(target=float_ref)) + deps[float_node] = {target_node} + deps.setdefault(target_node, set()) + by_node[float_node] = (fp, host_index, host_ref) + for node in _topo_order(deps): + entry = by_node.get(node) + if entry is None: + continue + fp, host_index, host_ref = entry + target_ref = ( + symbols.resolve(fp.attach_to) if fp.attach_to is not None else host_ref + ) + _emit_float(plan, host_after, pre, ws, ws.windows[host_index], target_ref, fp) def _emit_window( @@ -231,10 +308,11 @@ def _emit_window( window_ref: SlotRef, first_pane_ref: SlotRef, ) -> None: - """Emit a window's options, panes, sends, layout, pane focus, and floats. + """Emit a window's options, panes, sends, layout, and pane focus. *window_ref* addresses the window (rename/options/layout); *first_pane_ref* is - the captured id of the window's first pane. + the captured id of the window's first pane. Floating overlays are emitted in a + second phase (see :func:`_emit_pending_floats`) once every window exists. """ for key, value in window.options.items(): plan.add(SetWindowOption(target=window_ref, option=key, value=value)) @@ -272,9 +350,6 @@ def _emit_window( for key, value in window.options_after.items(): plan.add(SetWindowOption(target=window_ref, option=key, value=value)) - # Floating overlays come last: after the tiled layout, excluded from it. - _emit_floats(plan, host_after, pre, ws, window, first_pane_ref) - def _creator_environment(window: Window) -> dict[str, str]: """Env for a window's *first* (implicit) pane, applied via its creator's ``-e``. @@ -337,6 +412,8 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: plan.add(SetOption(option=key, value=value, global_=True)) window_refs: list[SlotRef] = [] + symbols = Symbols() + pending_floats: list[tuple[FloatingPane, int, SlotRef]] = [] for index, window in enumerate(ws.windows): if index == 0: # Reuse the session's implicit first window via its captured ids. @@ -365,7 +442,13 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: window_ref = slot first_pane_ref = slot.pane window_refs.append(window_ref) + if window.name is not None: + symbols.define(window.name, first_pane_ref) _emit_window(plan, host_after, pre, ws, window, window_ref, first_pane_ref) + pending_floats.extend((fp, index, first_pane_ref) for fp in window.floats) + + # Wire phase: every window now exists, so floats attach to any of them by name. + _emit_pending_floats(plan, host_after, pre, ws, symbols, pending_floats) for index, window in enumerate(ws.windows): if window.focus: diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py index 08486f8c5..a91151780 100644 --- a/tests/experimental/contract/test_workspace_floats.py +++ b/tests/experimental/contract/test_workspace_floats.py @@ -15,6 +15,7 @@ analyze, compile_workspace, ) +from libtmux.experimental.workspace.compiler import Symbols, _topo_order def test_float_to_dict_omits_defaults() -> None: @@ -134,23 +135,79 @@ def test_compiler_new_pane_geometry_and_command() -> None: assert any(isinstance(op, SendKeys) and op.keys == "lazygit" for op in ops) -def test_cross_window_attach_to_raises() -> None: - """A float attaching to a different window is rejected (not yet supported).""" +def test_cross_window_float_attaches_to_later_window() -> None: + """A float on an early window can attach to a window declared later.""" ws = Workspace( name="dev", windows=[ Window( "editor", panes=[Pane(run="vim")], + # attach_to a window declared AFTER this one (forward reference) floats=[FloatingPane(pane=Pane(run="lazygit"), attach_to="logs")], ), Window("logs", panes=[Pane(run="tail -f x")]), ], ) - with pytest.raises(WorkspaceCompileError, match="cross-window floats"): + # Compiles (no raise) and the float op lands after both windows exist. + kinds = [op.kind for op in compile_workspace(ws).operations] + assert kinds.count("new_window") == 1 # 'logs' is created + assert kinds.index("new_pane") > kinds.index("new_window") + + +def test_cross_window_float_builds_offline() -> None: + """A cross-window float resolves its target and builds in-memory.""" + from libtmux.experimental.engines import ConcreteEngine + + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[FloatingPane(pane=Pane(run="htop"), attach_to="logs")], + ), + Window("logs", panes=[Pane(run="tail -f x")]), + ], + ) + assert ws.build(ConcreteEngine(), preflight=False).ok + + +def test_unknown_attach_to_raises() -> None: + """A float attaching to an undeclared window name is rejected.""" + ws = Workspace( + name="dev", + windows=[ + Window( + "editor", + panes=[Pane(run="vim")], + floats=[FloatingPane(pane=Pane(run="lazygit"), attach_to="nope")], + ), + ], + ) + with pytest.raises(WorkspaceCompileError, match="names no declared window"): compile_workspace(ws) +def test_symbols_resolve_unknown_raises() -> None: + """Symbols.resolve raises a clear error for an undeclared name.""" + symbols = Symbols() + with pytest.raises(WorkspaceCompileError, match="names no declared window"): + symbols.resolve("ghost") + + +def test_topo_order_orders_dependencies() -> None: + """_topo_order returns each node after the nodes it depends on.""" + order = _topo_order({"float": {"win"}, "win": set()}) + assert order.index("win") < order.index("float") + + +def test_topo_order_detects_cycle() -> None: + """_topo_order rejects a dependency cycle.""" + with pytest.raises(WorkspaceCompileError, match="reference cycle"): + _topo_order({"a": {"b"}, "b": {"a"}}) + + def test_offline_build_with_float() -> None: """A float-bearing workspace builds over the in-memory engine.""" from libtmux.experimental.engines import ConcreteEngine From 1149789a481c3008365ae2223335d08f3b9f132e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 15:59:31 -0500 Subject: [PATCH 101/154] Query(feat): Add snapshot-backed live pane query why: The spine could list panes, but there was no ergonomic, chainable way to filter/order/project live panes the way QueryList powers server.panes -- the read half of the chainable-prototype DX. what: - Add panes() -> PaneQuery: an immutable, chainable query (filter/order_by/limit/all/first/map) over live panes - Resolve against a source that is either a TmuxEngine (a list-panes read) or a pure Sequence[PaneSnapshot]; filtering reuses QueryList so Django-style lookups (active=True, current_command="vim") work on snapshots - map() returns a MappedPaneQuery for pure data projections - Cover filter/order/limit/map/first/immutability, the empty-engine source, and a live engine-backed query scoped by window This is the live-object query layer (distinct from the declarative workspace IR); the command-building half (PaneRef + commands) is next. --- src/libtmux/experimental/query.py | 131 ++++++++++++++++++++++++++++++ tests/experimental/test_query.py | 104 ++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/libtmux/experimental/query.py create mode 100644 tests/experimental/test_query.py diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py new file mode 100644 index 000000000..9245f3a3e --- /dev/null +++ b/src/libtmux/experimental/query.py @@ -0,0 +1,131 @@ +"""A lazy, snapshot-backed query over live tmux panes. + +This is the live-object query layer (distinct from the declarative workspace IR): +``panes()`` builds an immutable query that resolves against a *snapshot* of the +live server -- either taken from an engine (a ``list-panes`` read) or supplied as +a pure sequence of :class:`~libtmux.experimental.models.snapshots.PaneSnapshot`. +Filtering reuses :class:`~libtmux._internal.query_list.QueryList`, so the same +Django-style lookups that power ``server.panes`` work here against snapshots. + +The query is pure and chainable; nothing runs until a terminal method +(:meth:`PaneQuery.all` / :meth:`PaneQuery.first`) resolves it against a source. + +Examples +-------- +>>> from libtmux.experimental.models.snapshots import PaneSnapshot +>>> rows = [ +... PaneSnapshot.from_format({"pane_id": "%1", "pane_index": "0", +... "pane_active": "1", "pane_current_command": "vim"}), +... PaneSnapshot.from_format({"pane_id": "%2", "pane_index": "1", +... "pane_active": "0", "pane_current_command": "zsh"}), +... ] +>>> panes().filter(active=True).map(lambda p: p.pane_id).all(rows) +('%1',) +>>> panes().order_by("pane_index").map(lambda p: p.pane_id).all(rows) +('%1', '%2') +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field, replace + +from libtmux._internal.query_list import QueryList +from libtmux.experimental.engines.base import TmuxEngine +from libtmux.experimental.ops import ListPanes, run + +if t.TYPE_CHECKING: + from collections.abc import Callable, Mapping, Sequence + + from libtmux.experimental.models.snapshots import PaneSnapshot + +#: A source of pane snapshots: an engine to read from, or pre-taken snapshots. +PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] +MappedT = t.TypeVar("MappedT") + + +def _snapshot_panes(source: PaneSource) -> tuple[PaneSnapshot, ...]: + """Resolve *source* into pane snapshots (read an engine, or pass through).""" + if isinstance(source, TmuxEngine): + return run(ListPanes(), source).panes + return tuple(source) + + +def _order_key(pane: PaneSnapshot, field_name: str) -> tuple[bool, t.Any]: + """Sort key for ``order_by`` that sorts missing (``None``) values last.""" + value = getattr(pane, field_name, None) + return (value is None, value) + + +@dataclass(frozen=True) +class PaneQuery: + """An immutable, chainable query over pane snapshots. + + Each method returns a new query; :meth:`all` / :meth:`first` resolve it + against a :data:`PaneSource`. + """ + + lookups: Mapping[str, t.Any] = field(default_factory=dict) + order: str | None = None + limit_count: int | None = None + + def filter(self, **lookups: t.Any) -> PaneQuery: + """Narrow by QueryList lookups (e.g. ``active=True``, ``pane_index=0``).""" + return replace(self, lookups={**self.lookups, **lookups}) + + def order_by(self, field_name: str) -> PaneQuery: + """Sort the results by a snapshot attribute (missing values last).""" + return replace(self, order=field_name) + + def limit(self, count: int) -> PaneQuery: + """Keep only the first *count* results.""" + return replace(self, limit_count=count) + + def all(self, source: PaneSource) -> tuple[PaneSnapshot, ...]: + """Resolve the query against *source* and return the matched snapshots.""" + rows: t.Any = QueryList(_snapshot_panes(source)) + if self.lookups: + rows = rows.filter(**self.lookups) + rows = list(rows) + if self.order is not None: + rows.sort(key=lambda pane: _order_key(pane, self.order)) + if self.limit_count is not None: + rows = rows[: self.limit_count] + return tuple(rows) + + def first(self, source: PaneSource) -> PaneSnapshot | None: + """Return the first matched snapshot, or ``None`` when none match.""" + rows = self.all(source) + return rows[0] if rows else None + + def map(self, fn: Callable[[PaneSnapshot], MappedT]) -> MappedPaneQuery[MappedT]: + """Project each matched snapshot through *fn* (a pure read projection).""" + return MappedPaneQuery(self, fn) + + +@dataclass(frozen=True) +class MappedPaneQuery(t.Generic[MappedT]): + """A :class:`PaneQuery` whose rows are projected through a function.""" + + query: PaneQuery + fn: Callable[[PaneSnapshot], MappedT] + + def all(self, source: PaneSource) -> tuple[MappedT, ...]: + """Resolve and project every matched snapshot.""" + return tuple(self.fn(pane) for pane in self.query.all(source)) + + def first(self, source: PaneSource) -> MappedT | None: + """Resolve and project the first matched snapshot, or ``None``.""" + first = self.query.first(source) + return self.fn(first) if first is not None else None + + +def panes() -> PaneQuery: + """Start a query over live panes. + + Examples + -------- + >>> panes().filter(active=True).order_by("pane_index").limit(1) + PaneQuery(lookups={'active': True}, order='pane_index', limit_count=1) + """ + return PaneQuery() diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py new file mode 100644 index 000000000..a59cfb5e0 --- /dev/null +++ b/tests/experimental/test_query.py @@ -0,0 +1,104 @@ +"""Tests for the live-pane query (``panes()``).""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.models.snapshots import PaneSnapshot +from libtmux.experimental.query import PaneQuery, panes + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _pane(pane_id: str, index: int, *, active: bool, command: str) -> PaneSnapshot: + return PaneSnapshot.from_format( + { + "pane_id": pane_id, + "pane_index": str(index), + "pane_active": "1" if active else "0", + "pane_current_command": command, + }, + ) + + +ROWS = ( + _pane("%1", 0, active=True, command="vim"), + _pane("%2", 1, active=False, command="zsh"), + _pane("%3", 2, active=False, command="vim"), +) + + +def test_panes_returns_query() -> None: + """panes() starts an empty, immutable query.""" + assert panes() == PaneQuery() + + +def test_filter_active() -> None: + """filter(active=True) keeps only the active pane.""" + assert [p.pane_id for p in panes().filter(active=True).all(ROWS)] == ["%1"] + + +def test_filter_lookup() -> None: + """Filter matches snapshot attributes (QueryList lookups).""" + result = panes().filter(current_command="vim").all(ROWS) + assert [p.pane_id for p in result] == ["%1", "%3"] + + +def test_order_by_and_limit() -> None: + """order_by sorts and limit truncates.""" + result = panes().order_by("pane_index").limit(2).all(ROWS) + assert [p.pane_id for p in result] == ["%1", "%2"] + + +def test_map_projection() -> None: + """Map projects each matched snapshot.""" + ids = panes().filter(current_command="vim").map(lambda p: p.pane_id).all(ROWS) + assert ids == ("%1", "%3") + + +def test_first_and_empty() -> None: + """First returns the first row, or None when nothing matches.""" + first = panes().order_by("pane_index").first(ROWS) + assert first is not None and first.pane_id == "%1" + assert panes().filter(current_command="nope").first(ROWS) is None + + +def test_mapped_first() -> None: + """A mapped query's first projects the first match (or None).""" + assert panes().filter(active=True).map(lambda p: p.pane_id).first(ROWS) == "%1" + assert panes().filter(active=False).map(lambda p: p.pane_id).first(()) is None + + +def test_query_is_immutable() -> None: + """Each builder method returns a new query; the original is unchanged.""" + base = panes() + narrowed = base.filter(active=True).order_by("pane_index").limit(1) + assert base == PaneQuery() + assert narrowed.lookups == {"active": True} + + +def test_empty_engine_source() -> None: + """A query resolves against an engine; the in-memory engine has no panes.""" + from libtmux.experimental.engines import ConcreteEngine + + assert panes().all(ConcreteEngine()) == () + + +def test_panes_live_against_engine(session: Session) -> None: + """panes() reads a live server through an engine and filters its panes.""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import SplitWindow, run + from libtmux.experimental.ops._types import WindowId + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + + # Scope to our window (list-panes -a spans the whole server). + window_panes = panes().filter(window_id=window.window_id).all(engine) + assert len(window_panes) == 2 + active = panes().filter(window_id=window.window_id, active=True).all(engine) + assert len(active) == 1 + assert active[0].active is True From 6129c388e9378d1893d7bc745af11fc60103ab62 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:05:25 -0500 Subject: [PATCH 102/154] Query(feat): Add per-pane command building that folds why: The query read live panes but could not act on them. The chainable prototype's headline DX is "do X to every pane matching Y in one tmux call" -- bulk commands over a filtered set, folded to a single dispatch. what: - Add PaneRef (a matched pane + a cmd namespace) and BoundPaneCommands (send_keys/resize/select/respawn/clear_history/kill), each recording a typed op into a shared plan - Add PaneQuery.commands(mapper) -> CommandPlan; CommandPlan.to_plan builds the ops against a snapshot (pure/inspectable) and CommandPlan.run reads the engine, builds, and dispatches folded (FoldingPlanner) by default - Layered entirely over LazyPlan/SlotRef/Planner -- no new execution path - Cover op-per-pane building, each command kind, the empty-match no-op, and a live folded run The bulk-command layer over the live query (G18); fluent split/forward handles remain a possible follow-up. --- src/libtmux/experimental/query.py | 153 +++++++++++++++++++++++++++++- tests/experimental/test_query.py | 65 +++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index 9245f3a3e..05d752fa9 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -32,12 +32,26 @@ from libtmux._internal.query_list import QueryList from libtmux.experimental.engines.base import TmuxEngine -from libtmux.experimental.ops import ListPanes, run +from libtmux.experimental.ops import ( + ClearHistory, + FoldingPlanner, + KillPane, + LazyPlan, + ListPanes, + PaneId, + ResizePane, + RespawnPane, + SelectPane, + SendKeys, + run, +) if t.TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence from libtmux.experimental.models.snapshots import PaneSnapshot + from libtmux.experimental.ops import Planner, PlanResult + from libtmux.experimental.ops._types import SlotRef #: A source of pane snapshots: an engine to read from, or pre-taken snapshots. PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] @@ -102,6 +116,15 @@ def map(self, fn: Callable[[PaneSnapshot], MappedT]) -> MappedPaneQuery[MappedT] """Project each matched snapshot through *fn* (a pure read projection).""" return MappedPaneQuery(self, fn) + def commands(self, mapper: Callable[[PaneRef], t.Any]) -> CommandPlan: + """Build commands for each matched pane (folds to one dispatch on run). + + *mapper* receives a :class:`PaneRef` per matched pane and records + operations through ``ref.cmd`` (e.g. ``ref.cmd.send_keys("clear")``); the + returned :class:`CommandPlan` is pure until :meth:`CommandPlan.run`. + """ + return CommandPlan(self, mapper) + @dataclass(frozen=True) class MappedPaneQuery(t.Generic[MappedT]): @@ -120,6 +143,134 @@ def first(self, source: PaneSource) -> MappedT | None: return self.fn(first) if first is not None else None +@dataclass(frozen=True) +class BoundPaneCommands: + """Pane commands bound to a target, recording into a shared plan. + + Each method appends a typed operation targeting the bound pane to the plan + and returns its :class:`~..ops._types.SlotRef`, so commands compose and the + plan folds to a single tmux dispatch. + """ + + plan: LazyPlan + target: PaneId + + def send_keys( + self, + keys: str, + *, + enter: bool = True, + suppress_history: bool = False, + ) -> SlotRef: + """Send *keys* to the pane (submitted with Enter unless ``enter=False``).""" + return self.plan.add( + SendKeys( + target=self.target, + keys=keys, + enter=enter, + suppress_history=suppress_history, + ), + ) + + def resize(self, *, height: int | None = None, width: int | None = None) -> SlotRef: + """Resize the pane to *height* rows and/or *width* columns.""" + return self.plan.add( + ResizePane(target=self.target, height=height, width=width), + ) + + def select(self, *, zoom: bool = False) -> SlotRef: + """Make the pane active (optionally zooming it).""" + return self.plan.add(SelectPane(target=self.target, zoom=zoom)) + + def respawn(self, *, kill: bool = False, shell: str | None = None) -> SlotRef: + """Respawn the pane's command (``kill=True`` replaces a live one).""" + return self.plan.add( + RespawnPane(target=self.target, kill=kill, shell=shell), + ) + + def clear_history(self) -> SlotRef: + """Clear the pane's scrollback history.""" + return self.plan.add(ClearHistory(target=self.target)) + + def kill(self) -> SlotRef: + """Kill the pane.""" + return self.plan.add(KillPane(target=self.target)) + + +@dataclass(frozen=True) +class PaneRef: + """A matched pane plus a ``cmd`` namespace that records into a plan.""" + + pane: PaneSnapshot + plan: LazyPlan + + @property + def pane_id(self) -> str: + """The pane's id.""" + return self.pane.pane_id + + @property + def active(self) -> bool: + """Whether the pane is active in its window.""" + return self.pane.active + + @property + def cmd(self) -> BoundPaneCommands: + """Pane commands bound to this pane (recorded into the plan).""" + return BoundPaneCommands(self.plan, PaneId(self.pane.pane_id)) + + +@dataclass(frozen=True) +class CommandPlan: + """A pending bulk-command build: a query plus a per-pane command mapper. + + Pure until resolved -- :meth:`to_plan` records the operations against a source + snapshot; :meth:`run` reads the source, builds, and dispatches (folding to a + single tmux call by default). + """ + + query: PaneQuery + mapper: Callable[[PaneRef], t.Any] + + def to_plan(self, source: PaneSource) -> LazyPlan: + """Resolve the matched panes and record each one's commands into a plan. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import PaneSnapshot + >>> rows = [PaneSnapshot.from_format({"pane_id": "%1", "pane_active": "1"})] + >>> cp = panes().filter(active=True).commands( + ... lambda p: p.cmd.send_keys("clear") + ... ) + >>> plan = cp.to_plan(rows) + >>> [op.kind for op in plan.operations] + ['send_keys'] + >>> plan.operations[0].render() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + plan = LazyPlan() + for pane in self.query.all(source): + self.mapper(PaneRef(pane, plan)) + return plan + + def run( + self, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Read panes from *engine*, build the plan, and dispatch it. + + Folds the per-pane commands into a single tmux dispatch by default; pass + *planner* to override. + """ + plan = self.to_plan(engine) + return plan.execute( + engine, version=version, planner=planner or FoldingPlanner() + ) + + def panes() -> PaneQuery: """Start a query over live panes. diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index a59cfb5e0..e3a7c8ec5 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -85,6 +85,71 @@ def test_empty_engine_source() -> None: assert panes().all(ConcreteEngine()) == () +def test_commands_to_plan_builds_one_op_per_pane() -> None: + """commands(mapper) records each matched pane's op into a plan.""" + plan = ( + panes() + .filter(current_command="vim") + .commands(lambda p: p.cmd.send_keys("clear")) + .to_plan(ROWS) + ) + assert [op.kind for op in plan.operations] == ["send_keys", "send_keys"] + assert plan.operations[0].render() == ("send-keys", "-t", "%1", "clear", "Enter") + assert plan.operations[1].render() == ("send-keys", "-t", "%3", "clear", "Enter") + + +def test_bound_pane_commands_record_each_kind() -> None: + """The cmd namespace records the expected operation kinds.""" + plan = ( + panes() + .filter(active=True) + .commands( + lambda p: ( + p.cmd.resize(height=20), + p.cmd.select(zoom=True), + p.cmd.clear_history(), + ), + ) + .to_plan(ROWS) + ) + assert [op.kind for op in plan.operations] == [ + "resize_pane", + "select_pane", + "clear_history", + ] + + +def test_commands_empty_match_is_noop() -> None: + """commands() over no matches builds an empty plan.""" + plan = ( + panes() + .filter(current_command="nope") + .commands(lambda p: p.cmd.send_keys("x")) + .to_plan(ROWS) + ) + assert list(plan.operations) == [] + + +def test_commands_run_live(session: Session) -> None: + """commands().run reads live panes, builds, and dispatches (folded).""" + from libtmux.experimental.engines import SubprocessEngine + from libtmux.experimental.ops import SplitWindow, run + from libtmux.experimental.ops._types import WindowId + + engine = SubprocessEngine.for_server(session.server) + window = session.active_window + assert window.window_id is not None + run(SplitWindow(target=WindowId(window.window_id)), engine).raise_for_status() + + result = ( + panes() + .filter(window_id=window.window_id) + .commands(lambda p: p.cmd.send_keys("true")) + .run(engine) + ) + assert result.ok + + def test_panes_live_against_engine(session: Session) -> None: """panes() reads a live server through an engine and filters its panes.""" from libtmux.experimental.engines import SubprocessEngine From 3fd0c04eb0d9c8180c9352f5381d1a54b93c90a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:14:04 -0500 Subject: [PATCH 103/154] Facade(feat[pane]): Add new_pane floating parity why: The typed pane facades exposed split() but not new_pane(), so floating panes were reachable from the ops/workspace tiers but not the eager/lazy/async handles that are the modern facade surface. what: - Add new_pane() to EagerPane (live handle), LazyPane (deferred handle over the plan), and AsyncPane (awaited live handle), each returning a handle to the created floating pane - Share a _new_pane_op builder across the three facades so the floating geometry vocabulary (width/height/x/y/zoom/empty/styles/...) stays in one place - Cover eager/lazy/async new_pane (live handle, recorded op + render, awaited handle) --- src/libtmux/experimental/facade/pane.py | 172 ++++++++++++++++++ tests/experimental/facade/test_pane_facade.py | 41 +++++ 2 files changed, 213 insertions(+) diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/facade/pane.py index 61aec8d23..9dba79910 100644 --- a/src/libtmux/experimental/facade/pane.py +++ b/src/libtmux/experimental/facade/pane.py @@ -23,6 +23,7 @@ from libtmux.experimental.ops import ( CapturePane, + NewPane, SendKeys, SplitWindow, arun, @@ -31,12 +32,52 @@ from libtmux.experimental.ops._types import PaneId if t.TYPE_CHECKING: + from collections.abc import Mapping + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine from libtmux.experimental.ops._types import Target from libtmux.experimental.ops.plan import LazyPlan from libtmux.experimental.ops.results import CapturePaneResult, Result +def _new_pane_op( + target: Target, + *, + width: int | str | None, + height: int | str | None, + x: int | str | None, + y: int | str | None, + zoom: bool, + detach: bool, + empty: bool, + start_directory: str | None, + environment: Mapping[str, str] | None, + style: str | None, + active_border_style: str | None, + inactive_border_style: str | None, + message: str | None, + shell_command: str | None, +) -> NewPane: + """Build a :class:`~..ops.NewPane` for *target* (shared by the facades).""" + return NewPane( + target=target, + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ) + + @dataclass(frozen=True) class EagerPane: """A live pane handle bound to an engine; methods execute immediately. @@ -57,6 +98,9 @@ class EagerPane: >>> child = pane.split(horizontal=True) >>> child.pane_id '%1' + >>> floating = pane.new_pane(width=80, height=20) + >>> floating.pane_id + '%2' >>> isinstance(pane.capture().lines, tuple) True """ @@ -87,6 +131,50 @@ def split( assert result.new_pane_id is not None return EagerPane(self.engine, result.new_pane_id, self.version) + def new_pane( + self, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + detach: bool = True, + empty: bool = False, + start_directory: str | None = None, + environment: Mapping[str, str] | None = None, + style: str | None = None, + active_border_style: str | None = None, + inactive_border_style: str | None = None, + message: str | None = None, + shell_command: str | None = None, + ) -> EagerPane: + """Create a floating pane (tmux 3.7+) and return a live handle to it.""" + result = run( + _new_pane_op( + PaneId(self.pane_id), + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return EagerPane(self.engine, result.new_pane_id, self.version) + def send_keys(self, keys: str, *, enter: bool = False) -> Result: """Send keys to this pane; return the typed result.""" return run( @@ -155,6 +243,46 @@ def split( ) return LazyPane(self.plan, slot) + def new_pane( + self, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + detach: bool = True, + empty: bool = False, + start_directory: str | None = None, + environment: Mapping[str, str] | None = None, + style: str | None = None, + active_border_style: str | None = None, + inactive_border_style: str | None = None, + message: str | None = None, + shell_command: str | None = None, + ) -> LazyPane: + """Record a floating-pane creation; return a deferred handle to it.""" + slot = self.plan.add( + _new_pane_op( + self.ref, + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ), + ) + return LazyPane(self.plan, slot) + def send_keys(self, keys: str, *, enter: bool = False) -> LazyPane: """Record a send-keys against this handle; return self for chaining.""" self.plan.add(SendKeys(target=self.ref, keys=keys, enter=enter)) @@ -212,6 +340,50 @@ async def split( assert result.new_pane_id is not None return AsyncPane(self.engine, result.new_pane_id, self.version) + async def new_pane( + self, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + detach: bool = True, + empty: bool = False, + start_directory: str | None = None, + environment: Mapping[str, str] | None = None, + style: str | None = None, + active_border_style: str | None = None, + inactive_border_style: str | None = None, + message: str | None = None, + shell_command: str | None = None, + ) -> AsyncPane: + """Create a floating pane (tmux 3.7+) and return a live async handle.""" + result = await arun( + _new_pane_op( + PaneId(self.pane_id), + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + detach=detach, + empty=empty, + start_directory=start_directory, + environment=environment, + style=style, + active_border_style=active_border_style, + inactive_border_style=inactive_border_style, + message=message, + shell_command=shell_command, + ), + self.engine, + version=self.version, + ) + result.raise_for_status() + assert result.new_pane_id is not None + return AsyncPane(self.engine, result.new_pane_id, self.version) + async def send_keys(self, keys: str, *, enter: bool = False) -> Result: """Send keys to this pane; return the typed result.""" return await arun( diff --git a/tests/experimental/facade/test_pane_facade.py b/tests/experimental/facade/test_pane_facade.py index d5508691a..b74ed3daf 100644 --- a/tests/experimental/facade/test_pane_facade.py +++ b/tests/experimental/facade/test_pane_facade.py @@ -48,6 +48,47 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") +def test_eager_new_pane_returns_live_pane() -> None: + """EagerPane.new_pane creates a floating pane and returns a live handle.""" + pane = EagerPane(ConcreteEngine(), "%0") + floating = pane.new_pane(width=80, height=20, x=5, y=3) + assert isinstance(floating, EagerPane) + assert floating.pane_id == "%1" + + +def test_lazy_new_pane_records_new_pane_op() -> None: + """LazyPane.new_pane records a new-pane op (deferred) with its geometry.""" + plan = LazyPlan() + LazyPane(plan, PaneId("%0")).new_pane(width="50%", height="40%") + assert plan.operations[0].kind == "new_pane" + assert plan.operations[0].render() == ( + "new-pane", + "-t", + "%0", + "-x50%", + "-y40%", + "-d", + "-P", + "-F", + "#{pane_id}", + ) + + +def test_async_new_pane_returns_live_pane() -> None: + """AsyncPane.new_pane creates a floating pane and returns a live handle.""" + import asyncio + + from libtmux.experimental.engines import AsyncConcreteEngine + from libtmux.experimental.facade import AsyncPane + + async def main() -> str: + pane = AsyncPane(AsyncConcreteEngine(), "%0") + floating = await pane.new_pane(width=80, height=20) + return floating.pane_id + + assert asyncio.run(main()) == "%1" + + def test_same_operation_backs_both_facades() -> None: """Eager and lazy facades render the identical underlying operation argv.""" eager_engine = ConcreteEngine() From e4e2b4598dfd6e8041339fa1e31040291e9eea4e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:22:12 -0500 Subject: [PATCH 104/154] Mcp(feat[pane]): Add curated new_pane floating tool why: NewPane auto-projects as op_new_pane, but that surface is hidden behind the per-op tag; agents reach for the curated, always-visible vocabulary. Floating panes had no curated tool, so they were effectively undiscoverable. what: - Add anew_pane to the pane vocabulary (async-first) and new_pane = synced(anew_pane); FastMCP derives the input schema from the signature and the output schema from PaneResult - Register ("new_pane", "mutating") in the adapter _TOOLS table; export anew_pane/new_pane from the vocabulary and new_pane from the mcp facade - The tool description notes the tmux 3.7+ requirement - Cover the curated new_pane tool over the in-memory engine Surfacing whole-op min_version into the auto-projected op_* schema (G8) remains a small follow-up. --- src/libtmux/experimental/mcp/__init__.py | 2 + .../experimental/mcp/fastmcp_adapter.py | 1 + .../experimental/mcp/vocabulary/__init__.py | 4 ++ .../experimental/mcp/vocabulary/pane.py | 41 +++++++++++++++++++ tests/experimental/mcp/test_vocabulary.py | 9 ++++ 5 files changed, 57 insertions(+) diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 4a66441ca..03e33ab6f 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -56,6 +56,7 @@ list_panes, list_sessions, list_windows, + new_pane, rename_session, rename_window, select_layout, @@ -292,6 +293,7 @@ def main(argv: Sequence[str] | None = None) -> None: "list_sessions", "list_windows", "main", + "new_pane", "preview_plan", "rename_session", "rename_window", diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 6c448550c..19589017a 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -45,6 +45,7 @@ ("create_session", "mutating"), ("create_window", "mutating"), ("split_pane", "mutating"), + ("new_pane", "mutating"), ("send_input", "mutating"), ("capture_pane", "readonly"), ("capture_active_pane", "readonly"), diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py index f39e93832..bc5e26b45 100644 --- a/src/libtmux/experimental/mcp/vocabulary/__init__.py +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -65,6 +65,7 @@ ajoin_pane, akill_pane, alist_panes, + anew_pane, aresize_pane, aresolve_relative_pane, arespawn_pane, @@ -83,6 +84,7 @@ join_pane, kill_pane, list_panes, + new_pane, resize_pane, resolve_relative_pane, respawn_pane, @@ -165,6 +167,7 @@ "alist_sessions", "alist_windows", "amove_window", + "anew_pane", "apaste_buffer", "arename_session", "arename_window", @@ -204,6 +207,7 @@ "list_sessions", "list_windows", "move_window", + "new_pane", "paste_buffer", "rename_session", "rename_window", diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 032312835..8e14a6db2 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -64,6 +64,7 @@ JoinPane, KillPane, ListPanes, + NewPane, ResizePane, RespawnPane, SelectPane, @@ -108,6 +109,45 @@ async def asplit_pane( return PaneResult(pane_id=result.new_pane_id or "") +async def anew_pane( + engine: AsyncTmuxEngine, + target: str | Target, + *, + width: int | str | None = None, + height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, + zoom: bool = False, + empty: bool = False, + start_directory: str | None = None, + shell_command: str | None = None, + version: str | None = None, +) -> PaneResult: + """Create a floating pane over *target*'s window (requires tmux 3.7+). + + Floating panes overlay the layout (popup-style, but non-modal) instead of + tiling. Geometry is absolute: ``width``/``height`` size it and ``x``/``y`` + position it, each in cells or an ``"N%"`` string. Returns the new pane id. + """ + result = await arun( + NewPane( + target=resolve_target(target), + width=width, + height=height, + x=x, + y=y, + zoom=zoom, + empty=empty, + start_directory=start_directory, + shell_command=shell_command, + ), + engine, + version=version, + ) + result.raise_for_status() + return PaneResult(pane_id=result.new_pane_id or "") + + async def asend_input( engine: AsyncTmuxEngine, target: str | Target, @@ -593,6 +633,7 @@ async def _raise_no_neighbour( split_pane = synced(asplit_pane) +new_pane = synced(anew_pane) send_input = synced(asend_input) capture_pane = synced(acapture_pane) capture_active_pane = synced(acapture_active_pane) diff --git a/tests/experimental/mcp/test_vocabulary.py b/tests/experimental/mcp/test_vocabulary.py index 790112953..c0c4ad372 100644 --- a/tests/experimental/mcp/test_vocabulary.py +++ b/tests/experimental/mcp/test_vocabulary.py @@ -18,6 +18,7 @@ list_panes, list_sessions, list_windows, + new_pane, rename_window, send_input, split_pane, @@ -51,6 +52,14 @@ def test_create_window_then_split() -> None: assert pane.pane_id.startswith("%") +def test_new_pane_creates_floating_pane() -> None: + """new_pane creates a floating pane and returns its id (in-memory).""" + engine = ConcreteEngine() + session = create_session(engine, name="dev") + pane = new_pane(engine, session.first_pane_id or "%1", width=80, height=20) + assert pane.pane_id.startswith("%") + + def test_send_input_is_fire_and_forget() -> None: """send_input runs without returning a value (and without raising).""" send_input(ConcreteEngine(), "%1", "echo hi", enter=True) From eb60a2ba8543a7963ca6fc12635b40895a415058 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:30:24 -0500 Subject: [PATCH 105/154] Mcp(feat[registry]): Surface whole-op min_version in schema why: The descriptor projected per-flag version gates but not a whole operation's min_version, so the auto-projected op_new_pane advertised no tmux requirement -- an agent on an older tmux would hit a raw VersionUnsupported instead of a documented gate. what: - Add ToolDescriptor.min_version, populated from OpSpec.min_version - Append "Requires tmux >= X.Y." to the projected tool description when a whole-command gate is set - Cover op_new_pane surfacing min_version 3.7 (and an ungated op not) --- src/libtmux/experimental/mcp/descriptor.py | 4 ++++ src/libtmux/experimental/mcp/registry.py | 8 +++++++- tests/experimental/mcp/test_mcp_projection.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/mcp/descriptor.py b/src/libtmux/experimental/mcp/descriptor.py index 929a9c414..c03cc016c 100644 --- a/src/libtmux/experimental/mcp/descriptor.py +++ b/src/libtmux/experimental/mcp/descriptor.py @@ -77,6 +77,9 @@ class ToolDescriptor: MCP-style hints derived from safety/effects. operation_cls The operation class :meth:`build` instantiates. + min_version + Minimum tmux version the whole operation requires, if any (surfaced in + the tool description so agents see the gate before dispatch). """ name: str @@ -92,6 +95,7 @@ class ToolDescriptor: version_gates: Mapping[str, str] effects: Mapping[str, t.Any] operation_cls: type[Operation[t.Any]] + min_version: str | None = None def input_schema(self) -> dict[str, t.Any]: """Render the JSON schema for this tool's input object.""" diff --git a/src/libtmux/experimental/mcp/registry.py b/src/libtmux/experimental/mcp/registry.py index 6f153d35e..1e1616989 100644 --- a/src/libtmux/experimental/mcp/registry.py +++ b/src/libtmux/experimental/mcp/registry.py @@ -127,10 +127,15 @@ def descriptors(self) -> list[ToolDescriptor]: def _build(self, spec: OpSpec) -> ToolDescriptor: """Project one ``OpSpec`` into a tool descriptor.""" + description = _summary(spec.operation_cls.__doc__) + if spec.min_version: + # Surface the whole-command version gate so an agent on an older tmux + # sees the requirement up front instead of a raw VersionUnsupported. + description = f"{description} Requires tmux >= {spec.min_version}." return ToolDescriptor( name=spec.kind, title=spec.kind.replace("_", " ").title(), - description=_summary(spec.operation_cls.__doc__), + description=description, scope=spec.scope, safety=spec.safety, params=self._params(spec), @@ -141,6 +146,7 @@ def _build(self, spec: OpSpec) -> ToolDescriptor: version_gates=dict(spec.flag_version_map), effects=dataclasses.asdict(spec.effects), operation_cls=spec.operation_cls, + min_version=spec.min_version, ) def _params(self, spec: OpSpec) -> dict[str, ParamDescriptor]: diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 237b6d0d6..4bb50d2e1 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -51,6 +51,18 @@ def test_split_window_descriptor_shape() -> None: assert desc.params["horizontal"].is_required is False +def test_min_version_surfaced_on_descriptor() -> None: + """A whole-op min_version projects onto the descriptor and its description.""" + desc = OperationToolRegistry().descriptor("new_pane") + assert desc.min_version == "3.7" + assert "tmux >= 3.7" in desc.description + + +def test_ungated_op_has_no_min_version() -> None: + """An op without a whole-command gate carries no min_version.""" + assert OperationToolRegistry().descriptor("split_window").min_version is None + + def test_readonly_op_annotation() -> None: """A readonly operation projects a readOnlyHint annotation + tag.""" desc = OperationToolRegistry().descriptor("has_session") From a7e2688ce27d97fe1a586170eb0feeb24c23182d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:19:06 -0500 Subject: [PATCH 106/154] Mcp(fix[prompts]): wait_for_output uses target= why: wait_for_output takes target=, not pane=; a recipe emitting pane= would fail FastMCP schema validation before dispatch. what: - Replace pane= with target= in run_and_wait, diagnose_failing_pane and interrupt_gracefully - Add parametrized regression test asserting target= usage --- src/libtmux/experimental/mcp/prompts.py | 6 ++-- tests/experimental/mcp/test_prompts.py | 40 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/mcp/prompts.py b/src/libtmux/experimental/mcp/prompts.py index 48c922169..582826ba5 100644 --- a/src/libtmux/experimental/mcp/prompts.py +++ b/src/libtmux/experimental/mcp/prompts.py @@ -21,7 +21,7 @@ def run_and_wait(command: str, pane_id: str, timeout: float = 60.0) -> str: pane to settle and inspect the result: 1. send_input(target={pane_id!r}, keys={command!r}, enter=True) -2. wait_for_output(pane={pane_id!r}, timeout={timeout}) -- folds the live output +2. wait_for_output(target={pane_id!r}, timeout={timeout}) -- folds the live output and returns when the pane goes quiet (needle-free: no regex, no sentinel). 3. Read done.pane_dead / done.pane_dead_status (exit code) and captured_text. "Settled" means output stopped, not that the command succeeded -- check the @@ -37,7 +37,7 @@ def diagnose_failing_pane(pane_id: str) -> str: 1. capture_pane(target={pane_id!r}) to read the scrollback (the active prompt and most recent output are at the bottom). -2. If the pane is still producing output, wait_for_output(pane={pane_id!r}) until +2. If the pane is still producing output, wait_for_output(target={pane_id!r}) until it settles, then capture again. 3. Identify the last command that ran (the prompt line and the line above it) and the last non-empty output line. @@ -70,7 +70,7 @@ def interrupt_gracefully(pane_id: str) -> str: 1. send_input(target={pane_id!r}, keys="C-c", enter=False) -- tmux interprets C-c as SIGINT. -2. wait_for_output(pane={pane_id!r}, timeout=5.0) -- wait for the pane to settle +2. wait_for_output(target={pane_id!r}, timeout=5.0) -- wait for the pane to settle back at a shell prompt. 3. If it does not settle, the process is ignoring SIGINT. Stop and ask the caller how to proceed -- do NOT escalate automatically to C-\ (SIGQUIT) or kill.""" diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py index 2d097c621..341d8fa84 100644 --- a/tests/experimental/mcp/test_prompts.py +++ b/tests/experimental/mcp/test_prompts.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import typing as t import pytest @@ -62,3 +63,42 @@ def test_prompt_bodies_use_engine_ops_vocabulary() -> None: assert "send_input" in run_and_wait("ls", "%1") assert "split_pane" in build_dev_workspace("dev") assert "wait_for_output" in interrupt_gracefully("%1") + + +class WaitForOutputCase(t.NamedTuple): + """A prompt body that calls ``wait_for_output``.""" + + test_id: str + body: str + + +def _wait_for_output_bodies() -> tuple[WaitForOutputCase, ...]: + from libtmux.experimental.mcp.prompts import ( + diagnose_failing_pane, + interrupt_gracefully, + run_and_wait, + ) + + return ( + WaitForOutputCase("run_and_wait", run_and_wait("ls", "%1")), + WaitForOutputCase("diagnose_failing_pane", diagnose_failing_pane("%1")), + WaitForOutputCase("interrupt_gracefully", interrupt_gracefully("%1")), + ) + + +_WAIT_FOR_OUTPUT_CASES = _wait_for_output_bodies() + + +@pytest.mark.parametrize( + "case", + _WAIT_FOR_OUTPUT_CASES, + ids=[c.test_id for c in _WAIT_FOR_OUTPUT_CASES], +) +def test_wait_for_output_uses_target_param(case: WaitForOutputCase) -> None: + """Prompts must call ``wait_for_output`` with ``target=`` (its real param). + + The tool signature is ``wait_for_output(target=..., ...)``; ``pane=`` is not a + parameter, so a recipe emitting it would fail FastMCP schema validation. + """ + assert "wait_for_output(target=" in case.body + assert "wait_for_output(pane=" not in case.body From ac4886dfe1e6d91c47f3c82a6b5f961622be7cb2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:24:10 -0500 Subject: [PATCH 107/154] Workspace(fix[analyze]): Reject bad command items why: A non-str/non-Mapping shell_command item (int, float, list) was silently dropped, hiding malformed config from the user. what: - Raise TypeError on unsupported shell_command items, matching the module's existing "unsupported pane config" error style - Keep None tolerated (a blank mixed with commands, tmuxp parity) - Add parametrized tests for rejected and normalized items --- .../experimental/workspace/analyzer.py | 5 ++ .../contract/test_workspace_analyze.py | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/experimental/contract/test_workspace_analyze.py diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 9eda00977..45d11f4aa 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -185,6 +185,11 @@ def _shell_commands(value: t.Any) -> tuple[str | Command, ...]: out.append(item) elif isinstance(item, collections.abc.Mapping): out.append(_command(item)) + elif item is None: + continue # a None mixed with real commands is a blank (tmuxp parity) + else: + msg = f"unsupported shell_command item: {item!r}" + raise TypeError(msg) return tuple(out) diff --git a/tests/experimental/contract/test_workspace_analyze.py b/tests/experimental/contract/test_workspace_analyze.py new file mode 100644 index 000000000..72b990d6a --- /dev/null +++ b/tests/experimental/contract/test_workspace_analyze.py @@ -0,0 +1,61 @@ +"""Tests for ``analyze`` shell_command normalization.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.workspace import analyze + + +def _config(shell_command: t.Any) -> dict[str, t.Any]: + """Build a minimal one-window/one-pane config carrying *shell_command*.""" + return { + "session_name": "s", + "windows": [{"panes": [{"shell_command": shell_command}]}], + } + + +class BadCase(t.NamedTuple): + """A shell_command list with an item ``analyze`` must reject.""" + + test_id: str + shell_command: t.Any + + +BAD_CASES = ( + BadCase("sole_int", [123]), + BadCase("int_mixed_with_command", ["echo hi", 123]), + BadCase("float", [1.5]), + BadCase("nested_list", [["echo"]]), +) + + +@pytest.mark.parametrize("case", BAD_CASES, ids=[c.test_id for c in BAD_CASES]) +def test_unsupported_shell_command_item_raises(case: BadCase) -> None: + """A non-str/non-Mapping item raises rather than silently vanishing.""" + with pytest.raises(TypeError, match="unsupported shell_command item"): + analyze(_config(case.shell_command)) + + +class OkCase(t.NamedTuple): + """A shell_command list ``analyze`` normalizes without raising.""" + + test_id: str + shell_command: t.Any + expected: tuple[str, ...] + + +OK_CASES = ( + OkCase("plain_strings", ["a", "b"], ("a", "b")), + OkCase("none_mixed_is_dropped", ["echo hi", None], ("echo hi",)), + OkCase("sole_none_is_blank", [None], ()), +) + + +@pytest.mark.parametrize("case", OK_CASES, ids=[c.test_id for c in OK_CASES]) +def test_supported_shell_command_items_normalize(case: OkCase) -> None: + """Valid items normalize; a None mixed with commands is dropped (tmuxp parity).""" + ws = analyze(_config(case.shell_command)) + assert ws.windows[0].panes[0].run == case.expected From 689c1f1acc55fff5c833c5f32d7d17b32c781e2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 17:32:35 -0500 Subject: [PATCH 108/154] Workspace(fix[env]): Inherit window env in splits why: A split pane with its own environment dropped the window environment entirely, contradicting the documented "inherited by its panes" contract and the first pane's merged creator env. what: - Merge window + pane environment for split-window -e (pane wins) - Correct the creator-env test to assert the merged split env - Add parametrized tests for window/pane env precedence --- .../experimental/workspace/compiler.py | 4 +- ..._async_control_engine_workspace_builder.py | 6 +- .../contract/test_workspace_environment.py | 61 +++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/experimental/contract/test_workspace_environment.py diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 59cd36eb0..13fe27b1f 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -332,9 +332,7 @@ def _emit_window( or window.start_directory or ws.start_directory ), - environment=( - dict(pane.environment) or dict(window.environment) or None - ), + environment={**window.environment, **pane.environment} or None, shell=pane.shell or window.window_shell, ), ) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 3715faa01..3a427c48c 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -546,7 +546,7 @@ def test_compile_folds_first_pane_env_into_creator() -> None: Window 0 reuses the session's implicit pane, so its env -- and its first pane's -- folds into ``new-session -e``; window 2..N fold into ``new-window - -e``. A *split* pane carries its own ``-e``. + -e``. A *split* pane inherits the window env, merged with its own ``-e``. """ ws = Workspace( name="ws-env", @@ -570,8 +570,8 @@ def test_compile_folds_first_pane_env_into_creator() -> None: assert new_session.environment == {"WIN_ENV": "w", "PANE_ENV": "p"} # window 1 folds into new-window -e assert new_window.environment == {"W2": "x"} - # a split pane carries its own env (falling back to the window's) - assert split.environment == {"SPLIT_ENV": "s"} + # a split pane inherits the window env, merged with its own (pane wins) + assert split.environment == {"WIN_ENV": "w", "SPLIT_ENV": "s"} def test_compile_first_window_start_directory_drives_new_session() -> None: diff --git a/tests/experimental/contract/test_workspace_environment.py b/tests/experimental/contract/test_workspace_environment.py new file mode 100644 index 000000000..d425db396 --- /dev/null +++ b/tests/experimental/contract/test_workspace_environment.py @@ -0,0 +1,61 @@ +"""Tests for environment merging when compiling a workspace to operations.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.ops import SplitWindow +from libtmux.experimental.workspace import Pane, Window, Workspace, compile_workspace + + +class EnvCase(t.NamedTuple): + """A window/split-pane env pair and the merged env the split op should carry.""" + + test_id: str + window_env: dict[str, str] + pane_env: dict[str, str] + expected: dict[str, str] | None + + +ENV_CASES = ( + EnvCase("window_only", {"TERM": "xterm"}, {}, {"TERM": "xterm"}), + EnvCase("pane_only", {}, {"DEBUG": "1"}, {"DEBUG": "1"}), + EnvCase( + "window_and_pane_merge", + {"TERM": "xterm"}, + {"DEBUG": "1"}, + {"TERM": "xterm", "DEBUG": "1"}, + ), + EnvCase("pane_overrides_window", {"K": "win"}, {"K": "pane"}, {"K": "pane"}), + EnvCase("none_when_both_empty", {}, {}, None), +) + + +@pytest.mark.parametrize("case", ENV_CASES, ids=[c.test_id for c in ENV_CASES]) +def test_split_pane_environment_merges_window_and_pane(case: EnvCase) -> None: + """A split pane's env merges the window env with its own (the pane wins). + + The window env is "inherited by its panes", so a split pane carrying its own + env must not discard the window env -- it merges, matching the first pane's + creator env. + """ + ws = Workspace( + name="s", + windows=[ + Window( + "w", + environment=case.window_env, + panes=[ + Pane(run="vim"), + Pane(run="htop", environment=case.pane_env), + ], + ), + ], + ) + splits = [ + op for op in compile_workspace(ws).operations if isinstance(op, SplitWindow) + ] + assert len(splits) == 1 + assert splits[0].environment == case.expected From a988b2b117f067e2dae9a54d4b63d2ebc634d0c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 09:52:12 -0500 Subject: [PATCH 109/154] docs(CHANGES) Note on updates --- CHANGES | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGES b/CHANGES index 6d66b008e..f8457f5ab 100644 --- a/CHANGES +++ b/CHANGES @@ -92,6 +92,41 @@ fold boundaries that a fold never crosses. Pass a {class}`~libtmux.experimental.ops.planner.SequentialPlanner` to ``build`` for one legible tmux call per operation when debugging. +#### Floating panes on tmux 3.7 (#690) + +On tmux 3.7, the operations create floating panes -- overlays with an absolute +size, position, and optional zoom. A ``new-pane`` operation, ``new_pane()`` on +the pane wrappers, and a curated MCP tool each open one, and a +{class}`~libtmux.experimental.workspace.ir.Workspace` can declare floating panes +on a window, including a pane that overlays a different window. + +#### Query and command live panes with `panes()` (#690) + +{func}`~libtmux.experimental.query.panes` is a lazy, chainable query over the +panes a running server has: ``filter``, ``order_by``, ``limit``, and ``map`` +compose and read nothing until a terminal call. The same query commands what it +selects -- ``commands()`` attaches per-pane actions (send keys, resize, select, +respawn, clear history, kill) that run as one folded tmux dispatch. A query +resolves against a live engine or a plain list of pane snapshots, so the same +code runs offline in tests. + +#### Drive tmux from an MCP server (#690) + +An optional Model Context Protocol server exposes tmux as tools an AI agent can +call, installed with the ``libtmux[mcp]`` extra and launched as +``libtmux-engine-mcp``. It offers a curated vocabulary of intuitive verbs +(``send_input``, ``wait_for_output``, ``split_pane``, ``capture_pane``, +``new_pane``, and the session, window, and pane lifecycle), a tool for every +individual operation, and plan tools that preview or build a whole workspace in +one call. + +The server is caller-aware: because a control-mode agent shares the server with +the panes it drives, it knows which pane launched it and refuses to kill or +respawn its own pane, window, or session. A safety level (``LIBTMUX_SAFETY``) +keeps mutating and destructive tools hidden until an operator opts in, and a +needle-free ``wait_for_output`` reports when a pane goes quiet after a command -- +no sentinel string -- and whether its process exited. + ## libtmux 0.61.0 (2026-07-04) libtmux 0.61.0 hardens support for the tmux 3.7 patch line. It fixes From 617de95e58a9d2f9b2fc4ae55b56f6c62b4039b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:08:01 -0500 Subject: [PATCH 110/154] Mcp(refactor): Drop dead is_conservative_caller why: is_conservative_caller had no call sites -- the self-kill guards check the caller pane id inline and call socket_could_match directly, so the helper was dead duplication. what: - Remove is_conservative_caller from vocabulary/_caller.py - Point the module docstring at socket_could_match, the comparator the guards actually use --- .../experimental/mcp/vocabulary/_caller.py | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/libtmux/experimental/mcp/vocabulary/_caller.py b/src/libtmux/experimental/mcp/vocabulary/_caller.py index ae080749f..2fde3d234 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_caller.py +++ b/src/libtmux/experimental/mcp/vocabulary/_caller.py @@ -14,7 +14,7 @@ Everything here is pure (no tmux call, no fastmcp). A pane id is unique only within one tmux server, so identity is socket-scoped: :func:`is_strict_caller` (realpath-only, for the ``is_caller`` annotation) and the fail-safe -:func:`is_conservative_caller` (true-when-uncertain, for destructive guards). +:func:`socket_could_match` (true-when-uncertain, for destructive guards). """ from __future__ import annotations @@ -364,30 +364,3 @@ def is_strict_caller( if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: return False return socket_matches(socket, caller) - - -def is_conservative_caller( - pane_id: str | None, - socket: str | None, - caller: CallerContext, -) -> bool: - """Whether *pane_id* could be the caller's pane (conservative, for guards). - - Scoped to a matching pane id but biased to block under socket uncertainty -- - the comparator the self-kill guards use, so better discovery never makes the - destructive surface fail open. A different pane, or the same ``%N`` on a - provably different socket, is not the caller. - - Examples - -------- - >>> caller = CallerContext.from_env({"TMUX_PANE": "%3", "TMUX": "/tmp/s,1,2"}) - >>> is_conservative_caller("%3", None, caller) - True - >>> is_conservative_caller("%9", None, caller) - False - >>> is_conservative_caller("%3", "/tmp/other", caller) - False - """ - if not caller.in_tmux or caller.pane_id is None or pane_id != caller.pane_id: - return False - return socket_could_match(socket, caller) From 1a569f530d22f25ea2ded3b75a3af87d050de319 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:15:02 -0500 Subject: [PATCH 111/154] Engines(fix): Report tmux version over control mode why: run/arun resolve an engine's tmux version to drop flags an older tmux rejects, but only the subprocess engines implemented tmux_version. Over a control-mode engine, gating silently assumed latest and could emit flags the connected server rejects. what: - Add tmux_version() to ControlModeEngine and AsyncControlModeEngine, reading the connected server's tmux -V - Cover engine version-capability advertisement in test_execute --- .../engines/async_control_mode.py | 14 +++++++ .../experimental/engines/control_mode.py | 14 +++++++ tests/experimental/ops/test_execute.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index fbd0fd1a5..48899e8fb 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -32,6 +32,7 @@ from dataclasses import dataclass, field from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import render_control_line from libtmux.experimental.engines.control_mode import ( ControlModeError, @@ -150,6 +151,19 @@ def __init__( self._started = False self._dead: BaseException | None = None + def tmux_version(self) -> str | None: + """Report the connected server's tmux version (``tmux -V``). + + Implements + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so + version-gated operations render correctly over control mode; in-memory + engines omit it and resolution assumes latest. + """ + try: + return str(get_version(self.tmux_bin)) + except exc.LibTmuxException: + return None + async def start(self) -> None: """Spawn ``tmux -C``, consume the startup ACK, and start the reader.""" async with self._start_lock: diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 591812f06..1701ed0dd 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -28,6 +28,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandResult, render_control_line if t.TYPE_CHECKING: @@ -188,6 +189,19 @@ def __init__( self._proc: subprocess.Popen[bytes] | None = None self._selector: selectors.DefaultSelector | None = None + def tmux_version(self) -> str | None: + """Report the connected server's tmux version (``tmux -V``). + + Implements + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so + version-gated operations render correctly over control mode; in-memory + engines omit it and resolution assumes latest. + """ + try: + return str(get_version(self.tmux_bin)) + except exc.LibTmuxException: + return None + def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" return self.run_batch([request])[0] diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py index 8a0ef83fb..524920ba4 100644 --- a/tests/experimental/ops/test_execute.py +++ b/tests/experimental/ops/test_execute.py @@ -11,6 +11,11 @@ import pytest +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.engines.base import SupportsTmuxVersion +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.control_mode import ControlModeEngine +from libtmux.experimental.engines.subprocess import SubprocessEngine from libtmux.experimental.ops import SendKeys, SplitWindow, arun, run from libtmux.experimental.ops._types import PaneId, WindowId from libtmux.experimental.ops.exc import TmuxCommandError @@ -140,6 +145,38 @@ def test_resolve_engine_version_none_without_capability() -> None: assert resolve_engine_version(FakeEngine(), None) is None +class _CapabilityCase(t.NamedTuple): + """One engine and whether it reports a tmux version to the resolver.""" + + test_id: str + make_engine: t.Callable[[], object] + reports_version: bool + + +_CAPABILITY_CASES: tuple[_CapabilityCase, ...] = ( + _CapabilityCase("subprocess", SubprocessEngine, True), + _CapabilityCase("control_mode", ControlModeEngine, True), + _CapabilityCase("async_control_mode", AsyncControlModeEngine, True), + _CapabilityCase("concrete", ConcreteEngine, False), +) + + +@pytest.mark.parametrize( + "case", + _CAPABILITY_CASES, + ids=[c.test_id for c in _CAPABILITY_CASES], +) +def test_engine_advertises_version_capability(case: _CapabilityCase) -> None: + """Real-tmux engines satisfy SupportsTmuxVersion; simulators assume latest. + + A control-mode engine can query its server's version, so version-gated + rendering must fire over it; an in-memory engine cannot, so it omits the + capability and resolution assumes latest. + """ + engine = case.make_engine() + assert isinstance(engine, SupportsTmuxVersion) is case.reports_version + + def test_run_auto_resolves_engine_version() -> None: """run() asks the engine for its version when none is passed; gating fires.""" from libtmux.experimental.ops import CapturePane From c0758abca39fc47550819f7da29b68bd1a6b6f2e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:17:50 -0500 Subject: [PATCH 112/154] Ops(docs): Fix stale fold comment in chain test why: the comment claimed fold defaults to False, but execute() has no fold kwarg -- folding is chosen by passing a planner, and the default SequentialPlanner dispatches one tmux call per op. what: - Replace the misleading comment in test_no_fold_dispatches_per_op --- tests/experimental/ops/test_chain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index d8babf1af..1ac1af278 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -117,7 +117,7 @@ def test_no_fold_dispatches_per_op() -> None: plan.add(RenameWindow(target=WindowId("@1"), name="x")) engine = _CountingEngine() - plan.execute(engine) # fold defaults to False + plan.execute(engine) # default planner: one dispatch per op assert len(engine.calls) == 2 From 312a5f307471a0e69f0c6539b2a30062162c6f0e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:34:52 -0500 Subject: [PATCH 113/154] Models(feat): Add PaneSnapshot.floating flag why: the live pane query filter(floating=True) matched nothing because PaneSnapshot exposed no floating attribute, though the neo groundwork (#{pane_floating_flag}, tmux 3.7) and the floating-pane build surface already landed. what: - Add typed PaneSnapshot.floating derived from #{pane_floating_flag} (False when the token is absent on tmux < 3.7) - Cover filter(floating=True/False) in test_query --- src/libtmux/experimental/models/snapshots.py | 9 +++++++++ tests/experimental/test_query.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/libtmux/experimental/models/snapshots.py b/src/libtmux/experimental/models/snapshots.py index 4af4652d0..e13368bea 100644 --- a/src/libtmux/experimental/models/snapshots.py +++ b/src/libtmux/experimental/models/snapshots.py @@ -76,6 +76,13 @@ class PaneSnapshot: ('%1', 0, True, 80) >>> pane.current_command 'zsh' + + The ``floating`` flag reflects ``#{pane_floating_flag}`` (tmux 3.7+): + + >>> PaneSnapshot.from_format({"pane_id": "%9", "pane_floating_flag": "1"}).floating + True + >>> pane.floating + False """ pane_id: str = "" @@ -89,6 +96,7 @@ class PaneSnapshot: current_path: str | None = None title: str | None = None pid: int | None = None + floating: bool = False fields: Mapping[str, str] = field(default_factory=dict) @classmethod @@ -106,6 +114,7 @@ def from_format(cls, raw: Mapping[str, str]) -> PaneSnapshot: current_path=raw.get("pane_current_path"), title=raw.get("pane_title"), pid=_as_int(raw.get("pane_pid")), + floating=_as_bool(raw.get("pane_floating_flag")), fields=dict(raw), ) diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index e3a7c8ec5..01e366f24 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -45,6 +45,16 @@ def test_filter_lookup() -> None: assert [p.pane_id for p in result] == ["%1", "%3"] +def test_filter_floating() -> None: + """filter(floating=True) selects floating overlays (tmux 3.7+).""" + rows = ( + _pane("%1", 0, active=True, command="vim"), + PaneSnapshot.from_format({"pane_id": "%9", "pane_floating_flag": "1"}), + ) + assert [p.pane_id for p in panes().filter(floating=True).all(rows)] == ["%9"] + assert [p.pane_id for p in panes().filter(floating=False).all(rows)] == ["%1"] + + def test_order_by_and_limit() -> None: """order_by sorts and limit truncates.""" result = panes().order_by("pane_index").limit(2).all(ROWS) From 232b15b22f4cd1a0a22dac06cc228a05bf6e6432 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:50:14 -0500 Subject: [PATCH 114/154] Engines(fix): Reap control-mode phantom sessions why: a bare `tmux -C` connect implies new-session, so every control-mode connection spawned a throwaway session on the target server and left it behind -- against the default socket these accumulate in the user's session switcher, and reconnects pile up more. what: - Both control engines set destroy-unattached on their own (phantom) session right after connect, so tmux reaps it on disconnect - Add live phantom tests (sync + async): marked on connect, gone on close --- .../engines/async_control_mode.py | 41 +++++++++++ .../experimental/engines/control_mode.py | 19 +++++ .../test_async_control_mode_phantom.py | 73 +++++++++++++++++++ .../engines/test_control_mode_phantom.py | 48 ++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 tests/experimental/engines/test_async_control_mode_phantom.py create mode 100644 tests/experimental/engines/test_control_mode_phantom.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 48899e8fb..1589e3188 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -108,6 +108,20 @@ def _offer( return 0 +def _swallow_future(future: asyncio.Future[t.Any]) -> None: + """Retrieve a fire-and-forget future's outcome so it isn't flagged unretrieved. + + A reap command is dispatched without an awaiter; its future resolves in the + reader. Calling :meth:`asyncio.Future.exception` marks the result retrieved + so a tmux-side ``%error`` never surfaces as a noisy "exception was never + retrieved" warning. + """ + if future.cancelled(): + return + with contextlib.suppress(Exception): + future.exception() + + class AsyncControlModeEngine: """Execute tmux commands over one persistent async ``tmux -C`` connection. @@ -189,6 +203,7 @@ async def start(self) -> None: self._reader(), name="libtmux-async-control-reader", ) + await self._reap_own_session() self._started = True async def _consume_startup(self) -> None: @@ -221,6 +236,32 @@ async def _consume_startup(self) -> None: if self._parser.blocks(): # startup ACK seen and discarded return + async def _reap_own_session(self) -> None: + """Mark this control client's throwaway session ``destroy-unattached``. + + A bare ``tmux -C`` connect implies ``new-session``, so each connection + spawns a phantom session on the target server. Setting + ``destroy-unattached on`` on the *current* session (no ``-t``/``-g``, so + scoped to exactly that phantom, never global) makes tmux reap it the + moment this client disconnects, so control mode never litters the server + with throwaway sessions. Fire-and-forget: the reader resolves the result + future, which is swallowed. + """ + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + argv = ("set-option", "destroy-unattached", "on") + async with self._write_lock: + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + try: + proc.stdin.write((render_control_line(argv) + "\n").encode()) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + return + async def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" return (await self.run_batch([request]))[0] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 1701ed0dd..31e15611a 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -305,6 +305,7 @@ def _ensure_started(self) -> None: self._proc = proc self._selector = selector self._consume_startup() + self._reap_own_session() def _consume_startup(self) -> None: """Read and discard tmux's startup ACK block before any command. @@ -326,6 +327,24 @@ def _consume_startup(self) -> None: return self._parser.notifications() + def _reap_own_session(self) -> None: + """Mark this control client's throwaway session ``destroy-unattached``. + + A bare ``tmux -C`` connect implies ``new-session``, so set + ``destroy-unattached on`` on the *current* session (the phantom; no + ``-t``/``-g``, scoped to exactly it) right after connect. tmux reaps it + the moment the client disconnects, so control mode leaves no throwaway + sessions. Its result block is read and discarded here -- before any user + command -- so it cannot desync the next command. Best-effort. + """ + proc = self._proc + if proc is None or proc.stdin is None: + return + argv = ("set-option", "destroy-unattached", "on") + with contextlib.suppress(OSError, BrokenPipeError, ControlModeError): + self._write((render_control_line(argv) + "\n").encode()) + self._read_blocks(command_count(argv)) + def _drain_unsolicited(self) -> None: """Discard any blocks/notifications already buffered (non-blocking).""" selector = self._selector diff --git a/tests/experimental/engines/test_async_control_mode_phantom.py b/tests/experimental/engines/test_async_control_mode_phantom.py new file mode 100644 index 000000000..5f3062534 --- /dev/null +++ b/tests/experimental/engines/test_async_control_mode_phantom.py @@ -0,0 +1,73 @@ +"""Live: a control-mode connection reaps its own throwaway (phantom) session. + +A bare ``tmux -C`` implies ``new-session``, so each connect spawns a phantom +session on the *target* server. The engine sets ``destroy-unattached on`` on that +phantom so tmux reaps it the moment the client attaches elsewhere or disconnects +-- control-mode never litters the server with throwaway sessions (the user's +default socket included), and a reconnect storm cannot accumulate them. +""" + +from __future__ import annotations + +import asyncio +import time +import typing as t + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.engines.base import CommandRequest + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_phantom_session_marked_destroy_unattached(session: Session) -> None: + """The engine marks its own throwaway session ``destroy-unattached`` on connect.""" + + async def main() -> tuple[list[str], list[str]]: + engine = AsyncControlModeEngine.for_server(session.server) + try: + await engine.start() + own = ( + await engine.run( + CommandRequest.from_args("display-message", "-p", "#{session_id}"), + ) + ).stdout + opt = ( + await engine.run( + CommandRequest.from_args( + "show-options", "-t", own[0], "-v", "destroy-unattached" + ), + ) + ).stdout + return list(own), list(opt) + finally: + await engine.aclose() + + own, opt = asyncio.run(main()) + assert own and own[0].startswith("$") + assert opt == ["on"] + + +def test_phantom_session_reaped_on_close(session: Session) -> None: + """No phantom session lingers once the control connection closes.""" + server = session.server + before = len(server.sessions) + + async def main() -> int: + engine = AsyncControlModeEngine.for_server(server) + try: + await engine.start() + return len(server.sessions) # the phantom is present during the connection + finally: + await engine.aclose() + + during = asyncio.run(main()) + assert during == before + 1 # the connection spawned its phantom + + # tmux reaps the unattached destroy-unattached session when the client exits; + # poll briefly since the reap is asynchronous to the proc terminating. + for _ in range(60): + if len(server.sessions) == before: + break + time.sleep(0.05) + assert len(server.sessions) == before diff --git a/tests/experimental/engines/test_control_mode_phantom.py b/tests/experimental/engines/test_control_mode_phantom.py new file mode 100644 index 000000000..cef367259 --- /dev/null +++ b/tests/experimental/engines/test_control_mode_phantom.py @@ -0,0 +1,48 @@ +"""Live: the sync control-mode engine reaps its own phantom session too. + +The synchronous twin of ``test_async_control_mode_phantom``: a bare ``tmux -C`` +connect spawns a throwaway session, and the engine marks it ``destroy-unattached`` +so it is reaped on disconnect -- no litter on the target server. +""" + +from __future__ import annotations + +import time +import typing as t + +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.engines.control_mode import ControlModeEngine + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_sync_phantom_marked_destroy_unattached(session: Session) -> None: + """The sync engine marks its throwaway session ``destroy-unattached``.""" + with ControlModeEngine.for_server(session.server) as engine: + own = engine.run( + CommandRequest.from_args("display-message", "-p", "#{session_id}"), + ).stdout + opt = engine.run( + CommandRequest.from_args( + "show-options", "-t", own[0], "-v", "destroy-unattached" + ), + ).stdout + assert own and own[0].startswith("$") + assert list(opt) == ["on"] + + +def test_sync_phantom_reaped_on_close(session: Session) -> None: + """No phantom session lingers once the sync connection closes.""" + server = session.server + before = len(server.sessions) + with ControlModeEngine.for_server(server) as engine: + engine.run(CommandRequest.from_args("display-message", "-p", "#{session_id}")) + during = len(server.sessions) + assert during == before + 1 + + for _ in range(60): + if len(server.sessions) == before: + break + time.sleep(0.05) + assert len(server.sessions) == before From c4e70dc767f40a965110ab7c11f9315809f6f013 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 10:54:33 -0500 Subject: [PATCH 115/154] Engines(fix): Close subscribers on engine death why: subscribe() looped on `await queue.get()` with nothing signalling death or close, so a consumer (the event push tool, the pull ring, the output monitor) hung forever once the async engine died or was closed. what: - Broadcast a stream-end sentinel to every subscriber queue from _mark_dead and aclose (force-put so it lands even on a full queue) - Gate subscribe() on _closing so a call after aclose ends at once - Add hermetic sentinel tests (death, full-queue eviction, post-close) --- .../engines/async_control_mode.py | 60 ++++++++++-- .../test_async_control_mode_sentinel.py | 92 +++++++++++++++++++ 2 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 tests/experimental/engines/test_async_control_mode_sentinel.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 1589e3188..277b41792 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -52,6 +52,7 @@ _DEFAULT_TIMEOUT = 30.0 _STARTUP_TIMEOUT = 5.0 _STOP_TIMEOUT = 2.0 +_STREAM_END = object() # broadcast to subscriber queues to end their async for @dataclass(frozen=True) @@ -108,6 +109,22 @@ def _offer( return 0 +def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: + """Put *item* on *queue*, evicting the oldest entry first when it is full. + + Like :func:`_offer` but drop-count-free: lands the stream-end sentinel even + on a queue already at ``maxsize``, so a slow consumer that hit backpressure + still gets closed instead of hanging on ``queue.get()``. + """ + try: + queue.put_nowait(item) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() # evict oldest; tolerable at death + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(item) + + def _swallow_future(future: asyncio.Future[t.Any]) -> None: """Retrieve a fire-and-forget future's outcome so it isn't flagged unretrieved. @@ -156,13 +173,14 @@ def __init__( self._parser = ControlModeParser() self._pending: collections.deque[_PendingCommand] = collections.deque() self._event_queue_size = event_queue_size - self._subscribers: set[asyncio.Queue[ControlNotification]] = set() + self._subscribers: set[asyncio.Queue[t.Any]] = set() self._dropped_notifications = 0 self._proc: asyncio.subprocess.Process | None = None self._reader_task: asyncio.Task[None] | None = None self._start_lock = asyncio.Lock() self._write_lock = asyncio.Lock() self._started = False + self._closing = False self._dead: BaseException | None = None def tmux_version(self) -> str | None: @@ -326,16 +344,25 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: Each subscriber gets its own queue, so concurrent subscribers (the event push tool, the pull ring, the output monitor) each see *every* notification rather than competing for one shared stream. The iterator - runs until the engine is closed or the caller stops iterating; its queue - is unregistered on exit. + runs until the engine dies or is closed, or the caller stops iterating; + its queue is unregistered on exit. When the engine dies or closes, + ``_STREAM_END`` is broadcast to every subscriber queue so the ``async + for`` ends cleanly instead of hanging on ``queue.get()``. A subscribe() + after :meth:`aclose` yields nothing (the ``_closing`` gate), since no + broadcast would ever reach its fresh queue. """ - queue: asyncio.Queue[ControlNotification] = asyncio.Queue( + if self._closing: + return + queue: asyncio.Queue[t.Any] = asyncio.Queue( maxsize=self._event_queue_size, ) self._subscribers.add(queue) try: while True: - yield await queue.get() + item = await queue.get() + if item is _STREAM_END: + return + yield item finally: self._subscribers.discard(queue) @@ -345,9 +372,14 @@ def dropped_notifications(self) -> int: return self._dropped_notifications async def aclose(self) -> None: - """Tear down the connection: cancel the reader, fail pending, kill proc.""" + """Tear down: flag closing, cancel the reader, end subscribers, kill proc. + + Setting ``_closing`` first makes a subscribe() racing the close end at + once instead of registering a queue no broadcast will reach. + """ if not self._started: return + self._closing = True self._started = False reader = self._reader_task self._reader_task = None @@ -355,6 +387,7 @@ async def aclose(self) -> None: reader.cancel() with contextlib.suppress(asyncio.CancelledError): await reader + self._broadcast_stream_end() self._fail_pending(ControlModeError("control-mode engine closed")) proc = self._proc self._proc = None @@ -433,11 +466,24 @@ def _publish(self, line: bytes) -> None: for queue in self._subscribers: self._dropped_notifications += _offer(queue, notification) + def _broadcast_stream_end(self) -> None: + """Push the stream-end sentinel to every subscriber, then clear them. + + Uses :func:`_force_put` so the sentinel lands even on a queue already at + ``maxsize`` (a slow consumer that hit backpressure); otherwise the + sentinel would be lost and the consumer would hang forever on + ``queue.get()`` -- the exact bug this guards against. + """ + for queue in list(self._subscribers): + _force_put(queue, _STREAM_END) + self._subscribers.clear() + def _mark_dead(self, error: BaseException) -> None: - """Record the engine as dead and fail all pending commands.""" + """Record the engine as dead, fail pending commands, end subscribers.""" if self._dead is None: self._dead = error self._fail_pending(error) + self._broadcast_stream_end() def _fail_pending(self, error: BaseException) -> None: """Fail every queued command future with *error*.""" diff --git a/tests/experimental/engines/test_async_control_mode_sentinel.py b/tests/experimental/engines/test_async_control_mode_sentinel.py new file mode 100644 index 000000000..f3bdf2029 --- /dev/null +++ b/tests/experimental/engines/test_async_control_mode_sentinel.py @@ -0,0 +1,92 @@ +"""A dead engine must CLOSE subscriber generators, not hang them.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines.async_control_mode import ( + AsyncControlModeEngine, + ControlNotification, +) +from libtmux.experimental.engines.control_mode import ControlModeError + + +def test_subscribe_ends_when_engine_marked_dead() -> None: + """A subscriber generator must finish (not hang) when the engine is marked dead.""" + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine() + # do not spawn tmux: drive subscribe() + _mark_dead directly + started = asyncio.Event() + seen: list[ControlNotification] = [] + + async def consume() -> None: + started.set() # signalled before the async for registers its queue + # collect via async comprehension (avoids PERF401 lint) + seen.extend([note async for note in engine.subscribe()]) + + task = asyncio.create_task(consume()) + await started.wait() # consumer registered its queue and is on queue.get() + engine._mark_dead(ControlModeError("boom")) + await asyncio.wait_for(task, timeout=1.0) # must NOT hang + return seen + + asyncio.run(main()) + + +def test_subscribe_ends_when_dead_with_full_queue() -> None: + """The death sentinel must land even when a subscriber queue is at maxsize. + + A slow consumer can let its bounded queue fill to ``maxsize`` (the + drop-oldest ``_offer`` path). The death broadcast must still close such a + consumer, so it evicts the oldest entry to make room for the sentinel + instead of silently dropping it. + """ + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine(event_queue_size=2) + started = asyncio.Event() + seen: list[ControlNotification] = [] + + async def consume() -> None: + started.set() + seen.extend([note async for note in engine.subscribe()]) + + task = asyncio.create_task(consume()) + await started.wait() # queue registered, consumer blocked on queue.get() + + queue: asyncio.Queue[t.Any] = next(iter(engine._subscribers)) + first = ControlNotification.parse(b"%output %1 first") + second = ControlNotification.parse(b"%output %2 second") + queue.put_nowait(first) + queue.put_nowait(second) # queue now at maxsize=2 (full) + + engine._mark_dead(ControlModeError("boom")) + await asyncio.wait_for(task, timeout=1.0) # must NOT hang despite full queue + # the oldest item was evicted so the sentinel could land + assert first not in seen + return seen + + asyncio.run(main()) + + +def test_subscribe_ends_immediately_after_close() -> None: + """A subscribe() after aclose() must end at once, not hang on queue.get(). + + aclose() broadcasts the stream-end sentinel and clears the subscriber set, + so a queue registered afterwards would never receive a sentinel. The + ``_closing`` gate makes subscribe() yield nothing and return immediately. + """ + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine() + engine._started = True # pretend a connection was established + await engine.aclose() # flips _closing, broadcasts sentinel, clears subs + + async def drain() -> list[ControlNotification]: + return [note async for note in engine.subscribe()] + + return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang + + assert asyncio.run(main()) == [] From cba3a33362d8284c5f5fbdec7a425fdd4acb80ae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:41:54 -0500 Subject: [PATCH 116/154] Query(feat): Split-type forward-ref pane handles why: a fluent build needs to target a pane an earlier op will create. Splitting the concrete PaneRef into two handle types makes reading an id off a not-yet-created pane a static type error under mypy and ty, not a runtime surprise. what: - Add _PaneRefBase (shared verbs: cmd, split, do), ForwardPaneRef (no snapshot reads), and PaneRef (concrete: pane_id/active/pane) - split() records the create and returns a forward handle; do() threads a recorder into a fluent chain - Widen BoundPaneCommands.target to Target so a forward SlotRef flows - Cover the split/do verbs and the concrete-vs-forward read surface --- src/libtmux/experimental/query.py | 100 +++++++++++++++++++++++++----- tests/experimental/test_query.py | 59 +++++++++++++++++- 2 files changed, 144 insertions(+), 15 deletions(-) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index 05d752fa9..0ff2ec57d 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -43,15 +43,18 @@ RespawnPane, SelectPane, SendKeys, + SplitWindow, run, ) if t.TYPE_CHECKING: from collections.abc import Callable, Mapping, Sequence + from typing_extensions import Self + from libtmux.experimental.models.snapshots import PaneSnapshot from libtmux.experimental.ops import Planner, PlanResult - from libtmux.experimental.ops._types import SlotRef + from libtmux.experimental.ops._types import SlotRef, Target #: A source of pane snapshots: an engine to read from, or pre-taken snapshots. PaneSource = t.Union["TmuxEngine", "Sequence[PaneSnapshot]"] @@ -149,11 +152,13 @@ class BoundPaneCommands: Each method appends a typed operation targeting the bound pane to the plan and returns its :class:`~..ops._types.SlotRef`, so commands compose and the - plan folds to a single tmux dispatch. + plan folds to a single tmux dispatch. ``target`` is a :data:`~..ops._types.Target` + so a forward :class:`~..ops._types.SlotRef` (a pane an earlier op creates) + flows through as well as a concrete :class:`~..ops._types.PaneId`. """ plan: LazyPlan - target: PaneId + target: Target def send_keys( self, @@ -198,26 +203,93 @@ def kill(self) -> SlotRef: @dataclass(frozen=True) -class PaneRef: - """A matched pane plus a ``cmd`` namespace that records into a plan.""" +class _PaneRefBase: + """The verbs shared by concrete and forward pane handles. + + An immutable pointer into a *mutable* :class:`LazyPlan`. Structural verbs + (:meth:`split`) record a create op and return a *forward* handle to the + not-yet-created pane; leaf commands live under :attr:`cmd`; :meth:`do` + threads a side-effecting recorder into a fluent chain. + """ - pane: PaneSnapshot plan: LazyPlan + target: Target + + @property + def cmd(self) -> BoundPaneCommands: + """Pane commands bound to this handle's target (recorded into the plan).""" + return BoundPaneCommands(self.plan, self.target) + + def split(self, *, horizontal: bool = False) -> ForwardPaneRef: + """Split this pane; return a forward handle to the new pane. + + Examples + -------- + >>> plan = LazyPlan() + >>> new = _PaneRefBase(plan, PaneId("%1")).split() + >>> isinstance(new, ForwardPaneRef) + True + >>> [op.kind for op in plan.operations] + ['split_window'] + """ + slot = self.plan.add(SplitWindow(target=self.target, horizontal=horizontal)) + return ForwardPaneRef(self.plan, slot) + + def do(self, fn: Callable[[BoundPaneCommands], object]) -> Self: + """Apply *fn* to this handle's :attr:`cmd`, returning the handle. + + Examples + -------- + >>> plan = LazyPlan() + >>> h = _PaneRefBase(plan, PaneId("%1")) + >>> h.do(lambda c: c.send_keys("vim")) is h + True + >>> [op.kind for op in plan.operations] + ['send_keys'] + """ + fn(self.cmd) + return self + + +@dataclass(frozen=True) +class ForwardPaneRef(_PaneRefBase): + """A pane an earlier operation will create. + + Carries a forward :class:`~..ops._types.SlotRef`; it has no snapshot, so + reading a pane id/attribute off it is a *static* type error (the id is + unknown until the plan runs). Keep building instead -- ``split().do(...)``. + """ + + +@dataclass(frozen=True) +class PaneRef(_PaneRefBase): + """A concrete matched pane: the shared verbs plus snapshot reads. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import PaneSnapshot + >>> snap = PaneSnapshot.from_format({"pane_id": "%1", "pane_active": "1"}) + >>> ref = PaneRef(LazyPlan(), PaneId("%1"), snapshot=snap) + >>> ref.pane_id, ref.active + ('%1', True) + """ + + snapshot: PaneSnapshot + + @property + def pane(self) -> PaneSnapshot: + """The underlying pane snapshot.""" + return self.snapshot @property def pane_id(self) -> str: """The pane's id.""" - return self.pane.pane_id + return self.snapshot.pane_id @property def active(self) -> bool: """Whether the pane is active in its window.""" - return self.pane.active - - @property - def cmd(self) -> BoundPaneCommands: - """Pane commands bound to this pane (recorded into the plan).""" - return BoundPaneCommands(self.plan, PaneId(self.pane.pane_id)) + return self.snapshot.active @dataclass(frozen=True) @@ -250,7 +322,7 @@ def to_plan(self, source: PaneSource) -> LazyPlan: """ plan = LazyPlan() for pane in self.query.all(source): - self.mapper(PaneRef(pane, plan)) + self.mapper(PaneRef(plan, PaneId(pane.pane_id), snapshot=pane)) return plan def run( diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index 01e366f24..a3d9c3a41 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -4,8 +4,11 @@ import typing as t +import pytest + from libtmux.experimental.models.snapshots import PaneSnapshot -from libtmux.experimental.query import PaneQuery, panes +from libtmux.experimental.ops import LazyPlan, PaneId +from libtmux.experimental.query import ForwardPaneRef, PaneQuery, PaneRef, panes if t.TYPE_CHECKING: from libtmux.session import Session @@ -29,6 +32,60 @@ def _pane(pane_id: str, index: int, *, active: bool, command: str) -> PaneSnapsh ) +def _concrete(plan: LazyPlan) -> PaneRef: + return PaneRef( + plan, + PaneId("%1"), + snapshot=_pane("%1", 0, active=True, command="vim"), + ) + + +def test_split_returns_forward_handle() -> None: + """A structural verb records a create and returns a forward handle.""" + plan = LazyPlan() + new = _concrete(plan).split() + assert isinstance(new, ForwardPaneRef) + assert [op.kind for op in plan.operations] == ["split_window"] + + +def test_do_chains_on_the_handle() -> None: + """do() records into the plan and returns the same handle.""" + plan = LazyPlan() + ref = _concrete(plan) + assert ref.do(lambda c: c.send_keys("vim")) is ref + assert [op.kind for op in plan.operations] == ["send_keys"] + + +class _ReadCase(t.NamedTuple): + """Whether a handle exposes concrete pane reads (concrete) or not (forward).""" + + test_id: str + forward: bool + + +_READ_CASES: tuple[_ReadCase, ...] = ( + _ReadCase("concrete_reads", forward=False), + _ReadCase("forward_no_reads", forward=True), +) + + +@pytest.mark.parametrize("case", _READ_CASES, ids=[c.test_id for c in _READ_CASES]) +def test_handle_read_surface(case: _ReadCase) -> None: + """Concrete handles expose pane reads; forward handles have none. + + The forward handle's absence of ``pane_id`` is what makes a premature read a + *static* type error (mypy + ty), with the structural absence as its runtime + shadow. + """ + plan = LazyPlan() + concrete = _concrete(plan) + handle: object = concrete.split() if case.forward else concrete + assert hasattr(handle, "pane_id") is (not case.forward) + if not case.forward: + assert concrete.pane_id == "%1" + assert concrete.active is True + + def test_panes_returns_query() -> None: """panes() starts an empty, immutable query.""" assert panes() == PaneQuery() From f097df756b52c3031a1ddf8c9d00449e06d8545b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:44:43 -0500 Subject: [PATCH 117/154] Fluent(feat): Add plan() forward-ref build tier why: the split-type pane handles give forward-ref panes, but a user still needs a fluent entry to declare a whole session tree. plan() records a session/window/pane build into one LazyPlan that folds to a few tmux dispatches instead of one call per operation. what: - Add experimental/fluent.py: plan() -> PlanBuilder, SessionRef, WindowRef - Name-address sessions/windows (folds stay intact); reach a pane via the creator's captured first-pane SlotRef (a forward handle) - run()/arun() default to MarkedPlanner; preview() is a pure argv dry-run - Cover the build shape, an offline fold over ConcreteEngine, and a live build --- src/libtmux/experimental/fluent.py | 196 +++++++++++++++++++++++++++++ tests/experimental/test_fluent.py | 89 +++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/libtmux/experimental/fluent.py create mode 100644 tests/experimental/test_fluent.py diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py new file mode 100644 index 000000000..5641287aa --- /dev/null +++ b/src/libtmux/experimental/fluent.py @@ -0,0 +1,196 @@ +"""A fluent, forward-ref builder that folds a session build to a few dispatches. + +``plan()`` opens a :class:`PlanBuilder` -- a thin recorder over a Core +:class:`~libtmux.experimental.ops.plan.LazyPlan`. Navigating it +(:meth:`PlanBuilder.new_session` -> :class:`SessionRef` -> +:class:`WindowRef` -> :meth:`WindowRef.pane`) records create operations and +hands back forward handles (:class:`~libtmux.experimental.query.ForwardPaneRef`) +that address objects the plan will create. Nothing runs until +:meth:`PlanBuilder.run` (or its async twin :meth:`PlanBuilder.arun`), which folds +the recorded operations into a handful of ``tmux a ; b`` dispatches by default +(a :class:`~libtmux.experimental.ops.planner.MarkedPlanner`). + +Named objects (sessions, windows) are addressed by name so their sub-operations +fold; a pane -- which has no name -- is addressed by a forward +:class:`~libtmux.experimental.ops._types.SlotRef`, resolved from the creating +operation's captured id at execution. + +Examples +-------- +>>> from libtmux.experimental.engines.concrete import ConcreteEngine +>>> p = plan() +>>> pane = p.new_session("dev").window().pane() +>>> bottom = pane.do(lambda c: c.send_keys("vim")).split() +>>> bottom.do(lambda c: c.send_keys("htop")) is bottom +True +>>> [op.kind for op in p.plan.operations] +['new_session', 'send_keys', 'split_window', 'send_keys'] +>>> p.run(ConcreteEngine()).ok +True +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops import ( + LazyPlan, + MarkedPlanner, + NameRef, + NewSession, + NewWindow, +) +from libtmux.experimental.query import ForwardPaneRef + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops import Planner, PlanResult + from libtmux.experimental.ops._types import SlotRef + + +@dataclass(frozen=True) +class WindowRef: + """A window in a plan; navigate to its first pane. + + ``first_pane`` is a forward :class:`~..ops._types.SlotRef` to the window's + first pane (captured by the creating ``new-session`` / ``new-window``). + """ + + plan: LazyPlan + first_pane: SlotRef + + def pane(self) -> ForwardPaneRef: + """Return a forward handle to the window's first pane. + + Examples + -------- + >>> p = plan() + >>> ref = p.new_session("dev").window().pane() + >>> isinstance(ref, ForwardPaneRef) + True + """ + return ForwardPaneRef(self.plan, self.first_pane) + + +@dataclass(frozen=True) +class SessionRef: + """A session in a plan; reach its first window or add another. + + The session is name-addressed (so its window operations fold); ``create`` is + the ``new-session`` slot, whose captured first pane backs the first window. + """ + + plan: LazyPlan + name: str + create: SlotRef + + def window(self) -> WindowRef: + """Return the session's first window. + + Examples + -------- + >>> isinstance(plan().new_session("dev").window(), WindowRef) + True + """ + return WindowRef(self.plan, self.create.pane) + + def new_window(self, name: str) -> WindowRef: + """Create another window in this session (name-addressed). + + Examples + -------- + >>> p = plan() + >>> _ = p.new_session("dev").new_window("logs") + >>> [op.kind for op in p.plan.operations] + ['new_session', 'new_window'] + """ + slot = self.plan.add( + NewWindow(target=NameRef(self.name), name=name, capture_pane=True), + ) + return WindowRef(self.plan, slot.pane) + + +@dataclass(frozen=True) +class PlanBuilder: + """A fluent recorder over a :class:`LazyPlan`; :meth:`run` folds by default.""" + + plan: LazyPlan = field(default_factory=LazyPlan) + + def new_session(self, name: str) -> SessionRef: + """Create a session, capturing its first pane for forward refs. + + Examples + -------- + >>> p = plan() + >>> ref = p.new_session("dev") + >>> isinstance(ref, SessionRef) + True + >>> [op.kind for op in p.plan.operations] + ['new_session'] + """ + slot = self.plan.add(NewSession(session_name=name, capture_panes=True)) + return SessionRef(self.plan, name, slot) + + def run( + self, + engine: TmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Build over *engine*, folding to a few dispatches (``MarkedPlanner``). + + Examples + -------- + >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> p = plan() + >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) + >>> p.run(ConcreteEngine()).ok + True + """ + return self.plan.execute( + engine, + version=version, + planner=planner or MarkedPlanner(), + ) + + async def arun( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> PlanResult: + """Async twin of :meth:`run` (same fold, ``await``ed).""" + return await self.plan.aexecute( + engine, + version=version, + planner=planner or MarkedPlanner(), + ) + + def preview(self, *, version: str | None = None) -> list[tuple[str, ...] | None]: + """Render a pure argv dry-run of the recorded plan (no engine). + + Examples + -------- + >>> p = plan() + >>> _ = p.new_session("dev") + >>> argv = p.preview()[0] + >>> argv[:4] + ('new-session', '-d', '-s', 'dev') + >>> argv[-1] + '#{session_id} #{window_id} #{pane_id}' + """ + return self.plan.preview(version=version) + + +def plan() -> PlanBuilder: + """Start a fluent, forward-ref plan build. + + Examples + -------- + >>> plan().plan.operations + () + """ + return PlanBuilder() diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py new file mode 100644 index 000000000..8e596b7a0 --- /dev/null +++ b/tests/experimental/test_fluent.py @@ -0,0 +1,89 @@ +"""Tests for the fluent forward-ref plan builder (``plan()``).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.fluent import PlanBuilder, plan +from libtmux.experimental.query import ForwardPaneRef + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _one_window_two_panes(p: PlanBuilder) -> None: + pane = p.new_session("dev").window().pane() + pane.do(lambda c: c.send_keys("vim")).split().do( + lambda c: c.send_keys("pytest -q"), + ) + + +def _two_windows(p: PlanBuilder) -> None: + sess = p.new_session("dev") + sess.window().pane().do(lambda c: c.send_keys("vim")) + sess.new_window("logs").pane().do(lambda c: c.send_keys("tail -f log")) + + +class _BuildCase(t.NamedTuple): + """A fluent build and the operation sequence it should record.""" + + test_id: str + build: t.Callable[[PlanBuilder], None] + kinds: list[str] + + +_BUILD_CASES: tuple[_BuildCase, ...] = ( + _BuildCase( + "one_window_two_panes", + _one_window_two_panes, + ["new_session", "send_keys", "split_window", "send_keys"], + ), + _BuildCase( + "two_windows", + _two_windows, + ["new_session", "send_keys", "new_window", "send_keys"], + ), +) + + +@pytest.mark.parametrize("case", _BUILD_CASES, ids=[c.test_id for c in _BUILD_CASES]) +def test_builder_records_ops(case: _BuildCase) -> None: + """The fluent build records the expected operation sequence.""" + p = plan() + case.build(p) + assert [op.kind for op in p.plan.operations] == case.kinds + + +@pytest.mark.parametrize("case", _BUILD_CASES, ids=[c.test_id for c in _BUILD_CASES]) +def test_builder_runs_offline(case: _BuildCase) -> None: + """The build resolves forward refs and folds over the in-memory engine.""" + p = plan() + case.build(p) + assert p.run(ConcreteEngine()).ok + + +def test_window_pane_is_forward_handle() -> None: + """A window's first pane is a forward handle with no snapshot reads.""" + ref = plan().new_session("dev").window().pane() + assert isinstance(ref, ForwardPaneRef) + assert not hasattr(ref, "pane_id") + + +def test_build_session_live(session: Session) -> None: + """A fluent build creates a real session with the declared panes.""" + from libtmux.experimental.engines.subprocess import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + p = plan() + pane = p.new_session("fluentdev").window().pane() + pane.do(lambda c: c.send_keys("echo top", enter=False)).split().do( + lambda c: c.send_keys("echo bottom", enter=False), + ) + p.run(engine).raise_for_status() + + built = [s for s in session.server.sessions if s.session_name == "fluentdev"] + assert len(built) == 1 + assert len(built[0].windows[0].panes) == 2 From fac08d5c3402a3bce88411b5f63ceb54ef6c5a60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:53:38 -0500 Subject: [PATCH 118/154] Workspace(feat): Add freeze (live server to IR) why: analyze() and to_dict() cover the config->IR->dict round-trip, but there was no way back from a LIVE server to declarative IR. freeze() closes the loop -- reverse-analyze a running session into a Workspace you can rebuild elsewhere (tmuxp `freeze`). what: - Add experimental/workspace/freeze.py: freeze(ServerSnapshot) -> Workspace (pure), plus freeze_server/afreeze_server rebuilding the whole tree from one list-panes -a read - Export freeze/freeze_server/afreeze_server from the workspace package --- .../experimental/workspace/__init__.py | 8 + src/libtmux/experimental/workspace/freeze.py | 249 ++++++++++++++++++ tests/experimental/test_freeze.py | 213 +++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 src/libtmux/experimental/workspace/freeze.py create mode 100644 tests/experimental/test_freeze.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 99b572142..7eb3e9049 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -38,6 +38,11 @@ WindowCreated, WorkspaceBuilt, ) +from libtmux.experimental.workspace.freeze import ( + afreeze_server, + freeze, + freeze_server, +) from libtmux.experimental.workspace.ir import ( Command, Float, @@ -65,9 +70,12 @@ "WorkspaceBuilt", "WorkspaceCompileError", "abuild_workspace", + "afreeze_server", "analyze", "build_workspace", "compile_full", "compile_workspace", "confirm", + "freeze", + "freeze_server", ) diff --git a/src/libtmux/experimental/workspace/freeze.py b/src/libtmux/experimental/workspace/freeze.py new file mode 100644 index 000000000..84c424fb0 --- /dev/null +++ b/src/libtmux/experimental/workspace/freeze.py @@ -0,0 +1,249 @@ +"""Reverse-analyze a live server snapshot into the declarative IR -- the round-trip. + +:func:`~libtmux.experimental.workspace.analyzer.analyze` lowers a tmuxp-style +config *into* a :class:`~libtmux.experimental.workspace.ir.Workspace`; +:func:`freeze` is its inverse over **live** state. It walks an immutable +:class:`~libtmux.experimental.models.snapshots.ServerSnapshot` back into a +``Workspace`` that :meth:`~..ir.Workspace.build` / :meth:`~..ir.Workspace.compile` +can replay, so a running session can be captured as reusable, version-controllable +IR (tmuxp's ``freeze``). It is **lossy by design**: scrollback, live process +state, and a pane sitting at a bare shell are not reconstructed. + +The north star -- *fewest backend calls* -- holds: :func:`freeze_server` / +:func:`afreeze_server` rebuild the **entire** session/window/pane tree from a +**single** ``list-panes -a -F`` read; the mapping itself is pure. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.experimental.models.snapshots import ServerSnapshot +from libtmux.experimental.workspace.ir import Pane, Window, Workspace + +if t.TYPE_CHECKING: + from collections.abc import Collection, Iterable + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.models.snapshots import ( + PaneSnapshot, + SessionSnapshot, + WindowSnapshot, + ) + +#: Bare shells whose presence as a pane's *current command* means "no command": +#: freezing such a pane yields an empty pane, not a nested shell (tmuxp parity). +#: Override via the ``shells`` argument to keep or widen the set. +SHELLS: frozenset[str] = frozenset( + { + "sh", + "bash", + "zsh", + "fish", + "dash", + "ksh", + "tcsh", + "csh", + "ash", + "nu", + "xonsh", + "elvish", + "pwsh", + }, +) + +#: The tmux fields one ``list-panes -a -F`` read needs to rebuild the whole tree. +FREEZE_FIELDS: tuple[str, ...] = ( + "session_id", + "session_name", + "window_id", + "window_index", + "window_name", + "window_layout", + "window_active", + "pane_id", + "pane_index", + "pane_active", + "pane_current_command", + "pane_current_path", +) +_SEP = "\t" +#: The ``-F`` format string covering :data:`FREEZE_FIELDS` (one read, whole tree). +FREEZE_FORMAT: str = _SEP.join(f"#{{{field}}}" for field in FREEZE_FIELDS) + + +def _pick_session( + server: ServerSnapshot, + selector: str | None, +) -> SessionSnapshot: + """Choose the one session to freeze (by name/id, or the sole one).""" + sessions = server.sessions + if not sessions: + msg = "cannot freeze an empty server (no sessions)" + raise ValueError(msg) + if selector is None: + if len(sessions) == 1: + return sessions[0] + names = ", ".join(s.name or s.session_id for s in sessions) + msg = ( + f"ambiguous freeze: {len(sessions)} sessions ({names}); " + f"pass session= to choose one" + ) + raise ValueError(msg) + for session in sessions: + if selector in (session.name, session.session_id): + return session + names = ", ".join(s.name or s.session_id for s in sessions) + msg = f"no session matching {selector!r} (have: {names})" + raise ValueError(msg) + + +def _freeze_pane(pane: PaneSnapshot, shells: Collection[str]) -> Pane: + """Map one pane snapshot to a declarative :class:`~..ir.Pane`. + + A pane sitting at a bare shell (its ``current_command`` is in *shells*) + freezes to an empty pane -- replaying it as a command would nest a shell. + """ + command = pane.current_command + run = None if command is None or command in shells else command + return Pane(run=run, focus=pane.active, start_directory=pane.current_path) + + +def _freeze_window(window: WindowSnapshot, shells: Collection[str]) -> Window: + """Map one window snapshot and its panes to a declarative :class:`~..ir.Window`.""" + return Window( + name=window.name, + layout=window.layout, + focus=window.active, + panes=[_freeze_pane(pane, shells) for pane in window.panes], + ) + + +def freeze( + snapshot: ServerSnapshot, + *, + session: str | None = None, + shells: Collection[str] = SHELLS, +) -> Workspace: + """Reverse-analyze a live :class:`ServerSnapshot` into a declarative Workspace. + + The inverse of :func:`~..analyzer.analyze`: capture what is *running* as + reusable IR. Pure -- no tmux. Lossy by design (no scrollback / process state; + a bare-shell pane becomes an empty pane). + + Parameters + ---------- + snapshot : ServerSnapshot + The live server tree (e.g. from :meth:`ServerSnapshot.from_pane_rows`). + session : str or None + Which session to freeze, by ``session_name`` or ``session_id``. ``None`` + freezes the sole session and raises when the server holds several. + shells : Collection[str] + Commands treated as "a bare shell" -> an empty pane (default + :data:`SHELLS`). + + Returns + ------- + Workspace + A declarative spec that ``build``/``compile`` replays. + + Raises + ------ + ValueError + When the server is empty, *session* is ambiguous, or the named session + is absent. + + Examples + -------- + >>> from libtmux.experimental.models.snapshots import ServerSnapshot + >>> server = ServerSnapshot.from_pane_rows([ + ... {"session_id": "$0", "session_name": "dev", "window_id": "@1", + ... "window_index": "0", "window_name": "editor", "pane_id": "%1", + ... "pane_index": "0", "pane_active": "1", "pane_current_command": "vim"}, + ... {"session_id": "$0", "session_name": "dev", "window_id": "@1", + ... "window_index": "0", "window_name": "editor", "pane_id": "%2", + ... "pane_index": "1", "pane_current_command": "zsh"}, + ... ]) + >>> ws = freeze(server) + >>> ws.name + 'dev' + >>> [c.cmd for c in ws.windows[0].panes[0].commands] + ['vim'] + >>> ws.windows[0].panes[1].run is None # a bare shell -> empty pane + True + """ + chosen = _pick_session(snapshot, session) + return Workspace( + name=chosen.name or chosen.session_id, + windows=[_freeze_window(window, shells) for window in chosen.windows], + ) + + +def _rows(stdout: Iterable[str]) -> list[dict[str, str]]: + """Parse ``list-panes -F`` tab-separated lines into per-pane field dicts.""" + rows: list[dict[str, str]] = [] + for line in stdout: + if not line: + continue + parts = line.split(_SEP) + # zip(strict=False) tolerates a short row (a trailing empty field tmux drops) + rows.append(dict(zip(FREEZE_FIELDS, parts, strict=False))) + return rows + + +def freeze_server( + engine: TmuxEngine, + *, + session: str | None = None, + shells: Collection[str] = SHELLS, +) -> Workspace: + r"""Freeze a live server into IR with a **single** ``list-panes`` read. + + Reads the whole session/window/pane tree in one ``list-panes -a -F`` dispatch, + builds a :class:`ServerSnapshot`, and reverse-analyzes it via :func:`freeze`. + + Examples + -------- + >>> from libtmux.experimental.engines.base import CommandResult + >>> class _Engine: # one read returns the whole tree + ... def run(self, request): + ... row = "$0\tdev\t@1\t0\teditor\t\t1\t%1\t0\t1\tvim\t/work" + ... return CommandResult(cmd=("tmux",), stdout=(row,)) + >>> freeze_server(_Engine()).name + 'dev' + """ + from libtmux.experimental.engines.base import CommandRequest + + result = engine.run( + CommandRequest.from_args("list-panes", "-a", "-F", FREEZE_FORMAT), + ) + server = ServerSnapshot.from_pane_rows(_rows(result.stdout)) + return freeze(server, session=session, shells=shells) + + +async def afreeze_server( + engine: AsyncTmuxEngine, + *, + session: str | None = None, + shells: Collection[str] = SHELLS, +) -> Workspace: + r"""Async twin of :func:`freeze_server` (one awaited ``list-panes`` read). + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.base import CommandResult + >>> class _AEngine: + ... async def run(self, request): + ... row = "$0\tdev\t@1\t0\tmain\t\t1\t%1\t0\t1\tvim\t/w" + ... return CommandResult(cmd=("tmux",), stdout=(row,)) + >>> asyncio.run(afreeze_server(_AEngine())).name + 'dev' + """ + from libtmux.experimental.engines.base import CommandRequest + + result = await engine.run( + CommandRequest.from_args("list-panes", "-a", "-F", FREEZE_FORMAT), + ) + server = ServerSnapshot.from_pane_rows(_rows(result.stdout)) + return freeze(server, session=session, shells=shells) diff --git a/tests/experimental/test_freeze.py b/tests/experimental/test_freeze.py new file mode 100644 index 000000000..54651c71a --- /dev/null +++ b/tests/experimental/test_freeze.py @@ -0,0 +1,213 @@ +"""Tests for ``freeze`` -- a live server snapshot reverse-analyzed into IR. + +The pure core (:func:`freeze`) maps an immutable +:class:`~libtmux.experimental.models.snapshots.ServerSnapshot` into a declarative +:class:`~libtmux.experimental.workspace.ir.Workspace`, closing the round-trip +``analyze`` opens. These units feed synthetic snapshots -- no tmux. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +import pytest + +from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.models.snapshots import ServerSnapshot +from libtmux.experimental.workspace.freeze import SHELLS, afreeze_server, freeze + +if t.TYPE_CHECKING: + from libtmux.experimental.workspace.ir import Workspace + from libtmux.session import Session + + +def _server(*rows: dict[str, str]) -> ServerSnapshot: + """Build a ServerSnapshot from flat per-pane rows (one list-panes read).""" + return ServerSnapshot.from_pane_rows(rows) + + +def test_freeze_maps_session_window_pane() -> None: + """A single session's tree becomes a Workspace of Windows of Panes.""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "editor", + "window_layout": "main-vertical", + "window_active": "1", + "pane_id": "%1", + "pane_index": "0", + "pane_active": "1", + "pane_current_command": "vim", + "pane_current_path": "/home/d/work", + }, + ) + ws = freeze(server) + assert ws.name == "dev" + assert [w.name for w in ws.windows] == ["editor"] + win = ws.windows[0] + assert win.layout == "main-vertical" + assert win.focus is True # the active window + pane = win.panes[0] + assert [c.cmd for c in pane.commands] == ["vim"] + assert pane.start_directory == "/home/d/work" + assert pane.focus is True # the active pane + + +def test_freeze_drops_shell_command() -> None: + """A pane sitting at a bare shell freezes to an empty pane (no nested shell).""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "main", + "pane_id": "%1", + "pane_index": "0", + "pane_current_command": "zsh", + }, + ) + pane = freeze(server).windows[0].panes[0] + assert pane.run is None + assert "zsh" in SHELLS # documents the default filter + + +def test_freeze_keeps_non_shell_command() -> None: + """A pane running a real program freezes that program as the pane command.""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "logs", + "pane_id": "%1", + "pane_index": "0", + "pane_current_command": "tail", + }, + ) + assert [c.cmd for c in freeze(server).windows[0].panes[0].commands] == ["tail"] + + +def test_freeze_selects_session_by_name() -> None: + """With many sessions, ``session=`` picks one to freeze.""" + server = _server( + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "window_name": "w", + "pane_id": "%1", + "pane_index": "0", + }, + { + "session_id": "$1", + "session_name": "b", + "window_id": "@2", + "window_index": "0", + "window_name": "w", + "pane_id": "%2", + "pane_index": "0", + }, + ) + assert freeze(server, session="b").name == "b" + assert freeze(server, session="$0").name == "a" + + +def test_freeze_ambiguous_session_raises() -> None: + """With many sessions and no selector, freeze refuses to guess.""" + server = _server( + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + "pane_index": "0", + }, + { + "session_id": "$1", + "session_name": "b", + "window_id": "@2", + "window_index": "0", + "pane_id": "%2", + "pane_index": "0", + }, + ) + with pytest.raises(ValueError, match=r"ambiguous|multiple|session="): + freeze(server) + + +def test_freeze_unknown_session_raises() -> None: + """A named session that is not present is an error, not an empty workspace.""" + server = _server( + { + "session_id": "$0", + "session_name": "a", + "window_id": "@1", + "window_index": "0", + "pane_id": "%1", + "pane_index": "0", + }, + ) + with pytest.raises(ValueError, match="nope"): + freeze(server, session="nope") + + +def test_freeze_round_trips_into_a_buildable_workspace() -> None: + """``freeze`` output compiles and builds -- the declarative round-trip closes.""" + server = _server( + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "editor", + "pane_id": "%1", + "pane_index": "0", + "pane_active": "1", + "pane_current_command": "vim", + }, + { + "session_id": "$0", + "session_name": "dev", + "window_id": "@1", + "window_index": "0", + "window_name": "editor", + "pane_id": "%2", + "pane_index": "1", + "pane_current_command": "tail", + }, + ) + ws = freeze(server) + assert ws.compile().operations[0].kind == "new_session" + assert ws.build(ConcreteEngine(), preflight=False).ok + + +def test_afreeze_server_captures_live_tree(session: Session) -> None: + """A real server freezes in ONE list-panes read, reproducing its windows. + + Validates ``FREEZE_FORMAT`` against live tmux: the frozen Workspace must carry + the live session's name and every window name, and remain buildable. + """ + session.new_window(window_name="logs") + live_names = {w.window_name for w in session.windows} + + async def main() -> Workspace: + engine = AsyncControlModeEngine.for_server(session.server) + try: + return await afreeze_server(engine, session=session.name) + finally: + await engine.aclose() + + ws = asyncio.run(main()) + assert ws.name == session.name + assert {w.name for w in ws.windows} == live_names + # The captured tree is a valid, buildable spec. + assert ws.compile().operations[0].kind == "new_session" From 12bf2a1ee957672fdef0aa7576b333c13829c295 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 12:59:42 -0500 Subject: [PATCH 119/154] Workspace(feat): Add variant expand + workspace sets why: building several near-identical sessions (per-app, per-env) meant declaring each by hand and dispatching each separately. expand() fans one workspace into named variants; WorkspaceSet folds N workspaces into one rebased plan so the whole batch builds in a single folded run. what: - Add workspace/expand.py: expand(workspace, variants) renders $name / ${name} tokens, leaving unknown tokens intact - Add workspace/sets.py: WorkspaceSet + compile_workspaces/build_workspaces /abuild_workspaces, rebasing each workspace's SlotRefs and host steps by a per-workspace offset into one LazyPlan - Export expand, WorkspaceSet(+Result/Compiled), build/abuild/compile_workspaces --- .../experimental/workspace/__init__.py | 16 + src/libtmux/experimental/workspace/expand.py | 87 ++++ src/libtmux/experimental/workspace/sets.py | 441 ++++++++++++++++++ .../contract/test_workspace_expand.py | 66 +++ .../contract/test_workspace_sets.py | 167 +++++++ 5 files changed, 777 insertions(+) create mode 100644 src/libtmux/experimental/workspace/expand.py create mode 100644 src/libtmux/experimental/workspace/sets.py create mode 100644 tests/experimental/contract/test_workspace_expand.py create mode 100644 tests/experimental/contract/test_workspace_sets.py diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 7eb3e9049..1e7126c39 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -38,6 +38,7 @@ WindowCreated, WorkspaceBuilt, ) +from libtmux.experimental.workspace.expand import expand from libtmux.experimental.workspace.freeze import ( afreeze_server, freeze, @@ -52,11 +53,20 @@ Workspace, ) from libtmux.experimental.workspace.runner import abuild_workspace, build_workspace +from libtmux.experimental.workspace.sets import ( + CompiledWorkspaceSet, + WorkspaceSet, + WorkspaceSetResult, + abuild_workspaces, + build_workspaces, + compile_workspaces, +) __all__ = ( "BuildEvent", "Command", "Compiled", + "CompiledWorkspaceSet", "ConfirmReport", "Float", "FloatingPane", @@ -69,13 +79,19 @@ "Workspace", "WorkspaceBuilt", "WorkspaceCompileError", + "WorkspaceSet", + "WorkspaceSetResult", "abuild_workspace", + "abuild_workspaces", "afreeze_server", "analyze", "build_workspace", + "build_workspaces", "compile_full", "compile_workspace", + "compile_workspaces", "confirm", + "expand", "freeze", "freeze_server", ) diff --git a/src/libtmux/experimental/workspace/expand.py b/src/libtmux/experimental/workspace/expand.py new file mode 100644 index 000000000..fe9af1e48 --- /dev/null +++ b/src/libtmux/experimental/workspace/expand.py @@ -0,0 +1,87 @@ +"""Pure variant expansion for declarative workspace specs.""" + +from __future__ import annotations + +import collections.abc +import dataclasses +import re +import typing as t +from collections.abc import Callable, Iterable, Mapping + +from libtmux.experimental.workspace.ir import Workspace + +Variant: t.TypeAlias = Mapping[str, object] +NameFactory: t.TypeAlias = Callable[[str, Mapping[str, object]], str] + +_TOKEN_RE = re.compile( + r"\$(?P\$)|\$\{(?P[A-Za-z_][A-Za-z0-9_]*)\}" + r"|\$(?P[A-Za-z_][A-Za-z0-9_]*)", +) + + +def expand( + workspace: Workspace, + variants: Iterable[Mapping[str, object]], + *, + variables: Mapping[str, object] | None = None, + name: NameFactory | None = None, +) -> tuple[Workspace, ...]: + """Return one rendered workspace per variant, without mutating *workspace*. + + String fields use shell-style ``$name`` / ``${name}`` placeholders. Unknown + variables stay intact, so shell variables and tmux formats survive expansion. + + Examples + -------- + >>> from libtmux.experimental.workspace import Pane, Window, Workspace, expand + >>> base = Workspace("svc-$app", windows=[Window("$app", panes=[Pane("$cmd")])]) + >>> [ws.name for ws in expand(base, [{"app": "api", "cmd": "uvicorn"}])] + ['svc-api'] + """ + expanded: list[Workspace] = [] + for variant in variants: + context: dict[str, object] = dict(variables or {}) + context.update(variant) + rendered = t.cast("Workspace", _render(workspace, context)) + if name is not None: + rendered = dataclasses.replace( + rendered, + name=name(workspace.name, context), + ) + expanded.append(rendered) + return tuple(expanded) + + +def _render(value: t.Any, context: collections.abc.Mapping[str, object]) -> t.Any: + """Recursively render strings inside dataclasses, mappings, and sequences.""" + if isinstance(value, str): + return _render_string(value, context) + if dataclasses.is_dataclass(value) and not isinstance(value, type): + changes = { + field.name: _render(getattr(value, field.name), context) + for field in dataclasses.fields(value) + } + return dataclasses.replace(value, **changes) + if isinstance(value, collections.abc.Mapping): + return { + _render(key, context): _render(item, context) for key, item in value.items() + } + if isinstance(value, tuple): + return tuple(_render(item, context) for item in value) + if isinstance(value, list): + return [_render(item, context) for item in value] + return value + + +def _render_string(value: str, context: collections.abc.Mapping[str, object]) -> str: + """Render known ``$name`` tokens and leave unknown shell text intact.""" + + def repl(match: re.Match[str]) -> str: + if match.group("escaped") is not None: + return "$" + key = match.group("braced") or match.group("named") + if key is None or key not in context: + return match.group(0) + return str(context[key]) + + return _TOKEN_RE.sub(repl, value) diff --git a/src/libtmux/experimental/workspace/sets.py b/src/libtmux/experimental/workspace/sets.py new file mode 100644 index 000000000..157ab2971 --- /dev/null +++ b/src/libtmux/experimental/workspace/sets.py @@ -0,0 +1,441 @@ +"""Batch declarative workspaces into one folded Core plan. + +``WorkspaceSet`` is the Declarative tier's collection primitive: a group of +workspace specs that compile into one :class:`~libtmux.experimental.ops.LazyPlan` +and therefore run through the same chainable, async-capable engine path as a +single workspace. It is deliberately still a library value -- no database, server +process, or product workflow -- so callers can layer worktrees, dashboards, or +agent launch policy outside libtmux. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux.experimental.ops import HasSession, KillSession, LazyPlan, arun, run +from libtmux.experimental.ops._types import NameRef, SlotRef, Target +from libtmux.experimental.ops.plan import PlanResult, StepReport +from libtmux.experimental.ops.planner import BoundedPlanner, MarkedPlanner +from libtmux.experimental.workspace.compiler import Compiled, HostStep, compile_full +from libtmux.experimental.workspace.events import WorkspaceBuilt, events_for +from libtmux.experimental.workspace.expand import ( + NameFactory, + Variant, + expand, +) +from libtmux.experimental.workspace.runner import _run_host_async, _run_host_sync + +if t.TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Iterable, Mapping + + from typing_extensions import Self + + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops.operation import Operation + from libtmux.experimental.ops.planner import Planner + from libtmux.experimental.workspace.events import BuildEvent + from libtmux.experimental.workspace.ir import Workspace + + +@dataclass(frozen=True) +class CompiledWorkspaceSet: + """A merged workspace-set plan plus batch metadata. + + Parameters + ---------- + plan : LazyPlan + The combined Core operation spine. + host_after : Mapping[int, tuple[HostStep, ...]] + Host steps scheduled after rebased operation indices. + pre : tuple[HostStep, ...] + Host steps to run before the first operation. + sessions : tuple[str, ...] + Session names in the input order. + session_slots : Mapping[str, int] + The plan index of each workspace's ``new-session`` operation. + end_indices : Mapping[str, int] + The final operation index for each workspace. + """ + + plan: LazyPlan + host_after: Mapping[int, tuple[HostStep, ...]] = field(default_factory=dict) + pre: tuple[HostStep, ...] = () + sessions: tuple[str, ...] = () + session_slots: Mapping[str, int] = field(default_factory=dict) + end_indices: Mapping[str, int] = field(default_factory=dict) + + +@dataclass(frozen=True) +class WorkspaceSetResult: + """Result of building a workspace set.""" + + result: PlanResult + sessions: tuple[str, ...] + reused: tuple[str, ...] = () + + @property + def ok(self) -> bool: + """Whether every dispatched operation completed successfully.""" + return self.result.ok + + @property + def bindings(self) -> dict[int | tuple[int, str], str]: + """Forward-ref bindings from the underlying plan result.""" + return self.result.bindings + + def raise_for_status(self) -> Self: + """Raise on the first failed operation; return ``self`` when OK.""" + self.result.raise_for_status() + return self + + +@dataclass(frozen=True) +class WorkspaceSet: + """A collection of declared workspaces compiled and built as one unit. + + Examples + -------- + >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.workspace import Pane, Window, Workspace + >>> ws = Workspace("dev", windows=[Window("w", panes=[Pane("echo hi")])]) + >>> WorkspaceSet((ws,)).build(ConcreteEngine(), preflight=False).ok + True + """ + + workspaces: tuple[Workspace, ...] + + def __init__(self, workspaces: Iterable[Workspace]) -> None: + object.__setattr__(self, "workspaces", _workspace_tuple(workspaces)) + + @classmethod + def from_variants( + cls, + workspace: Workspace, + variants: Iterable[Variant], + *, + variables: Mapping[str, object] | None = None, + name: NameFactory | None = None, + ) -> WorkspaceSet: + """Expand *workspace* over *variants* and wrap the rendered specs.""" + return cls(expand(workspace, variants, variables=variables, name=name)) + + def compile(self, *, version: str | None = None) -> CompiledWorkspaceSet: + """Compile this set into one rebased Core plan.""" + return compile_workspaces(self.workspaces, version=version) + + def build( + self, + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, + ) -> WorkspaceSetResult: + """Build this set synchronously over *engine*.""" + return build_workspaces( + self.workspaces, + engine, + version=version, + preflight=preflight, + on_event=on_event, + planner=planner, + ) + + async def abuild( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, + ) -> WorkspaceSetResult: + """Build this set asynchronously over *engine*.""" + return await abuild_workspaces( + self.workspaces, + engine, + version=version, + preflight=preflight, + on_event=on_event, + planner=planner, + ) + + +def _workspace_tuple(workspaces: Iterable[Workspace]) -> tuple[Workspace, ...]: + """Return workspaces as a tuple, rejecting duplicate session names.""" + rows = tuple(workspaces) + seen: set[str] = set() + duplicates: list[str] = [] + for workspace in rows: + if workspace.name in seen: + duplicates.append(workspace.name) + seen.add(workspace.name) + if duplicates: + msg = f"workspace set declares duplicate sessions: {', '.join(duplicates)}" + raise ValueError(msg) + return rows + + +def _rebase_slot(ref: SlotRef, offset: int) -> SlotRef: + """Return *ref* shifted by *offset* operation slots.""" + return dataclasses.replace(ref, slot=ref.slot + offset) + + +def _rebase_target(target: Target | None, offset: int) -> Target | None: + """Shift deferred targets by *offset* while leaving concrete ids unchanged.""" + if isinstance(target, SlotRef): + return _rebase_slot(target, offset) + return target + + +def _rebase_operation(operation: Operation[t.Any], offset: int) -> Operation[t.Any]: + """Shift operation targets from a per-workspace plan into the merged plan.""" + return dataclasses.replace( + operation, + target=_rebase_target(operation.target, offset), + src_target=_rebase_target(operation.src_target, offset), + ) + + +def _rebase_host_step(step: HostStep, offset: int) -> HostStep: + """Shift the pane ref carried by a host step, if any.""" + if step.pane is None: + return step + return dataclasses.replace(step, pane=_rebase_slot(step.pane, offset)) + + +def _extend_plan(plan: LazyPlan, compiled: Compiled, offset: int) -> None: + """Append one compiled workspace to *plan* with rebased refs.""" + for operation in compiled.plan.operations: + plan.add(_rebase_operation(operation, offset)) + + +def compile_workspaces( + workspaces: Iterable[Workspace], + *, + version: str | None = None, +) -> CompiledWorkspaceSet: + """Compile multiple workspaces into one rebased Core plan. + + Examples + -------- + >>> from libtmux.experimental.workspace import Pane, Window, Workspace + >>> compiled = compile_workspaces([ + ... Workspace("a", windows=[Window("w", panes=[Pane("one")])]), + ... Workspace("b", windows=[Window("w", panes=[Pane("two")])]), + ... ]) + >>> [op.kind for op in compiled.plan.operations].count("new_session") + 2 + """ + rows = _workspace_tuple(workspaces) + plan = LazyPlan() + pre: list[HostStep] = [] + host_after: dict[int, list[HostStep]] = {} + session_slots: dict[str, int] = {} + end_indices: dict[str, int] = {} + + for workspace in rows: + offset = len(plan) + compiled = compile_full(workspace, version=version) + if offset == 0: + pre.extend(_rebase_host_step(step, offset) for step in compiled.pre) + elif compiled.pre: + host_after.setdefault(offset - 1, []).extend( + _rebase_host_step(step, offset) for step in compiled.pre + ) + + for index, steps in compiled.host_after.items(): + host_after.setdefault(index + offset, []).extend( + _rebase_host_step(step, offset) for step in steps + ) + + _extend_plan(plan, compiled, offset) + if len(compiled.plan) > 0: + session_slots[workspace.name] = offset + end_indices[workspace.name] = offset + len(compiled.plan) - 1 + + return CompiledWorkspaceSet( + plan, + {key: tuple(value) for key, value in host_after.items()}, + tuple(pre), + tuple(workspace.name for workspace in rows), + session_slots, + end_indices, + ) + + +def _preflight_sync( + workspace: Workspace, + engine: TmuxEngine, + version: str | None, +) -> bool: + """Apply one workspace's ``on_exists`` policy before a batch build.""" + exists = run(HasSession(target=NameRef(workspace.name)), engine, version=version) + if not exists.exists: + return False + if workspace.on_exists == "replace": + run(KillSession(target=NameRef(workspace.name)), engine, version=version) + return False + if workspace.on_exists == "reuse": + return True + msg = f"session {workspace.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +async def _preflight_async( + workspace: Workspace, + engine: AsyncTmuxEngine, + version: str | None, +) -> bool: + """Async sibling of :func:`_preflight_sync`.""" + result = await arun( + HasSession(target=NameRef(workspace.name)), + engine, + version=version, + ) + if not result.exists: + return False + if workspace.on_exists == "replace": + await arun(KillSession(target=NameRef(workspace.name)), engine, version=version) + return False + if workspace.on_exists == "reuse": + return True + msg = f"session {workspace.name!r} already exists (on_exists='error')" + raise FileExistsError(msg) + + +def _split_reused_sync( + workspaces: tuple[Workspace, ...], + engine: TmuxEngine, + version: str | None, + preflight: bool, +) -> tuple[tuple[Workspace, ...], tuple[str, ...]]: + """Return workspaces to build plus names skipped by ``on_exists='reuse'``.""" + if not preflight: + return workspaces, () + active: list[Workspace] = [] + reused: list[str] = [] + for workspace in workspaces: + if _preflight_sync(workspace, engine, version): + reused.append(workspace.name) + else: + active.append(workspace) + return tuple(active), tuple(reused) + + +async def _split_reused_async( + workspaces: tuple[Workspace, ...], + engine: AsyncTmuxEngine, + version: str | None, + preflight: bool, +) -> tuple[tuple[Workspace, ...], tuple[str, ...]]: + """Async sibling of :func:`_split_reused_sync`.""" + if not preflight: + return workspaces, () + active: list[Workspace] = [] + reused: list[str] = [] + for workspace in workspaces: + if await _preflight_async(workspace, engine, version): + reused.append(workspace.name) + else: + active.append(workspace) + return tuple(active), tuple(reused) + + +def build_workspaces( + workspaces: Iterable[Workspace], + engine: TmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], None] | None = None, + planner: Planner | None = None, +) -> WorkspaceSetResult: + """Compile and execute multiple workspaces synchronously over *engine*.""" + rows = _workspace_tuple(workspaces) + active, reused = _split_reused_sync(rows, engine, version, preflight) + if not active: + return WorkspaceSetResult( + PlanResult((), {}), + tuple(ws.name for ws in rows), + reused, + ) + + compiled = compile_workspaces(active, version=version) + ops = compiled.plan.operations + end_to_session = {index: name for name, index in compiled.end_indices.items()} + for step in compiled.pre: + _run_host_sync(step, engine, {}, version) + + def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + on_event(event) + for host_step in compiled.host_after.get(index, ()): + _run_host_sync(host_step, engine, report.bindings, version) + if on_event is not None and index in end_to_session: + slot = compiled.session_slots[end_to_session[index]] + on_event(WorkspaceBuilt(report.bindings.get(slot, ""))) + + result = compiled.plan.execute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) + return WorkspaceSetResult(result, tuple(ws.name for ws in rows), reused) + + +async def abuild_workspaces( + workspaces: Iterable[Workspace], + engine: AsyncTmuxEngine, + *, + version: str | None = None, + preflight: bool = True, + on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, + planner: Planner | None = None, +) -> WorkspaceSetResult: + """Compile and execute multiple workspaces asynchronously over *engine*.""" + rows = _workspace_tuple(workspaces) + active, reused = await _split_reused_async(rows, engine, version, preflight) + if not active: + return WorkspaceSetResult( + PlanResult((), {}), + tuple(ws.name for ws in rows), + reused, + ) + + compiled = compile_workspaces(active, version=version) + ops = compiled.plan.operations + end_to_session = {index: name for name, index in compiled.end_indices.items()} + for step in compiled.pre: + await _run_host_async(step, engine, {}, version) + + async def on_step(report: StepReport) -> None: + for index, result in zip(report.step.indices, report.results, strict=True): + if on_event is not None: + for event in events_for(ops[index], result): + await on_event(event) + for host_step in compiled.host_after.get(index, ()): + await _run_host_async(host_step, engine, report.bindings, version) + if on_event is not None and index in end_to_session: + slot = compiled.session_slots[end_to_session[index]] + await on_event(WorkspaceBuilt(report.bindings.get(slot, ""))) + + result = await compiled.plan.aexecute( + engine, + version=version, + planner=BoundedPlanner( + planner or MarkedPlanner(), + frozenset(compiled.host_after), + ), + on_step=on_step, + ) + return WorkspaceSetResult(result, tuple(ws.name for ws in rows), reused) diff --git a/tests/experimental/contract/test_workspace_expand.py b/tests/experimental/contract/test_workspace_expand.py new file mode 100644 index 000000000..fe7a80629 --- /dev/null +++ b/tests/experimental/contract/test_workspace_expand.py @@ -0,0 +1,66 @@ +"""Tests for pure workspace variant expansion.""" + +from __future__ import annotations + +from libtmux.experimental.workspace import Pane, Window, Workspace, expand + + +def test_expand_renders_variants_without_mutating_base() -> None: + """Expand returns one rendered workspace per variant and leaves the base pure.""" + base = Workspace( + name="svc-$app", + start_directory="${root}/$app", + environment={"APP": "$app", "UNCHANGED": "$HOME"}, + windows=[ + Window( + name="$app", + panes=[ + Pane( + run=["cd ${root}/$app", "$cmd", "echo $(pwd) #{pane_id}"], + environment={"APP": "$app"}, + ), + ], + ), + ], + ) + + expanded = expand( + base, + [ + {"app": "api", "cmd": "uvicorn app:app"}, + {"app": "worker", "cmd": "python worker.py"}, + ], + variables={"root": "/srv"}, + ) + + assert [ws.name for ws in expanded] == ["svc-api", "svc-worker"] + assert expanded[0].start_directory == "/srv/api" + assert expanded[1].windows[0].name == "worker" + assert [cmd.cmd for cmd in expanded[0].windows[0].panes[0].commands] == [ + "cd /srv/api", + "uvicorn app:app", + "echo $(pwd) #{pane_id}", + ] + assert expanded[0].environment == {"APP": "api", "UNCHANGED": "$HOME"} + assert expanded[0].windows[0].panes[0].environment == {"APP": "api"} + assert base.name == "svc-$app" + assert base.windows[0].panes[0].commands[0].cmd == "cd ${root}/$app" + + +def test_expand_name_callable_controls_workspace_name() -> None: + """A name callable can build names outside the template strings.""" + base = Workspace(name="dev", windows=[Window("py-$python", panes=[Pane("tox")])]) + + expanded = expand( + base, + [{"python": "3.12"}, {"python": "3.13"}], + name=lambda base_name, variant: f"{base_name}-py{variant['python']}", + ) + + assert [ws.name for ws in expanded] == ["dev-py3.12", "dev-py3.13"] + assert [ws.windows[0].name for ws in expanded] == ["py-3.12", "py-3.13"] + + +def test_expand_empty_variants_returns_empty_tuple() -> None: + """No variants means no expanded workspaces.""" + assert expand(Workspace(name="dev"), []) == () diff --git a/tests/experimental/contract/test_workspace_sets.py b/tests/experimental/contract/test_workspace_sets.py new file mode 100644 index 000000000..4a2f4bd37 --- /dev/null +++ b/tests/experimental/contract/test_workspace_sets.py @@ -0,0 +1,167 @@ +"""Workspace sets batch declarative builds without losing plan semantics.""" + +from __future__ import annotations + +import asyncio +import dataclasses +import typing as t + +from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines.base import CommandResult +from libtmux.experimental.ops import SequentialPlanner +from libtmux.experimental.ops._types import SlotRef +from libtmux.experimental.workspace import ( + BuildEvent, + Pane, + Window, + Workspace, + WorkspaceBuilt, + WorkspaceSet, + build_workspaces, + compile_workspaces, +) + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest, TmuxEngine + + +@dataclasses.dataclass +class _RecordingEngine: + """Record dispatches while forwarding to an inner engine.""" + + inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) + + def run(self, request: CommandRequest) -> CommandResult: + """Record the argv and forward, faking a ready cursor for waits.""" + self.calls.append(request.args) + if "display-message" in request.args: + return CommandResult(cmd=("tmux", *request.args), stdout=("1,1",)) + return self.inner.run(request) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Execute each request in order.""" + return [self.run(req) for req in requests] + + +def _workspace(name: str, *, wait_pane: bool = False) -> Workspace: + """Return a two-pane workspace with a command after a split.""" + return Workspace( + name=name, + windows=[ + Window( + "editor", + panes=[ + Pane(run="echo first"), + Pane(run=["echo second", "echo third"]), + ], + ), + ], + wait_pane=wait_pane, + ) + + +def test_workspace_set_from_variants_expands_base() -> None: + """WorkspaceSet.from_variants delegates to expand and preserves ordering.""" + base = Workspace(name="dev-${app}", windows=[Window("w", panes=[Pane("${cmd}")])]) + workspace_set = WorkspaceSet.from_variants( + base, + [{"app": "api", "cmd": "pytest"}, {"app": "docs", "cmd": "sphinx-build"}], + ) + + assert [ws.name for ws in workspace_set.workspaces] == ["dev-api", "dev-docs"] + assert [ + ws.windows[0].panes[0].commands[0].cmd for ws in workspace_set.workspaces + ] == ["pytest", "sphinx-build"] + + +def test_compile_workspaces_rebases_slot_refs_and_host_steps() -> None: + """Merged plans offset later workspaces' SlotRefs and host-step targets.""" + compiled = compile_workspaces( + [ + _workspace("one"), + _workspace("two", wait_pane=True), + ], + ) + first_len = len(_workspace("one").compile().operations) + second_ops = compiled.plan.operations[first_len:] + send_ops = [op for op in second_ops if op.kind == "send_keys"] + assert send_ops + deferred_targets = [op.target for op in send_ops if isinstance(op.target, SlotRef)] + assert min(target.slot for target in deferred_targets) >= first_len + + wait_steps = [ + step + for steps in compiled.host_after.values() + for step in steps + if step.kind == "wait_pane" + ] + assert wait_steps + assert all( + step.pane is not None and step.pane.slot >= first_len for step in wait_steps + ) + + +def test_build_workspaces_folds_across_workspace_boundaries() -> None: + """Batch builds still use the folding planner over the merged operation stream.""" + default = _RecordingEngine() + build_workspaces([_workspace("one"), _workspace("two")], default, preflight=False) + sequential = _RecordingEngine() + build_workspaces( + [_workspace("one"), _workspace("two")], + sequential, + preflight=False, + planner=SequentialPlanner(), + ) + + assert len(default.calls) < len(sequential.calls) + assert any(";" in argv for argv in default.calls) + + +def test_workspace_set_all_reused_returns_noop_result() -> None: + """Preflight reuse skips every existing workspace without executing the plan.""" + reused = Workspace( + name="already", + windows=[Window("w", panes=[Pane("echo nope")])], + on_exists="reuse", + ) + engine = ConcreteEngine() + + first = build_workspaces([reused], engine, preflight=False) + second = build_workspaces([reused], engine) + + assert first.ok + assert second.ok + assert second.reused == ("already",) + assert second.result.results == () + + +def test_workspace_set_emits_built_event_per_workspace() -> None: + """Each workspace emits its own WorkspaceBuilt event.""" + events: list[BuildEvent] = [] + outcome = build_workspaces( + [_workspace("one"), _workspace("two")], + ConcreteEngine(), + preflight=False, + on_event=events.append, + ) + + built = [event for event in events if isinstance(event, WorkspaceBuilt)] + assert outcome.ok + assert len(built) == 2 + + +def test_workspace_set_async_build_matches_sync_shape() -> None: + """The async runner exposes the same result shape as the sync runner.""" + workspace_set = WorkspaceSet((_workspace("one"), _workspace("two"))) + outcome = asyncio.run( + workspace_set.abuild(AsyncConcreteEngine(), preflight=False), + ) + + assert outcome.ok + assert outcome.sessions == ("one", "two") + assert len(outcome.result.results) == len( + compile_workspaces(workspace_set.workspaces).plan.operations, + ) From 57a6c90984f9a58595833844094cb69654bcd402 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:07:52 -0500 Subject: [PATCH 120/154] Ops(feat): Add plan explain() and astream() why: folding collapses N ops into a few dispatches, which hides per-op structure -- a caller can't see why the chain broke where it did, and had no way to observe a plan step by step as it ran. what: - LazyPlan.explain(planner): annotate each dispatch step with why it is a boundary (marked-fold/folded/created-id/capture/single) -- pure, no I/O - LazyPlan.astream(engine): async-generator twin of aexecute over the same drive core, yielding a StepDone per bound step and a terminal PlanDone; pull-based, so backpressure needs no buffer and the loop never blocks - Export StepExplanation, StepDone, PlanDone, PlanEvent --- src/libtmux/experimental/ops/__init__.py | 14 ++- src/libtmux/experimental/ops/plan.py | 120 ++++++++++++++++++++++- tests/experimental/ops/test_plan.py | 72 ++++++++++++++ 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/ops/__init__.py b/src/libtmux/experimental/ops/__init__.py index 75d58453e..131020366 100644 --- a/src/libtmux/experimental/ops/__init__.py +++ b/src/libtmux/experimental/ops/__init__.py @@ -107,7 +107,15 @@ ) from libtmux.experimental.ops.execute import arun, run from libtmux.experimental.ops.operation import Operation -from libtmux.experimental.ops.plan import LazyPlan, PlanResult, StepReport +from libtmux.experimental.ops.plan import ( + LazyPlan, + PlanDone, + PlanEvent, + PlanResult, + StepDone, + StepExplanation, + StepReport, +) from libtmux.experimental.ops.planner import ( BoundedPlanner, FoldingPlanner, @@ -202,6 +210,8 @@ "PaneId", "PasteBuffer", "PipePane", + "PlanDone", + "PlanEvent", "PlanResult", "PlanStep", "Planner", @@ -241,6 +251,8 @@ "SplitWindowResult", "StartServer", "Status", + "StepDone", + "StepExplanation", "StepReport", "SuspendClient", "SwapPane", diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 8b3bb52f6..83f25ba11 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -38,7 +38,7 @@ from libtmux.experimental.ops.serialize import operation_from_dict, operation_to_dict if t.TYPE_CHECKING: - from collections.abc import Generator, Iterator + from collections.abc import AsyncGenerator, Generator, Iterator from typing_extensions import Self @@ -99,6 +99,40 @@ class _Host: report: StepReport +@dataclass(frozen=True) +class StepExplanation: + """Why one dispatch step is its own tmux call (from :meth:`LazyPlan.explain`). + + ``reason`` is one of ``"marked-fold"`` (a pane create plus its ``{marked}`` + decorates), ``"folded"`` (a ``;``-chain of chainable ops), ``"created-id"`` + (a create whose captured id a later op must target -- a true blocker), + ``"capture"`` (a non-chainable op whose stdout can't merge into a chain), or + ``"single"`` (a lone chainable op with nothing to fold with). + """ + + step: PlanStep + kinds: tuple[str, ...] + reason: str + + +@dataclass(frozen=True) +class StepDone: + """Stream event: a plan step finished and its results have bound.""" + + report: StepReport + + +@dataclass(frozen=True) +class PlanDone: + """Stream event: the plan finished; carries the full :class:`PlanResult`.""" + + result: PlanResult + + +#: An event yielded by :meth:`LazyPlan.astream`. +PlanEvent = StepDone | PlanDone + + def _target_from_id(value: str) -> Target: """Map a captured concrete id back to its typed target.""" if value.startswith("%"): @@ -275,6 +309,45 @@ def _render(op: Operation[t.Any]) -> tuple[str, ...] | None: return [_render(op) for op in self._operations] + def explain(self, planner: Planner | None = None) -> list[StepExplanation]: + """Explain why *planner* breaks the plan into the dispatches it does. + + A pure companion to :meth:`preview`: folding hides per-op structure, so + this annotates each dispatch step with the reason it can't fold further + (see :class:`StepExplanation`). Defaults to + :class:`~.planner.SequentialPlanner`. + + Examples + -------- + >>> from libtmux.experimental.ops import SplitWindow, SendKeys, MarkedPlanner + >>> from libtmux.experimental.ops._types import WindowId + >>> plan = LazyPlan() + >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) + >>> _ = plan.add(SendKeys(target=pane, keys="vim")) + >>> [(e.kinds, e.reason) for e in plan.explain(MarkedPlanner())] + [(('split_window', 'send_keys'), 'marked-fold')] + >>> [(e.kinds, e.reason) for e in plan.explain()] + [(('split_window',), 'created-id'), (('send_keys',), 'single')] + """ + steps = (planner or SequentialPlanner()).plan(self._operations) + out: list[StepExplanation] = [] + for step in steps: + kinds = tuple(self._operations[i].kind for i in step.indices) + if step.marked: + reason = "marked-fold" + elif len(step.indices) > 1: + reason = "folded" + else: + op = self._operations[step.indices[0]] + if op.effects.creates is not None: + reason = "created-id" + elif not op.chainable: + reason = "capture" + else: + reason = "single" + out.append(StepExplanation(step, kinds, reason)) + return out + def _drive( self, version: str | None, @@ -413,3 +486,48 @@ async def _adispatch( if isinstance(request, _Chain): return await engine.run(CommandRequest.from_args(*request.argv)) return await arun(request.op, engine, version=version) + + async def astream( + self, + engine: AsyncTmuxEngine, + *, + version: str | None = None, + planner: Planner | None = None, + ) -> AsyncGenerator[PlanEvent, None]: + """Execute the plan, streaming a :data:`PlanEvent` per step as it binds. + + The observe-as-you-go twin of :meth:`aexecute` over the same sans-I/O + resolution core: it yields a :class:`StepDone` after each dispatch binds + and a terminal :class:`PlanDone` carrying the full :class:`PlanResult`, so + ``[e async for e in plan.astream(engine)][-1].result`` equals ``await + plan.aexecute(engine)``. The stream is pull-based -- a slow ``async for`` + naturally paces the plan, so backpressure needs no buffer and the event + loop is never blocked between dispatches. Run one ``astream`` per engine + at a time (the engine's write order is shared). + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> plan = LazyPlan() + >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) + >>> async def drain() -> list[str]: + ... engine = AsyncConcreteEngine() + ... return [type(e).__name__ async for e in plan.astream(engine)] + >>> asyncio.run(drain()) + ['StepDone', 'PlanDone'] + """ + version = resolve_engine_version(engine, version) + gen = self._drive(version, planner or SequentialPlanner()) + try: + request = next(gen) + while True: + if isinstance(request, _Host): + yield StepDone(request.report) # pull point: consumer paces here + request = gen.send(None) + else: + request = gen.send(await self._adispatch(request, engine, version)) + except StopIteration as stop: + yield PlanDone(t.cast("PlanResult", stop.value)) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index cb5a8f24b..43ce28ebc 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -211,3 +211,75 @@ def test_plan_unresolvable_ref_fails_closed() -> None: assert exc_info.value.slot == 0 # points at the non-capturing creator # ForwardCaptureError stays an OperationError, so broad handlers keep working assert isinstance(exc_info.value, OperationError) + + +class _ExplainCase(t.NamedTuple): + """A planner and the (kinds, reason) each of its dispatch steps should carry.""" + + test_id: str + planner: t.Any + expected: list[tuple[tuple[str, ...], str]] + + +def _split_then_send() -> LazyPlan: + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim")) + return plan + + +_EXPLAIN_CASES: tuple[_ExplainCase, ...] = ( + _ExplainCase( + "sequential_created_then_single", + SequentialPlanner(), + [(("split_window",), "created-id"), (("send_keys",), "single")], + ), + _ExplainCase( + "marked_fold", + MarkedPlanner(), + [(("split_window", "send_keys"), "marked-fold")], + ), +) + + +@pytest.mark.parametrize( + "case", + _EXPLAIN_CASES, + ids=[c.test_id for c in _EXPLAIN_CASES], +) +def test_explain_annotates_dispatch_boundaries(case: _ExplainCase) -> None: + """explain() reports why each step is its own dispatch under a planner.""" + steps = _split_then_send().explain(case.planner) + assert [(e.kinds, e.reason) for e in steps] == case.expected + + +def test_astream_yields_step_then_plan_done() -> None: + """astream() streams a StepDone per step and a terminal PlanDone.""" + from libtmux.experimental.ops import PlanDone, StepDone + + plan = _split_then_send() + + async def drain() -> list[object]: + return [event async for event in plan.astream(AsyncConcreteEngine())] + + events = asyncio.run(drain()) + assert [type(e).__name__ for e in events] == ["StepDone", "StepDone", "PlanDone"] + assert isinstance(events[-1], PlanDone) + assert isinstance(events[0], StepDone) + # the terminal PlanDone carries the same result aexecute() would return + assert events[-1].result.ok + + +def test_astream_last_result_matches_aexecute() -> None: + """The terminal PlanDone.result equals what aexecute() returns.""" + from libtmux.experimental.ops import PlanDone + + async def both() -> tuple[bool, bool]: + streamed = [e async for e in _split_then_send().astream(AsyncConcreteEngine())] + direct = await _split_then_send().aexecute(AsyncConcreteEngine()) + last = streamed[-1] + assert isinstance(last, PlanDone) + return last.result.ok, direct.ok + + stream_ok, direct_ok = asyncio.run(both()) + assert stream_ok == direct_ok From b30c092d5246ae0e43a8a155b078dca3de214ba6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:27:05 -0500 Subject: [PATCH 121/154] Fluent(feat): Add sleep/wait host boundaries why: some builds need a real pause between dispatches -- a fixed delay, or waiting for a pane's shell prompt before sending the next command. A host step is a true blocker: it can't fold into a tmux chain, so the fold must break around it. what: - PlanBuilder.sleep(seconds): pause after the last recorded op - PlanBuilder.wait(pane): poll the pane's cursor until its prompt draws - run()/arun() bound the planner (BoundedPlanner) at each host boundary and run the recorded step from the on_step hook, sync and async --- src/libtmux/experimental/fluent.py | 118 +++++++++++++++++++++++++++-- tests/experimental/test_fluent.py | 48 ++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index 5641287aa..b0e6bf671 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -31,22 +31,47 @@ from __future__ import annotations +import asyncio +import time import typing as t from dataclasses import dataclass, field from libtmux.experimental.ops import ( + BoundedPlanner, + DisplayMessage, LazyPlan, MarkedPlanner, NameRef, NewSession, NewWindow, + arun, + run, ) -from libtmux.experimental.query import ForwardPaneRef +from libtmux.experimental.ops.plan import _resolve +from libtmux.experimental.query import ForwardPaneRef, _PaneRefBase if t.TYPE_CHECKING: from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine - from libtmux.experimental.ops import Planner, PlanResult - from libtmux.experimental.ops._types import SlotRef + from libtmux.experimental.ops import Planner, PlanResult, StepReport + from libtmux.experimental.ops._types import SlotRef, Target + +_CURSOR_FMT = "#{cursor_x},#{cursor_y}" +_WAIT_PANE_POLLS = 40 +_WAIT_PANE_INTERVAL = 0.05 + + +def _pane_ready(cursor: str) -> bool: + """Whether a pane's cursor has left the origin (its shell prompt drew).""" + return bool(cursor) and cursor != "0,0" + + +@dataclass(frozen=True) +class _HostAction: + """A host-side pause recorded after an operation (a hard fold boundary).""" + + kind: t.Literal["sleep", "wait"] + seconds: float = 0.0 + pane: Target | None = None @dataclass(frozen=True) @@ -116,6 +141,7 @@ class PlanBuilder: """A fluent recorder over a :class:`LazyPlan`; :meth:`run` folds by default.""" plan: LazyPlan = field(default_factory=LazyPlan) + _host_after: dict[int, list[_HostAction]] = field(default_factory=dict) def new_session(self, name: str) -> SessionRef: """Create a session, capturing its first pane for forward refs. @@ -132,6 +158,53 @@ def new_session(self, name: str) -> SessionRef: slot = self.plan.add(NewSession(session_name=name, capture_panes=True)) return SessionRef(self.plan, name, slot) + def sleep(self, seconds: float) -> PlanBuilder: + """Pause *seconds* after the last recorded op (a hard fold boundary). + + A host step never folds into a ``tmux`` dispatch, so the chain breaks + before and after it; the pause runs between dispatches at build time. + + Examples + -------- + >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> p = plan() + >>> pane = p.new_session("dev").window().pane() + >>> _ = pane.do(lambda c: c.send_keys("slow-start")) + >>> p.sleep(0.0).run(ConcreteEngine()).ok + True + """ + self._record_host(_HostAction("sleep", seconds=seconds)) + return self + + def wait(self, pane: _PaneRefBase) -> PlanBuilder: + """Wait for *pane*'s shell prompt before the next dispatch (anti-race). + + Polls the pane's cursor until it leaves the origin, so a follow-up + command isn't sent before the shell is ready. A hard fold boundary. + + Examples + -------- + >>> p = plan() + >>> pane = p.new_session("dev").window().pane() + >>> p.wait(pane) is p + True + """ + self._record_host(_HostAction("wait", pane=pane.target)) + return self + + def _record_host(self, action: _HostAction) -> None: + """Record *action* after the plan's current last operation.""" + index = len(self.plan.operations) - 1 + if index >= 0: + self._host_after.setdefault(index, []).append(action) + + def _planner(self, planner: Planner | None) -> Planner: + """Return the base planner, bounded by host-step boundaries if any.""" + base = planner or MarkedPlanner() + if self._host_after: + return BoundedPlanner(base, frozenset(self._host_after)) + return base + def run( self, engine: TmuxEngine, @@ -149,10 +222,26 @@ def run( >>> p.run(ConcreteEngine()).ok True """ + + def on_step(report: StepReport) -> None: + for action in self._host_after.get(report.step.indices[-1], ()): + if action.kind == "sleep": + time.sleep(action.seconds) + elif action.pane is not None: + op = _resolve( + DisplayMessage(target=action.pane, message=_CURSOR_FMT), + report.bindings, + ) + for _ in range(_WAIT_PANE_POLLS): + if _pane_ready(run(op, engine, version=version).text): + break + time.sleep(_WAIT_PANE_INTERVAL) + return self.plan.execute( engine, version=version, - planner=planner or MarkedPlanner(), + planner=self._planner(planner), + on_step=on_step, ) async def arun( @@ -162,11 +251,28 @@ async def arun( version: str | None = None, planner: Planner | None = None, ) -> PlanResult: - """Async twin of :meth:`run` (same fold, ``await``ed).""" + """Async twin of :meth:`run` (same fold and host steps, ``await``ed).""" + + async def on_step(report: StepReport) -> None: + for action in self._host_after.get(report.step.indices[-1], ()): + if action.kind == "sleep": + await asyncio.sleep(action.seconds) + elif action.pane is not None: + op = _resolve( + DisplayMessage(target=action.pane, message=_CURSOR_FMT), + report.bindings, + ) + for _ in range(_WAIT_PANE_POLLS): + result = await arun(op, engine, version=version) + if _pane_ready(result.text): + break + await asyncio.sleep(_WAIT_PANE_INTERVAL) + return await self.plan.aexecute( engine, version=version, - planner=planner or MarkedPlanner(), + planner=self._planner(planner), + on_step=on_step, ) def preview(self, *, version: str | None = None) -> list[tuple[str, ...] | None]: diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index 8e596b7a0..f126d328d 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -87,3 +87,51 @@ def test_build_session_live(session: Session) -> None: built = [s for s in session.server.sessions if s.session_name == "fluentdev"] assert len(built) == 1 assert len(built[0].windows[0].panes) == 2 + + +class _HostCase(t.NamedTuple): + """A host boundary recorded on the builder and its recorded action kind.""" + + test_id: str + record: t.Callable[[PlanBuilder, ForwardPaneRef], object] + kind: str + + +_HOST_CASES: tuple[_HostCase, ...] = ( + _HostCase("sleep", lambda p, _pane: p.sleep(0.0), "sleep"), + _HostCase("wait", lambda p, pane: p.wait(pane), "wait"), +) + + +@pytest.mark.parametrize("case", _HOST_CASES, ids=[c.test_id for c in _HOST_CASES]) +def test_host_step_recorded_after_last_op(case: _HostCase) -> None: + """sleep()/wait() record a host action keyed to the current last op.""" + p = plan() + pane = p.new_session("dev").window().pane() + case.record(p, pane) + assert list(p._host_after) == [0] # after new_session, the only op so far + assert len(p._host_after[0]) == 1 + assert p._host_after[0][0].kind == case.kind + + +def test_host_boundary_prevents_fold_across_it() -> None: + """No dispatch step may span a recorded host boundary (a true blocker).""" + p = plan() + pane = p.new_session("dev").window().pane() + pane.do(lambda c: c.send_keys("a")) # op 1 + p.sleep(0.0) # boundary after op 1 + pane.do(lambda c: c.send_keys("b")) # op 2 + + steps = p._planner(None).plan(p.plan.operations) + spanning = [s for s in steps if min(s.indices) <= 1 < max(s.indices)] + assert not spanning # nothing folds across the boundary at index 1 + + +def test_sleep_runs_offline() -> None: + """A build with a sleep boundary resolves and runs over the in-memory engine.""" + p = plan() + pane = p.new_session("dev").window().pane() + pane.do(lambda c: c.send_keys("vim")) + p.sleep(0.0) + pane.split().do(lambda c: c.send_keys("htop")) + assert p.run(ConcreteEngine()).ok From f4f7d9de6499cc9c08a5085d79872356bc7e7860 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:33:08 -0500 Subject: [PATCH 122/154] Mcp(feat): Add explain_plan tool why: an agent composing a plan over MCP could preview argv and query a result schema, but couldn't see why a planner folds or breaks the plan into the dispatches it does -- the fold hides that structure. what: - plan_tools.explain_plan(plan, planner): per-step boundary reasons (marked-fold/folded/created-id/capture/single), pure over a serialized plan - Register a readonly explain_plan tool in the fastmcp adapter - Export explain_plan from the mcp package --- src/libtmux/experimental/mcp/__init__.py | 2 ++ .../experimental/mcp/fastmcp_adapter.py | 12 +++++++++ src/libtmux/experimental/mcp/plan_tools.py | 27 +++++++++++++++++++ tests/experimental/mcp/test_mcp_projection.py | 14 ++++++++++ 4 files changed, 55 insertions(+) diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 03e33ab6f..074e1254a 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -35,6 +35,7 @@ aexecute_plan, build_workspace, execute_plan, + explain_plan, preview_plan, result_schema, ) @@ -286,6 +287,7 @@ def main(argv: Sequence[str] | None = None) -> None: "default_async_server", "default_server", "execute_plan", + "explain_plan", "kill_pane", "kill_session", "kill_window", diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 19589017a..0c2155604 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -459,6 +459,17 @@ def preview_plan( "argv": [list(item) if item is not None else None for item in preview.argv], } + def explain_plan( + operations: list[dict[str, t.Any]], + planner: str = "marked", + ) -> dict[str, t.Any]: + """Explain why *planner* folds or breaks a serialized plan (pure).""" + explanation = _plan.explain_plan( + _plan_from_dicts(operations), + planner=_planner(planner), + ) + return {"steps": explanation.steps} + def result_schema(kind: str) -> dict[str, t.Any]: """Report what an operation kind returns, for planning forward refs.""" schema = _plan.result_schema(reg, kind) @@ -471,6 +482,7 @@ def result_schema(kind: str) -> dict[str, t.Any]: tools: list[tuple[Callable[..., t.Any], str]] = [ (preview_plan, "readonly"), + (explain_plan, "readonly"), (result_schema, "readonly"), ] diff --git a/src/libtmux/experimental/mcp/plan_tools.py b/src/libtmux/experimental/mcp/plan_tools.py index 49da2edee..fb503af08 100644 --- a/src/libtmux/experimental/mcp/plan_tools.py +++ b/src/libtmux/experimental/mcp/plan_tools.py @@ -45,6 +45,33 @@ def preview_plan(plan: LazyPlan, *, version: str | None = None) -> PlanPreview: ) +@dataclass(frozen=True) +class PlanExplanation: + """Why a planner breaks a plan into its dispatch steps: one dict per step. + + Each entry carries ``indices`` (the operation indices in the step), + ``kinds`` (their operation kinds), and ``reason`` (the boundary reason -- + ``marked-fold`` / ``folded`` / ``created-id`` / ``capture`` / ``single``), so + an agent can see why a chain folds or breaks before it runs. + """ + + steps: list[dict[str, t.Any]] + + +def explain_plan(plan: LazyPlan, *, planner: Planner | None = None) -> PlanExplanation: + """Explain a plan's dispatch grouping under *planner* (pure, no engine).""" + return PlanExplanation( + steps=[ + { + "indices": list(entry.step.indices), + "kinds": list(entry.kinds), + "reason": entry.reason, + } + for entry in plan.explain(planner) + ], + ) + + @dataclass(frozen=True) class PlanOutcome: """The result of executing a plan: per-op result dicts + a bindings map.""" diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 4bb50d2e1..8adfab6a3 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -13,12 +13,14 @@ OperationToolRegistry, build_workspace, execute_plan, + explain_plan, preview_plan, resolve_target, result_schema, ) from libtmux.experimental.ops import ( LazyPlan, + MarkedPlanner, NewSession, SendKeys, SplitWindow, @@ -107,6 +109,18 @@ def test_preview_plan_marks_unresolved_forward_refs() -> None: assert preview.ok is False +def test_explain_plan_reports_boundary_reasons() -> None: + """explain_plan annotates each dispatch step with why it can't fold further.""" + plan = LazyPlan() + pane = plan.add(SplitWindow(target=WindowId("@1"))) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + steps = explain_plan(plan, planner=MarkedPlanner()).steps + assert len(steps) == 1 + assert steps[0]["indices"] == [0, 1] + assert steps[0]["kinds"] == ["split_window", "send_keys"] + assert steps[0]["reason"] == "marked-fold" + + def test_execute_plan_returns_bindings() -> None: """execute_plan resolves forward refs and returns a JSON bindings map.""" plan = LazyPlan() From c8509006697471d1320b646f201c38812a395446 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:50:39 -0500 Subject: [PATCH 123/154] Ops(feat): Add conditional find-or-create via ensure() why: rebuilding a workspace should reuse a session/window that already exists rather than error or duplicate it. Doing that in the pure, serializable plan model means the "does it exist?" branch has to live at execution time, not build time. what: - LazyPlan.ensure(index, probe): mark a create conditional -- the driver runs the probe (a read returning the object's capture format), and on success binds the slot to the found ids and skips the create - Reuse the create op's own build_result to parse the probe's ids, so a found object binds the same self/window/pane subrefs a created one would - Carry the probe through to_list/from_list so the conditional round-trips - Cover found-reuses / absent-creates and the serialization round-trip --- src/libtmux/experimental/ops/plan.py | 56 +++++++++++++++++-- tests/experimental/ops/test_plan.py | 80 +++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 83f25ba11..f7bd21b6d 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -245,6 +245,7 @@ class LazyPlan: def __init__(self) -> None: self._operations: list[Operation[t.Any]] = [] + self._ensures: dict[int, Operation[t.Any]] = {} def add(self, operation: Operation[t.Any]) -> SlotRef: """Record an operation; return a :class:`SlotRef` to its eventual id. @@ -255,6 +256,20 @@ def add(self, operation: Operation[t.Any]) -> SlotRef: self._operations.append(operation) return SlotRef(len(self._operations) - 1) + def ensure(self, index: int, probe: Operation[t.Any]) -> None: + """Make the create at *index* conditional: probe first, create only if absent. + + At execution the driver runs *probe* (a read that returns the object's + capture format -- e.g. ``display-message`` yielding ``#{session_id} ...``); + if it succeeds the create is *skipped* and the slot binds to the found + ids, so a find-or-create build reuses an existing object. The plan stays a + flat, serializable list of operations -- the branch lives in the driver. + The create at *index* must be a non-chainable create (so it is its own + dispatch step); *probe* must render the same capture format the create + captures, so :meth:`Operation.build_result` parses the found ids. + """ + self._ensures[index] = probe + @property def operations(self) -> tuple[Operation[t.Any], ...]: """The recorded operations, in order.""" @@ -269,14 +284,30 @@ def __iter__(self) -> Iterator[Operation[t.Any]]: return iter(self._operations) def to_list(self) -> list[dict[str, t.Any]]: - """Serialize the whole plan to a list of plain operation dicts.""" - return [operation_to_dict(operation) for operation in self._operations] + """Serialize the whole plan to a list of plain operation dicts. + + A find-or-create op (see :meth:`ensure`) carries its probe under an + ``"ensure"`` key so the conditional survives the round-trip. + """ + out: list[dict[str, t.Any]] = [] + for index, operation in enumerate(self._operations): + item = operation_to_dict(operation) + probe = self._ensures.get(index) + if probe is not None: + item["ensure"] = operation_to_dict(probe) + out.append(item) + return out @classmethod def from_list(cls, data: t.Sequence[t.Mapping[str, t.Any]]) -> LazyPlan: - """Reconstruct a plan from :meth:`to_list` output.""" + """Reconstruct a plan from :meth:`to_list` output (probes included).""" plan = cls() - plan._operations = [operation_from_dict(item) for item in data] + for index, item in enumerate(data): + rest = {key: value for key, value in item.items() if key != "ensure"} + plan._operations.append(operation_from_dict(rest)) + probe = item.get("ensure") + if probe is not None: + plan._ensures[index] = operation_from_dict(probe) return plan def add_chain(self, chain: OpChain) -> None: @@ -388,7 +419,22 @@ def _drive( bindings[create_idx] = new_id elif len(step.indices) == 1: index = step.indices[0] - result = yield _Single(_resolve(self._operations[index], bindings)) + probe = self._ensures.get(index) + if probe is not None: + found = yield _Single(_resolve(probe, bindings)) + if found.ok and found.text.strip(): + # The object exists: bind to its ids, skip the create. + result = self._operations[index].build_result( + returncode=0, + stdout=(found.text,), + version=version, + ) + else: + result = yield _Single( + _resolve(self._operations[index], bindings), + ) + else: + result = yield _Single(_resolve(self._operations[index], bindings)) results[index] = result if result.created_id is not None: bindings[index] = result.created_id diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 43ce28ebc..e311ed2c9 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -8,22 +8,28 @@ import pytest from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import ( BreakPane, + DisplayMessage, JoinPane, LazyPlan, MarkedPlanner, MovePane, + NewSession, SendKeys, SequentialPlanner, SplitWindow, StepReport, SwapPane, ) -from libtmux.experimental.ops._types import PaneId, SlotRef, WindowId +from libtmux.experimental.ops._types import NameRef, PaneId, SlotRef, WindowId from libtmux.experimental.ops.exc import ForwardCaptureError, OperationError if t.TYPE_CHECKING: + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest from libtmux.experimental.ops.operation import Operation @@ -283,3 +289,75 @@ async def both() -> tuple[bool, bool]: stream_ok, direct_ok = asyncio.run(both()) assert stream_ok == direct_ok + + +class _FindEngine: + """A fake engine where the probe reports found-or-not and the create makes one.""" + + def __init__(self, *, found: bool) -> None: + self.found = found + self.calls: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> CommandResult: + """Answer a display-message probe, or a new-session create.""" + self.calls.append(request.args) + cmd = ("tmux", *request.args) + if request.args[0] == "display-message": + if self.found: + return CommandResult(cmd=cmd, stdout=("$9 @9 %9",), returncode=0) + return CommandResult(cmd=cmd, stderr=("no session",), returncode=1) + return CommandResult(cmd=cmd, stdout=("$1 @1 %1",), returncode=0) + + def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: + """Run each request in order.""" + return [self.run(req) for req in requests] + + +class _EnsureCase(t.NamedTuple): + """Whether the probe finds the object, and the id + create-count expected.""" + + test_id: str + found: bool + session_id: str + creates: int + + +_ENSURE_CASES: tuple[_EnsureCase, ...] = ( + _EnsureCase("found_reuses", found=True, session_id="$9", creates=0), + _EnsureCase("absent_creates", found=False, session_id="$1", creates=1), +) + + +@pytest.mark.parametrize("case", _ENSURE_CASES, ids=[c.test_id for c in _ENSURE_CASES]) +def test_ensure_probes_then_creates_only_if_absent(case: _EnsureCase) -> None: + """An ensured create binds a found object's ids, or creates when absent.""" + plan = LazyPlan() + slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.ensure( + slot.slot, + DisplayMessage(target=NameRef("dev"), message="#{session_id}"), + ) + engine = _FindEngine(found=case.found) + result = plan.execute(engine) + + assert result.ok + assert result.bindings[0] == case.session_id + assert result.bindings[0, "pane"].startswith("%") # first-pane subref bound + creates = [call for call in engine.calls if call[0] == "new-session"] + assert len(creates) == case.creates # created only when the probe found nothing + + +def test_ensure_survives_serialization_round_trip() -> None: + """to_list/from_list carry an ensured op's probe, so the conditional persists.""" + plan = LazyPlan() + slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.ensure( + slot.slot, DisplayMessage(target=NameRef("dev"), message="#{session_id}") + ) + + revived = LazyPlan.from_list(plan.to_list()) + + assert revived.operations == plan.operations + engine = _FindEngine(found=True) + assert revived.execute(engine).bindings[0] == "$9" + assert not [call for call in engine.calls if call[0] == "new-session"] From 4ec66ee5e262f2760bfab7d37cea627ddc0cbaef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 13:50:46 -0500 Subject: [PATCH 124/154] Fluent(feat): Add find_or_create_session why: give the fluent builder an idempotent session entry, so re-running a build reuses the live session instead of duplicating it -- the tmuxp load -a shape, in the forward-ref builder. what: - PlanBuilder.find_or_create_session(name): record the same create as new_session, made conditional via LazyPlan.ensure with a display-message probe that captures the session's id, first window, and first pane - Cover the recorded shape and a live idempotent double-build --- src/libtmux/experimental/fluent.py | 30 ++++++++++++++++++++++++++++++ tests/experimental/test_fluent.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index b0e6bf671..265426f4c 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -58,6 +58,10 @@ _CURSOR_FMT = "#{cursor_x},#{cursor_y}" _WAIT_PANE_POLLS = 40 _WAIT_PANE_INTERVAL = 0.05 +#: The probe format for a session find-or-create -- the ids +#: ``NewSession(capture_panes=True)`` captures, so a found session binds the +#: same self/window/pane slots a created one would. +_SESSION_PROBE = "#{session_id} #{window_id} #{pane_id}" def _pane_ready(cursor: str) -> bool: @@ -158,6 +162,32 @@ def new_session(self, name: str) -> SessionRef: slot = self.plan.add(NewSession(session_name=name, capture_panes=True)) return SessionRef(self.plan, name, slot) + def find_or_create_session(self, name: str) -> SessionRef: + """Reach session *name*, creating it only if it does not exist. + + At build time this records the same create as :meth:`new_session`, but + makes it conditional (see :meth:`~..ops.plan.LazyPlan.ensure`): at + execution the plan probes for *name* and reuses the live session when it + is already there, so a re-run is idempotent instead of a duplicate. + + Examples + -------- + >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> p = plan() + >>> _ = p.find_or_create_session("dev").window().pane() + >>> [op.kind for op in p.plan.operations] + ['new_session'] + >>> p.run(ConcreteEngine()).ok + True + """ + create = NewSession(session_name=name, capture_panes=True) + slot = self.plan.add(create) + self.plan.ensure( + slot.slot, + DisplayMessage(target=NameRef(name), message=_SESSION_PROBE), + ) + return SessionRef(self.plan, name, slot) + def sleep(self, seconds: float) -> PlanBuilder: """Pause *seconds* after the last recorded op (a hard fold boundary). diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index f126d328d..742a4d690 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -135,3 +135,32 @@ def test_sleep_runs_offline() -> None: p.sleep(0.0) pane.split().do(lambda c: c.send_keys("htop")) assert p.run(ConcreteEngine()).ok + + +def test_find_or_create_session_records_a_conditional_create() -> None: + """find_or_create_session records one create, made conditional via ensure.""" + p = plan() + pane = p.find_or_create_session("dev").window().pane() + pane.do(lambda c: c.send_keys("vim")) + assert [op.kind for op in p.plan.operations] == ["new_session", "send_keys"] + assert 0 in p.plan._ensures # the create is conditional + + +def test_find_or_create_session_is_idempotent_live(session: Session) -> None: + """Building the same session name twice reuses it instead of duplicating.""" + from libtmux.experimental.engines.subprocess import SubprocessEngine + + engine = SubprocessEngine.for_server(session.server) + + def build() -> None: + p = plan() + p.find_or_create_session("fluent-idem").window().pane().do( + lambda c: c.send_keys("echo hi", enter=False), + ) + p.run(engine).raise_for_status() + + build() + build() # second run must find the existing session, not create a duplicate + + named = [s for s in session.server.sessions if s.session_name == "fluent-idem"] + assert len(named) == 1 From 18e457faae6072a75cbcfb974c32ddae1556e583 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 14:22:32 -0500 Subject: [PATCH 125/154] Test(fix): Clean up sessions in fluent live tests why: the fluent live tests created sessions on the session-scoped shared server and never removed them. On tmux 3.3a/3.5 under xdist, that leaked session perturbed the phantom-reap tests, which assert an exact global session count -- CI failed there though the local gate (newer tmux) did not. what: - Kill the created session in a finally block (test_build_session_live, test_find_or_create_session_is_idempotent_live) via a _kill_named helper --- tests/experimental/test_fluent.py | 50 +++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index 742a4d690..28b930bd4 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -11,6 +11,7 @@ from libtmux.experimental.query import ForwardPaneRef if t.TYPE_CHECKING: + from libtmux.server import Server from libtmux.session import Session @@ -72,21 +73,36 @@ def test_window_pane_is_forward_handle() -> None: assert not hasattr(ref, "pane_id") +def _kill_named(server: Server, name: str) -> None: + """Kill every session named *name* so a live test leaves the shared server clean. + + The ``server`` fixture is session-scoped, so a leaked session would perturb + later tests that measure global session counts (e.g. the phantom-reap tests). + """ + for sess in server.sessions: + if sess.session_name == name: + sess.kill() + + def test_build_session_live(session: Session) -> None: """A fluent build creates a real session with the declared panes.""" from libtmux.experimental.engines.subprocess import SubprocessEngine - engine = SubprocessEngine.for_server(session.server) - p = plan() - pane = p.new_session("fluentdev").window().pane() - pane.do(lambda c: c.send_keys("echo top", enter=False)).split().do( - lambda c: c.send_keys("echo bottom", enter=False), - ) - p.run(engine).raise_for_status() + server = session.server + engine = SubprocessEngine.for_server(server) + try: + p = plan() + pane = p.new_session("fluentdev").window().pane() + pane.do(lambda c: c.send_keys("echo top", enter=False)).split().do( + lambda c: c.send_keys("echo bottom", enter=False), + ) + p.run(engine).raise_for_status() - built = [s for s in session.server.sessions if s.session_name == "fluentdev"] - assert len(built) == 1 - assert len(built[0].windows[0].panes) == 2 + built = [s for s in server.sessions if s.session_name == "fluentdev"] + assert len(built) == 1 + assert len(built[0].windows[0].panes) == 2 + finally: + _kill_named(server, "fluentdev") class _HostCase(t.NamedTuple): @@ -150,7 +166,8 @@ def test_find_or_create_session_is_idempotent_live(session: Session) -> None: """Building the same session name twice reuses it instead of duplicating.""" from libtmux.experimental.engines.subprocess import SubprocessEngine - engine = SubprocessEngine.for_server(session.server) + server = session.server + engine = SubprocessEngine.for_server(server) def build() -> None: p = plan() @@ -159,8 +176,11 @@ def build() -> None: ) p.run(engine).raise_for_status() - build() - build() # second run must find the existing session, not create a duplicate + try: + build() + build() # second run must find the existing session, not create a duplicate - named = [s for s in session.server.sessions if s.session_name == "fluent-idem"] - assert len(named) == 1 + named = [s for s in server.sessions if s.session_name == "fluent-idem"] + assert len(named) == 1 + finally: + _kill_named(server, "fluent-idem") From 9f4b5ed9724c612d02a213ffe0231ed1130d08f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 14:50:49 -0500 Subject: [PATCH 126/154] docs(experimental): Document the fluent plan() builder why: the fluent forward-ref build tier had only method/module doctests; the experimental page covered the Core ops/engines/plans but not the declarative surface a user reaches for first. what: - Add a "Building fluently with plan()" section to the experimental page, proportional to the other sections: forward-ref handles, fold-unless- true-blocker, and find_or_create_session, with runnable ConcreteEngine doctests --- docs/experimental.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/experimental.md b/docs/experimental.md index 0cc6fbe65..83ebcf439 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -115,6 +115,45 @@ many times tmux is invoked: True ``` +## Building fluently with `plan()` + +{func}`~libtmux.experimental.fluent.plan` is a fluent builder over a plan: you +name a session, walk down to a pane, and record what each pane runs, without +threading the new ids through yourself. Nothing touches tmux until +{meth}`~libtmux.experimental.fluent.PlanBuilder.run`, which folds the whole +description into a few dispatches (its async twin is ``arun``): + +```python +>>> from libtmux.experimental.fluent import plan +>>> from libtmux.experimental.engines import ConcreteEngine +>>> p = plan() +>>> pane = p.new_session("dev").window().pane() +>>> _ = pane.do(lambda c: c.send_keys("vim")).split().do(lambda c: c.send_keys("htop")) +>>> p.run(ConcreteEngine()).ok +True +``` + +``.split()`` makes a new pane that does not exist yet, so it comes back as a +*forward* handle: you keep building on it, but reading its id is a static type +error (the concrete {class}`~libtmux.experimental.query.PaneRef` has +``.pane_id``; the {class}`~libtmux.experimental.query.ForwardPaneRef` does not), +resolved against the captured id only when the plan runs. + +``run()`` folds by default and breaks the fold only at a true blocker -- a +created id a later op needs, or a host pause recorded by ``sleep()``/``wait()``. +{meth}`~libtmux.experimental.fluent.PlanBuilder.find_or_create_session` makes the +create conditional, so re-running a build reuses a live session instead of +duplicating it: + +```python +>>> from libtmux.experimental.fluent import plan +>>> from libtmux.experimental.engines import ConcreteEngine +>>> p = plan() +>>> _ = p.find_or_create_session("dev").window().pane() +>>> p.run(ConcreteEngine()).ok +True +``` + ## Operation catalog The catalog below is generated from the operation registry, so it always matches From f92471511e03245261a91ad525329fcbeacc1a25 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 15:12:44 -0500 Subject: [PATCH 127/154] Mcp(fix): Preserve ensure across plan-tool serialization why: the MCP plan tools rebuilt a serialized plan with add() + operation_from_dict, which drops the `ensure` probe that to_list emits -- so a find-or-create plan round-tripped through MCP silently became an unconditional create (a duplicate-session footgun). what: - Route _plan_from_dicts through LazyPlan.from_list, which carries the ensure probe, so a conditional create survives the MCP round-trip --- src/libtmux/experimental/mcp/fastmcp_adapter.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index 0c2155604..e35d77c9c 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -425,7 +425,6 @@ def register_plan_tools( Planner, SequentialPlanner, ) - from libtmux.experimental.ops.serialize import operation_from_dict reg = registry if registry is not None else OperationToolRegistry() planners: dict[str, type[Planner]] = { @@ -435,10 +434,9 @@ def register_plan_tools( } def _plan_from_dicts(operations: list[dict[str, t.Any]]) -> LazyPlan: - plan = LazyPlan() - for data in operations: - plan.add(operation_from_dict(data)) - return plan + # from_list (not add) so a serialized find-or-create `ensure` probe + # survives the round-trip instead of being silently dropped. + return LazyPlan.from_list(operations) def _planner(name: str) -> Planner: chosen = planners.get(name) From 29a076de9fbb219c40333e35bb1a0fbed589104f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 15:12:44 -0500 Subject: [PATCH 128/154] Ops(test): Make the ensure probe test format-honest why: the fake engine returned all three ids for any display-message, so the ensure test asserted a pane binding while probing only #{session_id} -- it passed on ids the probe never requested and would not catch a real probe/capture format mismatch. what: - Make _FindEngine format-aware (return only the ids the probe requests) - Probe the full capture format in test_ensure_probes_then_creates - Add test_ensure_probe_must_match_create_capture: a session-only probe binds no pane subref, guarding the format contract --- tests/experimental/ops/test_plan.py | 43 +++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index e311ed2c9..61055c378 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -299,13 +299,22 @@ def __init__(self, *, found: bool) -> None: self.calls: list[tuple[str, ...]] = [] def run(self, request: CommandRequest) -> CommandResult: - """Answer a display-message probe, or a new-session create.""" + """Answer a display-message probe, or a new-session create. + + The probe is *format-aware*: it returns only the ids the probe's format + actually requests, so a probe that omits ``#{pane_id}`` yields no pane id + -- mirroring real tmux, so a test cannot pass on ids the probe never asked + for. + """ self.calls.append(request.args) cmd = ("tmux", *request.args) if request.args[0] == "display-message": - if self.found: - return CommandResult(cmd=cmd, stdout=("$9 @9 %9",), returncode=0) - return CommandResult(cmd=cmd, stderr=("no session",), returncode=1) + if not self.found: + return CommandResult(cmd=cmd, stderr=("no session",), returncode=1) + fmt = request.args[-1] # the -p value + ids = {"session_id": "$9", "window_id": "@9", "pane_id": "%9"} + text = " ".join(v for key, v in ids.items() if f"#{{{key}}}" in fmt) + return CommandResult(cmd=cmd, stdout=(text,), returncode=0) return CommandResult(cmd=cmd, stdout=("$1 @1 %1",), returncode=0) def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: @@ -333,9 +342,14 @@ def test_ensure_probes_then_creates_only_if_absent(case: _EnsureCase) -> None: """An ensured create binds a found object's ids, or creates when absent.""" plan = LazyPlan() slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + # The probe renders the SAME capture format the create captures, so a found + # session binds the same self/window/pane subrefs a created one would. plan.ensure( slot.slot, - DisplayMessage(target=NameRef("dev"), message="#{session_id}"), + DisplayMessage( + target=NameRef("dev"), + message="#{session_id} #{window_id} #{pane_id}", + ), ) engine = _FindEngine(found=case.found) result = plan.execute(engine) @@ -347,6 +361,25 @@ def test_ensure_probes_then_creates_only_if_absent(case: _EnsureCase) -> None: assert len(creates) == case.creates # created only when the probe found nothing +def test_ensure_probe_must_match_create_capture() -> None: + """A probe that omits the pane id binds no pane subref (the format contract). + + This guards the ensure() footgun: the probe must render the create's capture + format. A session-only probe finds the session but yields no pane id, so a + downstream ``.pane`` forward-ref would fail closed rather than mis-bind. + """ + plan = LazyPlan() + slot = plan.add(NewSession(session_name="dev", capture_panes=True)) + plan.ensure( + slot.slot, DisplayMessage(target=NameRef("dev"), message="#{session_id}") + ) + + result = plan.execute(_FindEngine(found=True)) + + assert result.bindings[0] == "$9" # the session bound + assert (0, "pane") not in result.bindings # but no pane id -- probe omitted it + + def test_ensure_survives_serialization_round_trip() -> None: """to_list/from_list carry an ensured op's probe, so the conditional persists.""" plan = LazyPlan() From cb5aa845c13739734f847eb3a1e44b6d1841d08e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 18:39:54 -0500 Subject: [PATCH 129/154] Workspace(docs): on_event and astream caveats why: The engine-ops streaming surface had two doc gaps a reader could trip on: abuild's on_event docstring invited a slow async sink (a fastmcp Context push) that would head-of-line-block the fold, and astream reads like a drop-in build though it skips host steps and can race send-keys against an unready shell. what: - runner/sets abuild(on_event=): note it is awaited inline on the dispatch coroutine (keep it fast and non-reentrant); a slow sink owns its own buffer and drains independently (the MCP _EventRing pattern); the Awaitable[None] type cannot enforce this - plan.astream: it observes dispatch only and skips host steps (sleep/before_script/wait_pane); use Workspace.abuild for a full build - comment the on_step closure so a future buffered mode uses bounded-queue backpressure, never drop -- BuildEvents carry unique tmux ids that must arrive exactly once --- src/libtmux/experimental/ops/plan.py | 7 +++++++ src/libtmux/experimental/workspace/runner.py | 13 ++++++++++--- src/libtmux/experimental/workspace/sets.py | 6 +++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index f7bd21b6d..be4ffb443 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -551,6 +551,13 @@ async def astream( loop is never blocked between dispatches. Run one ``astream`` per engine at a time (the engine's write order is shared). + Like :meth:`aexecute`, this observes plan *dispatch* only: it does not + run a workspace's host steps (``sleep`` / ``before_script`` / + ``wait_pane``, which the runner threads through ``on_step``), so + ``send-keys`` can reach a shell the plan never waited for. For a full + build that runs those, use + :meth:`~libtmux.experimental.workspace.ir.Workspace.abuild`. + Examples -------- >>> import asyncio diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index 2a73d1222..5bd9e8b32 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -202,9 +202,13 @@ async def abuild_workspace( ) -> PlanResult: """Compile and execute *ws* asynchronously over *engine* (same resolution). - *on_event* is awaited for each build event, so an async observer can stream - the structural progress (e.g. through a fastmcp Context). Folds by default; - see :func:`build_workspace` for the *planner* knob. + *on_event* is awaited inline on the coroutine that runs the interleaved host + steps and drives the next dispatch, so it must return promptly and must not + re-enter *engine* (``Awaitable[None]`` cannot enforce this). A slow network + or render sink should own its own buffer and drain independently -- e.g. the + ``register_events`` + ``_EventRing``-over-``engine.subscribe()`` pattern in + ``libtmux.experimental.mcp.events``. Folds by default; see + :func:`build_workspace` for the *planner* knob. """ if preflight and await _preflight_async(ws, engine, version): return PlanResult((), {}) @@ -213,6 +217,9 @@ async def abuild_workspace( for step in compiled.pre: await _run_host_async(step, engine, {}, version) + # on_event is awaited inline (keep it fast/non-reentrant); a future buffered + # mode must back-pressure a bounded queue, never drop -- every BuildEvent + # carries a unique tmux id that must arrive exactly once. async def on_step(report: StepReport) -> None: for index, result in zip(report.step.indices, report.results, strict=True): if on_event is not None: diff --git a/src/libtmux/experimental/workspace/sets.py b/src/libtmux/experimental/workspace/sets.py index 157ab2971..26dfddadb 100644 --- a/src/libtmux/experimental/workspace/sets.py +++ b/src/libtmux/experimental/workspace/sets.py @@ -402,7 +402,11 @@ async def abuild_workspaces( on_event: Callable[[BuildEvent], Awaitable[None]] | None = None, planner: Planner | None = None, ) -> WorkspaceSetResult: - """Compile and execute multiple workspaces asynchronously over *engine*.""" + """Compile and execute multiple workspaces asynchronously over *engine*. + + *on_event* has the same inline-await contract as :func:`abuild_workspace`: + keep it fast and non-reentrant, or buffer and drain it yourself. + """ rows = _workspace_tuple(workspaces) active, reused = await _split_reused_async(rows, engine, version, preflight) if not active: From d6480774cce645788b7c8e2af1ae4fc6112a2af1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 4 Jul 2026 22:11:23 -0500 Subject: [PATCH 130/154] Workspace(fix): Stringify option/env values why: A window/session option or environment value typed as an int in YAML (e.g. `main-pane-height: 35`) reached the ;-chain renderer as an int and raised AttributeError in _escape_arg. The IR declares these Mapping[str, str] and tmux wants string args (classic libtmux str()s every arg), so analyze must coerce at ingest. what: - Add _str_map(): stringify the keys and values of an options or environment mapping - Apply it to all 7 ingest sites (session/window/pane options/options_after/global_options/environment) - Add parametrized coercion tests + a live folded-build regression --- .../experimental/workspace/analyzer.py | 27 +++++-- .../contract/test_workspace_analyze.py | 75 +++++++++++++++++++ .../contract/test_workspace_folding.py | 34 ++++++++- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/libtmux/experimental/workspace/analyzer.py b/src/libtmux/experimental/workspace/analyzer.py index 45d11f4aa..3eb9d4630 100644 --- a/src/libtmux/experimental/workspace/analyzer.py +++ b/src/libtmux/experimental/workspace/analyzer.py @@ -49,9 +49,9 @@ def analyze(raw: collections.abc.Mapping[str, t.Any] | str) -> Workspace: name=data["session_name"], dimensions=_dimensions(data.get("dimensions")), start_directory=data.get("start_directory"), - environment=dict(data.get("environment", {}) or {}), - options=dict(data.get("options", {}) or {}), - global_options=dict(data.get("global_options", {}) or {}), + environment=_str_map(data.get("environment")), + options=_str_map(data.get("options")), + global_options=_str_map(data.get("global_options")), windows=windows, before_script=data.get("before_script"), on_exists=data.get("on_exists", "error"), @@ -84,6 +84,19 @@ def _dimensions(value: t.Any) -> tuple[int, int] | None: return (int(width), int(height)) +def _str_map(value: t.Any) -> dict[str, str]: + """Coerce a config options/environment mapping to ``dict[str, str]``. + + YAML types ``main-pane-height: 35`` as an int, but tmux option and + environment values are strings on the command line (libtmux's ``cmd`` + str()s every arg). Stringifying at ingest keeps the IR's declared + ``Mapping[str, str]`` honest, so the folded renderer never meets a non-str. + """ + if not value: + return {} + return {str(key): str(val) for key, val in dict(value).items()} + + def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: """Normalize one window config.""" return Window( @@ -91,9 +104,9 @@ def _window(raw: collections.abc.Mapping[str, t.Any]) -> Window: layout=raw.get("layout"), start_directory=raw.get("start_directory"), focus=bool(raw.get("focus", False)), - options=dict(raw.get("options", {}) or {}), - options_after=dict(raw.get("options_after", {}) or {}), - environment=dict(raw.get("environment", {}) or {}), + options=_str_map(raw.get("options")), + options_after=_str_map(raw.get("options_after")), + environment=_str_map(raw.get("environment")), window_shell=raw.get("window_shell"), window_index=raw.get("window_index"), panes=[_pane(p) for p in raw.get("panes", []) or []], @@ -156,7 +169,7 @@ def _pane(raw: t.Any) -> Pane: suppress_history=bool(raw.get("suppress_history", True)), sleep_before=raw.get("sleep_before"), sleep_after=raw.get("sleep_after"), - environment=dict(raw.get("environment", {}) or {}), + environment=_str_map(raw.get("environment")), shell=raw.get("shell"), ) msg = f"unsupported pane config: {raw!r}" diff --git a/tests/experimental/contract/test_workspace_analyze.py b/tests/experimental/contract/test_workspace_analyze.py index 72b990d6a..51ea616e7 100644 --- a/tests/experimental/contract/test_workspace_analyze.py +++ b/tests/experimental/contract/test_workspace_analyze.py @@ -59,3 +59,78 @@ def test_supported_shell_command_items_normalize(case: OkCase) -> None: """Valid items normalize; a None mixed with commands is dropped (tmuxp parity).""" ws = analyze(_config(case.shell_command)) assert ws.windows[0].panes[0].run == case.expected + + +class CoerceCase(t.NamedTuple): + """A config options/environment location analyze must stringify. + + YAML types ``main-pane-height: 35`` as an int, but the IR declares these + ``Mapping[str, str]`` and tmux wants string args -- so analyze must coerce. + """ + + test_id: str + config: dict[str, t.Any] + read: t.Callable[..., t.Any] + + +COERCE_CASES = ( + CoerceCase( + "session_options", + {"session_name": "s", "options": {"base-index": 35}, "windows": []}, + lambda ws: ws.options["base-index"], + ), + CoerceCase( + "session_global_options", + {"session_name": "s", "global_options": {"history-limit": 35}, "windows": []}, + lambda ws: ws.global_options["history-limit"], + ), + CoerceCase( + "session_environment", + {"session_name": "s", "environment": {"PORT": 35}, "windows": []}, + lambda ws: ws.environment["PORT"], + ), + CoerceCase( + "window_options", + { + "session_name": "s", + "windows": [{"options": {"main-pane-height": 35}, "panes": ["echo a"]}], + }, + lambda ws: ws.windows[0].options["main-pane-height"], + ), + CoerceCase( + "window_options_after", + { + "session_name": "s", + "windows": [ + {"options_after": {"main-pane-height": 35}, "panes": ["echo a"]}, + ], + }, + lambda ws: ws.windows[0].options_after["main-pane-height"], + ), + CoerceCase( + "window_environment", + { + "session_name": "s", + "windows": [{"environment": {"PORT": 35}, "panes": ["echo a"]}], + }, + lambda ws: ws.windows[0].environment["PORT"], + ), + CoerceCase( + "pane_environment", + { + "session_name": "s", + "windows": [ + {"panes": [{"shell_command": ["echo a"], "environment": {"PORT": 35}}]}, + ], + }, + lambda ws: ws.windows[0].panes[0].environment["PORT"], + ), +) + + +@pytest.mark.parametrize("case", COERCE_CASES, ids=[c.test_id for c in COERCE_CASES]) +def test_analyze_stringifies_option_and_env_values(case: CoerceCase) -> None: + """Non-str option/environment values coerce to str at every ingest site.""" + value = case.read(analyze(case.config)) + assert value == "35" + assert isinstance(value, str) diff --git a/tests/experimental/contract/test_workspace_folding.py b/tests/experimental/contract/test_workspace_folding.py index 78ca64b47..3f69ec4c3 100644 --- a/tests/experimental/contract/test_workspace_folding.py +++ b/tests/experimental/contract/test_workspace_folding.py @@ -16,7 +16,7 @@ from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import SequentialPlanner -from libtmux.experimental.workspace import Command, Pane, Window, Workspace +from libtmux.experimental.workspace import Command, Pane, Window, Workspace, analyze if t.TYPE_CHECKING: from collections.abc import Sequence @@ -146,3 +146,35 @@ def test_build_folds_live_subprocess(session: Session) -> None: # the build folded: at least one ; chain, fewer dispatches than operations assert any(";" in argv for argv in engine.calls) assert len(engine.calls) < len(spec.compile().operations) + + +def test_build_int_window_option_folds(session: Session) -> None: + """A non-str option value (YAML int) folds and builds without a render crash. + + Regression: ``main-pane-height: 35`` reached the ``;``-chain renderer as an + int, which raised ``AttributeError`` in ``_escape_arg``; analyze now + stringifies option values so the fold sees only strings. + """ + server = session.server + engine = _RecordingEngine(SubprocessEngine.for_server(server)) + spec = analyze( + { + "session_name": "int-opt", + "start_directory": "/tmp", + "windows": [ + { + "window_name": "main", + "layout": "main-horizontal", + "options": {"main-pane-height": 35}, + "panes": ["echo a", "echo b", "echo c"], + }, + ], + }, + ) + + result = spec.build(engine) + + assert result.ok + built = server.sessions.get(session_name="int-opt") + assert built is not None + assert len(built.windows[0].panes) == 3 From 18c8506d7b4623c82f454c2e6a118904d0bff91c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:36:05 -0500 Subject: [PATCH 131/154] Ops(fix): Skip marked fold for detached creators why: NewPane defaults to detached (-d), which does not focus the new pane. The {marked} fold's untargeted `select-pane -m` then marks the old active pane, so a NewPane >> SendKeys(slot) plan under the default MarkedPlanner sent keys into the wrong pane. SplitWindow is unaffected because it focuses its new pane. what: - _marked_decorates skips creators with detach=True; they dispatch alone so their decorates bind the captured pane id via the slot - Add a parametrized regression: split and focused NewPane still mark, detached NewPane does not --- src/libtmux/experimental/ops/planner.py | 5 ++++ tests/experimental/ops/test_planner.py | 38 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/libtmux/experimental/ops/planner.py b/src/libtmux/experimental/ops/planner.py index 393f1f8a9..f6b57da12 100644 --- a/src/libtmux/experimental/ops/planner.py +++ b/src/libtmux/experimental/ops/planner.py @@ -214,6 +214,11 @@ def _marked_decorates( creator = operations[index] if creator.effects.creates != "pane" or creator.chainable: return () + # A detached creator (e.g. NewPane ``-d``) does not focus its new pane, so + # the {marked} fold's untargeted ``select-pane -m`` would mark the wrong + # pane; fall back to lone dispatch (decorates bind the captured id via slot). + if getattr(creator, "detach", False): + return () decorates: list[int] = [] cursor = index + 1 while cursor < len(operations): diff --git a/tests/experimental/ops/test_planner.py b/tests/experimental/ops/test_planner.py index cc6261e69..1db01252d 100644 --- a/tests/experimental/ops/test_planner.py +++ b/tests/experimental/ops/test_planner.py @@ -15,6 +15,7 @@ FoldingPlanner, LazyPlan, MarkedPlanner, + NewPane, PlanStep, SendKeys, SequentialPlanner, @@ -128,6 +129,43 @@ def test_marked_falls_back_without_pattern() -> None: assert len(engine.calls) == 1 # folded as a plain ; chain +class _MarkedFoldCase(t.NamedTuple): + """A pane creator and whether its decorates should {marked}-fold.""" + + test_id: str + creator: Operation[t.Any] + marked: bool + + +_MARKED_FOLD_CASES = ( + _MarkedFoldCase("split_focuses_new_pane", SplitWindow(target=WindowId("@1")), True), + _MarkedFoldCase( + "detached_new_pane_falls_back", + NewPane(target=PaneId("%1"), width=80, height=15), + False, + ), + _MarkedFoldCase( + "focused_new_pane_marks", + NewPane(target=PaneId("%1"), width=80, height=15, detach=False), + True, + ), +) + + +@pytest.mark.parametrize( + "case", + _MARKED_FOLD_CASES, + ids=[c.test_id for c in _MARKED_FOLD_CASES], +) +def test_marked_fold_skips_detached_creator(case: _MarkedFoldCase) -> None: + """A detached creator's new pane isn't focused, so {marked} can't target it.""" + plan = LazyPlan() + pane = plan.add(case.creator) + plan.add(SendKeys(target=pane, keys="vim", enter=True)) + steps = MarkedPlanner().plan(plan.operations) + assert any(step.marked for step in steps) is case.marked + + def _split_decorate_plan() -> list[Operation[t.Any]]: """Return the {marked}-foldable shape: split @1, then two pane decorates.""" return [ From bf897fd448972ec24ac122d7425a68bcdbec7aef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:37:44 -0500 Subject: [PATCH 132/154] Engines(fix): End subscribe() after engine death why: subscribe() gated only on _closing, but a dead reader sets _dead, not _closing. A subscribe() after the engine died -- e.g. the output monitor or pull ring reconnecting once tmux closed stdout -- registered a fresh queue and blocked forever on queue.get(), the exact hang the close-subscribers work removed for the aclose() path. what: - Gate subscribe() on `_closing or _dead is not None` - Add a regression mirroring the after-close test but marking dead first --- .../engines/async_control_mode.py | 6 +++--- .../test_async_control_mode_sentinel.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 277b41792..059797f51 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -348,10 +348,10 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: its queue is unregistered on exit. When the engine dies or closes, ``_STREAM_END`` is broadcast to every subscriber queue so the ``async for`` ends cleanly instead of hanging on ``queue.get()``. A subscribe() - after :meth:`aclose` yields nothing (the ``_closing`` gate), since no - broadcast would ever reach its fresh queue. + after :meth:`aclose` or after the engine has died yields nothing, since + no broadcast would ever reach its fresh queue. """ - if self._closing: + if self._closing or self._dead is not None: return queue: asyncio.Queue[t.Any] = asyncio.Queue( maxsize=self._event_queue_size, diff --git a/tests/experimental/engines/test_async_control_mode_sentinel.py b/tests/experimental/engines/test_async_control_mode_sentinel.py index f3bdf2029..d3e2ecdf4 100644 --- a/tests/experimental/engines/test_async_control_mode_sentinel.py +++ b/tests/experimental/engines/test_async_control_mode_sentinel.py @@ -90,3 +90,24 @@ async def drain() -> list[ControlNotification]: return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang assert asyncio.run(main()) == [] + + +def test_subscribe_ends_immediately_after_death() -> None: + """A subscribe() after the engine died must end at once, not hang. + + _mark_dead() broadcasts the sentinel and clears the subscriber set but does + not flip ``_closing``; a queue registered afterwards would never receive a + sentinel. The ``_dead`` gate makes subscribe() yield nothing and return. + """ + + async def main() -> list[ControlNotification]: + engine = AsyncControlModeEngine() + engine._started = True # pretend a connection was established + engine._mark_dead(ControlModeError("tmux -C closed stdout")) + + async def drain() -> list[ControlNotification]: + return [note async for note in engine.subscribe()] + + return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang + + assert asyncio.run(main()) == [] From f279ccb92bfe3fc5d0d95a5ed60150ce380c2bc4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:39:18 -0500 Subject: [PATCH 133/154] Query(refactor): Namespace dataclasses.replace why: AGENTS.md's namespace-import rule exempts only dataclass/field from the dataclasses module; replace is a plain function and should be called as dataclasses.replace, matching the sibling snapshots.py. what: - import dataclasses; use dataclasses.replace in filter/order_by/limit --- src/libtmux/experimental/query.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libtmux/experimental/query.py b/src/libtmux/experimental/query.py index 0ff2ec57d..0a4ae575a 100644 --- a/src/libtmux/experimental/query.py +++ b/src/libtmux/experimental/query.py @@ -27,8 +27,9 @@ from __future__ import annotations +import dataclasses import typing as t -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from libtmux._internal.query_list import QueryList from libtmux.experimental.engines.base import TmuxEngine @@ -88,15 +89,15 @@ class PaneQuery: def filter(self, **lookups: t.Any) -> PaneQuery: """Narrow by QueryList lookups (e.g. ``active=True``, ``pane_index=0``).""" - return replace(self, lookups={**self.lookups, **lookups}) + return dataclasses.replace(self, lookups={**self.lookups, **lookups}) def order_by(self, field_name: str) -> PaneQuery: """Sort the results by a snapshot attribute (missing values last).""" - return replace(self, order=field_name) + return dataclasses.replace(self, order=field_name) def limit(self, count: int) -> PaneQuery: """Keep only the first *count* results.""" - return replace(self, limit_count=count) + return dataclasses.replace(self, limit_count=count) def all(self, source: PaneSource) -> tuple[PaneSnapshot, ...]: """Resolve the query against *source* and return the matched snapshots.""" From bc159d38726895cde7f94bd0d7d2cc119ddc9e95 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:42:08 -0500 Subject: [PATCH 134/154] docs(experimental): Drop prototype lineage why: AGENTS.md's shipped-vs-branch rule keeps never-shipped, branch-internal lineage out of docstrings; the "chainable-commands" and "libtmux-protocol-engines" prototypes are sibling branches the reader never experienced. Describe only current state. what: - Trim prototype references in ops/plan.py, ops/_chain.py, engines/base.py, engines/registry.py --- src/libtmux/experimental/engines/base.py | 3 +-- src/libtmux/experimental/engines/registry.py | 3 +-- src/libtmux/experimental/ops/_chain.py | 5 ++--- src/libtmux/experimental/ops/plan.py | 4 ++-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index f5c6bfd49..7393ed7ce 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -1,7 +1,6 @@ """Core engine abstractions: requests, results, and the engine protocols. -Adapted from the ``libtmux-protocol-engines`` prototype. A -:class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a +A :class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a :class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and :class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with the right methods is an engine -- including a live :class:`libtmux.Server` for diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py index 2809eb356..fb9315ea1 100644 --- a/src/libtmux/experimental/engines/registry.py +++ b/src/libtmux/experimental/engines/registry.py @@ -2,8 +2,7 @@ Lets engines be created by name (or :class:`~.base.EngineSpec`) so downstream code and the contract suite can select a transport without importing its class. -Fails closed on an unknown name. Adapted from the ``libtmux-protocol-engines`` -prototype. +Fails closed on an unknown name. """ from __future__ import annotations diff --git a/src/libtmux/experimental/ops/_chain.py b/src/libtmux/experimental/ops/_chain.py index 2cb254109..3bf94a389 100644 --- a/src/libtmux/experimental/ops/_chain.py +++ b/src/libtmux/experimental/ops/_chain.py @@ -2,9 +2,8 @@ A run of *chainable* operations can render to a single ``tmux a \; b`` invocation and dispatch once, instead of one process fork / control-mode command per -operation. This ports the chainable-commands prototype's fold onto the typed-op -model: only operations whose ``chainable`` class var is ``True`` (no captured -output, no created object) fold; the rest dispatch alone. +operation. Only operations whose ``chainable`` class var is ``True`` (no +captured output, no created object) fold; the rest dispatch alone. tmux runs a ``;`` sequence up to the first error and drops the remainder (``cmd-queue.c`` ``cmdq_remove_group``), returning one merged stdout/exit with no diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index be4ffb443..4f66542b5 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -6,8 +6,8 @@ a split is about to create); the plan resolves those references from captured ids at execution time. -Resolution is a sans-I/O generator -- the same yield-operation / resume-with- -result trampoline the chainable-commands prototype uses. The sync +Resolution is a sans-I/O generator -- a yield-operation / resume-with-result +trampoline. The sync :meth:`LazyPlan.execute` and async :meth:`LazyPlan.aexecute` drivers differ only in ``run(...)`` versus ``await arun(...)``; the resolution logic is written once. """ From e80239b447748b7978d574338318f94a02ea2dd2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:46:12 -0500 Subject: [PATCH 135/154] Workspace(fix): Honor window_shell on window 0 why: window_shell (a window's custom first-pane shell) rode new-window for windows 2..N, but window 0 is created by new-session, which had no shell parameter -- so the first window's first pane silently booted the default shell instead of the configured one. what: - Add window_shell to NewSession (a trailing shell-command, mirroring how NewWindow appends it for later windows) - compiler passes ws.windows[0].window_shell to NewSession - Test that window 0's window_shell rides new-session --- src/libtmux/experimental/ops/_ops/new_session.py | 7 +++++++ src/libtmux/experimental/workspace/compiler.py | 1 + .../test_async_control_engine_workspace_builder.py | 11 +++++++++++ 3 files changed, 19 insertions(+) diff --git a/src/libtmux/experimental/ops/_ops/new_session.py b/src/libtmux/experimental/ops/_ops/new_session.py index b92759c24..1e48ba176 100644 --- a/src/libtmux/experimental/ops/_ops/new_session.py +++ b/src/libtmux/experimental/ops/_ops/new_session.py @@ -35,6 +35,8 @@ class NewSession(Operation[CreateResult]): ('new-session', '-d', '-s', 'work', '-P', '-F', '#{session_id}') >>> NewSession(session_name="work", capture_panes=True).render()[-1] '#{session_id} #{window_id} #{pane_id}' + >>> NewSession(session_name="work", window_shell="fish").render()[-1] + 'fish' >>> NewSession().build_result(returncode=0, stdout=("$2",)).new_id '$2' >>> r = NewSession(capture_panes=True).build_result( @@ -60,6 +62,7 @@ class NewSession(Operation[CreateResult]): height: int | None = None capture: bool = True capture_panes: bool = False + window_shell: str | None = None def args(self, *, version: str | None = None) -> tuple[str, ...]: """Render ``new-session`` flags (always detached for headless use).""" @@ -81,6 +84,10 @@ def args(self, *, version: str | None = None) -> tuple[str, ...]: else "#{session_id}" ) out.extend(("-P", "-F", fmt)) + if self.window_shell is not None: + # A trailing shell-command runs in the first pane instead of the + # default shell (as NewWindow does for windows 2..N). + out.append(self.window_shell) return tuple(out) def _make_result( diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 13fe27b1f..4b9786eba 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -399,6 +399,7 @@ def compile_full(ws: Workspace, *, version: str | None = None) -> Compiled: width=width, height=height, environment=_creator_environment(ws.windows[0]) or None, + window_shell=ws.windows[0].window_shell, capture_panes=True, ), ) diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 3a427c48c..db1364213 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -646,6 +646,17 @@ def test_compile_threads_window_and_pane_shell() -> None: assert split.shell == "zsh" # pane.shell wins over window_shell +def test_compile_first_window_shell_rides_new_session() -> None: + """window_shell on window 0 rides new-session (not silently dropped).""" + ws = Workspace( + name="ws-shell0", + windows=[Window("a", window_shell="fish", panes=[Pane(run="x")])], + ) + ops = compile_full(ws).plan.operations + new_session = next(op for op in ops if isinstance(op, NewSession)) + assert new_session.window_shell == "fish" + + def test_compile_emits_options_after_following_layout() -> None: """options_after compile to set-window-option *after* the layout op.""" ws = Workspace( From 800c8c7d6747e2d7dee38bcf66d263f06faa513b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:49:47 -0500 Subject: [PATCH 136/154] Workspace(fix): wait_pane uses effective shell why: The wait_pane readiness guard skipped the wait when a first pane set `shell=`, but the first pane's own shell is never applied (only window_shell reaches its creator). So the pane launched the default shell and send-keys raced the prompt -- the exact race wait_pane exists to prevent. what: - _emit_pane_commands takes the shell the creator actually applies instead of re-deriving pane.shell|window_shell: first pane -> window_shell, split -> pane.shell|window_shell, float -> pane.shell - Parametrized regression: a first-pane pane.shell still waits, a first-pane window_shell skips --- .../experimental/workspace/compiler.py | 16 +++++-- ..._async_control_engine_workspace_builder.py | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/libtmux/experimental/workspace/compiler.py b/src/libtmux/experimental/workspace/compiler.py index 4b9786eba..4925d82a2 100644 --- a/src/libtmux/experimental/workspace/compiler.py +++ b/src/libtmux/experimental/workspace/compiler.py @@ -165,19 +165,21 @@ def _emit_pane_commands( host_after: dict[int, list[HostStep]], pre: list[HostStep], ws: Workspace, - window: Window, pane: Pane, target: SlotRef, + effective_shell: str | None, ) -> None: """Emit a pane's command sends with their host-side sleep / wait scheduling. Shared by tiled panes and floating-pane overlays so both honor ``wait_pane``, ``suppress_history``, and the per-pane / per-command sleeps identically. + *effective_shell* is the shell actually applied to this pane by its creator + (``None`` means the default shell), which gates the readiness wait. """ commands = pane.commands if not commands: return - if ws.wait_pane and (pane.shell or window.window_shell) is None: + if ws.wait_pane and effective_shell is None: # Wait for the pane's shell prompt before sending keys (anti-race); a pane # launching a custom shell/command does not get this wait, mirroring # tmuxp's `if pane_shell is None`. @@ -254,7 +256,7 @@ def _emit_float( shell_command=fp.pane.shell, ), ) - _emit_pane_commands(plan, host_after, pre, ws, host_window, fp.pane, float_ref) + _emit_pane_commands(plan, host_after, pre, ws, fp.pane, float_ref, fp.pane.shell) if fp.pane.focus: plan.add(SelectPane(target=float_ref)) @@ -322,8 +324,12 @@ def _emit_window( focus_targets: list[SlotRef] = [] for pane_index, pane in enumerate(panes): if pane_index == 0: + # The first pane rides the creator, which applies window_shell (not + # the pane's own shell); reflect that in the readiness gate. target: SlotRef = first_pane_ref + effective_shell = window.window_shell else: + effective_shell = pane.shell or window.window_shell target = plan.add( SplitWindow( target=prev, @@ -333,10 +339,10 @@ def _emit_window( or ws.start_directory ), environment={**window.environment, **pane.environment} or None, - shell=pane.shell or window.window_shell, + shell=effective_shell, ), ) - _emit_pane_commands(plan, host_after, pre, ws, window, pane, target) + _emit_pane_commands(plan, host_after, pre, ws, pane, target, effective_shell) if pane.focus: focus_targets.append(target) prev = target diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index db1364213..a5a0e4c4f 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -776,6 +776,54 @@ def test_compile_emits_wait_pane_when_enabled() -> None: ) +class _WaitFirstPaneCase(t.NamedTuple): + """A first-pane shell config and whether wait_pane should still wait.""" + + test_id: str + window_shell: str | None + pane_shell: str | None + expect_wait: bool + + +_WAIT_FIRST_PANE_CASES = ( + _WaitFirstPaneCase("plain_waits", None, None, True), + # Regression: the first pane's own shell is never applied, so the wait + # must still fire (the pane runs the default shell and draws a prompt). + _WaitFirstPaneCase("pane_shell_still_waits", None, "fish", True), + # window_shell IS applied to the first pane by its creator, so skip. + _WaitFirstPaneCase("window_shell_skips", "fish", None, False), +) + + +@pytest.mark.parametrize( + "case", + _WAIT_FIRST_PANE_CASES, + ids=[c.test_id for c in _WAIT_FIRST_PANE_CASES], +) +def test_compile_wait_pane_first_pane_effective_shell( + case: _WaitFirstPaneCase, +) -> None: + """wait_pane gates the first pane on the shell its creator actually applies.""" + ws = Workspace( + name="ws-wait-first", + wait_pane=True, + windows=[ + Window( + "w", + window_shell=case.window_shell, + panes=[Pane(run="x", shell=case.pane_shell)], + ), + ], + ) + compiled = compile_full(ws) + waited = any( + step.kind == "wait_pane" + for steps in (*compiled.host_after.values(), compiled.pre) + for step in steps + ) + assert waited is case.expect_wait + + def test_compile_window_index_targets_session_index() -> None: """window_index places a created window at session:N (capture preserved).""" ws = Workspace( From cf1535b76735c33ac2605621c2ee1515fb18ab08 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 05:51:09 -0500 Subject: [PATCH 137/154] docs(experimental): Add arun/aexecute doctests why: AGENTS.md requires working doctests on all methods; the async twins arun (fluent) and aexecute (plan) had none, unlike their documented siblings run and astream. what: - Add runnable async doctests (asyncio.run over AsyncConcreteEngine) --- src/libtmux/experimental/fluent.py | 12 +++++++++++- src/libtmux/experimental/ops/plan.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index 265426f4c..2fd913bc9 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -281,7 +281,17 @@ async def arun( version: str | None = None, planner: Planner | None = None, ) -> PlanResult: - """Async twin of :meth:`run` (same fold and host steps, ``await``ed).""" + """Async twin of :meth:`run` (same fold and host steps, ``await``ed). + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> p = plan() + >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) + >>> asyncio.run(p.arun(AsyncConcreteEngine())).ok + True + """ async def on_step(report: StepReport) -> None: for action in self._host_after.get(report.step.indices[-1], ()): diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index 4f66542b5..a3cc7485e 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -507,6 +507,17 @@ async def aexecute( """Resolve and execute the plan asynchronously (same resolution core). Mirrors :meth:`execute`; *on_step* is awaited per step. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.ops import SendKeys + >>> from libtmux.experimental.ops._types import PaneId + >>> plan = LazyPlan() + >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) + >>> asyncio.run(plan.aexecute(AsyncConcreteEngine())).ok + True """ version = resolve_engine_version(engine, version) gen = self._drive(version, planner or SequentialPlanner()) From 7f6ed383c6eab43781bf59f387258ddad620b25b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:28:41 -0500 Subject: [PATCH 138/154] Facade(fix): Add AsyncWindow.select_layout why: EagerWindow and LazyWindow expose select_layout, but AsyncWindow lacked it -- `await window.select_layout(...)` raised AttributeError, breaking eager/lazy/async parity for the layout op. what: - Add AsyncWindow.select_layout mirroring the eager/lazy method - Assert it in the async facade parity test --- src/libtmux/experimental/facade/window.py | 8 ++++++++ tests/experimental/facade/test_facade_matrix.py | 8 +++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/facade/window.py b/src/libtmux/experimental/facade/window.py index 32e4ed93a..f3a2476f9 100644 --- a/src/libtmux/experimental/facade/window.py +++ b/src/libtmux/experimental/facade/window.py @@ -188,6 +188,14 @@ async def rename(self, name: str) -> Result: version=self.version, ) + async def select_layout(self, layout: str) -> Result: + """Apply a layout to this window.""" + return await arun( + SelectLayout(target=WindowId(self.window_id), layout=layout), + self.engine, + version=self.version, + ) + async def kill(self) -> Result: """Kill this window.""" return await arun( diff --git a/tests/experimental/facade/test_facade_matrix.py b/tests/experimental/facade/test_facade_matrix.py index 0e37df75f..eefab9457 100644 --- a/tests/experimental/facade/test_facade_matrix.py +++ b/tests/experimental/facade/test_facade_matrix.py @@ -57,16 +57,18 @@ def test_lazy_window_records_and_executes() -> None: def test_async_window_and_pane() -> None: """Async facades mirror the eager ones via await.""" - async def main() -> tuple[str, bool]: + async def main() -> tuple[str, bool, bool]: window = AsyncWindow(AsyncConcreteEngine(), "@1") pane = await window.split() assert isinstance(pane, AsyncPane) sent = await pane.send_keys("echo hi", enter=True) - return pane.pane_id, sent.ok + laid_out = await window.select_layout("tiled") # parity with eager/lazy + return pane.pane_id, sent.ok, laid_out.ok - pane_id, ok = asyncio.run(main()) + pane_id, ok, laid_out = asyncio.run(main()) assert pane_id == "%1" assert ok + assert laid_out def test_eager_navigation_live(session: Session) -> None: From 5df981d460b4a320fcb38c570d6c98c4a3c71f5f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:31:06 -0500 Subject: [PATCH 139/154] Engines(fix): ImsgEngine reports tmux version why: ImsgEngine executes against a live tmux server but did not implement tmux_version(), unlike every other real-server engine. So resolve_engine_version() fell back to "assume latest", and a version-gated op run over imsg against an older server rendered a too-new flag that tmux rejects -- diverging from the subprocess and control-mode engines on the same server. what: - Add ImsgEngine.tmux_version(): memoized tmux -V via the resolved binary, mirroring SubprocessEngine - Test it satisfies SupportsTmuxVersion and reports/memoizes a version --- src/libtmux/experimental/engines/imsg/base.py | 18 ++++++++++++++++++ tests/experimental/engines/test_imsg.py | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/libtmux/experimental/engines/imsg/base.py b/src/libtmux/experimental/engines/imsg/base.py index 61e03f221..ba433a36e 100644 --- a/src/libtmux/experimental/engines/imsg/base.py +++ b/src/libtmux/experimental/engines/imsg/base.py @@ -14,6 +14,7 @@ import typing as t from libtmux import exc +from libtmux.common import get_version from libtmux.experimental.engines.base import CommandRequest, CommandResult from libtmux.experimental.engines.imsg.exc import ( ImsgProtocolError, @@ -227,6 +228,23 @@ def __init__(self, protocol_version: str | int | None = None) -> None: str(protocol_version) if protocol_version is not None else None ) self._resolved_tmux_bin: str | None = None + self._tmux_version: str | None = None + self._version_probed = False + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Like the other real-server engines, so version-gated ops render for the + true server version instead of degrading to "assume latest". Returns + ``None`` when the binary is missing or its version cannot be parsed. + """ + if not self._version_probed: + self._version_probed = True + try: + self._tmux_version = str(get_version(self._resolve_tmux_bin())) + except exc.LibTmuxException: + self._tmux_version = None + return self._tmux_version def run(self, request: CommandRequest) -> CommandResult: """Execute a tmux command over the server socket.""" diff --git a/tests/experimental/engines/test_imsg.py b/tests/experimental/engines/test_imsg.py index c70e39abe..6dae9e163 100644 --- a/tests/experimental/engines/test_imsg.py +++ b/tests/experimental/engines/test_imsg.py @@ -58,6 +58,17 @@ def test_imsg_registered() -> None: assert type(create_engine("imsg")).__name__ == "ImsgEngine" +def test_imsg_reports_tmux_version() -> None: + """ImsgEngine resolves its tmux version so version-gated ops render right.""" + from libtmux.experimental.engines.base import SupportsTmuxVersion + + engine = ImsgEngine() + assert isinstance(engine, SupportsTmuxVersion) # not "assume latest" + version = engine.tmux_version() + assert version is not None # real tmux is on PATH in the test env + assert version == engine.tmux_version() # memoized + + def test_v8_codec_header_round_trip() -> None: """A v8 frame packs to wire bytes and its header unpacks back (no tmux).""" codec = ProtocolV8Codec() From 83d305fbc10b60fad35a22bd14ed84fbd378f195 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:35:17 -0500 Subject: [PATCH 140/154] Mcp(fix): Gate wait_for_output prompts on events why: The run_and_wait / diagnose_failing_pane / interrupt_gracefully prompts steer the agent to call wait_for_output, but that tool is registered only on a streaming control-mode async server. On the sync server (and non-streaming async) the agent was told to call a tool that does not exist -> tool-not-found. what: - register_prompts(events_enabled=): register the wait_for_output prompts only when events streaming is enabled; build_dev_workspace always registers - build_async_server passes its computed events_enabled; the sync build_server leaves it False - Parametrized gate regression + a sync-server integration check --- .../experimental/mcp/fastmcp_adapter.py | 2 +- src/libtmux/experimental/mcp/prompts.py | 25 ++++++----- tests/experimental/mcp/test_prompts.py | 41 +++++++++++++++++-- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index e35d77c9c..f74ff427f 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -765,7 +765,7 @@ def build_async_server( if include_prompts: from libtmux.experimental.mcp.prompts import register_prompts - register_prompts(mcp) + register_prompts(mcp, events_enabled=events_enabled) if include_operations: register_operations( mcp, diff --git a/src/libtmux/experimental/mcp/prompts.py b/src/libtmux/experimental/mcp/prompts.py index 582826ba5..be4f9ccb3 100644 --- a/src/libtmux/experimental/mcp/prompts.py +++ b/src/libtmux/experimental/mcp/prompts.py @@ -3,8 +3,8 @@ Each function is a FastMCP prompt -- a template returning the text an MCP client sends to its model, packaging a few common workflows over the engine-ops vocabulary (``send_input`` / ``wait_for_output`` / ``capture_pane`` / -``create_session`` / ``split_pane``). Pure string builders, engine-agnostic, so -the same set registers on both the sync and async servers. +``create_session`` / ``split_pane``). Pure string builders, engine-agnostic; +:func:`register_prompts` picks which apply to a given server. """ from __future__ import annotations @@ -76,14 +76,19 @@ def interrupt_gracefully(pane_id: str) -> str: how to proceed -- do NOT escalate automatically to C-\ (SIGQUIT) or kill.""" -def register_prompts(mcp: FastMCP) -> None: - """Register the recipe prompts on *mcp*.""" +def register_prompts(mcp: FastMCP, *, events_enabled: bool = False) -> None: + """Register the recipe prompts on *mcp*. + + The prompts that steer an agent to call ``wait_for_output`` are registered + only when *events_enabled* -- that tool exists only on a streaming + control-mode server, so naming it elsewhere would send the agent to a + missing tool. ``build_dev_workspace`` needs no such tool and always + registers. + """ from fastmcp.prompts import Prompt - for fn in ( - run_and_wait, - diagnose_failing_pane, - build_dev_workspace, - interrupt_gracefully, - ): + prompts: list[t.Callable[..., str]] = [build_dev_workspace] + if events_enabled: + prompts += [run_and_wait, diagnose_failing_pane, interrupt_gracefully] + for fn in prompts: mcp.add_prompt(Prompt.from_function(fn, name=fn.__name__)) diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py index 341d8fa84..175737a83 100644 --- a/tests/experimental/mcp/test_prompts.py +++ b/tests/experimental/mcp/test_prompts.py @@ -30,15 +30,50 @@ ) -def test_prompts_registered() -> None: - """The four recipe prompts are registered on the server.""" +def test_sync_server_omits_wait_for_output_prompts() -> None: + """The sync server lacks wait_for_output, so those recipe prompts are gated off.""" server = build_server(ConcreteEngine()) async def main() -> set[str]: async with fastmcp.Client(server) as client: return {prompt.name for prompt in await client.list_prompts()} - assert asyncio.run(main()) >= _NAMES + names = asyncio.run(main()) + assert "build_dev_workspace" in names # needs no wait_for_output + assert names.isdisjoint(_NAMES - {"build_dev_workspace"}) + + +class _PromptGateCase(t.NamedTuple): + """events_enabled and the prompt names that should register.""" + + test_id: str + events_enabled: bool + expected: set[str] + + +_PROMPT_GATE_CASES = ( + _PromptGateCase("events_off", False, {"build_dev_workspace"}), + _PromptGateCase("events_on", True, _NAMES), +) + + +@pytest.mark.parametrize( + "case", + _PROMPT_GATE_CASES, + ids=[c.test_id for c in _PROMPT_GATE_CASES], +) +def test_register_prompts_gates_wait_for_output(case: _PromptGateCase) -> None: + """wait_for_output prompts register only when events (streaming) are enabled.""" + from libtmux.experimental.mcp.prompts import register_prompts + + mcp = fastmcp.FastMCP("test") + register_prompts(mcp, events_enabled=case.events_enabled) + + async def main() -> set[str]: + async with fastmcp.Client(mcp) as client: + return {prompt.name for prompt in await client.list_prompts()} + + assert asyncio.run(main()) == case.expected def test_prompt_bodies_use_engine_ops_vocabulary() -> None: From 942cbbee326c31c346046d5e77e2a191dd346e95 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:36:30 -0500 Subject: [PATCH 141/154] Mcp(fix): Redact non-str sensitive audit args why: The audit summary redacted a sensitive arg (keys/text/command/...) only when it was a str or dict; a non-conformant client sending e.g. command=["secret"] fell through to the raw-passthrough branch and logged the payload verbatim into the INFO audit record -- contradicting the documented "payload-bearing arguments never reach the log raw" promise. Schema validation rejects such calls, but _summarize_args runs first. what: - Shape-redact any other-typed sensitive value via _redacted_value_shape - Doctest that a list-valued sensitive arg is redacted --- src/libtmux/experimental/mcp/middleware.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/libtmux/experimental/mcp/middleware.py b/src/libtmux/experimental/mcp/middleware.py index d7510e03c..4b4f5c2fb 100644 --- a/src/libtmux/experimental/mcp/middleware.py +++ b/src/libtmux/experimental/mcp/middleware.py @@ -524,6 +524,8 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: 3 >>> "bar" in str(redacted) False + >>> "secret" in str(_summarize_args({"command": ["secret"]})) # non-str value + False """ summary: dict[str, t.Any] = {} for key, value in args.items(): @@ -531,6 +533,10 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: summary[key] = _redact_digest(value) elif key in _SENSITIVE_ARG_NAMES and isinstance(value, dict): summary[key] = {k: _redact_digest(str(v)) for k, v in value.items()} + elif key in _SENSITIVE_ARG_NAMES: + # Any other-typed sensitive value (e.g. a list from a non-conformant + # client) is shape-redacted, never passed through to the log raw. + summary[key] = _redacted_value_shape(value) elif key in _NESTED_ARG_LIST_NAMES: if isinstance(value, list): summary[key] = [ From 96ba314b08fcba5192d107078a3a5e1096d2670e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:38:10 -0500 Subject: [PATCH 142/154] docs(experimental): Trim more branch narrative why: AGENTS.md shipped-vs-branch keeps never-shipped, branch-internal narrative out of docstrings; round 1 missed a few. what: - control_mode.py: drop the "chainable-commands runner / libtmux-protocol-engines parser" lineage sentence - concrete.py: drop "preserving the historical behaviour" (an intermediate in-branch state) - new_pane.py: drop the "first operation gated by min_version" ordinal; keep only the current-state VersionUnsupported clause --- src/libtmux/experimental/engines/concrete.py | 6 +++--- src/libtmux/experimental/engines/control_mode.py | 3 +-- src/libtmux/experimental/ops/_ops/new_pane.py | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/concrete.py index 50774e431..78863eda8 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/concrete.py @@ -24,9 +24,9 @@ def _fabricate(fmt: str, counters: dict[str, int]) -> str: """Fabricate one id per ``#{..._id}`` token in *fmt*, in their order. - A single-token format (e.g. ``#{pane_id}``) yields one id, preserving the - historical behaviour; a multi-token capture (e.g. ``new-session -F - '#{session_id} #{window_id} #{pane_id}'``) yields a space-joined id per token. + A single-token format (e.g. ``#{pane_id}``) yields one id; a multi-token + capture (e.g. ``new-session -F '#{session_id} #{window_id} #{pane_id}'``) + yields a space-joined id per token. """ found: list[tuple[int, str, str]] = [] for key, sigil in (("session_id", "$"), ("window_id", "@"), ("pane_id", "%")): diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py index 31e15611a..2fee21f50 100644 --- a/src/libtmux/experimental/engines/control_mode.py +++ b/src/libtmux/experimental/engines/control_mode.py @@ -5,8 +5,7 @@ :class:`~.base.CommandResult`. Because it returns the same typed result the subprocess engine does, an operation run through control mode is indistinguishable -- at the result level -- from one run through a fork-per-call -subprocess. Adapted from the chainable-commands control runner and the -``libtmux-protocol-engines`` parser. +subprocess. The parser (:class:`ControlModeParser`) is I/O-free: it consumes bytes and emits parsed blocks, so it is unit-testable without spawning tmux. ``run_batch`` writes diff --git a/src/libtmux/experimental/ops/_ops/new_pane.py b/src/libtmux/experimental/ops/_ops/new_pane.py index 7409a29f8..c4398bb37 100644 --- a/src/libtmux/experimental/ops/_ops/new_pane.py +++ b/src/libtmux/experimental/ops/_ops/new_pane.py @@ -32,9 +32,9 @@ class NewPane(Operation[SplitWindowResult]): :class:`~.results.SplitWindowResult`, capturing the new pane id via ``-P -F '#{pane_id}'`` so plans, the facade, and MCP bind it the same way. - This is the first operation gated by :attr:`~.operation.Operation.min_version`: - rendering against a tmux older than 3.7 raises - :exc:`~.exc.VersionUnsupported`. + Rendering against a tmux older than 3.7 raises + :exc:`~.exc.VersionUnsupported` (this op sets + :attr:`~.operation.Operation.min_version`). Parameters ---------- From 155736d16831f7b861d10dfbea53837ee62bc53a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:38:42 -0500 Subject: [PATCH 143/154] Mcp(docs): Fix build_workspace registration claim why: register_plan_tools' docstring said build_workspace is registered only on the synchronous server, but the async branch registers it too (dispatching to abuild_workspace) -- misleading a maintainer about tool exposure. what: - State it registers on both servers, per-engine dispatch --- src/libtmux/experimental/mcp/fastmcp_adapter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/experimental/mcp/fastmcp_adapter.py b/src/libtmux/experimental/mcp/fastmcp_adapter.py index f74ff427f..e79a01457 100644 --- a/src/libtmux/experimental/mcp/fastmcp_adapter.py +++ b/src/libtmux/experimental/mcp/fastmcp_adapter.py @@ -411,8 +411,8 @@ def register_plan_tools( ``preview_plan`` / ``result_schema`` are pure; ``execute_plan`` runs a serialized plan (via :func:`~..mcp.plan_tools.aexecute_plan` on an async - engine). ``build_workspace`` is registered only on the synchronous server - (the declarative runner is synchronous). + engine). ``build_workspace`` is registered on both servers -- dispatching to + ``abuild_workspace`` on an async engine and ``build_workspace`` on a sync one. """ from fastmcp.tools import FunctionTool from mcp.types import ToolAnnotations From bad3ae50e75d9cb8d738df0e13fd6c5ba2faf519 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 06:57:34 -0500 Subject: [PATCH 144/154] Workspace(fix): Confirm cwd on the first pane why: The session start_directory lands on window 0's first pane, but confirm() read the window's active_pane. When a non-first pane is focused (or a split leaves focus off pane 0) with a different cwd, confirm falsely reported "first pane cwd != declared". what: - Check the first window's first pane (index 0), not active_pane - Live regression: a focused non-first pane at a different cwd still confirms ok --- src/libtmux/experimental/workspace/confirm.py | 6 +++- ..._async_control_engine_workspace_builder.py | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py index 12b958a54..cc92394d9 100644 --- a/src/libtmux/experimental/workspace/confirm.py +++ b/src/libtmux/experimental/workspace/confirm.py @@ -81,7 +81,11 @@ def _cwd_ok() -> bool: fresh = server.sessions.filter(session_id=session_id) if not fresh: return False - pane = next(iter(fresh[0].windows)).active_pane + # start_directory sets the session -c, which lands on window 0's + # FIRST pane -- not necessarily the active one (a split or focused + # non-first pane can carry a different cwd). + panes = list(next(iter(fresh[0].windows)).panes) + pane = panes[0] if panes else None return pane is not None and pane.pane_current_path == want_cwd if not retry_until(_cwd_ok, timeout, raises=False): diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index a5a0e4c4f..2bf4bd8b7 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -193,6 +193,37 @@ def test_workspace_builder_subprocess_live( assert report.ok, report.problems +def test_confirm_start_directory_checks_first_pane_not_active( + session: Session, + tmp_path: Path, +) -> None: + """start_directory is confirmed on window 0's FIRST pane, not the active one. + + Regression: confirm read active_pane, so a focused non-first pane with a + different cwd falsely reported "first pane cwd != declared". + """ + server = session.server + other = tmp_path / "other" + other.mkdir() + spec = Workspace( + name="ws-cwd-first", + start_directory=str(tmp_path), + windows=[ + Window( + "w", + panes=[ + Pane(run="echo a"), # first pane inherits ws.start_directory + Pane(run="echo b", start_directory=str(other), focus=True), + ], + ), + ], + ) + + assert spec.build(SubprocessEngine.for_server(server)).ok + report = confirm(spec, server) + assert report.ok, report.problems # first pane matches despite focus elsewhere + + # --- Robust QA: a rich workspace exercising the full feature surface --- From 2f7bce994a069027a5607ed3215cf7d1c8356663 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:21:21 -0500 Subject: [PATCH 145/154] Engines(feat[async_control_mode]): Add supervised reconnect why: A tmux restart or socket blip killed the async control-mode engine permanently -- the reader EOF'd, the engine went dead, and subscribers had to be torn down. A supervisor that self-heals keeps the event stream alive across the gap and lets subscribe() rely on _closing alone instead of a sticky _dead gate. what: - Replace the one-shot start()/reader with a supervisor that spawns tmux -C, replays desired subscriptions/attach, runs the reader inline (one at a time), and reconnects with deterministic jittered backoff when the reader returns on EOF. - Add add_subscription()/set_attach_targets() declarative state, replayed on every (re)connect; bump _generation per connect and reset parser/pending/attach before the new proc's bytes flow. - Surface first-connect failure to every start() caller; reset the backoff on a healthy connect. - Gate subscribe() on _closing only: a reconnecting (_dead) engine keeps its subscriber so the post-reconnect reader feeds it. - Cover supervisor reconnect, attach replay, and attach reset; drop the now-obsolete _dead-gate sentinel test. --- .../engines/async_control_mode.py | 434 ++++++++++++++---- .../test_async_control_mode_sentinel.py | 21 - .../test_async_control_mode_supervisor.py | 177 +++++++ tests/experimental/mcp/test_attach_reset.py | 13 + 4 files changed, 547 insertions(+), 98 deletions(-) create mode 100644 tests/experimental/engines/test_async_control_mode_supervisor.py create mode 100644 tests/experimental/mcp/test_attach_reset.py diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 059797f51..4de396497 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -14,10 +14,18 @@ - Command correlation is a FIFO of futures resolved in block-arrival order. A block that arrives with *no* pending command is **unsolicited** (a hook- triggered command, or the startup ACK) and is skipped, so correlation never - desyncs. The startup ACK is consumed synchronously in :meth:`start` before the - reader launches, closing the startup race. + desyncs. The startup ACK is consumed synchronously in :meth:`_spawn` before + the reader runs, closing the startup race. +- A supervisor owns the process lifecycle. :meth:`start` launches it once; it + spawns ``tmux -C``, replays the desired subscriptions, and runs the reader + inline (one reader at a time). When the reader returns on EOF, the supervisor + resets connection-scoped state -- a fresh parser, failed pending commands, + cleared attach -- bumps the connection generation, and reconnects with a + deterministic jittered backoff, so a tmux restart or socket blip self-heals + instead of freezing the engine. An intentional :meth:`aclose` flags + ``_closing`` first so the close is not mistaken for a crash and retried. - A reader failure or EOF marks the engine *dead* and fails every pending - command, rather than hanging. + command, rather than hanging; the supervisor then reconnects. - Notifications go to a bounded queue; on overflow the oldest is dropped and counted (backpressure), mirroring control mode's own ``%pause`` philosophy. """ @@ -52,6 +60,7 @@ _DEFAULT_TIMEOUT = 30.0 _STARTUP_TIMEOUT = 5.0 _STOP_TIMEOUT = 2.0 + _STREAM_END = object() # broadcast to subscriber queues to end their async for @@ -112,9 +121,10 @@ def _offer( def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: """Put *item* on *queue*, evicting the oldest entry first when it is full. - Like :func:`_offer` but drop-count-free: lands the stream-end sentinel even - on a queue already at ``maxsize``, so a slow consumer that hit backpressure - still gets closed instead of hanging on ``queue.get()``. + Like :func:`_offer` but drop-count-free: used to land the stream-end + sentinel even on a queue already at ``maxsize``, so a slow consumer that hit + backpressure still gets closed instead of hanging on ``queue.get()``. Pulled + out of the broadcast loop so the ``try``/``except`` stays out of it. """ try: queue.put_nowait(item) @@ -128,10 +138,11 @@ def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: def _swallow_future(future: asyncio.Future[t.Any]) -> None: """Retrieve a fire-and-forget future's outcome so it isn't flagged unretrieved. - A reap command is dispatched without an awaiter; its future resolves in the - reader. Calling :meth:`asyncio.Future.exception` marks the result retrieved - so a tmux-side ``%error`` never surfaces as a noisy "exception was never - retrieved" warning. + Subscription-replay commands are dispatched without an awaiter; their futures + resolve in the reader. Calling :meth:`asyncio.Future.exception` marks the + result retrieved so a tmux-side ``%error`` (or a reconnect that fails the + pending command) never surfaces as a noisy "exception was never retrieved" + warning. """ if future.cancelled(): return @@ -176,12 +187,20 @@ def __init__( self._subscribers: set[asyncio.Queue[t.Any]] = set() self._dropped_notifications = 0 self._proc: asyncio.subprocess.Process | None = None - self._reader_task: asyncio.Task[None] | None = None self._start_lock = asyncio.Lock() self._write_lock = asyncio.Lock() self._started = False - self._closing = False self._dead: BaseException | None = None + # Desired (declarative) state, replayed on every (re)connect. + self._desired_subscriptions: list[str] = [] + self._desired_attach: list[str] = [] + self._attached_session: str | None = None + # Supervisor / reconnect bookkeeping. + self._generation = 0 + self._closing = False + self._supervisor_task: asyncio.Task[None] | None = None + self._connected = asyncio.Event() + self._spawn_error: BaseException | None = None def tmux_version(self) -> str | None: """Report the connected server's tmux version (``tmux -V``). @@ -196,33 +215,120 @@ def tmux_version(self) -> str | None: except exc.LibTmuxException: return None + def add_subscription(self, spec: str) -> None: + """Record a desired ``refresh-client -B`` subscription (idempotent). + + The spec is stored in :attr:`_desired_subscriptions` and replayed on + every (re)connect by the supervisor, so a subscription survives a tmux + restart or socket blip. Adding the same spec twice is a no-op. + + Parameters + ---------- + spec : str + A ``refresh-client -B`` subscription spec, e.g. + ``"agentstate:%*:#{@agent_state}"``. + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine._desired_subscriptions + ['agentstate:%*:#{@agent_state}'] + """ + if spec not in self._desired_subscriptions: + self._desired_subscriptions.append(spec) + + def set_attach_targets(self, ids: list[str]) -> None: + """Record the sessions the engine should (re)attach to on reconnect. + + Stores a *copy* of *ids* in :attr:`_desired_attach`. The supervisor + replays these on every (re)connect via :meth:`_replay_attach`, so the + engine stays attached across a tmux restart or socket blip (a control + client attaches to one session at a time, so the last target wins). + + Parameters + ---------- + ids : list[str] + Session ids to attach to (e.g. ``["$0", "$1"]``). + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.set_attach_targets(["$0", "$1"]) + >>> engine._desired_attach + ['$0', '$1'] + """ + self._desired_attach = list(ids) + async def start(self) -> None: - """Spawn ``tmux -C``, consume the startup ACK, and start the reader.""" + """Launch the supervisor (once) and wait for its first connection. + + The supervisor owns the ``tmux -C`` process lifecycle: it spawns the + proc, consumes the startup ACK, replays desired subscriptions, runs the + reader, and reconnects with backoff when the reader returns. This method + is idempotent (the ``_start_lock`` + ``_started`` guard) and never + launches a second supervisor; all callers block until the first + connection is established. + """ async with self._start_lock: - if self._started: - return - tmux_bin = self.tmux_bin or shutil.which("tmux") - if tmux_bin is None: - raise exc.TmuxCommandNotFound - cmd = [tmux_bin, *self.server_args, "-C"] - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + if not self._started: + self._closing = False + self._spawn_error = None + self._connected.clear() + self._supervisor_task = asyncio.create_task( + self._supervisor(), + name="libtmux-async-control-supervisor", ) - except FileNotFoundError: - raise exc.TmuxCommandNotFound from None - self._proc = proc - self._dead = None - await self._consume_startup() - self._reader_task = asyncio.create_task( - self._reader(), - name="libtmux-async-control-reader", + self._started = True + # Block (every caller) until the supervisor's first connect resolves. + if self._supervisor_task is not None: + await self._connected.wait() + if self._spawn_error is not None: + # Keep _spawn_error set here: a *concurrent* start() caller from + # the same failed first connect must also observe it and raise, + # not see a nulled error and return "success" against a dead + # engine. The error is cleared only by the fresh-start reset + # above (the `if not self._started` block) when a NEW attempt + # begins, so every waiter from this failed connect raises + # consistently. + error = self._spawn_error + async with self._start_lock: + self._started = False + self._supervisor_task = None + raise error + + async def _spawn(self) -> None: + """Spawn a fresh ``tmux -C`` process and consume its startup ACK. + + Extracted from :meth:`start` so the supervisor can re-run it on every + reconnect. Sets :attr:`_proc`, then clears :attr:`_dead` only *after* the + startup ACK is consumed (so a command racing the reconnect still hits the + dead-guard). The caller is responsible for resetting the parser *before* + this runs, so the new process's startup bytes are parsed by a fresh parser. + """ + tmux_bin = self.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + cmd = [tmux_bin, *self.server_args, "-C"] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - await self._reap_own_session() - self._started = True + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + self._proc = proc + # Keep the death-sentinel set while the startup ACK is consumed: across a + # reconnect ``_connected`` stays set, so a concurrent ``run_batch`` would + # otherwise pass the dead-guard, write to the new proc, and have its reply + # DRAINED+DISCARDED by ``_consume_startup`` (its future then times out and + # its stale pending entry desyncs FIFO). Clearing ``_dead`` only after the + # ACK is consumed makes such a racing command hit the dead-guard instead. + await self._consume_startup() + self._dead = None async def _consume_startup(self) -> None: """Read and discard tmux's startup ACK block before commands flow. @@ -254,32 +360,6 @@ async def _consume_startup(self) -> None: if self._parser.blocks(): # startup ACK seen and discarded return - async def _reap_own_session(self) -> None: - """Mark this control client's throwaway session ``destroy-unattached``. - - A bare ``tmux -C`` connect implies ``new-session``, so each connection - spawns a phantom session on the target server. Setting - ``destroy-unattached on`` on the *current* session (no ``-t``/``-g``, so - scoped to exactly that phantom, never global) makes tmux reap it the - moment this client disconnects, so control mode never litters the server - with throwaway sessions. Fire-and-forget: the reader resolves the result - future, which is swallowed. - """ - proc = self._proc - if proc is None or proc.stdin is None: - return - loop = asyncio.get_running_loop() - argv = ("set-option", "destroy-unattached", "on") - async with self._write_lock: - future: asyncio.Future[CommandResult] = loop.create_future() - future.add_done_callback(_swallow_future) - self._pending.append(_PendingCommand(future, argv, command_count(argv))) - try: - proc.stdin.write((render_control_line(argv) + "\n").encode()) - await proc.stdin.drain() - except (BrokenPipeError, OSError): - return - async def run(self, request: CommandRequest) -> CommandResult: """Execute one tmux command over the control connection.""" return (await self.run_batch([request]))[0] @@ -344,14 +424,19 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: Each subscriber gets its own queue, so concurrent subscribers (the event push tool, the pull ring, the output monitor) each see *every* notification rather than competing for one shared stream. The iterator - runs until the engine dies or is closed, or the caller stops iterating; - its queue is unregistered on exit. When the engine dies or closes, - ``_STREAM_END`` is broadcast to every subscriber queue so the ``async - for`` ends cleanly instead of hanging on ``queue.get()``. A subscribe() - after :meth:`aclose` or after the engine has died yields nothing, since - no broadcast would ever reach its fresh queue. + runs until the engine is closed or the caller stops iterating; its queue + is unregistered on exit. When the engine dies, ``_STREAM_END`` is + broadcast to every subscriber queue so the ``async for`` ends cleanly + instead of hanging on ``queue.get()``. + + A subscribe() *after* :meth:`aclose` (which set :attr:`_closing`, + broadcast the stream-end sentinel, and cleared :attr:`_subscribers`) + would register a fresh queue no broadcast will ever touch, hanging the + consumer forever. So a permanently-closing engine yields nothing and + ends at once. A merely :attr:`_dead` (reconnecting) engine keeps the + subscriber, so the post-reconnect reader feeds it. """ - if self._closing or self._dead is not None: + if self._closing: return queue: asyncio.Queue[t.Any] = asyncio.Queue( maxsize=self._event_queue_size, @@ -372,21 +457,26 @@ def dropped_notifications(self) -> int: return self._dropped_notifications async def aclose(self) -> None: - """Tear down: flag closing, cancel the reader, end subscribers, kill proc. + """Tear down: flag closing, cancel the supervisor, fail pending, kill proc. - Setting ``_closing`` first makes a subscribe() racing the close end at - once instead of registering a queue no broadcast will reach. + Setting :attr:`_closing` *first* distinguishes an intentional close from a + crash, so cancelling the supervisor (and the reader it owns inline) ends + the loop instead of triggering a reconnect. """ if not self._started: return self._closing = True self._started = False - reader = self._reader_task - self._reader_task = None - if reader is not None: - reader.cancel() + supervisor = self._supervisor_task + self._supervisor_task = None + if supervisor is not None: + supervisor.cancel() with contextlib.suppress(asyncio.CancelledError): - await reader + await supervisor + # Release any start() still blocked on the first connection. The + # supervisor's finally covers a normal exit, but a task cancelled before + # it ever runs never reaches that finally, so set it here too. + self._connected.set() self._broadcast_stream_end() self._fail_pending(ControlModeError("control-mode engine closed")) proc = self._proc @@ -415,6 +505,196 @@ async def __aexit__( """Close the engine on context exit.""" await self.aclose() + async def _supervisor(self) -> None: + """Own the proc lifecycle: connect, replay desired state, read, reconnect. + + One supervisor runs at a time (launched once by :meth:`start`). Each + iteration resets connection-scoped state *before* the new process's bytes + flow -- a fresh :class:`~.control_mode.ControlModeParser`, failed pending + commands, cleared attach -- then spawns ``tmux -C``, bumps + :attr:`_generation`, replays subscriptions, and runs the reader inline so + there is never more than one reader. When the reader returns on EOF (and + the engine is not :attr:`_closing`), it backs off with deterministic + jitter and reconnects. An intentional :meth:`aclose` cancels this task, + which propagates into the inline reader. + """ + attempt = 0 + connected_once = False + try: + while not self._closing: + # Reset connection-scoped state BEFORE the new proc's bytes flow. + # Reconnect is the only place permitted to reset the parser and + # fail pending, keeping FIFO correlation aligned across the gap. + self._parser = ControlModeParser() + self._fail_pending(ControlModeError("control-mode reconnecting")) + self._reset_attach() + try: + await self._spawn() + except asyncio.CancelledError: + raise + except BaseException as error: + if not connected_once: + # First connect failed (e.g. missing binary): surface it + # to start() and stop -- a permanent error should not spin. + self._spawn_error = error + return + # A transient spawn failure mid-life: back off and retry. + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + continue + # The spawn succeeded and its startup ACK was consumed, so this + # connection is healthy: reset the backoff. A reconnect after a + # long healthy session then starts fast instead of waiting near + # the cap -- only *consecutive connect failures* (the except path + # above) escalate. + attempt = 0 + self._generation += 1 + connected_once = True + await self._reap_own_session() + await self._replay_subscriptions() + await self._replay_attach() + self._connected.set() # first connect done: unblock start() + # The reader runs inline (one reader at a time). On EOF it returns + # and we reconnect; on cancellation (aclose) it propagates out. + await self._reader() + if self._closing: + return + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + finally: + # Always release start() waiters -- even on cancel/return before the + # first connect -- so an aclose() racing a start() can never leave a + # waiter blocked on _connected.wait() forever. + self._connected.set() + + async def _reap_own_session(self) -> None: + """Mark this control client's throwaway session ``destroy-unattached``. + + A bare ``tmux -C`` connect implies ``new-session``, so each connection + spawns a phantom session on the target server. Right after connect -- while + the control client is still attached to that just-created session, before + :meth:`_replay_attach` moves it -- this sets ``destroy-unattached on`` on + the *current* session (the phantom; no ``-t`` and no ``-g``, so it is scoped + to exactly that session, never global). tmux then reaps the phantom the + moment the client attaches elsewhere or disconnects, so control-mode never + litters the server with throwaway sessions and a reconnect storm cannot pile + them up. Fire-and-forget, like :meth:`_replay_subscriptions` -- the result + block is swallowed since the reader has not started yet. + """ + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + argv = ("set-option", "destroy-unattached", "on") + async with self._write_lock: + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + try: + proc.stdin.write((render_control_line(argv) + "\n").encode()) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before the option landed; the reader will EOF and + # the supervisor reconnects (the next connect re-marks its phantom). + return + + async def _replay_subscriptions(self) -> None: + """Re-issue every desired subscription to the freshly connected proc. + + Each spec is sent as ``refresh-client -B `` with a queued pending + command, so the reader correlates its result block in FIFO order (the + replay commands sit at the front of the deque, ahead of any user command, + because :meth:`start` has not yet returned). The futures are + fire-and-forget: their outcome is swallowed rather than awaited, since the + reader has not started yet. Writing here re-enters neither :meth:`start` + nor :meth:`run_batch`, so the supervisor cannot recurse into itself. + """ + if not self._desired_subscriptions: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for spec in self._desired_subscriptions: + argv = ("refresh-client", "-B", spec) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + + async def _replay_attach(self) -> None: + """Re-attach to every desired session on the freshly connected proc. + + Mirrors :meth:`_replay_subscriptions`. A fresh ``tmux -C`` process is + attached to nothing, and per-pane ``%subscription-changed`` only flows + to an *attached* control client, so the supervisor must re-attach after + every (re)connect or a monitor that relies on the option channel goes + silent. Each target is written as ``attach-session -t `` directly + to stdin with a swallowed pending future (same FIFO + :attr:`_write_lock` + discipline as the subscription replay), so it re-enters neither + :meth:`start` nor :meth:`run_batch`. A control client attaches to one + session at a time, so the last target wins. The attach is fire-and-forget, + so it does not cache :attr:`_attached_session` here; the events layer sets + that on a confirmed attach and re-attaches on a miss. Does nothing when + :attr:`_desired_attach` is empty. + """ + if not self._desired_attach: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for target in self._desired_attach: + argv = ("attach-session", "-t", target) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + # The attach is fire-and-forget (swallowed future): its returncode is + # not awaited, so _attached_session is NOT cached optimistically here. + # The events layer caches it only on a confirmed attach and re-attaches + # on a miss, so a session that vanished during the disconnect surfaces a + # real error instead of a silently-empty capture. + + def _reset_attach(self) -> None: + """Clear the sticky attach so reconnect re-attaches from scratch. + + The events layer caches which session this engine attached to in + :attr:`_attached_session`; a fresh process is attached to nothing, so the + cache must be cleared on every (re)connect. + """ + self._attached_session = None + + @staticmethod + def _backoff(attempt: int) -> float: + """Deterministic jittered exponential backoff (seconds) for *attempt*. + + Capped exponential (``min(0.1 * 2**attempt, 5.0)``) plus a small jitter + derived solely from *attempt* -- never :mod:`random` or wall-clock time -- + so reconnect timing stays reproducible under test. + """ + base = min(0.1 * (2.0**attempt), 5.0) + jitter = 0.01 * float(attempt % 7) + return base + jitter + async def _reader(self) -> None: """Background task: read tmux output, resolve futures, publish events.""" proc = self._proc @@ -479,7 +759,7 @@ def _broadcast_stream_end(self) -> None: self._subscribers.clear() def _mark_dead(self, error: BaseException) -> None: - """Record the engine as dead, fail pending commands, end subscribers.""" + """Record the engine as dead and fail all pending commands.""" if self._dead is None: self._dead = error self._fail_pending(error) diff --git a/tests/experimental/engines/test_async_control_mode_sentinel.py b/tests/experimental/engines/test_async_control_mode_sentinel.py index d3e2ecdf4..f3bdf2029 100644 --- a/tests/experimental/engines/test_async_control_mode_sentinel.py +++ b/tests/experimental/engines/test_async_control_mode_sentinel.py @@ -90,24 +90,3 @@ async def drain() -> list[ControlNotification]: return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang assert asyncio.run(main()) == [] - - -def test_subscribe_ends_immediately_after_death() -> None: - """A subscribe() after the engine died must end at once, not hang. - - _mark_dead() broadcasts the sentinel and clears the subscriber set but does - not flip ``_closing``; a queue registered afterwards would never receive a - sentinel. The ``_dead`` gate makes subscribe() yield nothing and return. - """ - - async def main() -> list[ControlNotification]: - engine = AsyncControlModeEngine() - engine._started = True # pretend a connection was established - engine._mark_dead(ControlModeError("tmux -C closed stdout")) - - async def drain() -> list[ControlNotification]: - return [note async for note in engine.subscribe()] - - return await asyncio.wait_for(drain(), timeout=1.0) # must NOT hang - - assert asyncio.run(main()) == [] diff --git a/tests/experimental/engines/test_async_control_mode_supervisor.py b/tests/experimental/engines/test_async_control_mode_supervisor.py new file mode 100644 index 000000000..63fa5176c --- /dev/null +++ b/tests/experimental/engines/test_async_control_mode_supervisor.py @@ -0,0 +1,177 @@ +"""The engine reconnects and replays desired state after the proc dies.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine +from libtmux.experimental.engines.control_mode import ControlModeError + +if t.TYPE_CHECKING: + import pytest + + from libtmux.server import Server + from libtmux.session import Session + + +def test_desired_subscriptions_recorded_idempotently() -> None: + """``add_subscription`` records desired specs idempotently.""" + engine = AsyncControlModeEngine() + engine.add_subscription("agentstate:%*:#{@agent_state}") + engine.add_subscription("agentstate:%*:#{@agent_state}") # idempotent + assert engine._desired_subscriptions == ["agentstate:%*:#{@agent_state}"] + + +def test_reconnects_after_proc_exits(server: Server) -> None: + """The supervisor reconnects and bumps generation after the proc dies.""" + + async def main() -> int: + engine = AsyncControlModeEngine.for_server(server) + await engine.start() + gen0 = engine._generation + # simulate the control proc dying + assert engine._proc is not None + engine._proc.terminate() + await asyncio.sleep(1.5) # supervisor backoff + reconnect + # a fresh run must succeed over the reconnected proc + from libtmux.experimental.engines.base import CommandRequest + + result = await engine.run(CommandRequest.from_args("list-sessions")) + await engine.aclose() + assert result.returncode == 0 + return engine._generation - gen0 + + bumped = asyncio.run(main()) + assert bumped >= 1 + + +def test_attach_replayed_on_reconnect(session: Session) -> None: + """A reconnect runs the attach replay without optimistically caching. + + A fresh ``tmux -C`` proc is attached to nothing, so the supervisor replays + ``attach-session`` on every (re)connect. That replay is fire-and-forget, so + it must NOT cache ``_attached_session`` -- that cache is owned by the events + layer (set only on a confirmed attach, re-attached on a miss), so a session + that vanished during the disconnect surfaces a real error rather than a + silently-empty capture. This pins that the cache stays unset across a + *reconnect*, not just the first connect. + """ + + async def main() -> str | None: + from libtmux.experimental.engines.base import CommandRequest + + sid = session.session_id + assert sid is not None # a live session always has an id + engine = AsyncControlModeEngine.for_server(session.server) + engine.set_attach_targets([sid]) + await engine.start() + # The first connect replayed the attach but did not cache it. + assert engine._attached_session is None + # Kill the proc; the supervisor reconnects and replays the attach again. + assert engine._proc is not None + engine._proc.terminate() + await asyncio.sleep(1.6) # backoff + reconnect + replay + # A fresh command confirms the reconnected proc is live; by the time it + # returns the reconnect has run past _replay_attach. + await engine.run(CommandRequest.from_args("list-sessions")) + cached = engine._attached_session + await engine.aclose() + return cached + + assert asyncio.run(main()) is None + + +def test_spawn_keeps_dead_until_startup_ack_consumed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_spawn`` clears ``_dead`` only *after* the startup ACK is consumed. + + Across a reconnect ``_connected`` stays set, so a command racing the + reconnect's startup window must still hit the dead-guard rather than have its + reply drained and discarded by ``_consume_startup``. The fix keeps ``_dead`` + set until the ACK is fully consumed; this asserts that ordering deterministically + (no real proc, no timing) by observing ``_dead`` from inside startup. + """ + observed: dict[str, object] = {} + + class _FakeProc: + # _spawn only stores the proc; the overridden _consume_startup never + # reads it, so a bare placeholder process is enough here. + returncode: int | None = None + + class _Probe(AsyncControlModeEngine): + async def _consume_startup(self) -> None: + # liveness state at the instant the startup ACK begins draining + observed["dead_during_startup"] = self._dead + + async def _fake_exec(*_a: object, **_k: object) -> _FakeProc: + return _FakeProc() + + async def main() -> None: + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + engine = _Probe(tmux_bin="tmux") + engine._dead = ControlModeError("prior EOF") # simulate post-disconnect + await engine._spawn() + observed["dead_after"] = engine._dead + + asyncio.run(main()) + assert observed["dead_during_startup"] is not None # dead-guard still active + assert observed["dead_after"] is None # cleared only once the ACK is consumed + + +def test_concurrent_start_all_raise_on_first_connect_failure() -> None: + """Every concurrent ``start()`` raising on a first-connect spawn failure. + + The supervisor records the spawn error in ``_spawn_error`` then returns. The + first ``start()`` waiter must not null it out from under a second concurrent + waiter, or that second caller would observe ``_spawn_error is None`` and + return "success" against a dead engine. Both gathered ``start()`` calls must + raise the same spawn error. + + Deterministic: ``_spawn`` always raises (no real proc, no timing); the two + waiters wake FIFO from the supervisor's single ``_connected.set()``. + """ + + async def main() -> list[object]: + class _Probe(AsyncControlModeEngine): + async def _spawn(self) -> None: + msg = "spawn failed" + raise ControlModeError(msg) + + engine = _Probe() + return list( + await asyncio.gather(engine.start(), engine.start(), return_exceptions=True) + ) + + results = asyncio.run(main()) + assert len(results) == 2 + assert all(isinstance(r, ControlModeError) for r in results) + + +def test_aclose_releases_start_waiter_before_first_connect() -> None: + """``aclose`` racing a never-connected ``start`` must not hang the waiter. + + If the supervisor is cancelled before it ever sets ``_connected``, an + in-flight ``start()`` blocked on ``_connected.wait()`` would hang forever. + The supervisor's ``finally`` plus ``aclose``'s own ``_connected.set()`` release + it deterministically. + """ + + async def main() -> None: + block = asyncio.Event() # never set: the supervisor hangs until cancelled + + class _Probe(AsyncControlModeEngine): + async def _spawn(self) -> None: + await block.wait() # park in spawn, before _connected is ever set + + engine = _Probe() + start_task = asyncio.create_task(engine.start()) + # Let start() launch the supervisor and park on _connected.wait(), and the + # supervisor park in _spawn (so it has entered its try/finally). + for _ in range(5): + await asyncio.sleep(0) + await engine.aclose() # cancels the supervisor before it ever connected + await asyncio.wait_for(start_task, timeout=1.0) # must NOT hang + + asyncio.run(main()) diff --git a/tests/experimental/mcp/test_attach_reset.py b/tests/experimental/mcp/test_attach_reset.py new file mode 100644 index 000000000..0172c5826 --- /dev/null +++ b/tests/experimental/mcp/test_attach_reset.py @@ -0,0 +1,13 @@ +"""A reconnect must clear the sticky attach so %output flows again.""" + +from __future__ import annotations + +from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine + + +def test_reset_attach_clears_flag() -> None: + """_reset_attach() must set _attached_session to None.""" + engine = AsyncControlModeEngine() + engine._attached_session = "$0" + engine._reset_attach() + assert getattr(engine, "_attached_session", "sentinel") is None From 17e2e1e5c112da7ce5fe2b92cb2171ce6f9c8d15 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:48:34 -0500 Subject: [PATCH 146/154] Mcp(fix[events]): Restart the event drain after reconnect why: The engine's supervisor now reconnects on a tmux restart or socket blip, but _broadcast_stream_end ends the subscribe stream on the disconnect, so the pull ring's drain task completes. A bare ``is None`` guard never re-subscribed, silently freezing the cursor after a reconnect. what: - Restart the drain in _EventRing._ensure_started when the prior task has completed (not just when unset). - Clear a prior drain error at the start of _drain (a fresh attempt) rather than on restart, so a still-unread failure is not wiped before since() surfaces it -- a persistently dead stream must not read as empty-but-healthy. - Cover restart-only-if-done and persistent-error surfacing with parametrized tests. --- src/libtmux/experimental/mcp/events.py | 17 ++- tests/experimental/mcp/test_events.py | 142 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/libtmux/experimental/mcp/events.py b/src/libtmux/experimental/mcp/events.py index 3f290e54f..de22a01d5 100644 --- a/src/libtmux/experimental/mcp/events.py +++ b/src/libtmux/experimental/mcp/events.py @@ -178,8 +178,14 @@ def __init__(self, engine: _StreamEngine, maxlen: int = _RING_SIZE) -> None: self._error: str | None = None def _ensure_started(self) -> None: - """Start the drainer task once, lazily, on the running loop.""" - if self._task is None: + """Start the drainer task, lazily, on the running loop. + + Also restarts it after it has *completed* -- the engine's + ``_broadcast_stream_end`` ends the subscribe stream on a disconnect, so + the drain task finishes; a bare ``is None`` guard would never re-subscribe + after a reconnect, silently freezing the cursor. + """ + if self._task is None or self._task.done(): self._task = asyncio.create_task(self._drain(), name="libtmux-mcp-events") async def _drain(self) -> None: @@ -187,8 +193,13 @@ async def _drain(self) -> None: A reader failure is recorded and surfaced on the next :meth:`since` call, rather than silently freezing the cursor or raising at garbage-collection - time. The subscription is closed deterministically via ``aclosing``. + time. The subscription is closed deterministically via ``aclosing``. The + prior error is cleared here, as this fresh attempt begins -- not in + :meth:`_ensure_started`, so a restart cannot wipe a still-unread failure + before :meth:`since` surfaces it (which would mask a persistently dead + stream as empty-but-healthy). """ + self._error = None stream = t.cast("AsyncGenerator[t.Any, None]", self._engine.subscribe()) try: async with contextlib.aclosing(stream) as managed: diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index 079fbe9c9..de775d0ed 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import contextlib import typing as t import pytest @@ -49,6 +50,147 @@ async def subscribe(self) -> AsyncIterator[ControlNotification]: _MON_STREAM = (b"%output %1 a b", b"%output %1 c", b"%window-add @9") +class _BlockingStreamEngine: + """An async engine whose ``subscribe()`` never ends (a live stream).""" + + _attached_session: str | None = None + + async def run(self, request: CommandRequest) -> CommandResult: + """Acknowledge any command.""" + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Block forever (a never-ending stream).""" + await asyncio.Event().wait() + yield ControlNotification.parse(b"%unreachable") # present so this is a gen + + +class _RestartCase(t.NamedTuple): + """An EventRing drain state and whether _ensure_started should restart it.""" + + test_id: str + stream_ends: bool # True: prior drain completes; False: still running + expect_restart: bool + + +_RESTART_CASES = ( + _RestartCase("done_drain_restarts", stream_ends=True, expect_restart=True), + _RestartCase("running_drain_kept", stream_ends=False, expect_restart=False), +) + + +@pytest.mark.parametrize( + list(_RestartCase._fields), + _RESTART_CASES, + ids=[c.test_id for c in _RESTART_CASES], +) +def test_event_ring_restarts_completed_drain( + test_id: str, + stream_ends: bool, + expect_restart: bool, +) -> None: + """_ensure_started restarts a *completed* drain (post-reconnect), not a live one.""" + from libtmux.experimental.mcp.events import _EventRing + + async def main() -> bool: + engine = FakeStreamEngine(()) if stream_ends else _BlockingStreamEngine() + ring = _EventRing(engine) + ring.since(0) # starts the drain task + first = ring._task + assert first is not None + if stream_ends: + await first # the empty stream ends, so the drain completes + assert first.done() + ring.since(0) # _ensure_started: restart only if the prior task is done + second = ring._task + restarted = second is not first + for task in {first, second}: + if task is not None and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + return restarted + + assert asyncio.run(main()) is expect_restart + + +class _RaisingStreamEngine: + """An async engine whose ``subscribe()`` raises, so the drain records it.""" + + _attached_session: str | None = None + + async def run(self, request: CommandRequest) -> CommandResult: + """Acknowledge any command.""" + return CommandResult(cmd=("tmux", *request.args), returncode=0) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Acknowledge a batch of commands.""" + return [await self.run(r) for r in requests] + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Fail on first iteration (a permanently dead stream).""" + msg = "engine permanently dead" + raise RuntimeError(msg) + yield ControlNotification.parse(b"%unreachable") # marks this a generator + + +class _DrainErrorCase(t.NamedTuple): + """Whether a drain errored and whether since() must surface an ``error`` key.""" + + test_id: str + raises: bool # True: the drain fails; False: it ends cleanly + expect_error_key: bool + + +_DRAIN_ERROR_CASES = ( + _DrainErrorCase("persistent_error_surfaces", raises=True, expect_error_key=True), + _DrainErrorCase("clean_drain_no_error", raises=False, expect_error_key=False), +) + + +@pytest.mark.parametrize( + list(_DrainErrorCase._fields), + _DRAIN_ERROR_CASES, + ids=[c.test_id for c in _DRAIN_ERROR_CASES], +) +def test_since_surfaces_persistent_drain_error( + test_id: str, + raises: bool, + expect_error_key: bool, +) -> None: + """A failed drain must surface via since(), not be masked by the restart. + + Regression: clearing the error inside _ensure_started wiped it before since() + read it, so a permanently dead stream reported as empty-but-healthy forever. + """ + from libtmux.experimental.mcp.events import _EventRing + + async def main() -> bool: + engine = _RaisingStreamEngine() if raises else FakeStreamEngine(()) + ring = _EventRing(engine) + ring.since(0) # start the first drain + if ring._task is not None: + await ring._task # let it run to completion (error or clean end) + out = ring.since(0) # restarts the drain; a prior error must still show + if ring._task is not None and not ring._task.done(): + ring._task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await ring._task + return "error" in out + + assert asyncio.run(main()) is expect_error_key + + class InstrumentedEngine: """A fake stream engine that records commands and scripts a few responses. From afd3e2f171da963f0af7af9976cf69ab135d2aec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:48:34 -0500 Subject: [PATCH 147/154] Objects(refactor): Rename facade package to objects why: "Facade" is not idiomatic Python for these engine-typed classes. They are the domain-shaped tmux nouns -- server/session/window/pane/ client -- so the package reads as libtmux.experimental.objects, and an individual engine-bound class is a "wrapper" in prose. Retires the facade/handle vocabulary. what: - Rename src/libtmux/experimental/facade -> objects and its tests, keeping the Eager/Lazy/Async class names unchanged. - Reword package docstrings from "facade"/"handle" to "object". - Update the three stray package references (ops.results, ops.new_pane, docs/experimental) to "wrapper". --- docs/experimental.md | 2 +- .../{facade => objects}/__init__.py | 25 +++++++----- .../{facade => objects}/client.py | 10 ++--- .../experimental/{facade => objects}/pane.py | 38 +++++++++---------- .../{facade => objects}/server.py | 20 +++++----- .../{facade => objects}/session.py | 16 ++++---- .../{facade => objects}/window.py | 22 +++++------ src/libtmux/experimental/ops/_ops/new_pane.py | 2 +- src/libtmux/experimental/ops/results.py | 2 +- tests/experimental/facade/__init__.py | 3 -- tests/experimental/objects/__init__.py | 3 ++ .../test_matrix_complete.py | 4 +- .../test_object_matrix.py} | 12 +++--- .../test_pane_object.py} | 18 ++++----- 14 files changed, 91 insertions(+), 86 deletions(-) rename src/libtmux/experimental/{facade => objects}/__init__.py (52%) rename src/libtmux/experimental/{facade => objects}/client.py (91%) rename src/libtmux/experimental/{facade => objects}/pane.py (91%) rename src/libtmux/experimental/{facade => objects}/server.py (84%) rename src/libtmux/experimental/{facade => objects}/session.py (91%) rename src/libtmux/experimental/{facade => objects}/window.py (89%) delete mode 100644 tests/experimental/facade/__init__.py create mode 100644 tests/experimental/objects/__init__.py rename tests/experimental/{facade => objects}/test_matrix_complete.py (95%) rename tests/experimental/{facade/test_facade_matrix.py => objects/test_object_matrix.py} (88%) rename tests/experimental/{facade/test_pane_facade.py => objects/test_pane_object.py} (89%) diff --git a/docs/experimental.md b/docs/experimental.md index 83ebcf439..8c7c4c873 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -34,7 +34,7 @@ True ``` How a *failed* result is treated is the engine's policy: the classic subprocess -path raises in its facade to match today's libtmux behavior, while the newer +path raises in its wrapper to match today's libtmux behavior, while the newer engines hand the result back and let the caller decide. ## Choosing an engine diff --git a/src/libtmux/experimental/facade/__init__.py b/src/libtmux/experimental/objects/__init__.py similarity index 52% rename from src/libtmux/experimental/facade/__init__.py rename to src/libtmux/experimental/objects/__init__.py index 5d1a62943..958fc790e 100644 --- a/src/libtmux/experimental/facade/__init__.py +++ b/src/libtmux/experimental/objects/__init__.py @@ -1,6 +1,11 @@ -"""Engine-typed facades over the operation spine. +"""Engine-typed tmux objects over the operation spine. -The execution mode lives in the facade *type* (eager vs lazy vs async), so each +Each class binds an engine + a tmux id and exposes a curated, ergonomic method +set. The objects are the domain-shaped layer over the Core ``Operation``/engine +spine: the same server/session/window/pane/client nouns as tmux, with the +execution mode carried by the class. + +The execution mode lives in the object *type* (eager vs lazy vs async), so each method has one statically-known return type, while the operation definitions stay shared. The matrix over scope x mode: @@ -14,23 +19,23 @@ client EagerClient LazyClient AsyncClient ========== ============ ============ ============ -Eager handles execute immediately and return live handles; lazy handles record -into a :class:`~..ops.plan.LazyPlan`; async handles await an +Eager objects execute immediately and return live objects; lazy objects record +into a :class:`~..ops.plan.LazyPlan`; async objects await an :class:`~..engines.base.AsyncTmuxEngine`. "Control mode" is not a separate family --- any eager/async facade bound to a ``ControlModeEngine`` already uses it. +-- any eager/async object bound to a ``ControlModeEngine`` already uses it. """ from __future__ import annotations -from libtmux.experimental.facade.client import AsyncClient, EagerClient, LazyClient -from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane -from libtmux.experimental.facade.server import AsyncServer, EagerServer, LazyServer -from libtmux.experimental.facade.session import ( +from libtmux.experimental.objects.client import AsyncClient, EagerClient, LazyClient +from libtmux.experimental.objects.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.objects.server import AsyncServer, EagerServer, LazyServer +from libtmux.experimental.objects.session import ( AsyncSession, EagerSession, LazySession, ) -from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow +from libtmux.experimental.objects.window import AsyncWindow, EagerWindow, LazyWindow __all__ = ( "AsyncClient", diff --git a/src/libtmux/experimental/facade/client.py b/src/libtmux/experimental/objects/client.py similarity index 91% rename from src/libtmux/experimental/facade/client.py rename to src/libtmux/experimental/objects/client.py index 6cd280239..fb8de36de 100644 --- a/src/libtmux/experimental/facade/client.py +++ b/src/libtmux/experimental/objects/client.py @@ -1,8 +1,8 @@ -"""Client-scope facades (eager / lazy / async) over the operation spine. +"""Client-scope objects (eager / lazy / async) over the operation spine. A client is a *view* (a terminal attachment keyed by name/tty), not part of the ownership chain, but tmux exposes client-scoped commands -- ``detach-client``, -``switch-client``, ``refresh-client`` -- so it gets a facade like any other scope. +``switch-client``, ``refresh-client`` -- so it gets a object like any other scope. """ from __future__ import annotations @@ -27,7 +27,7 @@ @dataclass(frozen=True) class EagerClient: - """A live client handle; methods execute immediately. + """A live client object; methods execute immediately. Examples -------- @@ -70,7 +70,7 @@ def switch_to(self, session_id: str) -> Result: @dataclass(frozen=True) class LazyClient: - """A deferred client handle; methods record into a plan.""" + """A deferred client object; methods record into a plan.""" plan: LazyPlan client_name: str @@ -93,7 +93,7 @@ def switch_to(self, session_id: str) -> LazyClient: @dataclass(frozen=True) class AsyncClient: - """An async live client handle: the eager client, awaited.""" + """An async live client object: the eager client, awaited.""" engine: AsyncTmuxEngine client_name: str diff --git a/src/libtmux/experimental/facade/pane.py b/src/libtmux/experimental/objects/pane.py similarity index 91% rename from src/libtmux/experimental/facade/pane.py rename to src/libtmux/experimental/objects/pane.py index 9dba79910..419642a30 100644 --- a/src/libtmux/experimental/facade/pane.py +++ b/src/libtmux/experimental/objects/pane.py @@ -1,18 +1,18 @@ -"""Pane-scope facades demonstrating "mode lives in the type". +"""Pane-scope objects demonstrating "mode lives in the type". -Two thin facades over the *same* operation spine show why the execution mode +Two thin objects over the *same* operation spine show why the execution mode belongs in the class rather than a runtime flag: -- :class:`EagerPane` executes immediately and returns *live* handles +- :class:`EagerPane` executes immediately and returns *live* objects (``split()`` -> :class:`EagerPane`), so its return types are concrete. - :class:`LazyPane` records into a :class:`~libtmux.experimental.ops.plan.LazyPlan` - and returns *deferred* handles (``split()`` -> :class:`LazyPane`), executing + and returns *deferred* objects (``split()`` -> :class:`LazyPane`), executing only when the plan runs. Each ``split()`` therefore has exactly one statically-known return type -- a single ``Pane`` class with a runtime engine attribute could not express that. The same :class:`~libtmux.experimental.ops.SplitWindow` operation backs both; -only the facade differs. This is the seed of the wider facade matrix +only the object differs. This is the seed of the wider object matrix (``AsyncPane``, ``LazyControlWindow``, ...) described in issue 689. """ @@ -58,7 +58,7 @@ def _new_pane_op( message: str | None, shell_command: str | None, ) -> NewPane: - """Build a :class:`~..ops.NewPane` for *target* (shared by the facades).""" + """Build a :class:`~..ops.NewPane` for *target* (shared by the objects).""" return NewPane( target=target, width=width, @@ -80,7 +80,7 @@ def _new_pane_op( @dataclass(frozen=True) class EagerPane: - """A live pane handle bound to an engine; methods execute immediately. + """A live pane object bound to an engine; methods execute immediately. Parameters ---------- @@ -116,7 +116,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> EagerPane: - """Split this pane and return a live handle to the new pane.""" + """Split this pane and return a live object to the new pane.""" result = run( SplitWindow( target=PaneId(self.pane_id), @@ -149,7 +149,7 @@ def new_pane( message: str | None = None, shell_command: str | None = None, ) -> EagerPane: - """Create a floating pane (tmux 3.7+) and return a live handle to it.""" + """Create a floating pane (tmux 3.7+) and return a live object to it.""" result = run( _new_pane_op( PaneId(self.pane_id), @@ -196,14 +196,14 @@ def capture( @dataclass(frozen=True) class LazyPane: - """A deferred pane handle; methods record into a plan instead of running. + """A deferred pane object; methods record into a plan instead of running. Parameters ---------- plan : LazyPlan The plan operations are recorded into. ref : Target - The target this handle addresses (a concrete id, or a SlotRef for a + The target this object addresses (a concrete id, or a SlotRef for a pane created earlier in the plan). Examples @@ -232,7 +232,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> LazyPane: - """Record a split; return a deferred handle to the pane it will create.""" + """Record a split; return a deferred object to the pane it will create.""" slot = self.plan.add( SplitWindow( target=self.ref, @@ -261,7 +261,7 @@ def new_pane( message: str | None = None, shell_command: str | None = None, ) -> LazyPane: - """Record a floating-pane creation; return a deferred handle to it.""" + """Record a floating-pane creation; return a deferred object to it.""" slot = self.plan.add( _new_pane_op( self.ref, @@ -284,23 +284,23 @@ def new_pane( return LazyPane(self.plan, slot) def send_keys(self, keys: str, *, enter: bool = False) -> LazyPane: - """Record a send-keys against this handle; return self for chaining.""" + """Record a send-keys against this object; return self for chaining.""" self.plan.add(SendKeys(target=self.ref, keys=keys, enter=enter)) return self def capture(self, *, start: int | None = None, end: int | None = None) -> LazyPane: - """Record a capture against this handle; return self for chaining.""" + """Record a capture against this object; return self for chaining.""" self.plan.add(CapturePane(target=self.ref, start=start, end=end)) return self @dataclass(frozen=True) class AsyncPane: - """An async live pane handle: the eager pane, awaited. + """An async live pane object: the eager pane, awaited. Identical in shape to :class:`EagerPane` -- same operations, same spine -- but bound to an :class:`~..engines.base.AsyncTmuxEngine` and awaited. This is - why async is a sibling facade, not a transformation. + why async is a sibling object, not a transformation. Examples -------- @@ -325,7 +325,7 @@ async def split( start_directory: str | None = None, shell: str | None = None, ) -> AsyncPane: - """Split this pane and return a live async handle to the new pane.""" + """Split this pane and return a live async object to the new pane.""" result = await arun( SplitWindow( target=PaneId(self.pane_id), @@ -358,7 +358,7 @@ async def new_pane( message: str | None = None, shell_command: str | None = None, ) -> AsyncPane: - """Create a floating pane (tmux 3.7+) and return a live async handle.""" + """Create a floating pane (tmux 3.7+) and return a live async object.""" result = await arun( _new_pane_op( PaneId(self.pane_id), diff --git a/src/libtmux/experimental/facade/server.py b/src/libtmux/experimental/objects/server.py similarity index 84% rename from src/libtmux/experimental/facade/server.py rename to src/libtmux/experimental/objects/server.py index b10f6da7d..085d6cb16 100644 --- a/src/libtmux/experimental/facade/server.py +++ b/src/libtmux/experimental/objects/server.py @@ -1,11 +1,11 @@ -"""Server-scope facades -- the entry points for facade navigation.""" +"""Server-scope objects -- the entry points for object navigation.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.session import ( +from libtmux.experimental.objects.session import ( AsyncSession, EagerSession, LazySession, @@ -19,7 +19,7 @@ @dataclass(frozen=True) class EagerServer: - """A live server handle; the root of eager facade navigation. + """A live server object; the root of eager object navigation. Examples -------- @@ -42,7 +42,7 @@ def new_session( name: str | None = None, start_directory: str | None = None, ) -> EagerSession: - """Create a detached session; return a live session handle.""" + """Create a detached session; return a live session object.""" result = run( NewSession(session_name=name, start_directory=start_directory), self.engine, @@ -54,7 +54,7 @@ def new_session( @classmethod def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer: - """Bind an eager facade to a live :class:`libtmux.Server`'s classic engine.""" + """Bind an eager object to a live :class:`libtmux.Server`'s classic engine.""" from libtmux.experimental.engines import SubprocessEngine return cls(SubprocessEngine.for_server(server), version=version) @@ -62,7 +62,7 @@ def for_server(cls, server: t.Any, *, version: str | None = None) -> EagerServer @dataclass(frozen=True) class LazyServer: - """A deferred server handle; records session creation into a plan. + """A deferred server object; records session creation into a plan. Examples -------- @@ -84,7 +84,7 @@ def new_session( name: str | None = None, start_directory: str | None = None, ) -> LazySession: - """Record a new session; return a deferred session handle.""" + """Record a new session; return a deferred session object.""" slot = self.plan.add( NewSession(session_name=name, start_directory=start_directory), ) @@ -93,7 +93,7 @@ def new_session( @dataclass(frozen=True) class AsyncServer: - """An async live server handle: the eager server, awaited.""" + """An async live server object: the eager server, awaited.""" engine: AsyncTmuxEngine version: str | None = None @@ -104,7 +104,7 @@ async def new_session( name: str | None = None, start_directory: str | None = None, ) -> AsyncSession: - """Create a detached session; return a live async session handle.""" + """Create a detached session; return a live async session object.""" result = await arun( NewSession(session_name=name, start_directory=start_directory), self.engine, @@ -116,7 +116,7 @@ async def new_session( @classmethod def for_server(cls, server: t.Any, *, version: str | None = None) -> AsyncServer: - """Bind an async facade to a live :class:`libtmux.Server`'s socket.""" + """Bind an async object to a live :class:`libtmux.Server`'s socket.""" from libtmux.experimental.engines import AsyncSubprocessEngine return cls(AsyncSubprocessEngine.for_server(server), version=version) diff --git a/src/libtmux/experimental/facade/session.py b/src/libtmux/experimental/objects/session.py similarity index 91% rename from src/libtmux/experimental/facade/session.py rename to src/libtmux/experimental/objects/session.py index 83cc0d40d..14c1c1447 100644 --- a/src/libtmux/experimental/facade/session.py +++ b/src/libtmux/experimental/objects/session.py @@ -1,11 +1,11 @@ -"""Session-scope facades (eager / lazy / async) over the operation spine.""" +"""Session-scope objects (eager / lazy / async) over the operation spine.""" from __future__ import annotations import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.window import AsyncWindow, EagerWindow, LazyWindow +from libtmux.experimental.objects.window import AsyncWindow, EagerWindow, LazyWindow from libtmux.experimental.ops import ( KillSession, NewWindow, @@ -24,7 +24,7 @@ @dataclass(frozen=True) class EagerSession: - """A live session handle; methods execute immediately. + """A live session object; methods execute immediately. Examples -------- @@ -47,7 +47,7 @@ def new_window( name: str | None = None, start_directory: str | None = None, ) -> EagerWindow: - """Create a window in this session; return a live window handle.""" + """Create a window in this session; return a live window object.""" result = run( NewWindow( target=SessionId(self.session_id), @@ -80,7 +80,7 @@ def kill(self) -> Result: @dataclass(frozen=True) class LazySession: - """A deferred session handle; methods record into a plan. + """A deferred session object; methods record into a plan. Examples -------- @@ -104,7 +104,7 @@ def new_window( name: str | None = None, start_directory: str | None = None, ) -> LazyWindow: - """Record a new window; return a deferred window handle.""" + """Record a new window; return a deferred window object.""" slot = self.plan.add( NewWindow(target=self.ref, name=name, start_directory=start_directory), ) @@ -123,7 +123,7 @@ def kill(self) -> LazySession: @dataclass(frozen=True) class AsyncSession: - """An async live session handle: the eager session, awaited.""" + """An async live session object: the eager session, awaited.""" engine: AsyncTmuxEngine session_id: str @@ -135,7 +135,7 @@ async def new_window( name: str | None = None, start_directory: str | None = None, ) -> AsyncWindow: - """Create a window in this session; return a live async window handle.""" + """Create a window in this session; return a live async window object.""" result = await arun( NewWindow( target=SessionId(self.session_id), diff --git a/src/libtmux/experimental/facade/window.py b/src/libtmux/experimental/objects/window.py similarity index 89% rename from src/libtmux/experimental/facade/window.py rename to src/libtmux/experimental/objects/window.py index f3a2476f9..7bb827c09 100644 --- a/src/libtmux/experimental/facade/window.py +++ b/src/libtmux/experimental/objects/window.py @@ -1,9 +1,9 @@ -"""Window-scope facades (eager / lazy / async) over the operation spine. +"""Window-scope objects (eager / lazy / async) over the operation spine. -Mirrors the pane facades one scope up: an :class:`EagerWindow` executes now and -returns live handles (``split()`` -> :class:`~.pane.EagerPane`), a +Mirrors the pane objects one scope up: an :class:`EagerWindow` executes now and +returns live objects (``split()`` -> :class:`~.pane.EagerPane`), a :class:`LazyWindow` records into a plan, and an :class:`AsyncWindow` awaits. All -three drive the *same* window-scope operations; only the facade differs. +three drive the *same* window-scope operations; only the object differs. """ from __future__ import annotations @@ -11,7 +11,7 @@ import typing as t from dataclasses import dataclass -from libtmux.experimental.facade.pane import AsyncPane, EagerPane, LazyPane +from libtmux.experimental.objects.pane import AsyncPane, EagerPane, LazyPane from libtmux.experimental.ops import ( KillWindow, RenameWindow, @@ -31,7 +31,7 @@ @dataclass(frozen=True) class EagerWindow: - """A live window handle bound to an engine; methods execute immediately. + """A live window object bound to an engine; methods execute immediately. Examples -------- @@ -55,7 +55,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> EagerPane: - """Split this window's active pane; return a live pane handle.""" + """Split this window's active pane; return a live pane object.""" result = run( SplitWindow( target=WindowId(self.window_id), @@ -97,7 +97,7 @@ def kill(self) -> Result: @dataclass(frozen=True) class LazyWindow: - """A deferred window handle; methods record into a plan. + """A deferred window object; methods record into a plan. Examples -------- @@ -123,7 +123,7 @@ def split( start_directory: str | None = None, shell: str | None = None, ) -> LazyPane: - """Record a split; return a deferred pane handle to the new pane.""" + """Record a split; return a deferred pane object to the new pane.""" slot = self.plan.add( SplitWindow( target=self.ref, @@ -152,7 +152,7 @@ def kill(self) -> LazyWindow: @dataclass(frozen=True) class AsyncWindow: - """An async live window handle: the eager window, awaited.""" + """An async live window object: the eager window, awaited.""" engine: AsyncTmuxEngine window_id: str @@ -165,7 +165,7 @@ async def split( start_directory: str | None = None, shell: str | None = None, ) -> AsyncPane: - """Split this window's active pane; return a live async pane handle.""" + """Split this window's active pane; return a live async pane object.""" result = await arun( SplitWindow( target=WindowId(self.window_id), diff --git a/src/libtmux/experimental/ops/_ops/new_pane.py b/src/libtmux/experimental/ops/_ops/new_pane.py index c4398bb37..79b1d1fd4 100644 --- a/src/libtmux/experimental/ops/_ops/new_pane.py +++ b/src/libtmux/experimental/ops/_ops/new_pane.py @@ -30,7 +30,7 @@ class NewPane(Operation[SplitWindowResult]): Like :class:`~.split_window.SplitWindow` it reuses :class:`~.results.SplitWindowResult`, capturing the new pane id via - ``-P -F '#{pane_id}'`` so plans, the facade, and MCP bind it the same way. + ``-P -F '#{pane_id}'`` so plans, the wrapper, and MCP bind it the same way. Rendering against a tmux older than 3.7 raises :exc:`~.exc.VersionUnsupported` (this op sets diff --git a/src/libtmux/experimental/ops/results.py b/src/libtmux/experimental/ops/results.py index ebd41b19e..882cdc870 100644 --- a/src/libtmux/experimental/ops/results.py +++ b/src/libtmux/experimental/ops/results.py @@ -9,7 +9,7 @@ Results never raise on construction. Raising is opt-in via :meth:`Result.raise_for_status`, mirroring :meth:`subprocess.CompletedProcess.check_returncode`. *How* an engine treats a -failed result is the engine's policy: the classic engine raises in its facade to +failed result is the engine's policy: the classic engine raises in its wrapper to match today's behavior, while newer engines hand the result back and let the caller decide. """ diff --git a/tests/experimental/facade/__init__.py b/tests/experimental/facade/__init__.py deleted file mode 100644 index a260ebd4d..000000000 --- a/tests/experimental/facade/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Tests for libtmux.experimental.facade.""" - -from __future__ import annotations diff --git a/tests/experimental/objects/__init__.py b/tests/experimental/objects/__init__.py new file mode 100644 index 000000000..91862c9c7 --- /dev/null +++ b/tests/experimental/objects/__init__.py @@ -0,0 +1,3 @@ +"""Tests for libtmux.experimental.objects.""" + +from __future__ import annotations diff --git a/tests/experimental/facade/test_matrix_complete.py b/tests/experimental/objects/test_matrix_complete.py similarity index 95% rename from tests/experimental/facade/test_matrix_complete.py rename to tests/experimental/objects/test_matrix_complete.py index b5381a168..f20495415 100644 --- a/tests/experimental/facade/test_matrix_complete.py +++ b/tests/experimental/objects/test_matrix_complete.py @@ -1,11 +1,11 @@ -"""Tests completing the facade matrix: lazy/async Server+Session and Client.""" +"""Tests completing the object matrix: lazy/async Server+Session and Client.""" from __future__ import annotations import asyncio from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine -from libtmux.experimental.facade import ( +from libtmux.experimental.objects import ( AsyncClient, AsyncServer, EagerClient, diff --git a/tests/experimental/facade/test_facade_matrix.py b/tests/experimental/objects/test_object_matrix.py similarity index 88% rename from tests/experimental/facade/test_facade_matrix.py rename to tests/experimental/objects/test_object_matrix.py index eefab9457..7956da80d 100644 --- a/tests/experimental/facade/test_facade_matrix.py +++ b/tests/experimental/objects/test_object_matrix.py @@ -1,4 +1,4 @@ -"""Tests for the facade matrix (scope x mode) over the shared spine.""" +"""Tests for the object matrix (scope x mode) over the shared spine.""" from __future__ import annotations @@ -6,7 +6,7 @@ import typing as t from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine -from libtmux.experimental.facade import ( +from libtmux.experimental.objects import ( AsyncPane, AsyncWindow, EagerPane, @@ -55,7 +55,7 @@ def test_lazy_window_records_and_executes() -> None: def test_async_window_and_pane() -> None: - """Async facades mirror the eager ones via await.""" + """Async objects mirror the eager ones via await.""" async def main() -> tuple[str, bool, bool]: window = AsyncWindow(AsyncConcreteEngine(), "@1") @@ -72,11 +72,11 @@ async def main() -> tuple[str, bool, bool]: def test_eager_navigation_live(session: Session) -> None: - """Eager facade builds a real session/window/pane against tmux, then cleans up.""" + """Eager object builds a real session/window/pane against tmux, then cleans up.""" server = session.server - facade = EagerServer.for_server(server) + server_obj = EagerServer.for_server(server) - created = facade.new_session(name="facade-matrix-test") + created = server_obj.new_session(name="object-matrix-test") try: assert created.session_id.startswith("$") assert server.sessions.get(session_id=created.session_id) is not None diff --git a/tests/experimental/facade/test_pane_facade.py b/tests/experimental/objects/test_pane_object.py similarity index 89% rename from tests/experimental/facade/test_pane_facade.py rename to tests/experimental/objects/test_pane_object.py index b74ed3daf..448ff7c94 100644 --- a/tests/experimental/facade/test_pane_facade.py +++ b/tests/experimental/objects/test_pane_object.py @@ -1,16 +1,16 @@ -"""Tests for the eager and lazy pane facades.""" +"""Tests for the eager and lazy pane objects.""" from __future__ import annotations from libtmux.experimental.engines import ConcreteEngine -from libtmux.experimental.facade import EagerPane, LazyPane +from libtmux.experimental.objects import EagerPane, LazyPane from libtmux.experimental.ops import LazyPlan from libtmux.experimental.ops._types import PaneId from libtmux.experimental.ops.results import SplitWindowResult def test_eager_split_returns_live_pane() -> None: - """EagerPane.split executes now and returns a live EagerPane handle.""" + """EagerPane.split executes now and returns a live EagerPane object.""" pane = EagerPane(ConcreteEngine(), "%0") child = pane.split(horizontal=True) assert isinstance(child, EagerPane) @@ -25,7 +25,7 @@ def test_eager_capture_and_send() -> None: assert pane.send_keys("echo hi", enter=True).ok -def test_lazy_split_returns_deferred_handle_and_defers() -> None: +def test_lazy_split_returns_deferred_object_and_defers() -> None: """LazyPane.split records into a plan and returns a deferred LazyPane.""" plan = LazyPlan() root = LazyPane(plan, PaneId("%0")) @@ -49,7 +49,7 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: def test_eager_new_pane_returns_live_pane() -> None: - """EagerPane.new_pane creates a floating pane and returns a live handle.""" + """EagerPane.new_pane creates a floating pane and returns a live object.""" pane = EagerPane(ConcreteEngine(), "%0") floating = pane.new_pane(width=80, height=20, x=5, y=3) assert isinstance(floating, EagerPane) @@ -75,11 +75,11 @@ def test_lazy_new_pane_records_new_pane_op() -> None: def test_async_new_pane_returns_live_pane() -> None: - """AsyncPane.new_pane creates a floating pane and returns a live handle.""" + """AsyncPane.new_pane creates a floating pane and returns a live object.""" import asyncio from libtmux.experimental.engines import AsyncConcreteEngine - from libtmux.experimental.facade import AsyncPane + from libtmux.experimental.objects import AsyncPane async def main() -> str: pane = AsyncPane(AsyncConcreteEngine(), "%0") @@ -89,8 +89,8 @@ async def main() -> str: assert asyncio.run(main()) == "%1" -def test_same_operation_backs_both_facades() -> None: - """Eager and lazy facades render the identical underlying operation argv.""" +def test_same_operation_backs_both_objects() -> None: + """Eager and lazy objects render the identical underlying operation argv.""" eager_engine = ConcreteEngine() eager = EagerPane(eager_engine, "%0") # Capture the eager split's rendered argv via the engine-independent op. From d813523203d9a18e94ad5796a33cdc8bfe50f257 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 08:59:56 -0500 Subject: [PATCH 148/154] Engines(fix[async_control_mode]): Escalate backoff on connect-then-die why: A proc that connects then instantly dies still consumes its startup ACK, so the supervisor counted it as a healthy connection and reset the reconnect backoff to zero every loop. A persistently flapping proc (fatal config, permission, or resource error) then fork-stormed tmux at ~10 Hz instead of backing off. what: - Reset the backoff only for a connection that survived a minimum lifetime; a connect-then-immediately-die counts as a failed attempt, so the backoff escalates. - Add _HEALTHY_CONNECTION_SECONDS and a regression test asserting a connect-then-die loop escalates instead of pinning at _backoff(0). --- .../engines/async_control_mode.py | 23 ++++++++---- .../test_async_control_mode_supervisor.py | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 4de396497..6a7bac1e8 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -60,6 +60,10 @@ _DEFAULT_TIMEOUT = 30.0 _STARTUP_TIMEOUT = 5.0 _STOP_TIMEOUT = 2.0 +# A connection must survive at least this long to count as healthy and reset the +# reconnect backoff; a shorter-lived one is treated as a failed attempt so a +# persistently flapping proc escalates instead of fork-storming. +_HEALTHY_CONNECTION_SECONDS = 1.0 _STREAM_END = object() # broadcast to subscriber queues to end their async for @@ -542,12 +546,11 @@ async def _supervisor(self) -> None: await asyncio.sleep(self._backoff(attempt)) attempt += 1 continue - # The spawn succeeded and its startup ACK was consumed, so this - # connection is healthy: reset the backoff. A reconnect after a - # long healthy session then starts fast instead of waiting near - # the cap -- only *consecutive connect failures* (the except path - # above) escalate. - attempt = 0 + # The spawn succeeded and its startup ACK was consumed. Do NOT + # reset the backoff yet: a proc that connects then immediately + # dies (reader EOF within the grace) is not a healthy session, and + # resetting here would pin every reconnect at _backoff(0) and + # fork-storm tmux. The reset is gated on connection lifetime below. self._generation += 1 connected_once = True await self._reap_own_session() @@ -556,9 +559,17 @@ async def _supervisor(self) -> None: self._connected.set() # first connect done: unblock start() # The reader runs inline (one reader at a time). On EOF it returns # and we reconnect; on cancellation (aclose) it propagates out. + loop = asyncio.get_running_loop() + connected_at = loop.time() await self._reader() if self._closing: return + # Only a connection that survived a meaningful interval resets the + # backoff; a connect-then-immediately-die counts as a failed + # attempt, so a persistently flapping proc escalates instead of + # spinning at _backoff(0). + if loop.time() - connected_at >= _HEALTHY_CONNECTION_SECONDS: + attempt = 0 await asyncio.sleep(self._backoff(attempt)) attempt += 1 finally: diff --git a/tests/experimental/engines/test_async_control_mode_supervisor.py b/tests/experimental/engines/test_async_control_mode_supervisor.py index 63fa5176c..58cf667a4 100644 --- a/tests/experimental/engines/test_async_control_mode_supervisor.py +++ b/tests/experimental/engines/test_async_control_mode_supervisor.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import typing as t from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine @@ -175,3 +176,38 @@ async def _spawn(self) -> None: await asyncio.wait_for(start_task, timeout=1.0) # must NOT hang asyncio.run(main()) + + +def test_connect_then_die_escalates_backoff() -> None: + """A flapping connect-then-die escalates the backoff, not a fixed spin. + + Regression: the supervisor reset ``attempt`` to 0 on every spawn-success, so a + proc that connected then immediately EOF'd kept reconnecting at ``_backoff(0)`` + forever instead of escalating. The reset is now gated on connection lifetime. + """ + seen: list[int] = [] + + class _Probe(AsyncControlModeEngine): + async def _spawn(self) -> None: + return # connect "succeeds" instantly; _proc stays None + + async def _reader(self) -> None: + return # reader returns at once: a connect-then-die + + @staticmethod + def _backoff(attempt: int) -> float: + seen.append(attempt) + return 0.0 # no real delay, so the test runs fast + + async def main() -> None: + engine = _Probe() + task = asyncio.create_task(engine._supervisor()) + for _ in range(30): # let several connect-then-die iterations run + await asyncio.sleep(0) + engine._closing = True + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + asyncio.run(main()) + assert seen[:3] == [0, 1, 2] # escalates, not pinned at 0 From 51a122457c44db82bed283ed6b7b4e7f91249bf5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 09:01:06 -0500 Subject: [PATCH 149/154] Engines(fix[async_control_mode]): Terminate the prior proc on reconnect why: A reader that returned via an exception (not a clean EOF) leaves the tmux -C process alive. The supervisor then reconnected and _spawn overwrote _proc with the new process without terminating the old one, orphaning a control client on every such reconnect. what: - Terminate a still-alive prior proc at the top of _spawn before replacing it; a clean-EOF proc has already exited (no-op). - Add a regression test that _spawn terminates a live prior proc. --- .../engines/async_control_mode.py | 8 ++++ .../test_async_control_mode_supervisor.py | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py index 6a7bac1e8..5e8a65096 100644 --- a/src/libtmux/experimental/engines/async_control_mode.py +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -311,6 +311,14 @@ async def _spawn(self) -> None: dead-guard). The caller is responsible for resetting the parser *before* this runs, so the new process's startup bytes are parsed by a fresh parser. """ + # A reader that returned via an exception (not a clean EOF) leaves the + # prior tmux -C alive; terminate it before overwriting _proc so a + # reconnect never orphans a control client. A clean-EOF proc has already + # exited, so this is a no-op there. + old = self._proc + if old is not None and old.returncode is None: + with contextlib.suppress(ProcessLookupError): + old.terminate() tmux_bin = self.tmux_bin or shutil.which("tmux") if tmux_bin is None: raise exc.TmuxCommandNotFound diff --git a/tests/experimental/engines/test_async_control_mode_supervisor.py b/tests/experimental/engines/test_async_control_mode_supervisor.py index 58cf667a4..da7092c92 100644 --- a/tests/experimental/engines/test_async_control_mode_supervisor.py +++ b/tests/experimental/engines/test_async_control_mode_supervisor.py @@ -211,3 +211,40 @@ async def main() -> None: asyncio.run(main()) assert seen[:3] == [0, 1, 2] # escalates, not pinned at 0 + + +def test_spawn_terminates_a_live_prior_proc( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_spawn terminates a still-alive prior proc so a reconnect can't orphan it. + + On a reader-EXCEPTION reconnect (vs a clean EOF) the old tmux -C is still + alive; overwriting _proc without terminating it would leak a control client. + """ + terminated: list[bool] = [] + + class _AliveProc: + returncode: int | None = None + + def terminate(self) -> None: + terminated.append(True) + + class _NewProc: + returncode: int | None = None + + async def _fake_exec(*_a: object, **_k: object) -> _NewProc: + return _NewProc() + + class _Probe(AsyncControlModeEngine): + async def _consume_startup(self) -> None: + return # skip the real startup drain (the fake proc has no stdout) + + async def main() -> None: + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + engine = _Probe(tmux_bin="tmux") + # a prior, still-alive control client (as a reader-exception reconnect leaves) + engine._proc = t.cast("asyncio.subprocess.Process", _AliveProc()) + await engine._spawn() + + asyncio.run(main()) + assert terminated == [True] # the prior live proc was terminated From 34f1cafcddd35035193df8138380f175759dc9b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 11:12:06 -0500 Subject: [PATCH 150/154] Scripts(feat[bench]): Add hermetic engine build-benchmark grid why: Quantify the experimental workspace builder's build cost across engines and answer "which engine, how fast" reproducibly, without ever touching the developer's live tmux server. what: - Add scripts/bench_engines.py, a self-contained PEP 723 script (uv run) that sweeps scenarios x engines x wait-modes and reports min/avg/median/p90/p95/p99/max as rich tables + JSON. - Engines: classic; the builder on subprocess/control_mode/imsg/ concrete; and a pipelined prototype that batches independent creates via run_batch. - Sandboxed: per-run isolated sockets under a throwaway dir, TMUX unset, atexit teardown + orphan backstop -- the default server is never contacted. - Subcommands: run (in-process grid), cell (one build, for hyperfine), profile (cProfile). --- scripts/bench_engines.py | 470 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 scripts/bench_engines.py diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py new file mode 100644 index 000000000..50fd12db8 --- /dev/null +++ b/scripts/bench_engines.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["rich>=13", "typer>=0.12", "libtmux"] +# +# [tool.uv.sources] +# libtmux = { path = "..", editable = true } +# /// +"""Hermetic libtmux engine build-benchmark grid. + +Measures how long the experimental workspace builder (and the classic API, and a +hand-rolled pipelined prototype) take to build tmux session structures, sweeping +scenarios x engines x wait-modes. Reports min/avg/median/max/p90/p95/p99. + +Hermetic & sandboxed: every server runs on its OWN socket under a throwaway +mkdtemp dir; ``TMUX`` is unset at import so the ambient session is never touched; +an ``atexit`` hook kills every spawned server (and any orphan on our sockets) and +removes the dir. The default server is never contacted. + +Engines (``--engines``): + classic classic libtmux Server/Session/Window/Pane API (subprocess) + subprocess builder on SubprocessEngine (one tmux fork per op) + control_mode builder on ControlModeEngine (one persistent ``tmux -C``) + imsg builder on ImsgEngine (AF_UNIX imsg, socket-injected) + concrete builder on ConcreteEngine (offline, in-memory: Python floor) + pipelined prototype: batch independent creates via run_batch (control_mode) + +Timing (``run`` = in-process build-only, the clean signal; ``--hyperfine`` also +runs whole-process wall time via hyperfine over the ``cell`` subcommand). + +Run: uv run bench_engines.py run + uv run bench_engines.py run --engines control_mode,pipelined --wait + uv run bench_engines.py profile --engine control_mode --shape 8x4 + uv run bench_engines.py cell control_mode 8x4 # one build (for hyperfine) +""" + +from __future__ import annotations + +import atexit +import contextlib +import cProfile +import dataclasses +import io +import itertools +import json +import math +import os +import pathlib +import pstats +import shutil +import statistics +import subprocess +import tempfile +import time +import typing as t +import uuid + +# Never inherit the ambient tmux session -- do this BEFORE importing libtmux. +os.environ.pop("TMUX", None) +os.environ.pop("TMUX_PANE", None) + +import rich.console +import rich.table +import typer + +from libtmux.experimental.engines import ( + ConcreteEngine, + ControlModeEngine, + ImsgEngine, + SubprocessEngine, +) +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.workspace import Pane, Window, Workspace +from libtmux.server import Server + +console = rich.console.Console() +R = CommandRequest.from_args +_ctr = itertools.count() +STAT_LABELS = ("n", "min", "avg", "median", "p90", "p95", "p99", "max") + +# --------------------------------------------------------------------------- # +# Hermetic isolation # +# --------------------------------------------------------------------------- # +_SOCK_DIR = pathlib.Path( + tempfile.mkdtemp(prefix="ltbench-") +) # short: /tmp/ltbench-XXXX +_SERVERS: list[Server] = [] + + +def new_server() -> Server: + """Return a fresh isolated server on a unique socket under the scratch dir.""" + srv = Server(socket_path=str(_SOCK_DIR / f"{uuid.uuid4().hex[:8]}.sock")) + _SERVERS.append(srv) + return srv + + +def _cleanup() -> None: + for srv in _SERVERS: + with contextlib.suppress(Exception): + srv.kill() + # Backstop: SIGKILL any tmux server still bound to a socket in our dir. + with contextlib.suppress(Exception): + out = subprocess.run( + ["pgrep", "-f", f"tmux .*-S{_SOCK_DIR}/"], + capture_output=True, + text=True, + check=False, + ).stdout.split() + for pid in out: + with contextlib.suppress(Exception): + os.kill(int(pid), 9) + with contextlib.suppress(Exception): + shutil.rmtree(_SOCK_DIR, ignore_errors=True) + + +atexit.register(_cleanup) + + +def uniq() -> str: + """Return a process-unique session name (never collides across builds).""" + return f"b{next(_ctr)}" + + +# --------------------------------------------------------------------------- # +# Scenario spec + build implementations # +# --------------------------------------------------------------------------- # +def parse_shape(s: str) -> tuple[int, int]: + """'8x4' -> (8 windows, 4 panes-per-window).""" + w, _, p = s.lower().partition("x") + return int(w), int(p) + + +def spec(name: str, wins: int, panes: int) -> Workspace: + """Build the declarative Workspace IR for *wins* windows x *panes* panes.""" + return Workspace( + name=name, + on_exists="replace", + windows=[ + Window(name=f"w{w}", panes=[Pane() for _ in range(panes)]) + for w in range(wins) + ], + ) + + +def build_classic(server: Server, name: str, wins: int, panes: int) -> None: + """Build the structure with the classic Server/Session/Window/Pane API.""" + session = server.new_session(session_name=name, window_name="w0") + for _ in range(panes - 1): + session.active_window.split() + for wi in range(1, wins): + window = session.new_window(window_name=f"w{wi}") + for _ in range(panes - 1): + window.split() + + +def build_pipelined(engine: t.Any, name: str, wins: int, panes: int) -> None: + """Prototype: batch INDEPENDENT creates into few run_batch round-trips. + + new-session (1) + all new-windows in one run_batch (1) + all splits in one + run_batch (1) = 3 round-trips for any shape, vs ~1-per-op for the builder. + The control-mode run_batch pipelines (write all, read all reply blocks). + """ + engine.run(R("new-session", "-d", "-s", name, "-n", "w0")) + if wins > 1: + engine.run_batch( + [R("new-window", "-t", name, "-n", f"w{i}") for i in range(1, wins)] + ) + splits = [ + R("split-window", "-t", f"{name}:w{i}") + for i in range(wins) + for _ in range(panes - 1) + ] + if splits: + engine.run_batch(splits) + + +class ImsgForServer: + """Bind ImsgEngine to a specific server by injecting ``-S`` per call. + + ImsgEngine has no ``for_server`` -- it parses ``-L``/``-S`` from the command + args -- so this wrapper prepends the isolated socket flag to every request. + """ + + def __init__(self, server: Server) -> None: + self._e = ImsgEngine() + self._flag = ( + f"-S{server.socket_path}" + if server.socket_path + else f"-L{server.socket_name}" + ) + + def run(self, req: CommandRequest) -> t.Any: + """Run one request with the socket flag injected.""" + return self._e.run(R(self._flag, *req.args)) + + def run_batch(self, reqs: t.Sequence[CommandRequest]) -> t.Any: + """Run a batch of requests, each with the socket flag injected.""" + return self._e.run_batch([R(self._flag, *r.args) for r in reqs]) + + def tmux_version(self) -> t.Any: + """Report the underlying imsg engine's tmux version.""" + return self._e.tmux_version() + + +@dataclasses.dataclass(frozen=True) +class Impl: + """One benchmarked implementation: how to make its engine and build.""" + + name: str + kind: str # classic | builder | pipelined | offline + make_engine: t.Callable[[Server | None], t.Any] | None = None + needs_preboot: bool = False + preflight: bool = True + + +IMPLS: dict[str, Impl] = { + "classic": Impl("classic", "classic"), + "subprocess": Impl( + "subprocess", "builder", lambda s: SubprocessEngine.for_server(s) + ), + "control_mode": Impl( + "control_mode", "builder", lambda s: ControlModeEngine.for_server(s) + ), + "imsg": Impl("imsg", "builder", lambda s: ImsgForServer(s), needs_preboot=True), + "concrete": Impl( + "concrete", "offline", lambda s: ConcreteEngine(), preflight=False + ), + "pipelined": Impl( + "pipelined", "pipelined", lambda s: ControlModeEngine.for_server(s) + ), +} + + +def do_build( + impl: Impl, server: Server | None, engine: t.Any, name: str, w: int, p: int +) -> None: + """Dispatch one build of *w* x *p* to the right implementation path.""" + if impl.kind == "classic": + build_classic(server, name, w, p) # type: ignore[arg-type] + elif impl.kind == "pipelined": + build_pipelined(engine, name, w, p) + else: # builder / offline + spec(name, w, p).build(engine, preflight=impl.preflight) + + +def wait_ready( + server: Server, name: str, timeout: float = 2.0, interval: float = 0.015 +) -> None: + """Poll each pane until its shell has drawn something (a prompt) or timeout. + + Models the classic ``_wait_for_pane_ready`` cost -- the shell-readiness wait + that inflates 'realistic' build times. Engine-agnostic (reads via subprocess). + """ + ids = [ + x + for x in server.cmd("list-panes", "-s", "-t", name, "-F", "#{pane_id}").stdout + if x + ] + pending = set(ids) + deadline = time.monotonic() + timeout + while pending and time.monotonic() < deadline: + for pid in list(pending): + cap = server.cmd("capture-pane", "-p", "-t", pid).stdout + if any(line.strip() for line in cap): + pending.discard(pid) + if pending: + time.sleep(interval) + + +def run_cell( + impl: Impl, wins: int, panes: int, wait: bool, runs: int, warmup: int +) -> list[float]: + """Return per-build wall times (ms), in-process, with session cleanup.""" + if impl.kind == "offline": + engine = impl.make_engine(None) # type: ignore[misc] + for _ in range(warmup): + spec(uniq(), wins, panes).build(engine, preflight=False) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + spec(name, wins, panes).build(engine, preflight=False) + samples.append((time.perf_counter() - t0) * 1000) + return samples + + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + engine = impl.make_engine(server) if impl.make_engine else None + try: + for _ in range(warmup): + name = uniq() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + server.cmd("kill-session", "-t", name) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + samples.append((time.perf_counter() - t0) * 1000) + server.cmd("kill-session", "-t", name) # untimed cleanup -> no accumulation + return samples + finally: + with contextlib.suppress(Exception): + server.kill() + + +# --------------------------------------------------------------------------- # +# Stats (nearest-rank percentiles, like agentgrep's benchmark) # +# --------------------------------------------------------------------------- # +def percentile(sorted_vals: list[float], pct: float) -> float: + """Nearest-rank percentile of a pre-sorted sequence.""" + if not sorted_vals: + return float("nan") + rank = max(1, math.ceil(pct / 100.0 * len(sorted_vals))) + return sorted_vals[min(rank, len(sorted_vals)) - 1] + + +def summarize(samples: list[float]) -> dict[str, float]: + """Return min/avg/median/p90/p95/p99/max (and n) for *samples*.""" + s = sorted(samples) + return { + "n": float(len(s)), + "min": s[0], + "avg": statistics.fmean(s), + "median": statistics.median(s), + "p90": percentile(s, 90), + "p95": percentile(s, 95), + "p99": percentile(s, 99), + "max": s[-1], + } + + +# --------------------------------------------------------------------------- # +# CLI # +# --------------------------------------------------------------------------- # +app = typer.Typer(add_completion=False, help=__doc__) + + +@app.command() +def run( + shapes: str = typer.Option("1x1,1x4,3x3,5x4,8x4", help="comma WxP shapes"), + engines: str = typer.Option( + "classic,subprocess,control_mode,imsg,concrete,pipelined", + help="comma engine names", + ), + wait: bool = typer.Option(False, help="ALSO measure with shell-readiness wait"), + runs: int = typer.Option(20, help="timed builds per cell"), + warmup: int = typer.Option(3, help="warmup builds per cell"), + json_out: str = typer.Option("", help="write full JSON results here"), +) -> None: + """In-process build-only benchmark grid (the clean signal).""" + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + engine_list = [e for e in engines.split(",") if e in IMPLS] + wait_modes = [False, True] if wait else [False] + results: list[dict[str, t.Any]] = [] + + for wm in wait_modes: + for wins, panes in shape_list: + table = rich.table.Table( + title=f"[bold]{wins} win x {panes} pane ({wins * panes} panes)" + f"{' [wait]' if wm else ''} -- in-process build ms[/bold]" + ) + table.add_column("engine", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("vs classic", justify="right", style="green") + base_median = None + for name in engine_list: + impl = IMPLS[name] + if impl.kind == "offline" and wm: + continue # no real panes to wait on + samples = run_cell(impl, wins, panes, wm, runs, warmup) + st = summarize(samples) + if name == "classic": + base_median = st["median"] + speed = ( + f"{base_median / st['median']:.1f}x" + if base_median and st["median"] + else "-" + ) + table.add_row( + name, + f"{int(st['n'])}", + *[f"{st[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + results.append( + { + "engine": name, + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "wait": wm, + "samples_ms": samples, + **{f"{k}_ms": st[k] for k in STAT_LABELS}, + } + ) + console.print(table) + console.print() + + if json_out: + pathlib.Path(json_out).write_text(json.dumps(results, indent=2)) + console.print(f"[dim]wrote {json_out}[/dim]") + + +@app.command() +def cell(engine: str, shape: str, wait: bool = typer.Option(False)) -> None: + """Build ONE workspace of *shape* with *engine* (isolated). For hyperfine.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + if impl.kind == "offline": + spec(uniq(), wins, panes).build(impl.make_engine(None), preflight=False) # type: ignore[misc] + return + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if wait: + wait_ready(server, name) + finally: + with contextlib.suppress(Exception): + server.kill() + + +@app.command() +def profile( + engine: str = typer.Option("control_mode"), + shape: str = typer.Option("8x4"), + builds: int = typer.Option(5), + top: int = typer.Option(18), +) -> None: + """Profile *builds* builds of *shape* with *engine*; print slowest by cumtime.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + server = None if impl.kind == "offline" else new_server() + if server is not None and impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + warm = uniq() + do_build(impl, server, eng, warm, wins, panes) # warmup + if server is not None: + server.cmd("kill-session", "-t", warm) + pr = cProfile.Profile() + pr.enable() + for _ in range(builds): + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if server is not None: + server.cmd("kill-session", "-t", name) + pr.disable() + buf = io.StringIO() + pstats.Stats(pr, stream=buf).sort_stats("cumulative").print_stats(top) + console.print(f"[bold]profile: {engine} {shape} x{builds}[/bold]") + console.print(buf.getvalue()) + finally: + if server is not None: + with contextlib.suppress(Exception): + server.kill() + + +if __name__ == "__main__": + app() From 27f92a0cd06a87f683c751677ac3889e217769f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 11:13:24 -0500 Subject: [PATCH 151/154] Scripts(docs[bench]): Add engine benchmark results why: Version the measured build cost across engines (and the pipelining prototype) alongside the harness so the numbers are reproducible and reviewable. what: - Add scripts/bench-results/ with RESULTS.md (analysis: the engine grid, with/without shell-readiness wait, profile, and the ~79x reconciliation) plus grid.json / wait.json (raw per-run data from scripts/bench_engines.py). --- scripts/bench-results/RESULTS.md | 83 +++ scripts/bench-results/grid.json | 1082 ++++++++++++++++++++++++++++++ scripts/bench-results/wait.json | 314 +++++++++ 3 files changed, 1479 insertions(+) create mode 100644 scripts/bench-results/RESULTS.md create mode 100644 scripts/bench-results/grid.json create mode 100644 scripts/bench-results/wait.json diff --git a/scripts/bench-results/RESULTS.md b/scripts/bench-results/RESULTS.md new file mode 100644 index 000000000..f96debdeb --- /dev/null +++ b/scripts/bench-results/RESULTS.md @@ -0,0 +1,83 @@ +# libtmux engine build-benchmark — results + +Produced by `scripts/bench_engines.py` (a hermetic PEP 723 grid runner) plus a +one-off hyperfine end-to-end run. All builds are isolated: per-run sockets under +a throwaway dir, `TMUX` unset, servers killed on exit — the default tmux server +is never contacted. Reproduce with: + +```console +$ uv run scripts/bench_engines.py run +$ uv run scripts/bench_engines.py run --engines classic,control_mode,pipelined --wait +$ uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4 +``` + +Raw data: `grid.json` (no-wait grid), `wait.json` (wait comparison). + +## Engine grid — in-process build, median ms (xN vs classic), 20 runs + +Shape = `windows x panes-per-window`. Structural builds (no shell-readiness wait). + +| engine | 1x1 | 1x4 | 3x3 | 5x4 | 8x4 | +|---|--:|--:|--:|--:|--:| +| classic (Server/Session/Window/Pane) | 22.0 | 169.4 | 452.5 | 1442.2 | 3497.2 | +| builder / subprocess | 23.0 | 42.8 | 86.9 | 246.7 | 428.8 (8x) | +| builder / imsg | 20.6 | 31.1 | 62.6 | 153.4 | 262.0 (13x) | +| builder / control_mode | 2.5 | 9.4 | 26.5 | 103.3 | 166.7 (21x) | +| **pipelined (prototype)** | **1.4** | **7.9** | **20.2** | **65.3** | **115.7 (30x)** | +| concrete (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | + +Full percentiles at 8x4 (ms): + +| engine | min | avg | median | p90 | p95 | p99 | max | +|---|--:|--:|--:|--:|--:|--:|--:| +| classic | 2192 | 3404 | 3497 | 3931 | 4077 | 4432 | 4432 | +| subprocess | 358 | 426 | 429 | 479 | 481 | 487 | 487 | +| imsg | 222 | 283 | 262 | 342 | 421 | 455 | 455 | +| control_mode | 118 | 180 | 167 | 215 | 216 | 398 | 398 | +| pipelined | 97 | 123 | 116 | 156 | 165 | 194 | 194 | +| concrete | 1 | 2 | 2 | 2 | 2 | 2 | 2 | + +Reads: + +- **control_mode** (one persistent `tmux -C`, no per-op fork) is the fastest + shipped engine: 21x classic at 32 panes. +- **imsg** (AF_UNIX one-shot per call) sits between subprocess and control_mode; + its per-call handshake makes tiny builds no faster than classic. +- **pipelined** (prototype: batch independent creates into ~3 `run_batch` + round-trips instead of ~34) is fastest overall, ~1.4x over control_mode. Not + the 11x the round-trip count implies, because the build is **tmux-server-bound** + (one shell fork per pane), not round-trip-bound. `concrete` (offline, 1.5 ms) + is the Python floor: the plan/compile layer is negligible; the time is tmux. + +## With vs without shell-readiness wait + +`wait.json`, 10 runs. "wait" polls each pane until its shell has drawn a prompt. + +| shape | engine | nowait median | wait median | wait penalty | speedup vs classic | +|---|---|--:|--:|--:|--:| +| 1x4 | classic | 217.6 | 1134.5 | 5.2x | 1.0x | +| 1x4 | control_mode | 9.4 | 865.2 | 92x | 23.1x -> 1.3x | +| 1x4 | pipelined | 9.0 | 1004.3 | 112x | 24.3x -> 1.1x | +| 5x4 | classic | 1365.6 | 3252.7 | 2.4x | 1.0x | +| 5x4 | control_mode | 73.1 | 2194.5 | 30x | 18.7x -> 1.5x | +| 5x4 | pipelined | 66.6 | 2123.3 | 32x | 20.5x -> 1.5x | + +**The engine win only exists when nobody waits for shells.** Shell startup +(~0.8-2.1 s) dominates a fast build (30-112x penalty) but barely moves the slow +classic path (2-5x), so the ~20x engine advantage collapses to ~1.5x once both +sides wait. Comparing a classic-that-waits against a builder-that-doesn't is +apples-to-oranges (this is why an earlier ad-hoc run reported ~79x). + +## Profile — where a control_mode build spends time (32 panes x5) + +~200 ms/build; **68% is `select.epoll.poll`** in `control_mode._read_blocks` — +one round-trip per created id (each new window/pane id read back before the next +op targets it). Round-trip-latency bound, not CPU/Python bound. + +## Whole-process wall time (hyperfine, SubprocessEngine, end-to-end) + +For reference, whole-script wall time (Python start + import + build + teardown) +on `SubprocessEngine`, 50 runs: classic simple 210 ms / large 6764 ms; builder +simple 453 ms / large 1692 ms. End-to-end on the *slowest* engine understates the +builder (startup dwarfs a 3 ms build) — the in-process grid above is the clean +signal. Prefer `control_mode` and in-process timing to measure build cost. diff --git a/scripts/bench-results/grid.json b/scripts/bench-results/grid.json new file mode 100644 index 000000000..8df01a81e --- /dev/null +++ b/scripts/bench-results/grid.json @@ -0,0 +1,1082 @@ +[ + { + "engine": "classic", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.435490999370813, + 21.44604700151831, + 19.487711018882692, + 22.916321991942823, + 26.080587063916028, + 20.377128035761416, + 19.85792105551809, + 21.783681004308164, + 27.05869299825281, + 22.205271059647202, + 20.67994710523635, + 26.499966043047607, + 24.94822407606989, + 30.270048999227583, + 29.39265000168234, + 21.638029953464866, + 20.480802981182933, + 20.635419990867376, + 26.2573619838804, + 19.851638935506344 + ], + "n_ms": 20.0, + "min_ms": 19.487711018882692, + "avg_ms": 23.365147114964202, + "median_ms": 21.994476031977683, + "p90_ms": 27.05869299825281, + "p95_ms": 29.39265000168234, + "p99_ms": 30.270048999227583, + "max_ms": 30.270048999227583 + }, + { + "engine": "subprocess", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 22.950448095798492, + 23.71105900965631, + 27.512941975146532, + 24.13841593079269, + 22.9648700915277, + 23.848011973313987, + 25.90626000892371, + 20.328919985331595, + 21.03408903349191, + 21.60188800189644, + 26.37235203292221, + 22.638716967776418, + 20.477519021369517, + 19.63279501069337, + 28.77276297658682, + 25.713130016811192, + 20.554474089294672, + 20.25220892392099, + 28.945287107490003, + 22.18223095405847 + ], + "n_ms": 20.0, + "min_ms": 19.63279501069337, + "avg_ms": 23.47691906034015, + "median_ms": 22.957659093663096, + "p90_ms": 27.512941975146532, + "p95_ms": 28.77276297658682, + "p99_ms": 28.945287107490003, + "max_ms": 28.945287107490003 + }, + { + "engine": "control_mode", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 2.3771480191498995, + 2.6499099330976605, + 2.8321900172159076, + 2.5614179903641343, + 2.9272950487211347, + 2.5065389927476645, + 2.832969999872148, + 2.916119061410427, + 2.130561973899603, + 2.0278169540688396, + 2.620737999677658, + 2.21067201346159, + 1.859560958109796, + 2.2114990279078484, + 2.182059921324253, + 2.0884659606963396, + 2.447605016641319, + 4.296073107980192, + 4.0978980250656605, + 3.2866770634427667 + ], + "n_ms": 20.0, + "min_ms": 1.859560958109796, + "avg_ms": 2.653160854242742, + "median_ms": 2.5339784915558994, + "p90_ms": 3.2866770634427667, + "p95_ms": 4.0978980250656605, + "p99_ms": 4.296073107980192, + "max_ms": 4.296073107980192 + }, + { + "engine": "imsg", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.242659961804748, + 23.58605689369142, + 21.349252085201442, + 22.916006040759385, + 18.253559013828635, + 18.885195022448897, + 35.018087015487254, + 37.03230095561594, + 36.96872899308801, + 36.74224903807044, + 18.353665014728904, + 18.583990982733667, + 17.208026023581624, + 24.591958965174854, + 19.304446992464364, + 18.249089014716446, + 18.050470971502364, + 22.29922788683325, + 19.827933982014656, + 17.557888058945537 + ], + "n_ms": 20.0, + "min_ms": 17.208026023581624, + "avg_ms": 23.50103964563459, + "median_ms": 20.58859303360805, + "p90_ms": 36.74224903807044, + "p95_ms": 36.96872899308801, + "p99_ms": 37.03230095561594, + "max_ms": 37.03230095561594 + }, + { + "engine": "concrete", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 0.05781592335551977, + 0.05536503158509731, + 0.08927390445023775, + 0.06635801400989294, + 0.05585001781582832, + 0.05495199002325535, + 0.053521012887358665, + 0.05605991464108229, + 0.05437701474875212, + 0.052780029363930225, + 0.05164195317775011, + 0.0517780426889658, + 0.0520970206707716, + 0.05274603608995676, + 0.05097396206110716, + 0.05048594903200865, + 0.05105405580252409, + 0.051804003305733204, + 0.05062599666416645, + 0.0513358972966671 + ], + "n_ms": 20.0, + "min_ms": 0.05048594903200865, + "avg_ms": 0.05554478848353028, + "median_ms": 0.05276303272694349, + "p90_ms": 0.05781592335551977, + "p95_ms": 0.06635801400989294, + "p99_ms": 0.08927390445023775, + "max_ms": 0.08927390445023775 + }, + { + "engine": "pipelined", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 1.272339024581015, + 1.423096051439643, + 2.0246210042387247, + 2.4055520771071315, + 2.593372017145157, + 1.708880066871643, + 1.5237980987876654, + 1.258800970390439, + 1.4068959280848503, + 1.528986031189561, + 1.2518159346655011, + 1.1928770691156387, + 1.2443959712982178, + 1.61149597261101, + 1.3652080669999123, + 1.1852029711008072, + 1.2318679364398122, + 1.9605309935286641, + 2.02260899823159, + 1.3686279999092221 + ], + "n_ms": 20.0, + "min_ms": 1.1852029711008072, + "avg_ms": 1.5790486591868103, + "median_ms": 1.4149959897622466, + "p90_ms": 2.0246210042387247, + "p95_ms": 2.4055520771071315, + "p99_ms": 2.593372017145157, + "max_ms": 2.593372017145157 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 153.5736940568313, + 152.53363398369402, + 176.28063401207328, + 162.175236037001, + 175.2850729972124, + 169.6498990058899, + 160.512474947609, + 175.7287960499525, + 156.43915405962616, + 160.1339690387249, + 207.24134298507124, + 189.16924006771296, + 194.17230098042637, + 170.98306701518595, + 161.9495979975909, + 175.3947560209781, + 170.09779904037714, + 154.71308294218034, + 169.2105890251696, + 141.2136929575354 + ], + "n_ms": 20.0, + "min_ms": 141.2136929575354, + "avg_ms": 168.82290166104212, + "median_ms": 169.43024401552975, + "p90_ms": 189.16924006771296, + "p95_ms": 194.17230098042637, + "p99_ms": 207.24134298507124, + "max_ms": 207.24134298507124 + }, + { + "engine": "subprocess", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 38.20429800543934, + 34.88947299774736, + 56.08579399995506, + 35.82027601078153, + 36.9337199954316, + 35.59582296293229, + 46.69300094246864, + 39.19104894157499, + 37.6851549372077, + 35.862258984707296, + 30.545516056008637, + 41.76524991635233, + 50.84225791506469, + 50.91948399785906, + 55.70168199483305, + 49.489257973618805, + 43.84331707842648, + 59.19129599351436, + 52.224029088392854, + 54.89515699446201 + ], + "n_ms": 20.0, + "min_ms": 30.545516056008637, + "avg_ms": 44.318904739338905, + "median_ms": 42.804283497389406, + "p90_ms": 55.70168199483305, + "p95_ms": 56.08579399995506, + "p99_ms": 59.19129599351436, + "max_ms": 59.19129599351436 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 7.563800900243223, + 8.978422963991761, + 8.618877036496997, + 9.57214506343007, + 10.934944031760097, + 12.400830979458988, + 11.549205984920263, + 7.952383020892739, + 9.504236048087478, + 8.437798009254038, + 7.7332829823717475, + 9.446645970456302, + 9.59436094854027, + 8.322115987539291, + 9.378890972584486, + 8.756348979659379, + 9.824263979680836, + 7.038456969894469, + 10.727863991633058, + 11.08790806028992 + ], + "n_ms": 20.0, + "min_ms": 7.038456969894469, + "avg_ms": 9.37113914405927, + "median_ms": 9.412768471520394, + "p90_ms": 11.08790806028992, + "p95_ms": 11.549205984920263, + "p99_ms": 12.400830979458988, + "max_ms": 12.400830979458988 + }, + { + "engine": "imsg", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 36.68499703053385, + 34.726232988759875, + 24.83677805867046, + 26.792447897605598, + 36.66620596777648, + 28.368790983222425, + 27.89685397874564, + 37.136508035473526, + 34.588526003062725, + 31.007860088720918, + 26.524171931669116, + 33.32362789660692, + 44.18608907144517, + 39.438307052478194, + 28.73879298567772, + 27.788623003289104, + 30.046261032111943, + 31.244902056641877, + 33.68811297696084, + 28.78861501812935 + ], + "n_ms": 20.0, + "min_ms": 24.83677805867046, + "avg_ms": 32.123635202879086, + "median_ms": 31.126381072681397, + "p90_ms": 37.136508035473526, + "p95_ms": 39.438307052478194, + "p99_ms": 44.18608907144517, + "max_ms": 44.18608907144517 + }, + { + "engine": "concrete", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 0.13607402797788382, + 0.23710704408586025, + 0.14775199815630913, + 0.13310997746884823, + 0.12774800416082144, + 0.12735999189317226, + 0.1251481007784605, + 0.12269604485481977, + 0.12107205111533403, + 0.11823198292404413, + 0.27573294937610626, + 0.2059279941022396, + 0.1430619740858674, + 0.1845939550548792, + 0.13147201389074326, + 0.13140100054442883, + 0.125426915474236, + 0.1242499565705657, + 0.12552295811474323, + 0.12560293544083834 + ], + "n_ms": 20.0, + "min_ms": 0.11823198292404413, + "avg_ms": 0.14846459380351007, + "median_ms": 0.12957450235262513, + "p90_ms": 0.2059279941022396, + "p95_ms": 0.23710704408586025, + "p99_ms": 0.27573294937610626, + "max_ms": 0.27573294937610626 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.6258920123800635, + 9.559032041579485, + 9.58340906072408, + 5.963396979495883, + 8.836780907586217, + 10.417644982226193, + 7.979689980857074, + 8.669061004184186, + 6.5402110340073705, + 7.1102980291470885, + 6.61726703401655, + 7.768513984046876, + 8.26644105836749, + 6.927274982444942, + 6.618922925554216, + 6.4139129826799035, + 7.574769086204469, + 9.094446897506714, + 8.622737019322813, + 9.986574063077569 + ], + "n_ms": 20.0, + "min_ms": 5.963396979495883, + "avg_ms": 7.958813803270459, + "median_ms": 7.874101982451975, + "p90_ms": 9.58340906072408, + "p95_ms": 9.986574063077569, + "p99_ms": 10.417644982226193, + "max_ms": 10.417644982226193 + }, + { + "engine": "classic", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 463.1088289897889, + 403.2544719520956, + 459.22533597331494, + 505.975084961392, + 438.8479490298778, + 482.3058929760009, + 413.75007992610335, + 413.2016849471256, + 444.53056796919554, + 483.2421139581129, + 468.3551120106131, + 470.5149090150371, + 416.62799497134984, + 454.917594906874, + 409.0973420534283, + 427.1308289607987, + 460.38287493865937, + 450.0137569848448, + 479.48227205779403, + 442.03562603797764 + ], + "n_ms": 20.0, + "min_ms": 403.2544719520956, + "avg_ms": 449.3000161310192, + "median_ms": 452.4656759458594, + "p90_ms": 482.3058929760009, + "p95_ms": 483.2421139581129, + "p99_ms": 505.975084961392, + "max_ms": 505.975084961392 + }, + { + "engine": "subprocess", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 96.40262997709215, + 107.8126790234819, + 102.82182600349188, + 95.2509690541774, + 93.2349389186129, + 86.750422953628, + 89.36347393319011, + 89.38358607701957, + 113.29700902570039, + 82.9845640109852, + 90.94950300641358, + 83.90699897427112, + 81.64807397406548, + 87.03590603545308, + 73.54520098306239, + 82.44245196692646, + 72.46743002906442, + 82.35985110513866, + 79.32134799193591, + 83.34585605189204 + ], + "n_ms": 20.0, + "min_ms": 72.46743002906442, + "avg_ms": 88.71623595478013, + "median_ms": 86.89316449454054, + "p90_ms": 102.82182600349188, + "p95_ms": 107.8126790234819, + "p99_ms": 113.29700902570039, + "max_ms": 113.29700902570039 + }, + { + "engine": "control_mode", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 30.5233730468899, + 24.231599061749876, + 25.235411943867803, + 27.411659015342593, + 24.05042899772525, + 25.479506002739072, + 28.722655959427357, + 22.07455795723945, + 22.580575081519783, + 29.86845897976309, + 27.496899012476206, + 36.62381402682513, + 37.449890980497, + 26.981694041751325, + 24.941370938904583, + 25.788024999201298, + 30.847082030959427, + 26.0819629766047, + 23.499268922023475, + 27.957514976151288 + ], + "n_ms": 20.0, + "min_ms": 22.07455795723945, + "avg_ms": 27.39228744758293, + "median_ms": 26.531828509178013, + "p90_ms": 30.847082030959427, + "p95_ms": 36.62381402682513, + "p99_ms": 37.449890980497, + "max_ms": 37.449890980497 + }, + { + "engine": "imsg", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 73.45083705149591, + 70.16680098604411, + 78.33038899116218, + 74.66395699884742, + 56.962854927405715, + 57.84656805917621, + 64.33053908403963, + 54.64023910462856, + 58.12385701574385, + 62.07921903114766, + 55.78872701153159, + 59.88113197963685, + 63.08747094590217, + 69.321816903539, + 56.98866699822247, + 71.06748304795474, + 59.83901908621192, + 61.00640504155308, + 77.51034398097545, + 64.7579530486837 + ], + "n_ms": 20.0, + "min_ms": 54.64023910462856, + "avg_ms": 64.49221396469511, + "median_ms": 62.583344988524914, + "p90_ms": 74.66395699884742, + "p95_ms": 77.51034398097545, + "p99_ms": 78.33038899116218, + "max_ms": 78.33038899116218 + }, + { + "engine": "concrete", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 0.2903119893744588, + 0.28088700491935015, + 0.3294319612905383, + 0.2774670720100403, + 0.28050492983311415, + 0.29172003269195557, + 0.2925050212070346, + 0.4807590739801526, + 0.30473608057945967, + 0.33838499803096056, + 0.2801839727908373, + 0.26983011048287153, + 0.31153589952737093, + 0.30686904210597277, + 0.26840297505259514, + 0.2645669737830758, + 0.3714329795911908, + 0.28299796395003796, + 0.2705819206312299, + 0.28041808400303125 + ], + "n_ms": 20.0, + "min_ms": 0.2645669737830758, + "avg_ms": 0.3036764042917639, + "median_ms": 0.2866549766622484, + "p90_ms": 0.33838499803096056, + "p95_ms": 0.3714329795911908, + "p99_ms": 0.4807590739801526, + "max_ms": 0.4807590739801526 + }, + { + "engine": "pipelined", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 16.53240597806871, + 15.442625037394464, + 19.34879703912884, + 22.86289702169597, + 18.47324299160391, + 18.982085050083697, + 24.889598018489778, + 19.762809039093554, + 17.981484066694975, + 19.241684931330383, + 20.058405003510416, + 20.32635302748531, + 19.626682973466814, + 21.44766692072153, + 25.036148028448224, + 22.131431964226067, + 27.264841948635876, + 27.298758970573545, + 25.995625066570938, + 28.35541300009936 + ], + "n_ms": 20.0, + "min_ms": 15.442625037394464, + "avg_ms": 21.552947803866118, + "median_ms": 20.192379015497863, + "p90_ms": 27.264841948635876, + "p95_ms": 27.298758970573545, + "p99_ms": 28.35541300009936, + "max_ms": 28.35541300009936 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1281.397273996845, + 1210.668591898866, + 1242.258501937613, + 1174.9852310167626, + 1330.7410050183535, + 1521.1127869552001, + 1366.0107220057398, + 1602.0200490020216, + 1404.2648519389331, + 1466.6325360303745, + 1482.5694229220971, + 1441.553378943354, + 1442.840927047655, + 1607.132775010541, + 1580.0651350291446, + 1381.9000310031697, + 1454.8676230479032, + 1629.573607002385, + 1485.2986589539796, + 1375.63347897958 + ], + "n_ms": 20.0, + "min_ms": 1174.9852310167626, + "avg_ms": 1424.076329387026, + "median_ms": 1442.1971529955044, + "p90_ms": 1602.0200490020216, + "p95_ms": 1607.132775010541, + "p99_ms": 1629.573607002385, + "max_ms": 1629.573607002385 + }, + { + "engine": "subprocess", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 230.99103895947337, + 199.28296492435038, + 270.25563502684236, + 317.19566497486085, + 455.45686106197536, + 497.62218398973346, + 347.6630869554356, + 265.15684905461967, + 224.24249001778662, + 214.0263100154698, + 215.80458094831556, + 252.42189702112228, + 245.46299397479743, + 257.7294419752434, + 194.6850820677355, + 246.80601397994906, + 246.52875203173608, + 262.4232630478218, + 235.30278506223112, + 216.59297600854188 + ], + "n_ms": 20.0, + "min_ms": 194.6850820677355, + "avg_ms": 269.7825435549021, + "median_ms": 246.66738300584257, + "p90_ms": 347.6630869554356, + "p95_ms": 455.45686106197536, + "p99_ms": 497.62218398973346, + "max_ms": 497.62218398973346 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.78025595284998, + 79.53090802766383, + 236.67864105664194, + 233.00717200618237, + 328.0731838895008, + 131.67984399478883, + 164.9903169600293, + 336.2570349127054, + 328.47389997914433, + 113.03125496488065, + 83.44777394086123, + 72.73705000989139, + 70.41866099461913, + 179.36235608067364, + 102.63979400042444, + 71.07233500573784, + 73.72917397879064, + 70.85887296125293, + 104.05752004589885, + 94.78877391666174 + ], + "n_ms": 20.0, + "min_ms": 70.41866099461913, + "avg_ms": 147.53074113395996, + "median_ms": 103.34865702316165, + "p90_ms": 328.0731838895008, + "p95_ms": 328.47389997914433, + "p99_ms": 336.2570349127054, + "max_ms": 336.2570349127054 + }, + { + "engine": "imsg", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 134.2119559412822, + 210.86893905885518, + 148.33857398480177, + 138.0466379923746, + 154.90469709038734, + 130.4093300132081, + 151.83229907415807, + 218.34416908677667, + 124.5981550309807, + 128.86274000629783, + 159.59694795310497, + 145.80014697276056, + 135.36079099867493, + 260.35256509203464, + 163.294667028822, + 218.46147894393653, + 177.09315801039338, + 157.00659702997655, + 137.62785703875124, + 177.16083896812052 + ], + "n_ms": 20.0, + "min_ms": 124.5981550309807, + "avg_ms": 163.6086272657849, + "median_ms": 153.3684980822727, + "p90_ms": 218.34416908677667, + "p95_ms": 218.46147894393653, + "p99_ms": 260.35256509203464, + "max_ms": 260.35256509203464 + }, + { + "engine": "concrete", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1.1191670782864094, + 0.9929570369422436, + 0.8589229546487331, + 0.7576720090582967, + 1.3178159715607762, + 1.1864739935845137, + 1.3520210050046444, + 1.2667239643633366, + 1.0587309952825308, + 1.7452990869060159, + 1.4916330110281706, + 3.988807089626789, + 2.0200000144541264, + 1.7959449905902147, + 1.871323911473155, + 1.5445369062945247, + 1.4150440692901611, + 1.2863259762525558, + 0.6520430324599147, + 0.6350380135700107 + ], + "n_ms": 20.0, + "min_ms": 0.6350380135700107, + "avg_ms": 1.4178240555338562, + "median_ms": 1.302070973906666, + "p90_ms": 1.871323911473155, + "p95_ms": 2.0200000144541264, + "p99_ms": 3.988807089626789, + "max_ms": 3.988807089626789 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 113.15120500512421, + 54.02535106986761, + 64.92527201771736, + 78.20391294080764, + 57.41404299624264, + 70.83705102559179, + 92.94041607063264, + 56.410389952361584, + 69.41384205128998, + 65.76590496115386, + 108.70656406041235, + 64.65335004031658, + 75.56975504849106, + 68.74816899653524, + 75.74488001409918, + 60.71585509926081, + 57.00136907398701, + 61.06731400359422, + 58.447972987778485, + 59.52235695440322 + ], + "n_ms": 20.0, + "min_ms": 54.02535106986761, + "avg_ms": 70.66324871848337, + "median_ms": 65.34558848943561, + "p90_ms": 92.94041607063264, + "p95_ms": 108.70656406041235, + "p99_ms": 113.15120500512421, + "max_ms": 113.15120500512421 + }, + { + "engine": "classic", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 3666.3628419628367, + 3372.8903520386666, + 3848.983636009507, + 3508.1395419547334, + 3486.258820979856, + 3584.1793949948624, + 3485.946947010234, + 3771.2144720135257, + 3931.1889680102468, + 4432.214422966354, + 4076.763738063164, + 2764.4857480190694, + 3215.3420510003343, + 3295.630355947651, + 3329.8535760259256, + 3519.156881957315, + 3853.867839090526, + 2536.271504010074, + 2208.0091719981283, + 2192.4075819551945 + ], + "n_ms": 20.0, + "min_ms": 2192.4075819551945, + "avg_ms": 3403.95839230041, + "median_ms": 3497.1991814672947, + "p90_ms": 3931.1889680102468, + "p95_ms": 4076.763738063164, + "p99_ms": 4432.214422966354, + "max_ms": 4432.214422966354 + }, + { + "engine": "subprocess", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 392.0094169443473, + 399.5577469468117, + 461.8747670901939, + 463.75522401649505, + 403.79654499702156, + 478.78038801718503, + 486.85317998752, + 401.86715801246464, + 385.10203699115664, + 421.61177797243, + 463.4238800499588, + 357.96659102197737, + 435.90391403995454, + 470.3146460233256, + 480.929414043203, + 438.19006194826216, + 395.19416098482907, + 373.0431740405038, + 367.8618990816176, + 445.61960094142705 + ], + "n_ms": 20.0, + "min_ms": 357.96659102197737, + "avg_ms": 426.18277915753424, + "median_ms": 428.75784600619227, + "p90_ms": 478.78038801718503, + "p95_ms": 480.929414043203, + "p99_ms": 486.85317998752, + "max_ms": 486.85317998752 + }, + { + "engine": "control_mode", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 152.8084089513868, + 117.58937302511185, + 195.70027699228376, + 138.3822859497741, + 397.9049799963832, + 215.567508013919, + 138.99186393246055, + 188.69415298104286, + 189.87572100013494, + 173.7240820657462, + 158.34677510429174, + 215.0000180117786, + 194.2786539439112, + 176.19880801066756, + 153.5388040356338, + 159.58266996312886, + 143.19772401358932, + 153.82824302650988, + 178.53682104032487, + 147.99589000176638 + ], + "n_ms": 20.0, + "min_ms": 117.58937302511185, + "avg_ms": 179.48715300299227, + "median_ms": 166.65337601443753, + "p90_ms": 215.0000180117786, + "p95_ms": 215.567508013919, + "p99_ms": 397.9049799963832, + "max_ms": 397.9049799963832 + }, + { + "engine": "imsg", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 454.6383459819481, + 259.375739027746, + 249.91479504387826, + 420.9431590279564, + 222.0811820589006, + 264.5984790287912, + 231.42871004529297, + 333.20740202907473, + 254.01110399980098, + 341.4921569637954, + 272.3997130524367, + 239.1474029282108, + 242.0606450177729, + 231.84302891604602, + 285.94926302321255, + 250.6428079213947, + 266.55483595095575, + 276.24492603354156, + 326.5154130058363, + 226.46799904759973 + ], + "n_ms": 20.0, + "min_ms": 222.0811820589006, + "avg_ms": 282.4758554052096, + "median_ms": 261.9871090282686, + "p90_ms": 341.4921569637954, + "p95_ms": 420.9431590279564, + "p99_ms": 454.6383459819481, + "max_ms": 454.6383459819481 + }, + { + "engine": "concrete", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 1.0853420244529843, + 0.9852750226855278, + 0.8930320618674159, + 1.6033079009503126, + 1.4309020480141044, + 1.3377750292420387, + 2.1038519917055964, + 1.9198539666831493, + 1.9366199849173427, + 2.0881620002910495, + 1.9528960110619664, + 2.314181998372078, + 1.6242529964074492, + 1.7207990167662501, + 1.67950801551342, + 1.1982190189883113, + 1.0953579330816865, + 1.4674999983981252, + 1.091616926714778, + 1.1784050147980452 + ], + "n_ms": 20.0, + "min_ms": 0.8930320618674159, + "avg_ms": 1.5353429480455816, + "median_ms": 1.535403949674219, + "p90_ms": 2.0881620002910495, + "p95_ms": 2.1038519917055964, + "p99_ms": 2.314181998372078, + "max_ms": 2.314181998372078 + }, + { + "engine": "pipelined", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 100.61924997717142, + 96.57052101101726, + 99.77939596865326, + 117.95211094431579, + 113.54388005565852, + 98.9359940867871, + 124.54905500635505, + 101.82711097877473, + 156.30723407957703, + 125.18065399490297, + 110.4188309982419, + 133.29723698552698, + 140.21150907501578, + 165.18471902236342, + 121.83465505950153, + 193.51797795388848, + 152.09435508586466, + 102.82438900321722, + 101.34623397607356, + 110.86194100789726 + ], + "n_ms": 20.0, + "min_ms": 96.57052101101726, + "avg_ms": 123.3428527135402, + "median_ms": 115.74799549998716, + "p90_ms": 156.30723407957703, + "p95_ms": 165.18471902236342, + "p99_ms": 193.51797795388848, + "max_ms": 193.51797795388848 + } +] \ No newline at end of file diff --git a/scripts/bench-results/wait.json b/scripts/bench-results/wait.json new file mode 100644 index 000000000..0b138f7c4 --- /dev/null +++ b/scripts/bench-results/wait.json @@ -0,0 +1,314 @@ +[ + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 166.7344990419224, + 212.69031206611544, + 256.8014459684491, + 199.50575497932732, + 261.5824770182371, + 222.48539805877954, + 203.23852205183357, + 242.7966770483181, + 239.08080800902098, + 198.84139497298747 + ], + "n_ms": 10.0, + "min_ms": 166.7344990419224, + "avg_ms": 220.3757289214991, + "median_ms": 217.5878550624475, + "p90_ms": 256.8014459684491, + "p95_ms": 261.5824770182371, + "p99_ms": 261.5824770182371, + "max_ms": 261.5824770182371 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 8.247727062553167, + 10.702441912144423, + 14.183179941028357, + 10.467821964994073, + 8.07721505407244, + 9.324315935373306, + 8.775111986324191, + 9.275623015128076, + 9.485395043157041, + 13.174964929930866 + ], + "n_ms": 10.0, + "min_ms": 8.07721505407244, + "avg_ms": 10.171379684470594, + "median_ms": 9.404855489265174, + "p90_ms": 13.174964929930866, + "p95_ms": 14.183179941028357, + "p99_ms": 14.183179941028357, + "max_ms": 14.183179941028357 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.229061051271856, + 6.618206040002406, + 8.960460894741118, + 11.124748038128018, + 7.881589001044631, + 11.010617017745972, + 8.948027971200645, + 7.985224016010761, + 10.071107069961727, + 15.785112977027893 + ], + "n_ms": 10.0, + "min_ms": 6.229061051271856, + "avg_ms": 9.461415407713503, + "median_ms": 8.954244432970881, + "p90_ms": 11.124748038128018, + "p95_ms": 15.785112977027893, + "p99_ms": 15.785112977027893, + "max_ms": 15.785112977027893 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1330.9594680322334, + 1398.6252510221675, + 1403.3046170370653, + 1252.3579199332744, + 1248.6419779015705, + 1235.2563559543341, + 1639.7459730505943, + 1629.7535400371999, + 1400.728255044669, + 1332.566024037078 + ], + "n_ms": 10.0, + "min_ms": 1235.2563559543341, + "avg_ms": 1387.1939382050186, + "median_ms": 1365.5956375296228, + "p90_ms": 1629.7535400371999, + "p95_ms": 1639.7459730505943, + "p99_ms": 1639.7459730505943, + "max_ms": 1639.7459730505943 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 68.88843094930053, + 78.3604410244152, + 78.01781396847218, + 72.06234894692898, + 72.52011599484831, + 73.72508093249053, + 64.87809692043811, + 69.0728749614209, + 97.45409805327654, + 90.78932600095868 + ], + "n_ms": 10.0, + "min_ms": 64.87809692043811, + "avg_ms": 76.576862775255, + "median_ms": 73.12259846366942, + "p90_ms": 90.78932600095868, + "p95_ms": 97.45409805327654, + "p99_ms": 97.45409805327654, + "max_ms": 97.45409805327654 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.46699501108378, + 58.07583301793784, + 62.140439986251295, + 74.83123405836523, + 57.96935607213527, + 70.67135605029762, + 62.06154392566532, + 72.16213690117002, + 65.47302007675171, + 67.81659903936088 + ], + "n_ms": 10.0, + "min_ms": 57.96935607213527, + "avg_ms": 66.6668514139019, + "median_ms": 66.6448095580563, + "p90_ms": 74.83123405836523, + "p95_ms": 75.46699501108378, + "p99_ms": 75.46699501108378, + "max_ms": 75.46699501108378 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 2271.074099931866, + 1628.9673490682617, + 1180.6232570670545, + 1546.2298300117254, + 1129.7054740134627, + 1003.3381689572707, + 1103.009557002224, + 1139.2105700215325, + 1072.3191909492016, + 910.3259799303487 + ], + "n_ms": 10.0, + "min_ms": 910.3259799303487, + "avg_ms": 1298.4803476952948, + "median_ms": 1134.4580220174976, + "p90_ms": 1628.9673490682617, + "p95_ms": 2271.074099931866, + "p99_ms": 2271.074099931866, + "max_ms": 2271.074099931866 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 771.0779230110347, + 826.4453769661486, + 858.2343220477924, + 749.7300069080666, + 872.2627089591697, + 856.7365389317274, + 970.8761879010126, + 1058.184701949358, + 969.4039679598063, + 1015.9691049484536 + ], + "n_ms": 10.0, + "min_ms": 749.7300069080666, + "avg_ms": 894.892083958257, + "median_ms": 865.248515503481, + "p90_ms": 1015.9691049484536, + "p95_ms": 1058.184701949358, + "p99_ms": 1058.184701949358, + "max_ms": 1058.184701949358 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 924.671886023134, + 957.4840500717983, + 970.9887669887394, + 717.6021320046857, + 1027.1775299916044, + 1078.7920550210401, + 1047.6982130203396, + 1131.4134739805013, + 1078.2063950318843, + 981.4246169989929 + ], + "n_ms": 10.0, + "min_ms": 717.6021320046857, + "avg_ms": 991.545911913272, + "median_ms": 1004.3010734952986, + "p90_ms": 1078.7920550210401, + "p95_ms": 1131.4134739805013, + "p99_ms": 1131.4134739805013, + "max_ms": 1131.4134739805013 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2688.125988934189, + 3421.498993993737, + 2721.125219017267, + 3680.0719080492854, + 3053.872239892371, + 3266.9221319956705, + 3363.687581033446, + 3229.252888006158, + 3476.997076999396, + 3238.5111950570717 + ], + "n_ms": 10.0, + "min_ms": 2688.125988934189, + "avg_ms": 3214.006522297859, + "median_ms": 3252.716663526371, + "p90_ms": 3476.997076999396, + "p95_ms": 3680.0719080492854, + "p99_ms": 3680.0719080492854, + "max_ms": 3680.0719080492854 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2327.0793380215764, + 2169.6944349678233, + 2290.0202609598637, + 2193.4810240054503, + 2250.2999720163643, + 2082.6522540301085, + 2308.945218101144, + 2176.221609930508, + 2195.5926649970934, + 2136.908065993339 + ], + "n_ms": 10.0, + "min_ms": 2082.6522540301085, + "avg_ms": 2213.089484302327, + "median_ms": 2194.536844501272, + "p90_ms": 2308.945218101144, + "p95_ms": 2327.0793380215764, + "p99_ms": 2327.0793380215764, + "max_ms": 2327.0793380215764 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2107.4311519041657, + 2110.2887169690803, + 2249.8882319778204, + 2227.454266976565, + 2197.3950610263273, + 2126.2204200029373, + 2120.3758959891275, + 2134.5664139371365, + 2097.358933999203, + 2115.1353849563748 + ], + "n_ms": 10.0, + "min_ms": 2097.358933999203, + "avg_ms": 2148.611447773874, + "median_ms": 2123.2981579960324, + "p90_ms": 2227.454266976565, + "p95_ms": 2249.8882319778204, + "p99_ms": 2249.8882319778204, + "max_ms": 2249.8882319778204 + } +] \ No newline at end of file From f3083ffc7ffe21780dfd2a1d36e3b51f25de5f16 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 15:30:33 -0500 Subject: [PATCH 152/154] Scripts(fix[bench]): Make bench_engines mypy-clean under `mypy .` why: The committed benchmark script failed a repo-wide `mypy .` sweep with 5 errors. Four stem from the `typer` PEP 723 inline dependency, which the repo's mypy environment cannot resolve (cascading into untyped-decorator errors); one is a genuine Server|None narrowing gap. The configured scope (files = [src, tests]) hides them, but a broad `mypy .` surfaces them. what: - Add a file-level disable-error-code for the two typer environment artifacts (import-not-found, untyped-decorator), keeping every other check strict. - Accept `Server | None` in ImsgForServer and assert non-None so the make_engine callable type narrows correctly. --- scripts/bench_engines.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py index 50fd12db8..0130ed899 100644 --- a/scripts/bench_engines.py +++ b/scripts/bench_engines.py @@ -6,6 +6,13 @@ # [tool.uv.sources] # libtmux = { path = "..", editable = true } # /// + +# ``typer`` is a PEP 723 inline dependency, resolved only inside ``uv run``'s +# ephemeral venv; the repo's mypy environment can't import it, which also makes +# its command decorators look untyped. Suppress just those two environment +# artifacts -- every other check (including the ImsgForServer narrowing below) +# stays strict. +# mypy: disable-error-code="import-not-found, untyped-decorator" """Hermetic libtmux engine build-benchmark grid. Measures how long the experimental workspace builder (and the classic API, and a @@ -181,7 +188,8 @@ class ImsgForServer: args -- so this wrapper prepends the isolated socket flag to every request. """ - def __init__(self, server: Server) -> None: + def __init__(self, server: Server | None) -> None: + assert server is not None self._e = ImsgEngine() self._flag = ( f"-S{server.socket_path}" From b305ee3cdf150ca9bbf982f83362ad7f2650ccbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 17:47:57 -0500 Subject: [PATCH 153/154] Engines(refactor[mock]): Rename ConcreteEngine to MockEngine why: "Concrete" named the in-memory simulator engine, but every real engine (subprocess, control_mode, imsg) is equally a concrete implementation. Across the Python/Rust ecosystem "Concrete*" is a test-only convention for "a minimal instantiable ABC subclass", the opposite of this docs/doctest workhorse, and the word already carries its id/type sense throughout ops/query/objects. "Mock" names the engine by its role: the no-tmux, in-memory stand-in. what: - Rename ConcreteEngine -> MockEngine and AsyncConcreteEngine -> AsyncMockEngine; module concrete.py -> mock.py - EngineKind.CONCRETE ("concrete") -> EngineKind.MOCK ("mock"); EngineSpec.concrete() -> EngineSpec.mock(); registry key becomes "mock" (available_engines() re-sorts accordingly) - Rename the benchmark engine key/label; update RESULTS.md and grid.json to match - Keep docstrings describing the in-memory simulation so the name's test-double flavor does not mislead doctest readers - Leave "concrete" untouched where it means a concrete id/target/pane handle (ops, query, objects, workspace) --- docs/experimental.md | 28 +++++++++---------- scripts/bench-results/RESULTS.md | 6 ++-- scripts/bench-results/grid.json | 10 +++---- scripts/bench_engines.py | 10 +++---- src/libtmux/experimental/engines/__init__.py | 8 +++--- src/libtmux/experimental/engines/base.py | 8 +++--- .../engines/{concrete.py => mock.py} | 14 +++++----- src/libtmux/experimental/engines/registry.py | 10 +++---- src/libtmux/experimental/fluent.py | 20 ++++++------- src/libtmux/experimental/mcp/__init__.py | 2 +- .../experimental/mcp/vocabulary/__init__.py | 4 +-- .../experimental/mcp/vocabulary/_bridge.py | 4 +-- .../experimental/mcp/vocabulary/pane.py | 12 ++++---- .../experimental/mcp/vocabulary/server.py | 4 +-- .../experimental/mcp/vocabulary/session.py | 8 +++--- .../experimental/mcp/vocabulary/window.py | 4 +-- src/libtmux/experimental/objects/client.py | 4 +-- src/libtmux/experimental/objects/pane.py | 12 ++++---- src/libtmux/experimental/objects/server.py | 8 +++--- src/libtmux/experimental/objects/session.py | 8 +++--- src/libtmux/experimental/objects/window.py | 8 +++--- .../experimental/ops/_ops/list_clients.py | 4 +-- .../experimental/ops/_ops/list_panes.py | 4 +-- .../experimental/ops/_ops/list_sessions.py | 4 +-- .../experimental/ops/_ops/list_windows.py | 4 +-- src/libtmux/experimental/ops/plan.py | 14 +++++----- .../experimental/workspace/__init__.py | 4 +-- src/libtmux/experimental/workspace/cli.py | 6 ++-- src/libtmux/experimental/workspace/confirm.py | 2 +- src/libtmux/experimental/workspace/ir.py | 6 ++-- src/libtmux/experimental/workspace/runner.py | 6 ++-- src/libtmux/experimental/workspace/sets.py | 4 +-- .../contract/test_async_control_engine.py | 12 ++++---- ..._async_control_engine_workspace_builder.py | 20 ++++++------- .../contract/test_classic_engine.py | 18 ++++++------ .../contract/test_control_engine.py | 16 +++++------ .../contract/test_engine_contract.py | 16 +++++------ .../contract/test_workspace_floats.py | 8 +++--- .../contract/test_workspace_folding.py | 8 +++--- .../contract/test_workspace_sets.py | 10 +++---- tests/experimental/engines/test_registry.py | 4 +-- tests/experimental/mcp/test_adapter_async.py | 8 ++---- tests/experimental/mcp/test_caller.py | 22 +++++++-------- tests/experimental/mcp/test_events.py | 4 +-- .../experimental/mcp/test_fastmcp_adapter.py | 18 ++++++------ tests/experimental/mcp/test_mcp_projection.py | 8 +++--- tests/experimental/mcp/test_prompts.py | 4 +-- .../mcp/test_relative_special_guard.py | 14 +++++----- tests/experimental/mcp/test_resources.py | 4 +-- tests/experimental/mcp/test_safety_gate.py | 6 ++-- tests/experimental/mcp/test_vocabulary.py | 18 ++++++------ .../mcp/test_vocabulary_extended.py | 20 ++++++------- .../objects/test_matrix_complete.py | 12 ++++---- .../objects/test_object_matrix.py | 12 ++++---- .../experimental/objects/test_pane_object.py | 16 +++++------ tests/experimental/ops/test_ack_ops.py | 4 +-- tests/experimental/ops/test_chain.py | 4 +-- tests/experimental/ops/test_execute.py | 4 +-- tests/experimental/ops/test_plan.py | 28 +++++++++---------- tests/experimental/ops/test_read_ops.py | 4 +-- tests/experimental/test_fluent.py | 6 ++-- tests/experimental/test_freeze.py | 4 +-- tests/experimental/test_query.py | 4 +-- 63 files changed, 291 insertions(+), 295 deletions(-) rename src/libtmux/experimental/engines/{concrete.py => mock.py} (92%) diff --git a/docs/experimental.md b/docs/experimental.md index 8c7c4c873..d3b112412 100644 --- a/docs/experimental.md +++ b/docs/experimental.md @@ -25,8 +25,8 @@ inspect ``ok``/``status``, or opt into raising with ``raise_for_status()``: ```python >>> from libtmux.experimental.ops import HasSession, run >>> from libtmux.experimental.ops._types import SessionId ->>> from libtmux.experimental.engines import ConcreteEngine ->>> result = run(HasSession(target=SessionId("$0")), ConcreteEngine()) +>>> from libtmux.experimental.engines import MockEngine +>>> result = run(HasSession(target=SessionId("$0")), MockEngine()) >>> result.ok True >>> result.raise_for_status() is result @@ -46,12 +46,12 @@ only *how* and *where* the command runs. | Engine | Transport | Use it for | | --- | --- | --- | | ``SubprocessEngine`` | one ``tmux`` process per command | the classic path; reproduces today's libtmux behavior | -| ``ConcreteEngine`` | in-memory, no tmux | tests and dry runs (deterministic, fabricated output) | +| ``MockEngine`` | in-memory, no tmux | tests and dry runs (deterministic, fabricated output) | | ``ControlModeEngine`` | a persistent ``tmux -C`` connection | many commands over one long-lived session | | ``ImsgEngine`` | tmux's native binary peer protocol | an opt-in easter egg | Each has an ``Async*`` counterpart (``AsyncSubprocessEngine``, -``AsyncConcreteEngine``, ``AsyncControlModeEngine``) behind ``AsyncTmuxEngine``. +``AsyncMockEngine``, ``AsyncControlModeEngine``) behind ``AsyncTmuxEngine``. Construct one directly, bind it to a live server with ``SubprocessEngine.for_server(server)``, or select one by name from the engine registry: @@ -61,8 +61,8 @@ registry: >>> from libtmux.experimental.ops import HasSession, run >>> from libtmux.experimental.ops._types import SessionId >>> available_engines() -('concrete', 'control_mode', 'imsg', 'subprocess') ->>> engine = create_engine("concrete") +('control_mode', 'imsg', 'mock', 'subprocess') +>>> engine = create_engine("mock") >>> run(HasSession(target=SessionId("$0")), engine).status 'complete' ``` @@ -77,11 +77,11 @@ later operation can target something that does not exist yet. ``execute`` ```python >>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys >>> from libtmux.experimental.ops._types import WindowId ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> plan = LazyPlan() >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) >>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) ->>> outcome = plan.execute(ConcreteEngine()) +>>> outcome = plan.execute(MockEngine()) >>> outcome.ok True >>> [r.status for r in outcome.results] @@ -107,11 +107,11 @@ many times tmux is invoked: ```python >>> from libtmux.experimental.ops import LazyPlan, SplitWindow, SendKeys, FoldingPlanner >>> from libtmux.experimental.ops._types import WindowId ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> plan = LazyPlan() >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) >>> _ = plan.add(SendKeys(target=pane, keys="echo hi", enter=True)) ->>> plan.execute(ConcreteEngine(), planner=FoldingPlanner()).ok +>>> plan.execute(MockEngine(), planner=FoldingPlanner()).ok True ``` @@ -125,11 +125,11 @@ description into a few dispatches (its async twin is ``arun``): ```python >>> from libtmux.experimental.fluent import plan ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> p = plan() >>> pane = p.new_session("dev").window().pane() >>> _ = pane.do(lambda c: c.send_keys("vim")).split().do(lambda c: c.send_keys("htop")) ->>> p.run(ConcreteEngine()).ok +>>> p.run(MockEngine()).ok True ``` @@ -147,10 +147,10 @@ duplicating it: ```python >>> from libtmux.experimental.fluent import plan ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> p = plan() >>> _ = p.find_or_create_session("dev").window().pane() ->>> p.run(ConcreteEngine()).ok +>>> p.run(MockEngine()).ok True ``` diff --git a/scripts/bench-results/RESULTS.md b/scripts/bench-results/RESULTS.md index f96debdeb..578bbd6fa 100644 --- a/scripts/bench-results/RESULTS.md +++ b/scripts/bench-results/RESULTS.md @@ -24,7 +24,7 @@ Shape = `windows x panes-per-window`. Structural builds (no shell-readiness wait | builder / imsg | 20.6 | 31.1 | 62.6 | 153.4 | 262.0 (13x) | | builder / control_mode | 2.5 | 9.4 | 26.5 | 103.3 | 166.7 (21x) | | **pipelined (prototype)** | **1.4** | **7.9** | **20.2** | **65.3** | **115.7 (30x)** | -| concrete (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | +| mock (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | Full percentiles at 8x4 (ms): @@ -35,7 +35,7 @@ Full percentiles at 8x4 (ms): | imsg | 222 | 283 | 262 | 342 | 421 | 455 | 455 | | control_mode | 118 | 180 | 167 | 215 | 216 | 398 | 398 | | pipelined | 97 | 123 | 116 | 156 | 165 | 194 | 194 | -| concrete | 1 | 2 | 2 | 2 | 2 | 2 | 2 | +| mock | 1 | 2 | 2 | 2 | 2 | 2 | 2 | Reads: @@ -46,7 +46,7 @@ Reads: - **pipelined** (prototype: batch independent creates into ~3 `run_batch` round-trips instead of ~34) is fastest overall, ~1.4x over control_mode. Not the 11x the round-trip count implies, because the build is **tmux-server-bound** - (one shell fork per pane), not round-trip-bound. `concrete` (offline, 1.5 ms) + (one shell fork per pane), not round-trip-bound. `mock` (offline, 1.5 ms) is the Python floor: the plan/compile layer is negligible; the time is tmux. ## With vs without shell-readiness wait diff --git a/scripts/bench-results/grid.json b/scripts/bench-results/grid.json index 8df01a81e..cfb79abbf 100644 --- a/scripts/bench-results/grid.json +++ b/scripts/bench-results/grid.json @@ -144,7 +144,7 @@ "max_ms": 37.03230095561594 }, { - "engine": "concrete", + "engine": "mock", "shape": "1x1", "panes": 1, "wait": false, @@ -360,7 +360,7 @@ "max_ms": 44.18608907144517 }, { - "engine": "concrete", + "engine": "mock", "shape": "1x4", "panes": 4, "wait": false, @@ -576,7 +576,7 @@ "max_ms": 78.33038899116218 }, { - "engine": "concrete", + "engine": "mock", "shape": "3x3", "panes": 9, "wait": false, @@ -792,7 +792,7 @@ "max_ms": 260.35256509203464 }, { - "engine": "concrete", + "engine": "mock", "shape": "5x4", "panes": 20, "wait": false, @@ -1008,7 +1008,7 @@ "max_ms": 454.6383459819481 }, { - "engine": "concrete", + "engine": "mock", "shape": "8x4", "panes": 32, "wait": false, diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py index 0130ed899..e99f3b703 100644 --- a/scripts/bench_engines.py +++ b/scripts/bench_engines.py @@ -29,7 +29,7 @@ subprocess builder on SubprocessEngine (one tmux fork per op) control_mode builder on ControlModeEngine (one persistent ``tmux -C``) imsg builder on ImsgEngine (AF_UNIX imsg, socket-injected) - concrete builder on ConcreteEngine (offline, in-memory: Python floor) + mock builder on MockEngine (offline, in-memory: Python floor) pipelined prototype: batch independent creates via run_batch (control_mode) Timing (``run`` = in-process build-only, the clean signal; ``--hyperfine`` also @@ -71,9 +71,9 @@ import typer from libtmux.experimental.engines import ( - ConcreteEngine, ControlModeEngine, ImsgEngine, + MockEngine, SubprocessEngine, ) from libtmux.experimental.engines.base import CommandRequest @@ -230,9 +230,7 @@ class Impl: "control_mode", "builder", lambda s: ControlModeEngine.for_server(s) ), "imsg": Impl("imsg", "builder", lambda s: ImsgForServer(s), needs_preboot=True), - "concrete": Impl( - "concrete", "offline", lambda s: ConcreteEngine(), preflight=False - ), + "mock": Impl("mock", "offline", lambda s: MockEngine(), preflight=False), "pipelined": Impl( "pipelined", "pipelined", lambda s: ControlModeEngine.for_server(s) ), @@ -353,7 +351,7 @@ def summarize(samples: list[float]) -> dict[str, float]: def run( shapes: str = typer.Option("1x1,1x4,3x3,5x4,8x4", help="comma WxP shapes"), engines: str = typer.Option( - "classic,subprocess,control_mode,imsg,concrete,pipelined", + "classic,subprocess,control_mode,imsg,mock,pipelined", help="comma engine names", ), wait: bool = typer.Option(False, help="ALSO measure with shell-readiness wait"), diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py index 3969e73f5..2ad70542c 100644 --- a/src/libtmux/experimental/engines/__init__.py +++ b/src/libtmux/experimental/engines/__init__.py @@ -3,7 +3,7 @@ An *engine* executes a rendered tmux command and returns a structured result. Engines are interchangeable behind the :class:`~.base.TmuxEngine` / :class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run -through a subprocess (classic), an in-memory simulator (concrete), a persistent +through a subprocess (classic), an in-memory simulator (mock), a persistent ``tmux -C`` control connection, an async transport, or (as an easter egg) tmux's native binary peer protocol -- and return the *same* typed result. @@ -26,13 +26,13 @@ SupportsTmuxVersion, TmuxEngine, ) -from libtmux.experimental.engines.concrete import AsyncConcreteEngine, ConcreteEngine from libtmux.experimental.engines.control_mode import ( ControlModeEngine, ControlModeError, ControlModeParser, ) from libtmux.experimental.engines.imsg import ImsgEngine +from libtmux.experimental.engines.mock import AsyncMockEngine, MockEngine from libtmux.experimental.engines.registry import ( available_engines, create_engine, @@ -41,13 +41,12 @@ from libtmux.experimental.engines.subprocess import SubprocessEngine __all__ = ( - "AsyncConcreteEngine", "AsyncControlModeEngine", + "AsyncMockEngine", "AsyncSubprocessEngine", "AsyncTmuxEngine", "CommandRequest", "CommandResult", - "ConcreteEngine", "ControlModeEngine", "ControlModeError", "ControlModeParser", @@ -55,6 +54,7 @@ "EngineKind", "EngineSpec", "ImsgEngine", + "MockEngine", "SubprocessEngine", "SupportsTmuxVersion", "TmuxEngine", diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py index 7393ed7ce..b813a12ba 100644 --- a/src/libtmux/experimental/engines/base.py +++ b/src/libtmux/experimental/engines/base.py @@ -100,7 +100,7 @@ class EngineKind(str, enum.Enum): """Named engine families.""" SUBPROCESS = "subprocess" - CONCRETE = "concrete" + MOCK = "mock" CONTROL_MODE = "control_mode" IMSG = "imsg" @@ -139,9 +139,9 @@ def subprocess(cls, *, protocol_version: int | None = None) -> EngineSpec: return cls(kind=EngineKind.SUBPROCESS, protocol_version=protocol_version) @classmethod - def concrete(cls) -> EngineSpec: - """Build a concrete (in-memory) engine spec.""" - return cls(kind=EngineKind.CONCRETE) + def mock(cls) -> EngineSpec: + """Build a mock (in-memory) engine spec.""" + return cls(kind=EngineKind.MOCK) @classmethod def control_mode(cls) -> EngineSpec: diff --git a/src/libtmux/experimental/engines/concrete.py b/src/libtmux/experimental/engines/mock.py similarity index 92% rename from src/libtmux/experimental/engines/concrete.py rename to src/libtmux/experimental/engines/mock.py index 78863eda8..a7d1fdb21 100644 --- a/src/libtmux/experimental/engines/concrete.py +++ b/src/libtmux/experimental/engines/mock.py @@ -1,10 +1,10 @@ """Deterministic, in-memory engines for tests and docs (no tmux server). -The concrete engines simulate just enough tmux behaviour to exercise the +The mock engines simulate just enough tmux behaviour to exercise the operation contract offline: creation commands that ask for an id (``-P -F '#{pane_id}'``) get a fabricated, monotonic id, ``capture-pane`` returns canned lines, and everything else succeeds with empty output. A sync -(:class:`ConcreteEngine`) and async (:class:`AsyncConcreteEngine`) variant share +(:class:`MockEngine`) and async (:class:`AsyncMockEngine`) variant share the same simulation, so the same operation returns the same typed result through either, with no tmux required. """ @@ -65,7 +65,7 @@ def _new_counters() -> dict[str, int]: return {"pane_id": 0, "window_id": 0, "session_id": 0} -class ConcreteEngine: +class MockEngine: """Execute operations against an in-memory simulation (synchronous). Parameters @@ -84,7 +84,7 @@ class ConcreteEngine: -------- >>> from libtmux.experimental.ops import SplitWindow, CapturePane, run >>> from libtmux.experimental.ops._types import WindowId, PaneId - >>> engine = ConcreteEngine(capture_lines=("hello", "world")) + >>> engine = MockEngine(capture_lines=("hello", "world")) >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id '%1' >>> run(SplitWindow(target=WindowId("@1")), engine).new_pane_id @@ -106,8 +106,8 @@ def run_batch(self, requests: Sequence[CommandRequest]) -> list[CommandResult]: return [self.run(req) for req in requests] -class AsyncConcreteEngine: - """Async sibling of :class:`ConcreteEngine` for offline async tests/docs. +class AsyncMockEngine: + """Async sibling of :class:`MockEngine` for offline async tests/docs. Examples -------- @@ -115,7 +115,7 @@ class AsyncConcreteEngine: >>> from libtmux.experimental.ops import SplitWindow, arun >>> from libtmux.experimental.ops._types import WindowId >>> async def main(): - ... return await arun(SplitWindow(target=WindowId("@1")), AsyncConcreteEngine()) + ... return await arun(SplitWindow(target=WindowId("@1")), AsyncMockEngine()) >>> asyncio.run(main()).new_pane_id '%1' """ diff --git a/src/libtmux/experimental/engines/registry.py b/src/libtmux/experimental/engines/registry.py index fb9315ea1..b3786f211 100644 --- a/src/libtmux/experimental/engines/registry.py +++ b/src/libtmux/experimental/engines/registry.py @@ -11,8 +11,8 @@ from libtmux import exc from libtmux.experimental.engines.base import EngineKind -from libtmux.experimental.engines.concrete import ConcreteEngine from libtmux.experimental.engines.control_mode import ControlModeEngine +from libtmux.experimental.engines.mock import MockEngine from libtmux.experimental.engines.subprocess import SubprocessEngine if t.TYPE_CHECKING: @@ -34,7 +34,7 @@ def available_engines() -> tuple[str, ...]: Examples -------- >>> from libtmux.experimental.engines import available_engines - >>> "concrete" in available_engines() + >>> "mock" in available_engines() True >>> "subprocess" in available_engines() True @@ -48,8 +48,8 @@ def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: Examples -------- >>> from libtmux.experimental.engines import create_engine - >>> create_engine("concrete") - + >>> create_engine("mock") + >>> create_engine("nope") Traceback (most recent call last): ... @@ -65,5 +65,5 @@ def create_engine(name: str | EngineKind, **kwargs: t.Any) -> TmuxEngine: register_engine(EngineKind.SUBPROCESS.value, SubprocessEngine) -register_engine(EngineKind.CONCRETE.value, ConcreteEngine) +register_engine(EngineKind.MOCK.value, MockEngine) register_engine(EngineKind.CONTROL_MODE.value, ControlModeEngine) diff --git a/src/libtmux/experimental/fluent.py b/src/libtmux/experimental/fluent.py index 2fd913bc9..24fc58d59 100644 --- a/src/libtmux/experimental/fluent.py +++ b/src/libtmux/experimental/fluent.py @@ -17,7 +17,7 @@ Examples -------- ->>> from libtmux.experimental.engines.concrete import ConcreteEngine +>>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> pane = p.new_session("dev").window().pane() >>> bottom = pane.do(lambda c: c.send_keys("vim")).split() @@ -25,7 +25,7 @@ True >>> [op.kind for op in p.plan.operations] ['new_session', 'send_keys', 'split_window', 'send_keys'] ->>> p.run(ConcreteEngine()).ok +>>> p.run(MockEngine()).ok True """ @@ -172,12 +172,12 @@ def find_or_create_session(self, name: str) -> SessionRef: Examples -------- - >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> _ = p.find_or_create_session("dev").window().pane() >>> [op.kind for op in p.plan.operations] ['new_session'] - >>> p.run(ConcreteEngine()).ok + >>> p.run(MockEngine()).ok True """ create = NewSession(session_name=name, capture_panes=True) @@ -196,11 +196,11 @@ def sleep(self, seconds: float) -> PlanBuilder: Examples -------- - >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> pane = p.new_session("dev").window().pane() >>> _ = pane.do(lambda c: c.send_keys("slow-start")) - >>> p.sleep(0.0).run(ConcreteEngine()).ok + >>> p.sleep(0.0).run(MockEngine()).ok True """ self._record_host(_HostAction("sleep", seconds=seconds)) @@ -246,10 +246,10 @@ def run( Examples -------- - >>> from libtmux.experimental.engines.concrete import ConcreteEngine + >>> from libtmux.experimental.engines.mock import MockEngine >>> p = plan() >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) - >>> p.run(ConcreteEngine()).ok + >>> p.run(MockEngine()).ok True """ @@ -286,10 +286,10 @@ async def arun( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.engines.mock import AsyncMockEngine >>> p = plan() >>> _ = p.new_session("dev").window().pane().do(lambda c: c.send_keys("vim")) - >>> asyncio.run(p.arun(AsyncConcreteEngine())).ok + >>> asyncio.run(p.arun(AsyncMockEngine())).ok True """ diff --git a/src/libtmux/experimental/mcp/__init__.py b/src/libtmux/experimental/mcp/__init__.py index 074e1254a..c754b0516 100644 --- a/src/libtmux/experimental/mcp/__init__.py +++ b/src/libtmux/experimental/mcp/__init__.py @@ -14,7 +14,7 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> reg = OperationToolRegistry() >>> reg.descriptor("new_session").safety 'mutating' diff --git a/src/libtmux/experimental/mcp/vocabulary/__init__.py b/src/libtmux/experimental/mcp/vocabulary/__init__.py index bc5e26b45..a6330652d 100644 --- a/src/libtmux/experimental/mcp/vocabulary/__init__.py +++ b/src/libtmux/experimental/mcp/vocabulary/__init__.py @@ -11,8 +11,8 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine ->>> engine = ConcreteEngine() +>>> from libtmux.experimental.engines import MockEngine +>>> engine = MockEngine() >>> session = create_session(engine, name="dev") >>> session.session_id '$1' diff --git a/src/libtmux/experimental/mcp/vocabulary/_bridge.py b/src/libtmux/experimental/mcp/vocabulary/_bridge.py index db78380d7..0bbabe6a9 100644 --- a/src/libtmux/experimental/mcp/vocabulary/_bridge.py +++ b/src/libtmux/experimental/mcp/vocabulary/_bridge.py @@ -41,8 +41,8 @@ class SyncToAsyncEngine: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> bridge = SyncToAsyncEngine(ConcreteEngine()) + >>> from libtmux.experimental.engines import MockEngine + >>> bridge = SyncToAsyncEngine(MockEngine()) >>> hasattr(bridge, "run") and hasattr(bridge, "run_batch") True """ diff --git a/src/libtmux/experimental/mcp/vocabulary/pane.py b/src/libtmux/experimental/mcp/vocabulary/pane.py index 8e14a6db2..35a2b99d0 100644 --- a/src/libtmux/experimental/mcp/vocabulary/pane.py +++ b/src/libtmux/experimental/mcp/vocabulary/pane.py @@ -217,8 +217,8 @@ async def acapture_active_pane( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> capture_active_pane(ConcreteEngine(capture_lines=("hi",))).lines + >>> from libtmux.experimental.engines import MockEngine + >>> capture_active_pane(MockEngine(capture_lines=("hi",))).lines ('hi',) """ result = await arun( @@ -252,8 +252,8 @@ async def agrep_pane( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + >>> from libtmux.experimental.engines import MockEngine + >>> engine = MockEngine(capture_lines=("foo", "bar baz", "foobar")) >>> grep_pane(engine, "%1", "foo").lines ('foo', 'foobar') """ @@ -502,8 +502,8 @@ async def aselect_pane( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> select_pane(ConcreteEngine(), "%1", direction="left") + >>> from libtmux.experimental.engines import MockEngine + >>> select_pane(MockEngine(), "%1", direction="left") PaneRef(pane_id=None) """ if direction in DIR_FLAG: diff --git a/src/libtmux/experimental/mcp/vocabulary/server.py b/src/libtmux/experimental/mcp/vocabulary/server.py index d4f166746..83c9cc90e 100644 --- a/src/libtmux/experimental/mcp/vocabulary/server.py +++ b/src/libtmux/experimental/mcp/vocabulary/server.py @@ -53,8 +53,8 @@ async def arun_tmux( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> run_tmux(ConcreteEngine(), ["list-sessions"]).ok + >>> from libtmux.experimental.engines import MockEngine + >>> run_tmux(MockEngine(), ["list-sessions"]).ok True """ raw = await engine.run(CommandRequest.from_args(*args)) diff --git a/src/libtmux/experimental/mcp/vocabulary/session.py b/src/libtmux/experimental/mcp/vocabulary/session.py index e5e0e65e1..0b539ae22 100644 --- a/src/libtmux/experimental/mcp/vocabulary/session.py +++ b/src/libtmux/experimental/mcp/vocabulary/session.py @@ -39,8 +39,8 @@ async def acreate_session( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> r = create_session(ConcreteEngine(), name="work") + >>> from libtmux.experimental.engines import MockEngine + >>> r = create_session(MockEngine(), name="work") >>> (r.session_id, r.name, r.first_pane_id) ('$1', 'work', '%1') """ @@ -117,8 +117,8 @@ async def ahas_session( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> has_session(ConcreteEngine(), "$1") + >>> from libtmux.experimental.engines import MockEngine + >>> has_session(MockEngine(), "$1") True """ result = await arun( diff --git a/src/libtmux/experimental/mcp/vocabulary/window.py b/src/libtmux/experimental/mcp/vocabulary/window.py index dc10b2fc5..ef0fa85c5 100644 --- a/src/libtmux/experimental/mcp/vocabulary/window.py +++ b/src/libtmux/experimental/mcp/vocabulary/window.py @@ -37,8 +37,8 @@ async def acreate_window( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> w = create_window(ConcreteEngine(), "$1", name="logs") + >>> from libtmux.experimental.engines import MockEngine + >>> w = create_window(MockEngine(), "$1", name="logs") >>> w.window_id.startswith("@"), w.name (True, 'logs') """ diff --git a/src/libtmux/experimental/objects/client.py b/src/libtmux/experimental/objects/client.py index fb8de36de..435bcf608 100644 --- a/src/libtmux/experimental/objects/client.py +++ b/src/libtmux/experimental/objects/client.py @@ -31,8 +31,8 @@ class EagerClient: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> client = EagerClient(ConcreteEngine(), "/dev/pts/3") + >>> from libtmux.experimental.engines import MockEngine + >>> client = EagerClient(MockEngine(), "/dev/pts/3") >>> client.refresh().ok True >>> client.switch_to("$1").ok diff --git a/src/libtmux/experimental/objects/pane.py b/src/libtmux/experimental/objects/pane.py index 419642a30..ff4a5cef9 100644 --- a/src/libtmux/experimental/objects/pane.py +++ b/src/libtmux/experimental/objects/pane.py @@ -93,8 +93,8 @@ class EagerPane: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> pane = EagerPane(ConcreteEngine(), "%0") + >>> from libtmux.experimental.engines import MockEngine + >>> pane = EagerPane(MockEngine(), "%0") >>> child = pane.split(horizontal=True) >>> child.pane_id '%1' @@ -208,14 +208,14 @@ class LazyPane: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> from libtmux.experimental.ops._types import PaneId >>> plan = LazyPlan() >>> root = LazyPane(plan, PaneId("%0")) >>> child = root.split() >>> _ = child.send_keys("vim", enter=True) - >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome = plan.execute(MockEngine()) >>> outcome.results[0].new_pane_id '%1' >>> outcome.results[1].argv @@ -305,9 +305,9 @@ class AsyncPane: Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines import AsyncConcreteEngine + >>> from libtmux.experimental.engines import AsyncMockEngine >>> async def main(): - ... pane = AsyncPane(AsyncConcreteEngine(), "%0") + ... pane = AsyncPane(AsyncMockEngine(), "%0") ... child = await pane.split(horizontal=True) ... return child.pane_id >>> asyncio.run(main()) diff --git a/src/libtmux/experimental/objects/server.py b/src/libtmux/experimental/objects/server.py index 085d6cb16..fccc7de27 100644 --- a/src/libtmux/experimental/objects/server.py +++ b/src/libtmux/experimental/objects/server.py @@ -23,8 +23,8 @@ class EagerServer: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> server = EagerServer(ConcreteEngine()) + >>> from libtmux.experimental.engines import MockEngine + >>> server = EagerServer(MockEngine()) >>> session = server.new_session(name="work") >>> session.session_id '$1' @@ -66,13 +66,13 @@ class LazyServer: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> plan = LazyPlan() >>> server = LazyServer(plan) >>> session = server.new_session(name="work") >>> _ = session.new_window(name="build") - >>> plan.execute(ConcreteEngine()).ok + >>> plan.execute(MockEngine()).ok True """ diff --git a/src/libtmux/experimental/objects/session.py b/src/libtmux/experimental/objects/session.py index 14c1c1447..7f1adcbd1 100644 --- a/src/libtmux/experimental/objects/session.py +++ b/src/libtmux/experimental/objects/session.py @@ -28,8 +28,8 @@ class EagerSession: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> session = EagerSession(ConcreteEngine(), "$0") + >>> from libtmux.experimental.engines import MockEngine + >>> session = EagerSession(MockEngine(), "$0") >>> window = session.new_window(name="build") >>> window.window_id '@1' @@ -84,14 +84,14 @@ class LazySession: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> from libtmux.experimental.ops._types import SessionId >>> plan = LazyPlan() >>> session = LazySession(plan, SessionId("$0")) >>> window = session.new_window(name="build") >>> _ = session.rename("work") - >>> plan.execute(ConcreteEngine()).ok + >>> plan.execute(MockEngine()).ok True """ diff --git a/src/libtmux/experimental/objects/window.py b/src/libtmux/experimental/objects/window.py index 7bb827c09..582d2e7dd 100644 --- a/src/libtmux/experimental/objects/window.py +++ b/src/libtmux/experimental/objects/window.py @@ -35,8 +35,8 @@ class EagerWindow: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine - >>> window = EagerWindow(ConcreteEngine(), "@1") + >>> from libtmux.experimental.engines import MockEngine + >>> window = EagerWindow(MockEngine(), "@1") >>> pane = window.split(horizontal=True) >>> pane.pane_id '%1' @@ -101,14 +101,14 @@ class LazyWindow: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import LazyPlan >>> from libtmux.experimental.ops._types import WindowId >>> plan = LazyPlan() >>> window = LazyWindow(plan, WindowId("@1")) >>> pane = window.split() >>> _ = window.rename("build") - >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome = plan.execute(MockEngine()) >>> outcome.ok True """ diff --git a/src/libtmux/experimental/ops/_ops/list_clients.py b/src/libtmux/experimental/ops/_ops/list_clients.py index dfc1023f1..f48e5c2e7 100644 --- a/src/libtmux/experimental/ops/_ops/list_clients.py +++ b/src/libtmux/experimental/ops/_ops/list_clients.py @@ -26,9 +26,9 @@ class ListClients(Operation[ListClientsResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run - >>> run(ListClients(), ConcreteEngine(), version="3.6a").clients + >>> run(ListClients(), MockEngine(), version="3.6a").clients () """ diff --git a/src/libtmux/experimental/ops/_ops/list_panes.py b/src/libtmux/experimental/ops/_ops/list_panes.py index 702a4d740..23347f861 100644 --- a/src/libtmux/experimental/ops/_ops/list_panes.py +++ b/src/libtmux/experimental/ops/_ops/list_panes.py @@ -36,14 +36,14 @@ class ListPanes(Operation[ListPanesResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run >>> op = ListPanes() >>> op.render(version="3.6a")[:1] ('list-panes',) >>> "-a" in op.render(version="3.6a") True - >>> result = run(op, ConcreteEngine(), version="3.6a") + >>> result = run(op, MockEngine(), version="3.6a") >>> result.rows () >>> result.server.sessions diff --git a/src/libtmux/experimental/ops/_ops/list_sessions.py b/src/libtmux/experimental/ops/_ops/list_sessions.py index c120b06f1..f9f598896 100644 --- a/src/libtmux/experimental/ops/_ops/list_sessions.py +++ b/src/libtmux/experimental/ops/_ops/list_sessions.py @@ -26,9 +26,9 @@ class ListSessions(Operation[ListSessionsResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run - >>> run(ListSessions(), ConcreteEngine(), version="3.6a").sessions + >>> run(ListSessions(), MockEngine(), version="3.6a").sessions () """ diff --git a/src/libtmux/experimental/ops/_ops/list_windows.py b/src/libtmux/experimental/ops/_ops/list_windows.py index 3126c58b3..16d60e4a6 100644 --- a/src/libtmux/experimental/ops/_ops/list_windows.py +++ b/src/libtmux/experimental/ops/_ops/list_windows.py @@ -31,9 +31,9 @@ class ListWindows(Operation[ListWindowsResult]): Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.ops import run - >>> run(ListWindows(), ConcreteEngine(), version="3.6a").windows + >>> run(ListWindows(), MockEngine(), version="3.6a").windows () """ diff --git a/src/libtmux/experimental/ops/plan.py b/src/libtmux/experimental/ops/plan.py index a3cc7485e..27a5294c5 100644 --- a/src/libtmux/experimental/ops/plan.py +++ b/src/libtmux/experimental/ops/plan.py @@ -228,15 +228,15 @@ class LazyPlan: Examples -------- Build a plan that splits a window then types into the *new* pane, and run it - against the in-memory concrete engine (no tmux required): + against the in-memory mock engine (no tmux required): >>> from libtmux.experimental.ops import SplitWindow, SendKeys >>> from libtmux.experimental.ops._types import WindowId - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> plan = LazyPlan() >>> pane = plan.add(SplitWindow(target=WindowId("@1"))) >>> _ = plan.add(SendKeys(target=pane, keys="vim", enter=True)) - >>> outcome = plan.execute(ConcreteEngine()) + >>> outcome = plan.execute(MockEngine()) >>> outcome.bindings {0: '%1'} >>> outcome.results[1].argv @@ -511,12 +511,12 @@ async def aexecute( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.engines.mock import AsyncMockEngine >>> from libtmux.experimental.ops import SendKeys >>> from libtmux.experimental.ops._types import PaneId >>> plan = LazyPlan() >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) - >>> asyncio.run(plan.aexecute(AsyncConcreteEngine())).ok + >>> asyncio.run(plan.aexecute(AsyncMockEngine())).ok True """ version = resolve_engine_version(engine, version) @@ -572,13 +572,13 @@ async def astream( Examples -------- >>> import asyncio - >>> from libtmux.experimental.engines.concrete import AsyncConcreteEngine + >>> from libtmux.experimental.engines.mock import AsyncMockEngine >>> from libtmux.experimental.ops import SendKeys >>> from libtmux.experimental.ops._types import PaneId >>> plan = LazyPlan() >>> _ = plan.add(SendKeys(target=PaneId("%1"), keys="vim")) >>> async def drain() -> list[str]: - ... engine = AsyncConcreteEngine() + ... engine = AsyncMockEngine() ... return [type(e).__name__ async for e in plan.astream(engine)] >>> asyncio.run(drain()) ['StepDone', 'PlanDone'] diff --git a/src/libtmux/experimental/workspace/__init__.py b/src/libtmux/experimental/workspace/__init__.py index 1e7126c39..4d9d7cceb 100644 --- a/src/libtmux/experimental/workspace/__init__.py +++ b/src/libtmux/experimental/workspace/__init__.py @@ -11,12 +11,12 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> ws = analyze({ ... "session_name": "dev", ... "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], ... }) ->>> ws.build(ConcreteEngine(), preflight=False).ok +>>> ws.build(MockEngine(), preflight=False).ok True """ diff --git a/src/libtmux/experimental/workspace/cli.py b/src/libtmux/experimental/workspace/cli.py index 4489d4d10..b1c1f832f 100644 --- a/src/libtmux/experimental/workspace/cli.py +++ b/src/libtmux/experimental/workspace/cli.py @@ -207,7 +207,7 @@ def _print_dry_run( ) -> None: r"""Print the tmux commands a build would run, without touching tmux. - The plan is resolved against the in-memory ``ConcreteEngine`` (which + The plan is resolved against the in-memory ``MockEngine`` (which fabricates ids) through the *same* planner the real build uses, so the printed lines are the folded ``;`` dispatches that would actually run -- not an unfolded op-per-line view. Pass ``fold=False`` for one tmux call per @@ -217,7 +217,7 @@ def _print_dry_run( """ import shlex - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( BoundedPlanner, MarkedPlanner, @@ -237,7 +237,7 @@ def _print_dry_run( if fold else SequentialPlanner() ) - engine = _RecordingEngine(ConcreteEngine()) + engine = _RecordingEngine(MockEngine()) hosts_per_dispatch: list[tuple[HostStep, ...]] = [] def on_step(report: StepReport) -> None: diff --git a/src/libtmux/experimental/workspace/confirm.py b/src/libtmux/experimental/workspace/confirm.py index cc92394d9..80e9054c3 100644 --- a/src/libtmux/experimental/workspace/confirm.py +++ b/src/libtmux/experimental/workspace/confirm.py @@ -2,7 +2,7 @@ Reads the live server through the classic libtmux objects and diffs the observed session/window/pane structure against the declared :class:`~.ir.Workspace`. Used by -the live test track; the offline (``ConcreteEngine``) track asserts on the +the live test track; the offline (``MockEngine``) track asserts on the compiled plan instead, since a stateless engine has no structure to read back. """ diff --git a/src/libtmux/experimental/workspace/ir.py b/src/libtmux/experimental/workspace/ir.py index 3dd57305d..5a850b5aa 100644 --- a/src/libtmux/experimental/workspace/ir.py +++ b/src/libtmux/experimental/workspace/ir.py @@ -9,7 +9,7 @@ Examples -------- ->>> from libtmux.experimental.engines import ConcreteEngine +>>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane >>> ws = Workspace( ... name="dev", @@ -23,7 +23,7 @@ ... ) >>> ws.compile().operations[0].kind 'new_session' ->>> ws.build(ConcreteEngine(), preflight=False).ok +>>> ws.build(MockEngine(), preflight=False).ok True """ @@ -463,7 +463,7 @@ def build( """Compile and execute this workspace synchronously over *engine*. Set ``preflight=False`` to skip the ``on_exists`` ``has-session`` check - (e.g. against the stateless ``ConcreteEngine``, which has no real + (e.g. against the stateless ``MockEngine``, which has no real sessions to detect). Pass *on_event* to observe the structural build stream (see :mod:`~.events`). The build folds dispatches by default; pass *planner* (e.g. :class:`~..ops.planner.SequentialPlanner`) to override. diff --git a/src/libtmux/experimental/workspace/runner.py b/src/libtmux/experimental/workspace/runner.py index 5bd9e8b32..f756d9e81 100644 --- a/src/libtmux/experimental/workspace/runner.py +++ b/src/libtmux/experimental/workspace/runner.py @@ -17,7 +17,7 @@ only difference is ``run`` vs ``await arun`` and the host-step executor. ``preflight=False`` skips the ``on_exists`` ``has-session`` check; use it offline -against the stateless ``ConcreteEngine`` (whose ``has-session`` is always true). +against the stateless ``MockEngine`` (whose ``has-session`` is always true). """ from __future__ import annotations @@ -156,10 +156,10 @@ def build_workspace( Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.workspace.ir import Workspace, Window, Pane >>> ws = Workspace(name="dev", windows=[Window("w", panes=[Pane(run="vim")])]) - >>> build_workspace(ws, ConcreteEngine(), preflight=False).ok + >>> build_workspace(ws, MockEngine(), preflight=False).ok True """ if preflight and _preflight_sync(ws, engine, version): diff --git a/src/libtmux/experimental/workspace/sets.py b/src/libtmux/experimental/workspace/sets.py index 26dfddadb..8ef9f18ad 100644 --- a/src/libtmux/experimental/workspace/sets.py +++ b/src/libtmux/experimental/workspace/sets.py @@ -97,10 +97,10 @@ class WorkspaceSet: Examples -------- - >>> from libtmux.experimental.engines import ConcreteEngine + >>> from libtmux.experimental.engines import MockEngine >>> from libtmux.experimental.workspace import Pane, Window, Workspace >>> ws = Workspace("dev", windows=[Window("w", panes=[Pane("echo hi")])]) - >>> WorkspaceSet((ws,)).build(ConcreteEngine(), preflight=False).ok + >>> WorkspaceSet((ws,)).build(MockEngine(), preflight=False).ok True """ diff --git a/tests/experimental/contract/test_async_control_engine.py b/tests/experimental/contract/test_async_control_engine.py index bcdc24580..116ee99cb 100644 --- a/tests/experimental/contract/test_async_control_engine.py +++ b/tests/experimental/contract/test_async_control_engine.py @@ -11,8 +11,8 @@ import typing as t from libtmux.experimental.engines import ( - AsyncConcreteEngine, AsyncControlModeEngine, + AsyncMockEngine, CommandRequest, ControlNotification, ) @@ -90,8 +90,8 @@ async def main() -> tuple[str | None, str | None]: assert first != second -def test_async_control_concrete_parity(session: Session) -> None: - """Async control-mode and concrete engines agree on result type and argv.""" +def test_async_control_mock_parity(session: Session) -> None: + """Async control-mode and mock engines agree on result type and argv.""" server = session.server window_id = session.active_window.window_id assert window_id is not None @@ -102,9 +102,9 @@ async def main() -> SplitWindowResult: return await arun(operation, engine) control = asyncio.run(main()) - concrete = asyncio.run(arun(operation, AsyncConcreteEngine())) - assert type(control) is type(concrete) is SplitWindowResult - assert control.argv == concrete.argv == operation.render() + mock = asyncio.run(arun(operation, AsyncMockEngine())) + assert type(control) is type(mock) is SplitWindowResult + assert control.argv == mock.argv == operation.render() def test_async_control_event_stream(session: Session) -> None: diff --git a/tests/experimental/contract/test_async_control_engine_workspace_builder.py b/tests/experimental/contract/test_async_control_engine_workspace_builder.py index 2bf4bd8b7..11cbf4680 100644 --- a/tests/experimental/contract/test_async_control_engine_workspace_builder.py +++ b/tests/experimental/contract/test_async_control_engine_workspace_builder.py @@ -4,7 +4,7 @@ (:mod:`libtmux.experimental.workspace`): a YAML/dict is analyzed into a structural ``Workspace`` spec, compiled to a Core ``LazyPlan``, and executed. Two tracks: -* **offline** -- compile against the in-memory ``ConcreteEngine`` and assert the +* **offline** -- compile against the in-memory ``MockEngine`` and assert the op sequence and planner-equivalence (no tmux); * **live** -- build over the async control-mode engine *and* the sync subprocess engine against a real tmux server, then confirm the live structure matches the @@ -19,9 +19,9 @@ import pytest from libtmux.experimental.engines import ( - AsyncConcreteEngine, AsyncControlModeEngine, - ConcreteEngine, + AsyncMockEngine, + MockEngine, SubprocessEngine, ) from libtmux.experimental.ops import ( @@ -129,8 +129,8 @@ def test_workspace_compiles_to_core_ops() -> None: def test_workspace_offline_build_and_planner_equivalence() -> None: """The compiled plan runs offline and the optimizer preserves the result.""" plan = analyze(_YAML).compile() - sequential = plan.execute(ConcreteEngine(), planner=SequentialPlanner()) - folded = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + sequential = plan.execute(MockEngine(), planner=SequentialPlanner()) + folded = plan.execute(MockEngine(), planner=FoldingPlanner()) assert sequential.ok assert folded.ok assert [r.status for r in sequential.results] == [r.status for r in folded.results] @@ -152,7 +152,7 @@ def test_first_pane_focus_multipane_compiles() -> None: plan = ws.compile() assert "select_pane" in [op.kind for op in plan.operations] # offline execution resolves the first-pane sub-ref without error - assert plan.execute(ConcreteEngine()).ok + assert plan.execute(MockEngine()).ok def test_empty_workspace_is_rejected() -> None: @@ -274,7 +274,7 @@ def test_workspace_all_planners_agree(tmp_path: Path) -> None: """Sequential, Folding, and Marked planners give an identical PlanResult.""" plan = _rich_spec(str(tmp_path)).compile() runs = { - name: plan.execute(ConcreteEngine(), planner=planner()) + name: plan.execute(MockEngine(), planner=planner()) for name, planner in ( ("sequential", SequentialPlanner), ("folding", FoldingPlanner), @@ -752,7 +752,7 @@ def test_build_emits_event_stream_sync_and_async() -> None: ) sync_events: list[BuildEvent] = [] - ws.build(ConcreteEngine(), preflight=False, on_event=sync_events.append) + ws.build(MockEngine(), preflight=False, on_event=sync_events.append) async_events: list[BuildEvent] = [] @@ -760,7 +760,7 @@ async def collect(event: BuildEvent) -> None: async_events.append(event) async def main() -> None: - await ws.abuild(AsyncConcreteEngine(), preflight=False, on_event=collect) + await ws.abuild(AsyncMockEngine(), preflight=False, on_event=collect) asyncio.run(main()) @@ -864,7 +864,7 @@ def test_compile_window_index_targets_session_index() -> None: Window("b", window_index=5, panes=[Pane(run="echo y")]), ], ) - results = ws.build(ConcreteEngine(), preflight=False).results + results = ws.build(MockEngine(), preflight=False).results new_window = next(r for r in results if r.argv[0] == "new-window") assert "$1:5" in new_window.argv # targeted at the explicit session index assert new_window.ok # the -P -F capture still binds the new window id diff --git a/tests/experimental/contract/test_classic_engine.py b/tests/experimental/contract/test_classic_engine.py index 927a6be3a..9db74db4e 100644 --- a/tests/experimental/contract/test_classic_engine.py +++ b/tests/experimental/contract/test_classic_engine.py @@ -1,8 +1,8 @@ -"""Classic engine against a real tmux server, and parity with concrete. +"""Classic engine against a real tmux server, and parity with mock. These use the libtmux pytest fixtures (a live tmux server), so they exercise the classic :class:`~libtmux.experimental.engines.subprocess.SubprocessEngine` path -end to end and assert it returns the *same typed result shape* the concrete +end to end and assert it returns the *same typed result shape* the mock engine does. """ @@ -10,7 +10,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.ops import ( CapturePane, SelectLayout, @@ -81,16 +81,16 @@ def test_classic_capture_returns_lines(session: Session) -> None: assert isinstance(result.lines, tuple) -def test_classic_concrete_parity(session: Session) -> None: - """Classic and concrete engines agree on result type and argv (not payload).""" +def test_classic_mock_parity(session: Session) -> None: + """Classic and mock engines agree on result type and argv (not payload).""" server = session.server window = session.active_window assert window.window_id is not None operation = SplitWindow(target=WindowId(window.window_id)) classic = run(operation, SubprocessEngine.for_server(server)) - concrete = run(operation, ConcreteEngine()) + mock = run(operation, MockEngine()) - assert type(classic) is type(concrete) is SplitWindowResult - assert classic.argv == concrete.argv == operation.render() - assert classic.ok and concrete.ok + assert type(classic) is type(mock) is SplitWindowResult + assert classic.argv == mock.argv == operation.render() + assert classic.ok and mock.ok diff --git a/tests/experimental/contract/test_control_engine.py b/tests/experimental/contract/test_control_engine.py index dd280cc98..a92d4ae77 100644 --- a/tests/experimental/contract/test_control_engine.py +++ b/tests/experimental/contract/test_control_engine.py @@ -1,4 +1,4 @@ -"""Control-mode engine against a real tmux server, and parity with concrete. +"""Control-mode engine against a real tmux server, and parity with mock. Exercises the persistent ``tmux -C`` engine end to end and asserts it returns the same typed result shape the other engines do. The engine is used as a @@ -9,7 +9,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine, ControlModeEngine +from libtmux.experimental.engines import ControlModeEngine, MockEngine from libtmux.experimental.ops import ( CapturePane, SendKeys, @@ -84,8 +84,8 @@ def test_control_batches_multiple_commands(session: Session) -> None: assert captured.ok -def test_control_concrete_parity(session: Session) -> None: - """Control-mode and concrete engines agree on result type and argv.""" +def test_control_mock_parity(session: Session) -> None: + """Control-mode and mock engines agree on result type and argv.""" server = session.server window = session.active_window assert window.window_id is not None @@ -93,8 +93,8 @@ def test_control_concrete_parity(session: Session) -> None: with ControlModeEngine.for_server(server) as engine: control = run(operation, engine) - concrete = run(operation, ConcreteEngine()) + mock = run(operation, MockEngine()) - assert type(control) is type(concrete) is SplitWindowResult - assert control.argv == concrete.argv == operation.render() - assert control.ok and concrete.ok + assert type(control) is type(mock) is SplitWindowResult + assert control.argv == mock.argv == operation.render() + assert control.ok and mock.ok diff --git a/tests/experimental/contract/test_engine_contract.py b/tests/experimental/contract/test_engine_contract.py index 34e60096c..0df490463 100644 --- a/tests/experimental/contract/test_engine_contract.py +++ b/tests/experimental/contract/test_engine_contract.py @@ -1,9 +1,9 @@ -"""Engine-agnostic operation contract (runs offline via the concrete engine). +"""Engine-agnostic operation contract (runs offline via the mock engine). These assertions hold for *any* engine because they are properties of the operation executed through the engine: the result is the operation's typed result class, its argv is the operation's render, and it serializes round-trip. -The concrete engine lets the whole matrix run without a tmux server. +The mock engine lets the whole matrix run without a tmux server. """ from __future__ import annotations @@ -12,7 +12,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( CapturePane, SelectLayout, @@ -40,14 +40,14 @@ @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_result_type_matches_operation(operation: Operation[t.Any]) -> None: """An engine returns the operation's declared result type.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert type(result) is operation.result_cls @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_result_argv_is_render(operation: Operation[t.Any]) -> None: """The result's argv equals the operation's pure render.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert result.argv == operation.render() assert result.ok @@ -55,13 +55,13 @@ def test_result_argv_is_render(operation: Operation[t.Any]) -> None: @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_result_serialization_round_trip(operation: Operation[t.Any]) -> None: """A result produced by an engine survives a dict round-trip.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert result_from_dict(result_to_dict(result)) == result @pytest.mark.parametrize("operation", _CONTRACT_OPS) def test_same_result_across_engine_instances(operation: Operation[t.Any]) -> None: """Two fresh engines yield equal typed results -- determinism contract.""" - first = run(operation, ConcreteEngine()) - second = run(operation, ConcreteEngine()) + first = run(operation, MockEngine()) + second = run(operation, MockEngine()) assert first == second diff --git a/tests/experimental/contract/test_workspace_floats.py b/tests/experimental/contract/test_workspace_floats.py index a91151780..f515aa6d8 100644 --- a/tests/experimental/contract/test_workspace_floats.py +++ b/tests/experimental/contract/test_workspace_floats.py @@ -157,7 +157,7 @@ def test_cross_window_float_attaches_to_later_window() -> None: def test_cross_window_float_builds_offline() -> None: """A cross-window float resolves its target and builds in-memory.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine ws = Workspace( name="dev", @@ -170,7 +170,7 @@ def test_cross_window_float_builds_offline() -> None: Window("logs", panes=[Pane(run="tail -f x")]), ], ) - assert ws.build(ConcreteEngine(), preflight=False).ok + assert ws.build(MockEngine(), preflight=False).ok def test_unknown_attach_to_raises() -> None: @@ -210,7 +210,7 @@ def test_topo_order_detects_cycle() -> None: def test_offline_build_with_float() -> None: """A float-bearing workspace builds over the in-memory engine.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine ws = Workspace( name="dev", @@ -227,7 +227,7 @@ def test_offline_build_with_float() -> None: ), ], ) - assert ws.build(ConcreteEngine(), preflight=False).ok + assert ws.build(MockEngine(), preflight=False).ok def test_events_for_new_pane() -> None: diff --git a/tests/experimental/contract/test_workspace_folding.py b/tests/experimental/contract/test_workspace_folding.py index 3f69ec4c3..9e9a8dd38 100644 --- a/tests/experimental/contract/test_workspace_folding.py +++ b/tests/experimental/contract/test_workspace_folding.py @@ -13,7 +13,7 @@ import dataclasses import typing as t -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import SequentialPlanner from libtmux.experimental.workspace import Command, Pane, Window, Workspace, analyze @@ -35,7 +35,7 @@ class _RecordingEngine: fast. """ - inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + inner: TmuxEngine = dataclasses.field(default_factory=MockEngine) calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) def run(self, request: CommandRequest) -> CommandResult: @@ -83,9 +83,9 @@ def test_build_folds_by_default() -> None: def test_build_planner_equivalence() -> None: """The default (folding) build yields the same PlanResult as the sequential one.""" - folded = _spec().build(ConcreteEngine(), preflight=False) + folded = _spec().build(MockEngine(), preflight=False) sequential = _spec().build( - ConcreteEngine(), + MockEngine(), preflight=False, planner=SequentialPlanner(), ) diff --git a/tests/experimental/contract/test_workspace_sets.py b/tests/experimental/contract/test_workspace_sets.py index 4a2f4bd37..5afc72af0 100644 --- a/tests/experimental/contract/test_workspace_sets.py +++ b/tests/experimental/contract/test_workspace_sets.py @@ -6,7 +6,7 @@ import dataclasses import typing as t -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import SequentialPlanner from libtmux.experimental.ops._types import SlotRef @@ -31,7 +31,7 @@ class _RecordingEngine: """Record dispatches while forwarding to an inner engine.""" - inner: TmuxEngine = dataclasses.field(default_factory=ConcreteEngine) + inner: TmuxEngine = dataclasses.field(default_factory=MockEngine) calls: list[tuple[str, ...]] = dataclasses.field(default_factory=list) def run(self, request: CommandRequest) -> CommandResult: @@ -127,7 +127,7 @@ def test_workspace_set_all_reused_returns_noop_result() -> None: windows=[Window("w", panes=[Pane("echo nope")])], on_exists="reuse", ) - engine = ConcreteEngine() + engine = MockEngine() first = build_workspaces([reused], engine, preflight=False) second = build_workspaces([reused], engine) @@ -143,7 +143,7 @@ def test_workspace_set_emits_built_event_per_workspace() -> None: events: list[BuildEvent] = [] outcome = build_workspaces( [_workspace("one"), _workspace("two")], - ConcreteEngine(), + MockEngine(), preflight=False, on_event=events.append, ) @@ -157,7 +157,7 @@ def test_workspace_set_async_build_matches_sync_shape() -> None: """The async runner exposes the same result shape as the sync runner.""" workspace_set = WorkspaceSet((_workspace("one"), _workspace("two"))) outcome = asyncio.run( - workspace_set.abuild(AsyncConcreteEngine(), preflight=False), + workspace_set.abuild(AsyncMockEngine(), preflight=False), ) assert outcome.ok diff --git a/tests/experimental/engines/test_registry.py b/tests/experimental/engines/test_registry.py index 7e315cc60..b11a5ae7b 100644 --- a/tests/experimental/engines/test_registry.py +++ b/tests/experimental/engines/test_registry.py @@ -19,7 +19,7 @@ def test_available_engines_are_registered() -> None: """The registry exposes exactly the constructable (sync) engine kinds.""" assert set(available_engines()) == { "subprocess", - "concrete", + "mock", "control_mode", "imsg", } @@ -40,7 +40,7 @@ class CreateCase(t.NamedTuple): CREATE_CASES = ( CreateCase("subprocess", "subprocess"), - CreateCase("concrete", "concrete"), + CreateCase("mock", "mock"), CreateCase("control_mode", "control_mode"), ) diff --git a/tests/experimental/mcp/test_adapter_async.py b/tests/experimental/mcp/test_adapter_async.py index e34890b3c..e10acb15a 100644 --- a/tests/experimental/mcp/test_adapter_async.py +++ b/tests/experimental/mcp/test_adapter_async.py @@ -1,6 +1,6 @@ """The async-first FastMCP adapter -- awaited tools, per-op, and plan tiers. -Exercised offline via an in-process FastMCP client over a sync ``ConcreteEngine`` +Exercised offline via an in-process FastMCP client over a sync ``MockEngine`` wrapped into the async protocol, so the async registration path is validated with no tmux. """ @@ -12,7 +12,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine fastmcp = pytest.importorskip("fastmcp") @@ -22,9 +22,7 @@ def _async_server(**kwargs: t.Any) -> t.Any: """Build an async server over a wrapped in-memory engine.""" from libtmux.experimental.mcp.fastmcp_adapter import build_async_server - return build_async_server( - SyncToAsyncEngine(ConcreteEngine()), events="off", **kwargs - ) + return build_async_server(SyncToAsyncEngine(MockEngine()), events="off", **kwargs) def test_async_server_exposes_curated_and_conveniences() -> None: diff --git a/tests/experimental/mcp/test_caller.py b/tests/experimental/mcp/test_caller.py index 317b0c342..dc0270d09 100644 --- a/tests/experimental/mcp/test_caller.py +++ b/tests/experimental/mcp/test_caller.py @@ -14,7 +14,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.vocabulary import ( create_session, kill_pane, @@ -113,7 +113,7 @@ def test_get_caller_context_tool(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%7") monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") - server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + server = build_async_server(SyncToAsyncEngine(MockEngine()), events="off") async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -130,7 +130,7 @@ def test_instructions_include_caller(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%7") monkeypatch.setenv("TMUX", "/tmp/tmux-1000/sock,1,3") - server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + server = build_async_server(SyncToAsyncEngine(MockEngine()), events="off") assert "%7" in (server.instructions or "") assert "is_caller" in (server.instructions or "") @@ -143,7 +143,7 @@ def test_instructions_outside_tmux(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TMUX_PANE", raising=False) # Disable the /proc parent walk so a test runner inside tmux is not discovered. monkeypatch.setenv("LIBTMUX_MCP_DISCOVER", "0") - server = build_async_server(SyncToAsyncEngine(ConcreteEngine()), events="off") + server = build_async_server(SyncToAsyncEngine(MockEngine()), events="off") assert "not running inside a tmux pane" in (server.instructions or "") @@ -185,7 +185,7 @@ def test_resolve_origin_same_server_uses_caller( """resolve_origin trusts the caller pane when the engine shares its server.""" monkeypatch.setenv("TMUX_PANE", "%3") monkeypatch.setenv("TMUX", "/tmp/a,1,2") - engine = SyncToAsyncEngine(ConcreteEngine()) # default socket -> ambient server + engine = SyncToAsyncEngine(MockEngine()) # default socket -> ambient server async def main() -> str: return await resolve_origin(engine, None, None) @@ -203,7 +203,7 @@ def test_resolve_origin_cross_server_requires_explicit( class CrossServer(SyncToAsyncEngine): server_args = ("-S", "/tmp/b") # a different socket than the caller's - engine = CrossServer(ConcreteEngine()) + engine = CrossServer(MockEngine()) async def main() -> str: return await resolve_origin(engine, None, None) @@ -309,7 +309,7 @@ def test_kill_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") with pytest.raises(ToolError, match="this MCP server"): - kill_pane(ConcreteEngine(), "%9") + kill_pane(MockEngine(), "%9") def test_respawn_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> None: @@ -317,14 +317,14 @@ def test_respawn_pane_refuses_caller_pane(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") with pytest.raises(ToolError, match="this MCP server"): - respawn_pane(ConcreteEngine(), "%9") + respawn_pane(MockEngine(), "%9") def test_kill_pane_allows_other_pane(monkeypatch: pytest.MonkeyPatch) -> None: """A different pane is not the caller, so it is not refused.""" monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") - assert kill_pane(ConcreteEngine(), "%1") is None + assert kill_pane(MockEngine(), "%1") is None def test_self_kill_refusals_live( @@ -374,7 +374,7 @@ def test_op_kill_pane_is_guarded(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TMUX_PANE", "%9") monkeypatch.setenv("TMUX", "/s,1,2") server = build_async_server( - SyncToAsyncEngine(ConcreteEngine()), + SyncToAsyncEngine(MockEngine()), events="off", expose_operations=True, safety_level="destructive", # op_kill_* is destructive-tier @@ -399,7 +399,7 @@ class Pathed(SyncToAsyncEngine): server_args = ("-S", "/tmp/explicit-socket") async def main() -> str | None: - return await conservative_socket(Pathed(ConcreteEngine()), None) + return await conservative_socket(Pathed(MockEngine()), None) assert asyncio.run(main()) == "/tmp/explicit-socket" diff --git a/tests/experimental/mcp/test_events.py b/tests/experimental/mcp/test_events.py index de775d0ed..7b95ad244 100644 --- a/tests/experimental/mcp/test_events.py +++ b/tests/experimental/mcp/test_events.py @@ -593,12 +593,12 @@ async def main() -> t.Any: def test_no_event_tools_without_a_stream() -> None: """A non-streaming engine registers no event tools, even when asked.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine from libtmux.experimental.mcp.fastmcp_adapter import build_async_server from libtmux.experimental.mcp.vocabulary._bridge import SyncToAsyncEngine server = build_async_server( - SyncToAsyncEngine(ConcreteEngine()), + SyncToAsyncEngine(MockEngine()), events="both", include_operations=False, include_plan_tools=False, diff --git a/tests/experimental/mcp/test_fastmcp_adapter.py b/tests/experimental/mcp/test_fastmcp_adapter.py index 2fd5ffd43..baea9b7e8 100644 --- a/tests/experimental/mcp/test_fastmcp_adapter.py +++ b/tests/experimental/mcp/test_fastmcp_adapter.py @@ -3,7 +3,7 @@ Proves the framework-agnostic projection actually drives fastmcp: the curated vocabulary registers as typed tools (engine bound out of the schema, safety -> annotations), and an in-process client can list and call them -- offline against -the ``ConcreteEngine`` and live against a real tmux server. Skipped entirely when +the ``MockEngine`` and live against a real tmux server. Skipped entirely when the ``mcp`` extra (fastmcp) is not installed. """ @@ -14,7 +14,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.fastmcp_adapter import build_server from libtmux.experimental.ops import NewSession from libtmux.experimental.ops.serialize import operation_to_dict @@ -28,7 +28,7 @@ def test_adapter_registers_typed_tools() -> None: """The curated vocabulary appears as typed tools with safety annotations.""" # destructive tier so the kill_* tools this asserts on are visible - server = build_server(ConcreteEngine(), safety_level="destructive") + server = build_server(MockEngine(), safety_level="destructive") async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -59,7 +59,7 @@ async def main() -> t.Any: def test_adapter_calls_tool_offline() -> None: """Calling a tool through the in-process client returns structured output.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -96,7 +96,7 @@ async def main() -> str | None: def test_adapter_operations_hidden_by_default() -> None: """Per-operation tools are registered but hidden; plan tools stay visible.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -116,7 +116,7 @@ def test_adapter_exposes_per_op_tools() -> None: """``expose_operations`` reveals one typed ``op_`` per operation.""" # destructive tier so the destructive op_kill_* tools are visible too server = build_server( - ConcreteEngine(), + MockEngine(), expose_operations=True, safety_level="destructive", ) @@ -141,7 +141,7 @@ async def main() -> t.Any: def test_adapter_per_op_call_offline() -> None: """A per-op tool builds + runs its operation, returning the serialized result.""" - server = build_server(ConcreteEngine(), expose_operations=True) + server = build_server(MockEngine(), expose_operations=True) async def main() -> t.Any: async with fastmcp.Client(server) as client: @@ -154,7 +154,7 @@ async def main() -> t.Any: def test_adapter_plan_tools_offline() -> None: """preview/execute/result_schema drive a serialized plan with forward refs.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) operations = [operation_to_dict(NewSession(session_name="dev", capture_panes=True))] async def main() -> tuple[t.Any, t.Any, t.Any]: @@ -175,7 +175,7 @@ async def main() -> tuple[t.Any, t.Any, t.Any]: def test_adapter_build_workspace_offline() -> None: """The workspace tool builds a declarative spec in one call (preflight off).""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) spec = { "session_name": "ws", "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], diff --git a/tests/experimental/mcp/test_mcp_projection.py b/tests/experimental/mcp/test_mcp_projection.py index 8adfab6a3..5af9a1440 100644 --- a/tests/experimental/mcp/test_mcp_projection.py +++ b/tests/experimental/mcp/test_mcp_projection.py @@ -3,12 +3,12 @@ Exercises descriptor generation from the operation registry, agent target resolution, plan preview/execute with forward-ref bindings, result-schema introspection, and the build_workspace tool -- all against the in-memory -``ConcreteEngine`` so the projection is provably correct offline. +``MockEngine`` so the projection is provably correct offline. """ from __future__ import annotations -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.mcp import ( OperationToolRegistry, build_workspace, @@ -126,7 +126,7 @@ def test_execute_plan_returns_bindings() -> None: plan = LazyPlan() session = plan.add(NewSession(session_name="dev", capture_panes=True)) plan.add(SendKeys(target=session.pane, keys="vim", enter=True)) - outcome = execute_plan(plan, ConcreteEngine()) + outcome = execute_plan(plan, MockEngine()) assert outcome.ok assert outcome.bindings["0"].startswith("$") assert outcome.bindings["0:pane"].startswith("%") @@ -151,7 +151,7 @@ def test_build_workspace_tool_offline() -> None: "session_name": "dev", "windows": [{"window_name": "editor", "panes": ["vim", "pytest -q"]}], }, - ConcreteEngine(), + MockEngine(), preflight=False, ) assert outcome.ok diff --git a/tests/experimental/mcp/test_prompts.py b/tests/experimental/mcp/test_prompts.py index 175737a83..40592baa3 100644 --- a/tests/experimental/mcp/test_prompts.py +++ b/tests/experimental/mcp/test_prompts.py @@ -7,7 +7,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine fastmcp = pytest.importorskip("fastmcp") @@ -32,7 +32,7 @@ def test_sync_server_omits_wait_for_output_prompts() -> None: """The sync server lacks wait_for_output, so those recipe prompts are gated off.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> set[str]: async with fastmcp.Client(server) as client: diff --git a/tests/experimental/mcp/test_relative_special_guard.py b/tests/experimental/mcp/test_relative_special_guard.py index 152b68a51..7b7400a89 100644 --- a/tests/experimental/mcp/test_relative_special_guard.py +++ b/tests/experimental/mcp/test_relative_special_guard.py @@ -13,7 +13,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.vocabulary import ( break_pane, capture_pane, @@ -42,25 +42,25 @@ def test_grep_rejects_relative_special(token: str) -> None: """grep_pane with a directional special target raises a targeted hint.""" with pytest.raises(ToolError, match="control-mode client"): - grep_pane(ConcreteEngine(capture_lines=("x",)), token, "x") + grep_pane(MockEngine(capture_lines=("x",)), token, "x") def test_capture_rejects_relative_special() -> None: """capture_pane rejects a directional special target.""" with pytest.raises(ToolError, match="control-mode client"): - capture_pane(ConcreteEngine(), "{down-of}") + capture_pane(MockEngine(), "{down-of}") def test_send_rejects_relative_special() -> None: """send_input rejects a directional special target.""" with pytest.raises(ToolError, match="control-mode client"): - send_input(ConcreteEngine(), "{left-of}", "ls") + send_input(MockEngine(), "{left-of}", "ls") @pytest.mark.parametrize("token", ["{marked}", "{last}"]) def test_anchor_specials_pass_through(token: str) -> None: """Anchor special targets are not rejected (real tmux semantics).""" - engine = ConcreteEngine(capture_lines=("hi",)) + engine = MockEngine(capture_lines=("hi",)) assert capture_pane(engine, token).lines == ("hi",) @@ -90,13 +90,13 @@ def test_anchor_specials_pass_through(token: str) -> None: def test_mutating_tools_reject_relative_special(call: t.Any) -> None: """Destructive/mutating pane tools reject a relative special target too.""" with pytest.raises(ToolError, match="control-mode client"): - call(ConcreteEngine()) + call(MockEngine()) def test_grep_pane_invalid_regex_hint() -> None: """An invalid search regex is surfaced as a targeted hint, not a raw re.error.""" with pytest.raises(ToolError, match="invalid search pattern"): - grep_pane(ConcreteEngine(capture_lines=("x",)), "%1", "[unclosed") + grep_pane(MockEngine(capture_lines=("x",)), "%1", "[unclosed") def test_capture_relative_pane_resolves_concrete_live(session: Session) -> None: diff --git a/tests/experimental/mcp/test_resources.py b/tests/experimental/mcp/test_resources.py index 8f5303939..d72bcb4a6 100644 --- a/tests/experimental/mcp/test_resources.py +++ b/tests/experimental/mcp/test_resources.py @@ -8,7 +8,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine fastmcp = pytest.importorskip("fastmcp") @@ -25,7 +25,7 @@ def _text(contents: t.Any) -> str: def test_resources_read_offline_returns_json() -> None: """The sessions resource is registered and returns a JSON array.""" - server = build_server(ConcreteEngine()) + server = build_server(MockEngine()) async def main() -> t.Any: async with fastmcp.Client(server) as client: diff --git a/tests/experimental/mcp/test_safety_gate.py b/tests/experimental/mcp/test_safety_gate.py index 5de29f5f0..6dac00d50 100644 --- a/tests/experimental/mcp/test_safety_gate.py +++ b/tests/experimental/mcp/test_safety_gate.py @@ -7,7 +7,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine fastmcp = pytest.importorskip("fastmcp") @@ -18,7 +18,7 @@ def _names_at(level: str, **kwargs: t.Any) -> set[str]: """Return the visible tool names from a server built at *level*.""" async def main() -> set[str]: - server = build_server(ConcreteEngine(), safety_level=level, **kwargs) + server = build_server(MockEngine(), safety_level=level, **kwargs) async with fastmcp.Client(server) as client: return {tool.name for tool in await client.list_tools()} @@ -59,7 +59,7 @@ def test_safety_gate_blocks_destructive_call_at_readonly() -> None: """A destructive tool cannot be successfully called at the readonly tier.""" async def main() -> tuple[str, t.Any]: - server = build_server(ConcreteEngine(), safety_level="readonly") + server = build_server(MockEngine(), safety_level="readonly") async with fastmcp.Client(server) as client: try: result = await client.call_tool("kill_session", {"target": "$1"}) diff --git a/tests/experimental/mcp/test_vocabulary.py b/tests/experimental/mcp/test_vocabulary.py index c0c4ad372..189d4adb7 100644 --- a/tests/experimental/mcp/test_vocabulary.py +++ b/tests/experimental/mcp/test_vocabulary.py @@ -1,6 +1,6 @@ """The curated core vocabulary -- intuitive named tmux tools. -Pure tests run the vocabulary against the in-memory ``ConcreteEngine`` (no tmux); +Pure tests run the vocabulary against the in-memory ``MockEngine`` (no tmux); a live test drives a real tmux server end to end (create -> window -> split -> send -> capture -> rename -> kill) over the subprocess engine. """ @@ -9,7 +9,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp import ( capture_pane, create_session, @@ -34,7 +34,7 @@ def test_create_session_returns_typed_result() -> None: """create_session yields a typed result with the captured first pane id.""" - result = create_session(ConcreteEngine(), name="dev") + result = create_session(MockEngine(), name="dev") assert result.session_id == "$1" assert result.name == "dev" assert result.first_window_id == "@1" @@ -43,7 +43,7 @@ def test_create_session_returns_typed_result() -> None: def test_create_window_then_split() -> None: """create_window captures a first pane id that split_pane can target.""" - engine = ConcreteEngine() + engine = MockEngine() session = create_session(engine, name="dev") window = create_window(engine, session.session_id, name="logs") assert window.window_id.startswith("@") @@ -54,7 +54,7 @@ def test_create_window_then_split() -> None: def test_new_pane_creates_floating_pane() -> None: """new_pane creates a floating pane and returns its id (in-memory).""" - engine = ConcreteEngine() + engine = MockEngine() session = create_session(engine, name="dev") pane = new_pane(engine, session.first_pane_id or "%1", width=80, height=20) assert pane.pane_id.startswith("%") @@ -62,18 +62,18 @@ def test_new_pane_creates_floating_pane() -> None: def test_send_input_is_fire_and_forget() -> None: """send_input runs without returning a value (and without raising).""" - send_input(ConcreteEngine(), "%1", "echo hi", enter=True) + send_input(MockEngine(), "%1", "echo hi", enter=True) def test_capture_pane_returns_lines() -> None: """capture_pane surfaces the pane's lines.""" - engine = ConcreteEngine(capture_lines=("line-1", "line-2")) + engine = MockEngine(capture_lines=("line-1", "line-2")) assert capture_pane(engine, "%1").lines == ("line-1", "line-2") def test_list_tools_return_listings() -> None: """The list_* tools return a Listing of format rows.""" - engine = ConcreteEngine() + engine = MockEngine() assert isinstance(list_sessions(engine).rows, tuple) assert isinstance(list_windows(engine).rows, tuple) assert isinstance(list_panes(engine).rows, tuple) @@ -81,7 +81,7 @@ def test_list_tools_return_listings() -> None: def test_target_accepts_string_or_typed() -> None: """A vocabulary target may be a string or an already-typed Target.""" - engine = ConcreteEngine() + engine = MockEngine() assert create_window(engine, "$1").window_id.startswith("@") assert create_window(engine, SessionId("$1")).window_id.startswith("@") diff --git a/tests/experimental/mcp/test_vocabulary_extended.py b/tests/experimental/mcp/test_vocabulary_extended.py index 81929265c..9b36b4444 100644 --- a/tests/experimental/mcp/test_vocabulary_extended.py +++ b/tests/experimental/mcp/test_vocabulary_extended.py @@ -1,6 +1,6 @@ """Extended vocabulary -- new verbs, conveniences, the async surface, the bridge. -Pure tests run against the in-memory ``ConcreteEngine`` and the pure geometry +Pure tests run against the in-memory ``MockEngine`` and the pure geometry helpers (no tmux); live tests drive a real tmux server for the geometry-resolved conveniences (``resolve_relative_pane`` / ``find_pane_by_position`` / directional ``select_pane``) that only mean something against a real layout. @@ -13,7 +13,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine, SubprocessEngine +from libtmux.experimental.engines import MockEngine, SubprocessEngine from libtmux.experimental.mcp.vocabulary import ( PaneRef, acreate_session, @@ -103,7 +103,7 @@ def test_corner_pane_uses_edge_predicates() -> None: # --------------------------------------------------------------------------- # def test_synced_twin_runs_over_sync_engine() -> None: """A synced twin drives its async source over a plain sync engine.""" - result = create_session(ConcreteEngine(), name="dev") # create_session is a twin + result = create_session(MockEngine(), name="dev") # create_session is a twin assert result.session_id == "$1" @@ -134,25 +134,25 @@ async def tool(engine: t.Any, value: int) -> int: # --------------------------------------------------------------------------- # def test_grep_pane_filters_lines() -> None: """grep_pane returns only the captured lines matching the pattern.""" - engine = ConcreteEngine(capture_lines=("foo", "bar baz", "foobar")) + engine = MockEngine(capture_lines=("foo", "bar baz", "foobar")) assert grep_pane(engine, "%1", "foo").lines == ("foo", "foobar") def test_grep_pane_ignore_case() -> None: """grep_pane honours the ignore_case flag.""" - engine = ConcreteEngine(capture_lines=("FOO", "bar")) + engine = MockEngine(capture_lines=("FOO", "bar")) assert grep_pane(engine, "%1", "foo", ignore_case=True).lines == ("FOO",) def test_capture_active_pane_needs_no_target() -> None: """capture_active_pane captures with no explicit target.""" - engine = ConcreteEngine(capture_lines=("hello",)) + engine = MockEngine(capture_lines=("hello",)) assert capture_active_pane(engine).lines == ("hello",) def test_resize_and_run_tmux_offline() -> None: """resize_pane is fire-and-forget; run_tmux returns a raw outcome.""" - engine = ConcreteEngine() + engine = MockEngine() assert resize_pane(engine, "%1", width=80) is None raw = run_tmux(engine, ["list-sessions"]) assert raw.ok and raw.returncode == 0 @@ -160,12 +160,12 @@ def test_resize_and_run_tmux_offline() -> None: def test_has_session_returns_bool() -> None: """has_session answers an existence query as a bool.""" - assert has_session(ConcreteEngine(), "$1") is True + assert has_session(MockEngine(), "$1") is True def test_geometry_tools_return_paneref_offline() -> None: """Geometry-resolved tools return a PaneRef even with nothing to resolve.""" - engine = ConcreteEngine() + engine = MockEngine() assert isinstance(resolve_relative_pane(engine, "right", "%1"), PaneRef) assert isinstance(select_pane(engine, "%1", direction="left"), PaneRef) @@ -177,7 +177,7 @@ def test_async_surface_over_wrapped_engine() -> None: """The a-prefixed tools run over an async engine (sync engine wrapped).""" async def main() -> tuple[str, tuple[str, ...]]: - engine = SyncToAsyncEngine(ConcreteEngine(capture_lines=("x", "y"))) + engine = SyncToAsyncEngine(MockEngine(capture_lines=("x", "y"))) session = await acreate_session(engine, name="dev") grep = await agrep_pane(engine, "%1", "x") return session.session_id, grep.lines diff --git a/tests/experimental/objects/test_matrix_complete.py b/tests/experimental/objects/test_matrix_complete.py index f20495415..bbee5f973 100644 --- a/tests/experimental/objects/test_matrix_complete.py +++ b/tests/experimental/objects/test_matrix_complete.py @@ -4,7 +4,7 @@ import asyncio -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.objects import ( AsyncClient, AsyncServer, @@ -24,7 +24,7 @@ def test_lazy_server_session_window_plan() -> None: window.split() assert len(plan) == 3 # new-session, new-window, split-window - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.ok assert [r.created_id for r in outcome.results] == ["$1", "@1", "%1"] @@ -33,7 +33,7 @@ def test_async_server_navigation() -> None: """AsyncServer->AsyncSession->AsyncWindow navigation via await.""" async def main() -> str: - server = AsyncServer(AsyncConcreteEngine()) + server = AsyncServer(AsyncMockEngine()) session = await server.new_session(name="work") window = await session.new_window() pane = await window.split() @@ -44,7 +44,7 @@ async def main() -> str: def test_eager_client_methods() -> None: """EagerClient detach/refresh/switch_to return successful results.""" - client = EagerClient(ConcreteEngine(), "/dev/pts/3") + client = EagerClient(MockEngine(), "/dev/pts/3") assert client.refresh().ok assert client.switch_to("$1").ok assert client.detach().ok @@ -56,14 +56,14 @@ def test_lazy_client_records() -> None: client = LazyClient(plan, "/dev/pts/3") client.refresh().switch_to("$1") assert [op.kind for op in plan] == ["refresh_client", "switch_client"] - assert plan.execute(ConcreteEngine()).ok + assert plan.execute(MockEngine()).ok def test_async_client() -> None: """AsyncClient mirrors the eager client via await.""" async def main() -> bool: - client = AsyncClient(AsyncConcreteEngine(), "/dev/pts/3") + client = AsyncClient(AsyncMockEngine(), "/dev/pts/3") return (await client.refresh()).ok assert asyncio.run(main()) diff --git a/tests/experimental/objects/test_object_matrix.py b/tests/experimental/objects/test_object_matrix.py index 7956da80d..1e5dc6e4e 100644 --- a/tests/experimental/objects/test_object_matrix.py +++ b/tests/experimental/objects/test_object_matrix.py @@ -5,7 +5,7 @@ import asyncio import typing as t -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.objects import ( AsyncPane, AsyncWindow, @@ -22,8 +22,8 @@ def test_eager_full_navigation_offline() -> None: - """Eager Server->Session->Window->Pane navigation via the concrete engine.""" - server = EagerServer(ConcreteEngine()) + """Eager Server->Session->Window->Pane navigation via the mock engine.""" + server = EagerServer(MockEngine()) session = server.new_session(name="work") assert session.session_id == "$1" window = session.new_window(name="build") @@ -35,7 +35,7 @@ def test_eager_full_navigation_offline() -> None: def test_eager_window_methods() -> None: """EagerWindow rename/select_layout/kill return successful results.""" - window = EagerWindow(ConcreteEngine(), "@1") + window = EagerWindow(MockEngine(), "@1") assert window.rename("x").ok assert window.select_layout("tiled").ok assert window.kill().ok @@ -49,7 +49,7 @@ def test_lazy_window_records_and_executes() -> None: window.rename("build") assert len(plan) == 2 - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.ok assert outcome.results[0].created_id == "%1" @@ -58,7 +58,7 @@ def test_async_window_and_pane() -> None: """Async objects mirror the eager ones via await.""" async def main() -> tuple[str, bool, bool]: - window = AsyncWindow(AsyncConcreteEngine(), "@1") + window = AsyncWindow(AsyncMockEngine(), "@1") pane = await window.split() assert isinstance(pane, AsyncPane) sent = await pane.send_keys("echo hi", enter=True) diff --git a/tests/experimental/objects/test_pane_object.py b/tests/experimental/objects/test_pane_object.py index 448ff7c94..14f0d58f0 100644 --- a/tests/experimental/objects/test_pane_object.py +++ b/tests/experimental/objects/test_pane_object.py @@ -2,7 +2,7 @@ from __future__ import annotations -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.objects import EagerPane, LazyPane from libtmux.experimental.ops import LazyPlan from libtmux.experimental.ops._types import PaneId @@ -11,7 +11,7 @@ def test_eager_split_returns_live_pane() -> None: """EagerPane.split executes now and returns a live EagerPane object.""" - pane = EagerPane(ConcreteEngine(), "%0") + pane = EagerPane(MockEngine(), "%0") child = pane.split(horizontal=True) assert isinstance(child, EagerPane) assert child.pane_id == "%1" @@ -19,7 +19,7 @@ def test_eager_split_returns_live_pane() -> None: def test_eager_capture_and_send() -> None: """Eager capture/send-keys return typed results.""" - engine = ConcreteEngine(capture_lines=("a", "b")) + engine = MockEngine(capture_lines=("a", "b")) pane = EagerPane(engine, "%1") assert pane.capture().lines == ("a", "b") assert pane.send_keys("echo hi", enter=True).ok @@ -40,7 +40,7 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: root = LazyPane(plan, PaneId("%0")) root.split().send_keys("vim", enter=True) - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) first = outcome.results[0] assert isinstance(first, SplitWindowResult) @@ -50,7 +50,7 @@ def test_lazy_chain_resolves_forward_ref_on_execute() -> None: def test_eager_new_pane_returns_live_pane() -> None: """EagerPane.new_pane creates a floating pane and returns a live object.""" - pane = EagerPane(ConcreteEngine(), "%0") + pane = EagerPane(MockEngine(), "%0") floating = pane.new_pane(width=80, height=20, x=5, y=3) assert isinstance(floating, EagerPane) assert floating.pane_id == "%1" @@ -78,11 +78,11 @@ def test_async_new_pane_returns_live_pane() -> None: """AsyncPane.new_pane creates a floating pane and returns a live object.""" import asyncio - from libtmux.experimental.engines import AsyncConcreteEngine + from libtmux.experimental.engines import AsyncMockEngine from libtmux.experimental.objects import AsyncPane async def main() -> str: - pane = AsyncPane(AsyncConcreteEngine(), "%0") + pane = AsyncPane(AsyncMockEngine(), "%0") floating = await pane.new_pane(width=80, height=20) return floating.pane_id @@ -91,7 +91,7 @@ async def main() -> str: def test_same_operation_backs_both_objects() -> None: """Eager and lazy objects render the identical underlying operation argv.""" - eager_engine = ConcreteEngine() + eager_engine = MockEngine() eager = EagerPane(eager_engine, "%0") # Capture the eager split's rendered argv via the engine-independent op. plan = LazyPlan() diff --git a/tests/experimental/ops/test_ack_ops.py b/tests/experimental/ops/test_ack_ops.py index 3e03d8b54..e1981f012 100644 --- a/tests/experimental/ops/test_ack_ops.py +++ b/tests/experimental/ops/test_ack_ops.py @@ -6,7 +6,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( KillPane, KillWindow, @@ -58,7 +58,7 @@ def test_no_output_ops_return_ack( expected_argv: tuple[str, ...], ) -> None: """No-output operations render correctly and yield an AckResult.""" - result = run(operation, ConcreteEngine()) + result = run(operation, MockEngine()) assert type(result) is AckResult assert result.argv == expected_argv assert result.ok diff --git a/tests/experimental/ops/test_chain.py b/tests/experimental/ops/test_chain.py index 1ac1af278..bf1e59b2c 100644 --- a/tests/experimental/ops/test_chain.py +++ b/tests/experimental/ops/test_chain.py @@ -142,9 +142,9 @@ def test_fold_keeps_creation_ops_unfolded() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) # not chainable plan.add(SendKeys(target=pane, keys="vim")) # chainable, targets new pane plan.add(RenameWindow(target=WindowId("@1"), name="x")) # chainable - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine - outcome = plan.execute(ConcreteEngine(), planner=FoldingPlanner()) + outcome = plan.execute(MockEngine(), planner=FoldingPlanner()) # split resolved the pane id; the send-keys folded with rename, retargeted assert outcome.results[1].argv[:3] == ("send-keys", "-t", "%1") diff --git a/tests/experimental/ops/test_execute.py b/tests/experimental/ops/test_execute.py index 524920ba4..b0c73892e 100644 --- a/tests/experimental/ops/test_execute.py +++ b/tests/experimental/ops/test_execute.py @@ -13,8 +13,8 @@ from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine from libtmux.experimental.engines.base import SupportsTmuxVersion -from libtmux.experimental.engines.concrete import ConcreteEngine from libtmux.experimental.engines.control_mode import ControlModeEngine +from libtmux.experimental.engines.mock import MockEngine from libtmux.experimental.engines.subprocess import SubprocessEngine from libtmux.experimental.ops import SendKeys, SplitWindow, arun, run from libtmux.experimental.ops._types import PaneId, WindowId @@ -157,7 +157,7 @@ class _CapabilityCase(t.NamedTuple): _CapabilityCase("subprocess", SubprocessEngine, True), _CapabilityCase("control_mode", ControlModeEngine, True), _CapabilityCase("async_control_mode", AsyncControlModeEngine, True), - _CapabilityCase("concrete", ConcreteEngine, False), + _CapabilityCase("mock", MockEngine, False), ) diff --git a/tests/experimental/ops/test_plan.py b/tests/experimental/ops/test_plan.py index 61055c378..cc288e3ba 100644 --- a/tests/experimental/ops/test_plan.py +++ b/tests/experimental/ops/test_plan.py @@ -7,7 +7,7 @@ import pytest -from libtmux.experimental.engines import AsyncConcreteEngine, ConcreteEngine +from libtmux.experimental.engines import AsyncMockEngine, MockEngine from libtmux.experimental.engines.base import CommandResult from libtmux.experimental.ops import ( BreakPane, @@ -48,7 +48,7 @@ def test_plan_resolves_forward_ref() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) plan.add(SendKeys(target=pane, keys="vim", enter=True)) - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.bindings == {0: "%1"} assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") @@ -59,7 +59,7 @@ def test_plan_execute_auto_resolves_engine_version() -> None: """plan.execute() resolves the engine version so folded renders are gated.""" from libtmux.experimental.ops import FoldingPlanner, RespawnPane - class VersionedConcreteEngine(ConcreteEngine): + class VersionedMockEngine(MockEngine): def tmux_version(self) -> str | None: return "2.9" @@ -67,7 +67,7 @@ def tmux_version(self) -> str | None: plan.add(RespawnPane(target=PaneId("%1"), environment={"E": "1"})) plan.add(RespawnPane(target=PaneId("%2"), environment={"E": "2"})) - outcome = plan.execute(VersionedConcreteEngine(), planner=FoldingPlanner()) + outcome = plan.execute(VersionedMockEngine(), planner=FoldingPlanner()) # -e is gated at tmux 3.0; on the engine's resolved 2.9 it is dropped even # from the folded (rendered-in-_drive) dispatch. @@ -101,7 +101,7 @@ def test_plan_resolves_src_target(test_id: str, op: Operation[t.Any]) -> None: plan = LazyPlan() plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 plan.add(op) - outcome = plan.execute(ConcreteEngine()) + outcome = plan.execute(MockEngine()) assert outcome.ok assert outcome.results[1].argv[-2:] == ("-s", "%1") @@ -134,7 +134,7 @@ def test_marked_plan_resolves_decorate_src_target( plan.add(SplitWindow(target=WindowId("@1"))) # slot 0 -> %1 (own dispatch) plan.add(SplitWindow(target=WindowId("@1"))) # slot 1 -> the marked-fold creator plan.add(op) # slot 2 -> decorate: target {marked}, src_target -> slot 0 - outcome = plan.execute(ConcreteEngine(), planner=MarkedPlanner()) + outcome = plan.execute(MockEngine(), planner=MarkedPlanner()) assert outcome.ok assert outcome.results[2].argv[-2:] == ("-s", "%1") @@ -145,7 +145,7 @@ def test_plan_aexecute_matches_execute() -> None: pane = plan.add(SplitWindow(target=WindowId("@1"))) plan.add(SendKeys(target=pane, keys="vim", enter=True)) - outcome = asyncio.run(plan.aexecute(AsyncConcreteEngine())) + outcome = asyncio.run(plan.aexecute(AsyncMockEngine())) assert outcome.bindings == {0: "%1"} assert outcome.results[1].argv == ("send-keys", "-t", "%1", "vim", "Enter") @@ -159,7 +159,7 @@ def test_execute_on_step_reports_each_step() -> None: reports: list[StepReport] = [] outcome = plan.execute( - ConcreteEngine(), + MockEngine(), planner=SequentialPlanner(), on_step=reports.append, ) @@ -183,7 +183,7 @@ def test_aexecute_on_step_matches_execute() -> None: sync_steps: list[tuple[int, ...]] = [] plan.execute( - ConcreteEngine(), + MockEngine(), on_step=lambda report: sync_steps.append(report.step.indices), ) @@ -192,7 +192,7 @@ def test_aexecute_on_step_matches_execute() -> None: async def collect(report: StepReport) -> None: async_steps.append(report.step.indices) - asyncio.run(plan.aexecute(AsyncConcreteEngine(), on_step=collect)) + asyncio.run(plan.aexecute(AsyncMockEngine(), on_step=collect)) assert async_steps == sync_steps == [(0,), (1,)] @@ -213,7 +213,7 @@ def test_plan_unresolvable_ref_fails_closed() -> None: typed = plan.add(SendKeys(target=PaneId("%1"), keys="x")) # creates no id plan.add(SendKeys(target=typed, keys="y")) with pytest.raises(ForwardCaptureError, match="captured no id") as exc_info: - plan.execute(ConcreteEngine()) + plan.execute(MockEngine()) assert exc_info.value.slot == 0 # points at the non-capturing creator # ForwardCaptureError stays an OperationError, so broad handlers keep working assert isinstance(exc_info.value, OperationError) @@ -266,7 +266,7 @@ def test_astream_yields_step_then_plan_done() -> None: plan = _split_then_send() async def drain() -> list[object]: - return [event async for event in plan.astream(AsyncConcreteEngine())] + return [event async for event in plan.astream(AsyncMockEngine())] events = asyncio.run(drain()) assert [type(e).__name__ for e in events] == ["StepDone", "StepDone", "PlanDone"] @@ -281,8 +281,8 @@ def test_astream_last_result_matches_aexecute() -> None: from libtmux.experimental.ops import PlanDone async def both() -> tuple[bool, bool]: - streamed = [e async for e in _split_then_send().astream(AsyncConcreteEngine())] - direct = await _split_then_send().aexecute(AsyncConcreteEngine()) + streamed = [e async for e in _split_then_send().astream(AsyncMockEngine())] + direct = await _split_then_send().aexecute(AsyncMockEngine()) last = streamed[-1] assert isinstance(last, PlanDone) return last.result.ok, direct.ok diff --git a/tests/experimental/ops/test_read_ops.py b/tests/experimental/ops/test_read_ops.py index 3a3ebb5eb..94414d7c8 100644 --- a/tests/experimental/ops/test_read_ops.py +++ b/tests/experimental/ops/test_read_ops.py @@ -4,7 +4,7 @@ import typing as t -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.ops import ( ListPanes, ListSessions, @@ -72,7 +72,7 @@ def test_list_result_serialization_round_trip() -> None: def test_empty_output_yields_empty_views() -> None: """No panes -> empty rows, empty snapshot, no error.""" - result = run(ListPanes(), ConcreteEngine(), version="3.6a") + result = run(ListPanes(), MockEngine(), version="3.6a") assert result.rows == () assert result.server.sessions == () assert result.ok diff --git a/tests/experimental/test_fluent.py b/tests/experimental/test_fluent.py index 28b930bd4..9146a2ab8 100644 --- a/tests/experimental/test_fluent.py +++ b/tests/experimental/test_fluent.py @@ -6,7 +6,7 @@ import pytest -from libtmux.experimental.engines.concrete import ConcreteEngine +from libtmux.experimental.engines.mock import MockEngine from libtmux.experimental.fluent import PlanBuilder, plan from libtmux.experimental.query import ForwardPaneRef @@ -63,7 +63,7 @@ def test_builder_runs_offline(case: _BuildCase) -> None: """The build resolves forward refs and folds over the in-memory engine.""" p = plan() case.build(p) - assert p.run(ConcreteEngine()).ok + assert p.run(MockEngine()).ok def test_window_pane_is_forward_handle() -> None: @@ -150,7 +150,7 @@ def test_sleep_runs_offline() -> None: pane.do(lambda c: c.send_keys("vim")) p.sleep(0.0) pane.split().do(lambda c: c.send_keys("htop")) - assert p.run(ConcreteEngine()).ok + assert p.run(MockEngine()).ok def test_find_or_create_session_records_a_conditional_create() -> None: diff --git a/tests/experimental/test_freeze.py b/tests/experimental/test_freeze.py index 54651c71a..90f6d59c5 100644 --- a/tests/experimental/test_freeze.py +++ b/tests/experimental/test_freeze.py @@ -13,7 +13,7 @@ import pytest -from libtmux.experimental.engines import ConcreteEngine +from libtmux.experimental.engines import MockEngine from libtmux.experimental.engines.async_control_mode import AsyncControlModeEngine from libtmux.experimental.models.snapshots import ServerSnapshot from libtmux.experimental.workspace.freeze import SHELLS, afreeze_server, freeze @@ -187,7 +187,7 @@ def test_freeze_round_trips_into_a_buildable_workspace() -> None: ) ws = freeze(server) assert ws.compile().operations[0].kind == "new_session" - assert ws.build(ConcreteEngine(), preflight=False).ok + assert ws.build(MockEngine(), preflight=False).ok def test_afreeze_server_captures_live_tree(session: Session) -> None: diff --git a/tests/experimental/test_query.py b/tests/experimental/test_query.py index a3d9c3a41..d41256cfe 100644 --- a/tests/experimental/test_query.py +++ b/tests/experimental/test_query.py @@ -147,9 +147,9 @@ def test_query_is_immutable() -> None: def test_empty_engine_source() -> None: """A query resolves against an engine; the in-memory engine has no panes.""" - from libtmux.experimental.engines import ConcreteEngine + from libtmux.experimental.engines import MockEngine - assert panes().all(ConcreteEngine()) == () + assert panes().all(MockEngine()) == () def test_commands_to_plan_builds_one_op_per_pane() -> None: From e88e87b6d6ef22ac08eb4025de78e5f9ec205928 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Jul 2026 18:12:26 -0500 Subject: [PATCH 154/154] chore(deps): Sync lock with master's gp-sphinx 0.0.1a33 why: Rebasing onto master pulled in the gp-sphinx 0.0.1a33 docs-dep bump; the sibling sphinx-autodoc-* pins in uv.lock had to move to match or `uv lock --locked` fails. what: - Regenerate uv.lock: sphinx-autodoc-api-style and sphinx-autodoc-pytest-fixtures 0.0.1a32 -> 0.0.1a33 --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index f8d179172..ca3f4adf0 100644 --- a/uv.lock +++ b/uv.lock @@ -1247,8 +1247,8 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx-autobuild" }, - { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a32" }, - { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a32" }, + { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a33" }, + { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a33" }, { name = "tomlkit" }, { name = "ty" }, { name = "types-docutils" },