diff --git a/CHANGES b/CHANGES index 3798c5eb3..d157b5b2c 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,20 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### New: chainable tmux commands (`libtmux._experimental.chain`) + +Build a sequence of tmux commands and run it in a single tmux call, +instead of one subprocess per command. References to panes, windows, +and sessions can be lazy — point at an object a command will create +and keep building against it — and they all resolve together when the +chain runs. Experimental and opt-in: import from +`libtmux._experimental.chain`, not the top-level `libtmux`. +When callers need per-command output, the experimental control-mode +runner batches command lines through one persistent `tmux -C` client +and returns one result per command. (#685) + ## libtmux 0.58.1 (2026-06-16) libtmux 0.58.1 restores compatibility with pytest 9.1. The bundled diff --git a/docs/experiment/api/libtmux._experimental.chain._async.md b/docs/experiment/api/libtmux._experimental.chain._async.md new file mode 100644 index 000000000..6cbda1af6 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain._async.md @@ -0,0 +1,13 @@ +# Async - `libtmux._experimental.chain._async` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain._async + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain._connection.md b/docs/experiment/api/libtmux._experimental.chain._connection.md new file mode 100644 index 000000000..07bfe4531 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain._connection.md @@ -0,0 +1,13 @@ +# Connecting to live tmux sessions - `libtmux._experimental.chain._connection` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain._connection + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.chain.md b/docs/experiment/api/libtmux._experimental.chain.chain.md new file mode 100644 index 000000000..308abb56a --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.chain.md @@ -0,0 +1,13 @@ +# Chain - `libtmux._experimental.chain.chain` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.chain + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.control.md b/docs/experiment/api/libtmux._experimental.chain.control.md new file mode 100644 index 000000000..abbaf2ce8 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.control.md @@ -0,0 +1,13 @@ +# Control mode - `libtmux._experimental.chain.control` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.control + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.ir.md b/docs/experiment/api/libtmux._experimental.chain.ir.md new file mode 100644 index 000000000..7d11ee5ee --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.ir.md @@ -0,0 +1,13 @@ +# Intermediate representation - `libtmux._experimental.chain.ir` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.ir + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.plan.md b/docs/experiment/api/libtmux._experimental.chain.plan.md new file mode 100644 index 000000000..61bc53884 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.plan.md @@ -0,0 +1,13 @@ +# Expressions - `libtmux._experimental.chain.plan` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.plan + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/index.md b/docs/experiment/index.md new file mode 100644 index 000000000..226588834 --- /dev/null +++ b/docs/experiment/index.md @@ -0,0 +1,195 @@ +(experimental)= + +# Experimental + +:::{danger} +**No stability guarantee.** Everything under `libtmux._experimental` is **not** +covered by the project's versioning policy. It can change or be removed between +any releases without notice. + +These APIs are published so the design can be exercised and reviewed before any +stability commitment. If you depend on something here and want it stabilized, +please [file an issue](https://github.com/tmux-python/libtmux/issues). +::: + +## Chainable commands + +`libtmux._experimental.chain` lets you build an ordered sequence of +typed tmux commands that runs as **one** native `tmux ... \; ...` invocation, +instead of one subprocess per command. The pieces layer up, so you can reach for +as much or as little as you need: + +- **Intermediate representation** -- the typed argv layer beneath everything: a + {class}`~libtmux._experimental.chain.ir.CommandCall` is a single + command, and a + {class}`~libtmux._experimental.chain.ir.CommandChain` is an + ordered group that renders to one argv (with standalone `;` separators) and + dispatches once. +- **Expressions** -- compose commands from a lazy, target-safe pane query. A + {class}`~libtmux._experimental.chain.plan.PaneQuery` resolves + against a pure + {class}`~libtmux._experimental.chain.plan.TmuxSnapshot`, maps each + typed row to commands, and compiles to one sequence -- so you can build and + assert the result without touching tmux. +- **Async** -- {mod}`~libtmux._experimental.chain._async` mirrors the + same query and dispatch API with `await`, while command construction stays + synchronous and one expression still compiles to one invocation. +- **Connecting to live tmux sessions** -- the bridge to a real server: + {func}`~libtmux._experimental.chain._connection.snapshot_from_session` + reads live panes, and + {class}`~libtmux._experimental.chain._connection.SessionPlanExecutor` + (with its async counterpart + {class}`~libtmux._experimental.chain._connection.AsyncSessionPlanExecutor`) + resolves and runs an expression against a live {class}`~libtmux.Session` in one + invocation. +- **Control mode** -- + {class}`~libtmux._experimental.chain.control.ControlModeRunner` + batches command lines through one persistent `tmux -C` client and returns one + result per command when callers need per-command output. +- **Chainability** -- + {mod}`~libtmux._experimental.chain.chain` decides which commands + may share one invocation: the static + {attr}`~libtmux._experimental.chain.ir.CommandSpec.chainable` + flag, plus a deferred result that won't hand back output until the chain has + run. + +::::{grid} 1 2 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Intermediate representation +:link: api/libtmux._experimental.chain.ir +:link-type: doc +The typed argv layer: `CommandCall`, `CommandChain`, `CommandSpec`. +::: + +:::{grid-item-card} Expressions +:link: api/libtmux._experimental.chain.plan +:link-type: doc +Build commands from a lazy, target-safe pane query. +::: + +:::{grid-item-card} Async +:link: api/libtmux._experimental.chain._async +:link-type: doc +The same query and dispatch API, with `await`. +::: + +:::{grid-item-card} Connecting to live tmux sessions +:link: api/libtmux._experimental.chain._connection +:link-type: doc +Read live panes and run an expression against a real session. +::: + +:::{grid-item-card} Chainability +:link: api/libtmux._experimental.chain.chain +:link-type: doc +Which commands may share one invocation. +::: + +:::{grid-item-card} Control mode +:link: api/libtmux._experimental.chain.control +:link-type: doc +Batch command lines with per-command results. +::: + +:::: + +## At a glance + +Compose typed calls and dispatch them as one tmux invocation: + +```python +>>> from libtmux._experimental.chain.ir import CommandCall +>>> sequence = ( +... CommandCall("set-option", ("-g", "@cc_docs_a", "1")) +... >> CommandCall("set-option", ("-g", "@cc_docs_b", "2")) +... ) +>>> sequence.argv() +('set-option', '-g', '@cc_docs_a', '1', ';', 'set-option', '-g', '@cc_docs_b', '2') +>>> sequence.run(session.server).returncode +0 +>>> session.server.cmd("show-option", "-gv", "@cc_docs_b").stdout +['2'] +``` + +Build an expression from a query and compile it to one sequence -- pure, no tmux +required: + +```python +>>> from libtmux._experimental.chain.plan import ( +... PaneRef, +... PaneTarget, +... SessionTarget, +... TmuxSnapshot, +... WindowTarget, +... panes, +... ) +>>> snapshot = TmuxSnapshot( +... panes=( +... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", +... pane_index=0, active=True, title="editor"), +... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", +... pane_index=1, active=True, title="logs"), +... ), +... ) +>>> plan = ( +... panes() +... .filter(active=True) +... .order_by("pane_index") +... .commands(lambda pane: pane.cmd.resize_pane(height=20)) +... ) +>>> plan.to_chain(snapshot).argvs() +(('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) +``` + +Against a live server, run the same expression in one invocation with +{class}`~libtmux._experimental.chain._connection.SessionPlanExecutor`: + +```python +>>> from libtmux._experimental.chain import SessionPlanExecutor, panes +>>> runner = SessionPlanExecutor(session) +>>> live_plan = panes().filter(active=True).commands( +... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), +... ) +>>> live_plan.run(runner) +``` + +The same expression can be built and compiled asynchronously -- construction +stays synchronous; only resolution and dispatch await: + +```python +>>> import asyncio +>>> from libtmux._experimental.chain import aio +>>> from libtmux._experimental.chain.plan import ( +... PaneRef, +... PaneTarget, +... SessionTarget, +... TmuxSnapshot, +... WindowTarget, +... ) +>>> snapshot = TmuxSnapshot( +... panes=( +... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", +... pane_index=0, active=True, title="editor"), +... ), +... ) +>>> async def _resize() -> tuple[tuple[str, ...], ...]: +... plan = aio.panes().filter(active=True).commands( +... lambda pane: pane.cmd.resize_pane(height=20), +... ) +... return (await plan.to_chain(snapshot)).argvs() +>>> asyncio.run(_resize()) +(('resize-pane', '-t', '%1', '-y', '20'),) +``` + +```{toctree} +:hidden: +:maxdepth: 1 + +api/libtmux._experimental.chain.ir +api/libtmux._experimental.chain.plan +api/libtmux._experimental.chain._async +api/libtmux._experimental.chain._connection +api/libtmux._experimental.chain.chain +api/libtmux._experimental.chain.control +``` diff --git a/docs/index.md b/docs/index.md index f6e763e0c..d9457d8d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,6 +101,7 @@ topics/index api/index api/testing/index internals/index +experiment/index project/index history migration diff --git a/docs/project/public-api.md b/docs/project/public-api.md index 7fe1367db..805ab7d70 100644 --- a/docs/project/public-api.md +++ b/docs/project/public-api.md @@ -29,12 +29,16 @@ This includes: ## What Is Internal -Modules under `libtmux._internal` and `libtmux._vendor` are **not public**. -They may change or be removed without notice between any release. +Modules under `libtmux._internal`, `libtmux._vendor`, and +`libtmux._experimental` are **not public**. They may change or be removed +without notice between any release. `libtmux._experimental` additionally hosts +in-progress designs that are published for feedback before any stability +commitment (see {ref}`the experimental docs `). Do not import from: - `libtmux._internal.*` - `libtmux._vendor.*` +- `libtmux._experimental.*` ## Pre-1.0 Stability Policy diff --git a/pyproject.toml b/pyproject.toml index 2575789c4..45cf8c701 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + "pytest-asyncio", # Coverage "codecov", "coverage", @@ -87,6 +88,7 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + "pytest-asyncio", ] coverage =[ "codecov", @@ -242,6 +244,8 @@ doctest_optionflags = [ "ELLIPSIS", "NORMALIZE_WHITESPACE" ] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" testpaths = [ "src/libtmux", "tests", diff --git a/src/libtmux/_experimental/__init__.py b/src/libtmux/_experimental/__init__.py new file mode 100644 index 000000000..93fa87f98 --- /dev/null +++ b/src/libtmux/_experimental/__init__.py @@ -0,0 +1,13 @@ +"""Experimental libtmux APIs. + +Note +---- +This is an **experimental** namespace. Everything under +:mod:`libtmux._experimental` is **not** covered by the project's versioning +policy and may change or be removed between any releases without notice. + +If you depend on something here and want it stabilized, please +`file an issue `_. +""" + +from __future__ import annotations diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py new file mode 100644 index 000000000..07b410a94 --- /dev/null +++ b/src/libtmux/_experimental/chain/__init__.py @@ -0,0 +1,159 @@ +r"""Typed, chainable tmux command sequences (experimental). + +This package promotes the converged design from the ``chainable-commands`` +research into a typed, documented API. It lets callers author an ordered +sequence of tmux commands that compiles to **one** native ``tmux ... \\; ...`` +invocation and dispatches once, instead of issuing one subprocess per command. + +The layers build on each other: + +- :mod:`~libtmux._experimental.chain.ir` -- the immutable argv + intermediate representation (``CommandCall``, ``CommandChain``). +- :mod:`~libtmux._experimental.chain.plan` -- typed, target-safe + deferred query-command plans (``panes()``, ``CommandPlan``). +- :mod:`~libtmux._experimental.chain._async` -- an async facade over + the same engine, exposed publicly as ``aio`` (``aio.panes()``), preserving + one dispatch per plan. +- :mod:`~libtmux._experimental.chain._connection` -- live-tmux + connection helpers (``snapshot_from_session``, ``SessionPlanExecutor``, + ``AsyncSessionPlanExecutor``). +- :mod:`~libtmux._experimental.chain.chain` -- the chainability + contract that decides which commands may fold into one dispatch + (``CommandSpec.chainable`` + ``DeferredCommandResult``). + +Forward references come in two shapes; pick by whether the handles are +independent: + +- A **linear chain** -- ``PaneRef.split().split().do(...)`` in + :mod:`~libtmux._experimental.chain.plan`. Each step creates the object the next + step builds on (split a pane, then split *that* pane). Because tmux keeps the + freshly-created object active, the whole chain addresses it with no ``-t`` and + folds into **one** ``\\;`` invocation (``to_chain()`` / ``run()``). Use it when + the forward objects form a single line of descent. +- A **multi-handle plan** -- ``ForwardPlan``. Hands out + **independent** handles (two splits off one pane, two windows in a new + session) and resolves them over the minimum number of dispatches, capturing each + new id with ``-P -F`` and substituting it downstream. Use it when you hold more + than one forward object at once, or need a new id back + (``Resolved.bindings`` / ``Resolved.pane(...)``). + +A lone-pane ``ForwardPlan`` still folds to one dispatch (via the marked register), +so the two shapes overlap only there; reach for the linear chain for a pure line +of splits and ``ForwardPlan`` the moment the handles fan out. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. A ``\\;`` +sequence returns one merged result, so per-command output is not separable; reach +for individual typed calls (or ``run_deferred``) when you need a command's own +output. +""" + +from __future__ import annotations + +from libtmux._experimental.chain import _async as aio +from libtmux._experimental.chain._connection import ( + AsyncServerPlanRunner, + AsyncSessionPlanExecutor, + ServerPlanRunner, + SessionPlanExecutor, + snapshot_from_session, +) +from libtmux._experimental.chain._resolve import ( + ForwardDispatchError, + ForwardHandle, + ForwardPlan, + Resolved, +) +from libtmux._experimental.chain.chain import ( + ChainabilityError, + CommandScopeError, + DeferredCommandResult, + DeferredOutputUnavailable, + ensure_chainable, + is_chainable, + validate_command_scope, +) +from libtmux._experimental.chain.control import ( + ControlModeBlock, + ControlModeError, + ControlModeParser, + ControlModeResult, + ControlModeRunner, +) +from libtmux._experimental.chain.ir import ( + Arg, + CommandCall, + CommandChain, + CommandResultLike, + CommandRunner, + CommandScope, + CommandSpec, +) +from libtmux._experimental.chain.plan import ( + CommandPlan, + CommandValue, + ForwardDataUnavailable, + NoCommandsResolved, + PaneQuery, + PaneRef, + PaneTarget, + PendingTarget, + PlanRunner, + SessionRef, + SessionTarget, + TmuxSnapshot, + WindowRef, + WindowTarget, + new_session, + panes, +) + +__all__ = [ + "Arg", + "AsyncServerPlanRunner", + "AsyncSessionPlanExecutor", + "ChainabilityError", + "CommandCall", + "CommandChain", + "CommandPlan", + "CommandResultLike", + "CommandRunner", + "CommandScope", + "CommandScopeError", + "CommandSpec", + "CommandValue", + "ControlModeBlock", + "ControlModeError", + "ControlModeParser", + "ControlModeResult", + "ControlModeRunner", + "DeferredCommandResult", + "DeferredOutputUnavailable", + "ForwardDataUnavailable", + "ForwardDispatchError", + "ForwardHandle", + "ForwardPlan", + "NoCommandsResolved", + "PaneQuery", + "PaneRef", + "PaneTarget", + "PendingTarget", + "PlanRunner", + "Resolved", + "ServerPlanRunner", + "SessionPlanExecutor", + "SessionRef", + "SessionTarget", + "TmuxSnapshot", + "WindowRef", + "WindowTarget", + "aio", + "ensure_chainable", + "is_chainable", + "new_session", + "panes", + "snapshot_from_session", + "validate_command_scope", +] diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py new file mode 100644 index 000000000..dc187e9bc --- /dev/null +++ b/src/libtmux/_experimental/chain/_async.py @@ -0,0 +1,299 @@ +r"""Asyncio facade over the deferred query-command plan. + +This is a thin wrapper over the sync +:mod:`~libtmux._experimental.chain.plan` engine: command +*construction* stays synchronous, and only snapshot resolution and command +dispatch become awaitable. A plan still compiles to exactly one +:class:`~libtmux._experimental.chain.ir.CommandChain`, so the +"one plan = one native ``\\;`` dispatch" guarantee is preserved -- it just runs +without blocking the event loop, and independent plans can resolve and dispatch +concurrently. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import dataclasses +import logging +import typing as t +from dataclasses import dataclass + +from libtmux._experimental.chain import plan as sync_plan +from libtmux._experimental.chain.chain import DeferredCommandResult +from libtmux._experimental.chain.ir import ( + Arg, + CommandChain, + CommandResultLike, +) + +logger = logging.getLogger(__name__) + +MappedT = t.TypeVar("MappedT") + +NoCommandsResolved = sync_plan.NoCommandsResolved + + +class AsyncCommandRunner(t.Protocol): + """Object capable of asynchronously dispatching one tmux command argv.""" + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command asynchronously.""" + ... + + +class AsyncSnapshotProvider(t.Protocol): + """Object that can asynchronously provide a pure tmux snapshot.""" + + async def snapshot(self) -> sync_plan.TmuxSnapshot: + """Return a tmux snapshot asynchronously.""" + ... + + +class AsyncPlanRunner(AsyncCommandRunner, AsyncSnapshotProvider, t.Protocol): + """A runner that can asynchronously resolve snapshots and dispatch.""" + + +AsyncSnapshotSource: t.TypeAlias = "sync_plan.TmuxSnapshot | AsyncSnapshotProvider" + + +@dataclass(frozen=True, slots=True) +class PaneQuery: + """An async lazy pane query backed by the sync query object. + + Construction stays synchronous; only :meth:`all`/:meth:`first` await a + snapshot source. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import ( + ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, + ... ) + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... )) + >>> rows = asyncio.run(panes().filter(active=True).all(snapshot)) + >>> [row.pane_id.value for row in rows] + ['%1'] + """ + + query: sync_plan.PaneQuery + + def filter(self, *, active: bool) -> PaneQuery: + """Return a query filtered by active state. + + Examples + -------- + >>> panes().filter(active=True).query.active_filter + True + """ + return dataclasses.replace(self, query=self.query.filter(active=active)) + + def order_by(self, field: sync_plan.OrderField) -> PaneQuery: + """Return a query ordered by a known pane field. + + Examples + -------- + >>> panes().order_by("pane_index").query.ordering + 'pane_index' + """ + return dataclasses.replace(self, query=self.query.order_by(field)) + + def limit(self, count: int) -> PaneQuery: + """Return a query capped to ``count`` rows. + + Examples + -------- + >>> panes().limit(3).query.limit_count + 3 + """ + return dataclasses.replace(self, query=self.query.limit(count)) + + async def all(self, source: AsyncSnapshotSource) -> list[sync_plan.PaneRef]: + """Evaluate the query against an async snapshot source.""" + snapshot = await _resolve_snapshot(source) + return self.query.all(snapshot) + + async def first(self, source: AsyncSnapshotSource) -> sync_plan.PaneRef | None: + """Evaluate the query and return its first row, or ``None``.""" + snapshot = await _resolve_snapshot(source) + return self.query.first(snapshot) + + def map( + self, + mapper: t.Callable[[sync_plan.PaneRef], MappedT], + ) -> MappedPaneQuery[MappedT]: + """Return a data-only transformation query (no commands).""" + return MappedPaneQuery(query=self, mapper=mapper) + + def commands(self, mapper: sync_plan.CommandMapper) -> CommandPlan: + """Return a deferred async multi-command side-effect plan.""" + return CommandPlan(query=self, mapper=mapper) + + +@dataclass(frozen=True, slots=True) +class MappedPaneQuery(t.Generic[MappedT]): + """An async data-only query transformation over pane rows.""" + + query: PaneQuery + mapper: t.Callable[[sync_plan.PaneRef], MappedT] + + async def all(self, source: AsyncSnapshotSource) -> list[MappedT]: + """Evaluate the query and transform every row.""" + return [self.mapper(row) for row in await self.query.all(source)] + + async def first(self, source: AsyncSnapshotSource) -> MappedT | None: + """Evaluate the query and transform the first row, or ``None``.""" + row = await self.query.first(source) + if row is None: + return None + return self.mapper(row) + + +@dataclass(frozen=True, slots=True) +class CommandPlan: + """An async command plan that resolves a query into a command sequence. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import ( + ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, + ... ) + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... )) + >>> async def _demo(): + ... plan = ( + ... panes() + ... .filter(active=True) + ... .order_by("pane_index") + ... .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ... ) + ... sequence = await plan.to_chain(snapshot) + ... return sequence.argvs() + >>> asyncio.run(_demo()) + (('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) + """ + + query: PaneQuery + mapper: sync_plan.CommandMapper + + async def to_chain(self, source: AsyncSnapshotSource) -> CommandChain: + """Resolve the async query and compile mapped commands. + + Reuses the sync compile path, so a plan still produces exactly one + :class:`~libtmux._experimental.chain.ir.CommandChain`. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import PaneRef, TmuxSnapshot + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... )) + >>> async def _to_chain(): + ... plan = panes().commands(lambda p: p.cmd.resize_pane(height=10)) + ... return (await plan.to_chain(snapshot)).argvs() + >>> asyncio.run(_to_chain()) + (('resize-pane', '-t', '%1', '-y', '10'),) + """ + snapshot = await _resolve_snapshot(source) + return self.query.query.commands(self.mapper).to_chain(snapshot) + + async def run(self, runner: AsyncPlanRunner) -> None: + """Resolve, compile, and dispatch the plan in one async invocation. + + An empty plan is a no-op (it does not raise). + + Examples + -------- + Resolve and dispatch against a live session in one async invocation: + + >>> import asyncio + >>> from libtmux._experimental.chain import aio, AsyncSessionPlanExecutor + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> asyncio.run(plan.run(AsyncSessionPlanExecutor(session))) + """ + try: + sequence = await self.to_chain(runner) + except NoCommandsResolved: + return None + argv = sequence.argv() + logger.debug( + "tmux command sequence dispatched", + extra={"tmux_cmd": " ".join(argv), "tmux_subcommand": argv[0]}, + ) + result = await runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence complete", + extra={"tmux_exit_code": result.returncode}, + ) + return None + + async def run_deferred( + self, + runner: AsyncPlanRunner, + ) -> tuple[DeferredCommandResult, ...]: + r"""Async: dispatch once and return a resolved deferred result per command. + + Mirrors :meth:`CommandPlan.run_deferred + `: one ``\\;`` + dispatch, each handle resolved with the chain's merged result. An empty + plan returns an empty tuple. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain import aio, AsyncSessionPlanExecutor + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> results = asyncio.run( + ... plan.run_deferred(AsyncSessionPlanExecutor(session)) + ... ) + >>> all(r.returncode == 0 for r in results) + True + """ + try: + sequence = await self.to_chain(runner) + except NoCommandsResolved: + return () + argv = sequence.argv() + result = await runner.cmd(argv[0], *argv[1:]) + return tuple( + DeferredCommandResult(call).resolve(result) for call in sequence.calls + ) + + +def panes() -> PaneQuery: + """Start an async lazy pane query. + + Examples + -------- + >>> panes().query + PaneQuery(active_filter=None, ordering=None, limit_count=None) + """ + return PaneQuery(sync_plan.panes()) + + +async def _resolve_snapshot(source: AsyncSnapshotSource) -> sync_plan.TmuxSnapshot: + if isinstance(source, sync_plan.TmuxSnapshot): + return source + return await source.snapshot() diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py new file mode 100644 index 000000000..b2237a50c --- /dev/null +++ b/src/libtmux/_experimental/chain/_connection.py @@ -0,0 +1,248 @@ +"""Live-tmux connection helpers for chainable-commands plans. + +These bridge the typed plan layer to a real :class:`libtmux.Session`: +:func:`snapshot_from_session` reads live panes into a pure +:class:`~libtmux._experimental.chain.plan.TmuxSnapshot`, and +:class:`SessionPlanExecutor` satisfies +:class:`~libtmux._experimental.chain.plan.PlanRunner` so a +:class:`~libtmux._experimental.chain.plan.CommandPlan` resolves and +dispatches against an actual tmux server in one invocation. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import asyncio +import typing as t + +from libtmux._experimental.chain.ir import ( + Arg, + CommandResultLike, + CommandRunner, +) +from libtmux._experimental.chain.plan import ( + PaneRef, + PaneTarget, + SessionTarget, + TmuxSnapshot, + WindowTarget, +) + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +def snapshot_from_session(session: Session) -> TmuxSnapshot: + """Read a live session's panes into a pure snapshot. + + Parameters + ---------- + session : libtmux.Session + A live session to read panes from. + + Returns + ------- + TmuxSnapshot + Typed pane rows with their pane, window, and session targets. + + Examples + -------- + >>> snapshot = snapshot_from_session(session) + >>> len(snapshot.panes) >= 1 + True + >>> snapshot.panes[0].pane_id.value.startswith("%") + True + """ + rows: list[PaneRef] = [] + for pane in session.panes: + pane_id = pane.pane_id + window_id = pane.window_id + session_id = pane.session_id + # Fail closed: a missing id would render an empty ``-t ''`` target, + # which tmux resolves to the current/attached target. Skip the row + # rather than emit a target that silently mis-resolves. + if pane_id is None or window_id is None or session_id is None: + continue + rows.append( + PaneRef.concrete( + pane_id=PaneTarget(pane_id), + window_id=WindowTarget(window_id), + session_id=SessionTarget(session_id), + pane_index=int(pane.pane_index) if pane.pane_index is not None else 0, + active=pane.pane_active == "1", + title=pane.pane_title or "", + ), + ) + return TmuxSnapshot(panes=tuple(rows)) + + +class SessionPlanExecutor: + r"""A :class:`PlanRunner` backed by a live :class:`libtmux.Session`. + + Dispatches commands through ``session.server.cmd`` and resolves snapshots + via :func:`snapshot_from_session`, so a plan executes against real tmux in a + single native ``\\;`` invocation. + + Examples + -------- + >>> runner = SessionPlanExecutor(session) + >>> runner.snapshot().panes[0].pane_id.value.startswith("%") + True + + A plan resolves and dispatches once through the runner: + + >>> from libtmux._experimental.chain.plan import panes + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> plan.run(runner) + """ + + def __init__(self, session: Session) -> None: + self.session = session + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command through the live server. + + Examples + -------- + >>> SessionPlanExecutor(session).cmd( + ... "set-option", "-g", "@cc_conn_demo", "1" + ... ).returncode + 0 + """ + # A live Server already satisfies the CommandRunner protocol; the cast + # keeps the variadic dispatch cleanly typed (mypy and ty both resolve + # ``Server.cmd`` to a union otherwise). + runner = t.cast("CommandRunner", self.session.server) + return runner.cmd(cmd, *args, target=target) + + def snapshot(self) -> TmuxSnapshot: + """Return a fresh snapshot of the session's panes. + + Examples + -------- + >>> SessionPlanExecutor(session).snapshot().panes[0].pane_id.value[0] + '%' + """ + return snapshot_from_session(self.session) + + +class AsyncSessionPlanExecutor: + """An ``AsyncPlanRunner`` backed by a live :class:`libtmux.Session`. + + libtmux dispatch is synchronous, so this offloads each blocking call to a + worker thread with :func:`asyncio.to_thread`. The plan still compiles to one + native sequence and dispatches once; it simply does not block the event + loop, so independent plans can resolve and dispatch concurrently. + + Examples + -------- + >>> import asyncio + >>> runner = AsyncSessionPlanExecutor(session) + >>> async def _demo() -> bool: + ... snapshot = await runner.snapshot() + ... return snapshot.panes[0].pane_id.value.startswith("%") + >>> asyncio.run(_demo()) + True + + A plan resolves and dispatches once, without blocking the loop: + + >>> from libtmux._experimental.chain import aio + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> asyncio.run(plan.run(runner)) + """ + + def __init__(self, session: Session) -> None: + self._sync = SessionPlanExecutor(session) + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command in a worker thread.""" + return await asyncio.to_thread(self._sync.cmd, cmd, *args, target=target) + + async def snapshot(self) -> TmuxSnapshot: + """Return a fresh snapshot, read in a worker thread.""" + return await asyncio.to_thread(self._sync.snapshot) + + +class ServerPlanRunner: + """A ``PlanRunner`` backed by a live :class:`libtmux.Server`. + + For create-from-scratch plans (``ForwardPlan().new_session(...)``) that have + no -- and need no -- pre-existing session: it dispatches straight through + ``server.cmd`` instead of borrowing an unrelated session's executor. + ``snapshot()`` is empty, since a server-level runner is for creation, not + query seeding -- a query-seeded plan still wants a :class:`SessionPlanExecutor`. + + Examples + -------- + >>> runner = ServerPlanRunner(server) + >>> runner.snapshot().panes + () + >>> runner.cmd("set-option", "-g", "@cc_srv_demo", "1").returncode + 0 + """ + + def __init__(self, server: Server) -> None: + self.server = server + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command through the live server.""" + runner = t.cast("CommandRunner", self.server) + return runner.cmd(cmd, *args, target=target) + + def snapshot(self) -> TmuxSnapshot: + """Return an empty snapshot (a server runner is for creation, not queries).""" + return TmuxSnapshot(panes=()) + + +class AsyncServerPlanRunner: + """An ``AsyncPlanRunner`` backed by a live :class:`libtmux.Server`. + + The async companion to :class:`ServerPlanRunner`; offloads the blocking + dispatch via :func:`asyncio.to_thread`. + + Examples + -------- + >>> import asyncio + >>> asyncio.run(AsyncServerPlanRunner(server).snapshot()).panes + () + """ + + def __init__(self, server: Server) -> None: + self._sync = ServerPlanRunner(server) + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command in a worker thread.""" + return await asyncio.to_thread(self._sync.cmd, cmd, *args, target=target) + + async def snapshot(self) -> TmuxSnapshot: + """Return an empty snapshot in a worker thread.""" + return await asyncio.to_thread(self._sync.snapshot) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py new file mode 100644 index 000000000..c290c927f --- /dev/null +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -0,0 +1,714 @@ +r"""Multi-dispatch resolution for independent forward handles (sans-I/O core). + +A *linear* forward chain folds into one ``tmux a \; b`` invocation: tmux +addresses the active (or single marked) object with no ``-t``. But it cannot +address two **independent** forward handles in one invocation -- ``-t`` is a +fixed argv token, and a freshly-created id escapes only as ``-P -F`` stdout. So +holding several independent handles needs **multiple dispatches**: each creation +runs on its own with ``-P -F '#{pane_id}'`` to capture its real id, which is +then substituted into the downstream commands. + +The resolution is a **sans-I/O generator** -- the same yield-request / +resume-with-result trampoline asyncio itself uses (``Future.__await__`` yields a +request, ``Task.__step`` ``.send()``\\s the result back). One core; two short +drivers (sync ``runner.cmd``, async ``await runner.cmd``). The N-dispatch logic +is never duplicated, and the generator is suspended at a ``yield`` between +dispatches, so it never blocks the event loop. + +Note +---- +This is an **experimental** prototype, not covered by the versioning policy. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux._experimental.chain.chain import ensure_chainable +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandChain, + CommandResultLike, + SlotRef, +) +from libtmux._experimental.chain.plan import ( + AnyTarget, + BoundPaneCommands, + BoundSessionCommands, + BoundWindowCommands, + PaneQuery, + PaneRef, + PaneTarget, + SessionRef, + SessionTarget, + WindowRef, + WindowTarget, + _target_arg, + _to_calls, +) + +if t.TYPE_CHECKING: + import collections.abc as cabc + + from libtmux._experimental.chain._async import AsyncPlanRunner + from libtmux._experimental.chain.plan import IntoCommands, PlanRunner + from libtmux.pane import Pane + from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window + +# A live libtmux object, a chain ref, a typed target, or a bare id string. +PaneSeed: t.TypeAlias = "Pane | PaneRef | PaneTarget | str" +WindowSeed: t.TypeAlias = "Window | WindowRef | WindowTarget | str" +SessionSeed: t.TypeAlias = "Session | SessionRef | SessionTarget | str" + +Kind = t.Literal["pane", "window", "session"] +_CAPTURE: dict[Kind, str] = { + "pane": "#{pane_id}", + "window": "#{window_id}", + "session": "#{session_id}", +} +_SEED = -1 # reserved binding key for a query-resolved seed +_MARKED = "{marked}" # tmux's single server-wide marked-pane target token + + +class NoSeedResolved(RuntimeError): + """Raised when a query-seeded forward plan matches no pane.""" + + +class ForwardDispatchError(RuntimeError): + r"""A forward creation dispatch failed -- nonzero exit or no captured id. + + Raised by the resolver when a ``split``/``new-window``/``new-session`` did + not print the id it was asked to capture (tmux rejected the target, ran out + of space, etc.). It turns what was an opaque ``IndexError`` on empty stdout + into a clear failure carrying the offending ``argv`` and the tmux result. + + Examples + -------- + >>> class _Result: + ... stdout: list[str] = [] + ... stderr = ["no space for new pane"] + ... returncode = 1 + >>> err = ForwardDispatchError(("split-window", "-t", "%1"), _Result()) + >>> err.argv + ('split-window', '-t', '%1') + >>> print(str(err)) + forward dispatch failed (exit 1): split-window -t %1: no space for new pane + """ + + def __init__(self, argv: tuple[str, ...], result: CommandResultLike) -> None: + self.argv = argv + self.result = result + stderr = " ".join(result.stderr).strip() + detail = f": {stderr}" if stderr else "" + cmd = " ".join(argv) + msg = f"forward dispatch failed (exit {result.returncode}): {cmd}{detail}" + super().__init__(msg) + + +def _capture_id(argv: tuple[str, ...], result: CommandResultLike) -> str: + """Read the id a creation dispatch printed, or fail loudly.""" + if result.returncode != 0 or not result.stdout: + raise ForwardDispatchError(argv, result) + return result.stdout[0].strip() + + +# --- plan IR ---------------------------------------------------------------- +@dataclass(frozen=True, slots=True) +class _Create: + """A creation step; ``call.target`` is concrete, ``None``, or a parent SlotRef.""" + + slot: int + kind: Kind + call: CommandCall + + +@dataclass(frozen=True, slots=True) +class _Decorate: + """A decoration step; ``call.target`` may be a SlotRef into an earlier slot.""" + + call: CommandCall + + +_Step: t.TypeAlias = "_Create | _Decorate" + + +# --- the sans-I/O protocol -------------------------------------------------- +@dataclass(frozen=True, slots=True) +class SnapshotRequest: + """The driver must supply a tmux snapshot (sync or awaited).""" + + +@dataclass(frozen=True, slots=True) +class Dispatch: + """A tmux dispatch the driver runs, handing back its result. + + ``captures`` names the forward slot this dispatch creates an id for + (``None`` for a decorate- or cleanup-only dispatch); the core binds + ``stdout[0]`` to that slot once the driver returns the result. + """ + + argv: tuple[str, ...] + captures: int | None + + +Request: t.TypeAlias = "SnapshotRequest | Dispatch" + + +@dataclass(frozen=True, slots=True) +class Resolved: + """The outcome of a multi-dispatch resolution. + + ``bindings`` maps each forward slot to the concrete id tmux assigned + (``%N``/``@N``/``$N``); ``results`` holds each resolution dispatch's + result in order (a recovery ``select-pane -M`` after a failed marked + fold is not captured). + """ + + bindings: dict[int, str] = field(default_factory=dict) + results: tuple[CommandResultLike, ...] = () + + def pane(self, slot: int, server: Server) -> Pane: + """Look up the live pane a forward slot resolved to (by captured id).""" + pane = server.panes.get(pane_id=self.bindings[slot]) + assert pane is not None # get() raises ObjectDoesNotExist rather than None + return pane + + def window(self, slot: int, server: Server) -> Window: + """Look up the live window a forward slot resolved to (by captured id).""" + window = server.windows.get(window_id=self.bindings[slot]) + assert window is not None + return window + + def session(self, slot: int, server: Server) -> Session: + """Look up the live session a forward slot resolved to (by captured id).""" + session = server.sessions.get(session_id=self.bindings[slot]) + assert session is not None + return session + + +def _capturing(call: CommandCall, kind: Kind) -> CommandCall: + """Append ``-P -F '#{_id}'`` so the creation prints its stable id.""" + return dataclasses.replace(call, args=(*call.args, "-P", "-F", _CAPTURE[kind])) + + +def _with_capture(call: CommandCall, kind: Kind) -> tuple[str, ...]: + """Render a capturing creation as argv (the multi-dispatch per-step form).""" + return _capturing(call, kind).argv() + + +def _subst(call: CommandCall, bindings: dict[int, str]) -> CommandCall: + """Replace a SlotRef target with the captured concrete id (plus its suffix).""" + if isinstance(call.target, SlotRef): + resolved = bindings[call.target.slot] + call.target.suffix + return dataclasses.replace(call, target=resolved) + return call + + +# --- strategy: a lone pane handle folds into one {marked} dispatch ---------- +def _to_marked(call: CommandCall) -> CommandCall: + """Retarget a SlotRef call to tmux's ``{marked}`` register (single-dispatch).""" + if isinstance(call.target, SlotRef): + return dataclasses.replace(call, target=_MARKED) + return call + + +def _marked_eligible(steps: tuple[_Step, ...]) -> _Create | None: + """Return the lone pane creation when the plan folds into one dispatch. + + The marked register is a single server-wide slot, and only a non-detached + pane creation (``split-window``) leaves its result active to be marked. So + exactly one pane :class:`_Create` with no preceding decoration is the one + plan shape that resolves in a single ``{marked}`` invocation; any other shape + needs the multi-dispatch path. + """ + creates = [step for step in steps if isinstance(step, _Create)] + if len(creates) != 1 or creates[0].kind != "pane": + return None + create = creates[0] + for step in steps: + if step is create: + return create + if isinstance(step, _Decorate): + return None + return create + + +def _marked_invocation( + create: _Create, + decorates: tuple[CommandCall, ...], + bindings: dict[int, str], +) -> tuple[str, ...]: + r"""Fold a lone pane creation and its decorates into one ``\;`` invocation. + + Emits `` \; select-pane -m \; ... \; select-pane -M``: the split's new pane is active, ``-m`` + marks it, every decorate addresses it through tmux's ``{marked}`` register + (which resolves for window- and session-scoped decorates too), and a trailing + ``-M`` clears the register. Should the chain fail after the mark is set, + :func:`drive` issues a recovery ``-M``, so no server-wide mark leaks. With no + decorates only the capturing creation runs -- the mark would have no reader. + """ + capture = _capturing(_subst(create.call, bindings), create.kind) + if not decorates: + return capture.argv() + calls = [capture, CommandCall("select-pane", ("-m",))] + calls.extend(_to_marked(call) for call in decorates) + calls.append(CommandCall("select-pane", ("-M",))) + return CommandChain(tuple(calls)).argv() + + +def drive( + steps: tuple[_Step, ...], + *, + seed_query: PaneQuery | None = None, + allow_marked: bool = True, +) -> t.Generator[Request, t.Any, Resolved]: + r"""Sans-I/O resolution core: yield a :class:`Request`, resume via ``.send``. + + The plan shape picks the cheapest correct strategy (see :func:`_marked_eligible`): + a lone pane creation folds into **one** ``{marked}`` invocation; otherwise each + :class:`_Create` is dispatched alone with ``-P -F`` id capture and a run of + :class:`_Decorate`\\s folds into one trailing ``\;`` chain with the captured ids + substituted. No awaits, no runner, no threads -- a pure state machine the + sync/async drivers feed results into. ``allow_marked=False`` forbids the + single-dispatch ``{marked}`` fold, so the resolver never touches tmux's + server-wide marked pane. + """ + bindings: dict[int, str] = {} + results: list[CommandResultLike] = [] + tail: list[CommandCall] = [] + + if seed_query is not None: + snapshot = yield SnapshotRequest() + seed = seed_query.first(snapshot) + if seed is None: + msg = "query matched no pane to seed the forward plan" + raise NoSeedResolved(msg) + bindings[_SEED] = str(_target_arg(seed.pane_id)) + + solo = _marked_eligible(steps) if allow_marked else None + if solo is not None: + decorates = tuple(s.call for s in steps if isinstance(s, _Decorate)) + argv = _marked_invocation(solo, decorates, bindings) + result = yield Dispatch(argv, solo.slot) + if decorates and result.returncode != 0 and result.stdout: + yield Dispatch(("select-pane", "-M"), None) + bindings[solo.slot] = _capture_id(argv, result) + return Resolved(bindings, (result,)) + + for step in steps: + if isinstance(step, _Create): + if tail: + chain = CommandChain(tuple(_subst(c, bindings) for c in tail)) + results.append((yield Dispatch(chain.argv(), None))) + tail = [] + argv = _with_capture(_subst(step.call, bindings), step.kind) + result = yield Dispatch(argv, step.slot) + results.append(result) + bindings[step.slot] = _capture_id(argv, result) + else: + tail.append(step.call) + + if tail: + chain = CommandChain(tuple(_subst(c, bindings) for c in tail)) + results.append((yield Dispatch(chain.argv(), None))) + + return Resolved(bindings, tuple(results)) + + +# --- the two thin drivers (the only sync/async divergence) ------------------ +def run_sync( + gen: t.Generator[Request, t.Any, Resolved], runner: PlanRunner +) -> Resolved: + """Drive the resolution core with blocking calls.""" + try: + request = next(gen) + while True: + if isinstance(request, SnapshotRequest): + request = gen.send(runner.snapshot()) + else: + request = gen.send(runner.cmd(request.argv[0], *request.argv[1:])) + except StopIteration as stop: + return t.cast("Resolved", stop.value) + + +async def run_async( + gen: t.Generator[Request, t.Any, Resolved], + runner: AsyncPlanRunner, +) -> Resolved: + """Drive the *same* core with ``await`` -- no resolution logic duplicated.""" + try: + request = next(gen) + while True: + if isinstance(request, SnapshotRequest): + request = gen.send(await runner.snapshot()) + else: + result = await runner.cmd(request.argv[0], *request.argv[1:]) + request = gen.send(result) + except StopIteration as stop: + return t.cast("Resolved", stop.value) + + +# --- the builder + handles -------------------------------------------------- +def _location_args( + start_directory: str | None, environment: dict[str, str] | None +) -> tuple[str, ...]: + """Render create-time ``-c`` / ``-e=`` flags (as libtmux renders them). + + Examples + -------- + >>> _location_args("/tmp", {"FOO": "bar"}) + ('-c/tmp', '-eFOO=bar') + >>> _location_args(None, None) + () + """ + args: list[str] = [] + if start_directory is not None: + args.append(f"-c{start_directory}") + if environment: + args.extend(f"-e{key}={value}" for key, value in environment.items()) + return tuple(args) + + +def _split_args( + horizontal: bool, + shell: str | None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, +) -> tuple[str, ...]: + """Render the ``split-window`` flags shared by the plan- and handle-level split.""" + args = ["-h" if horizontal else "-v", *_location_args(start_directory, environment)] + if shell is not None: + args.append(shell) + return tuple(args) + + +def _id_of(obj: object, attr: str) -> str: + """Read a tmux id string from a live libtmux object, a chain ref, or a str. + + Examples + -------- + >>> _id_of("%1", "pane_id") + '%1' + >>> _id_of(PaneTarget("%2"), "pane_id") + '%2' + >>> _id_of(PaneRef.concrete(pane_id="%3", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title=""), "pane_id") + '%3' + """ + if isinstance(obj, str): + return obj + if isinstance(obj, (PaneTarget, WindowTarget, SessionTarget)): + return obj.value + value = getattr(obj, attr) + if isinstance(value, (PaneTarget, WindowTarget, SessionTarget)): + return value.value + return str(value) + + +def _kind_of(target: AnyTarget) -> Kind: + """Return the tmux scope a concrete seed target addresses.""" + if isinstance(target, PaneTarget): + return "pane" + if isinstance(target, WindowTarget): + return "window" + if isinstance(target, SessionTarget): + return "session" + msg = f"cannot seed a forward plan from {type(target).__name__}" + raise TypeError(msg) + + +class ForwardHandle: + """A reference to one object inside a :class:`ForwardPlan` -- forward or seed. + + One type spans all three tmux scopes and both lifetimes: a *forward* handle + is bound to a :class:`~libtmux._experimental.chain.ir.SlotRef` (a not-yet- + created object whose id the resolver substitutes); a *seed* handle is bound + to a concrete id string (an object that already exists). The handle knows its + ``kind`` so creation verbs stay scope-correct -- ``new_window()`` only on a + session, ``split()`` only on a pane or window -- while the ``.cmd``/ + ``.window``/``.session`` command namespaces are reused unchanged. + """ + + def __init__(self, plan: ForwardPlan, ref: SlotRef | str, kind: Kind) -> None: + self._plan = plan + self._ref = ref + self._kind = kind + + @property + def cmd(self) -> BoundPaneCommands: + """Pane-scoped commands bound to this handle.""" + ref = self._ref if isinstance(self._ref, SlotRef) else PaneTarget(self._ref) + return BoundPaneCommands(ref) + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands (a pane id resolves up to its window).""" + ref = self._ref if isinstance(self._ref, SlotRef) else WindowTarget(self._ref) + return BoundWindowCommands(ref) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this handle's session.""" + ref = self._ref if isinstance(self._ref, SlotRef) else SessionTarget(self._ref) + return BoundSessionCommands(ref) + + def _parent(self, suffix: str = "") -> str | int | None | SlotRef: + """Return the ``-t`` parent for a creation off this handle (slot or id).""" + if isinstance(self._ref, SlotRef): + return SlotRef(self._ref.slot, suffix) + return f"{self._ref}{suffix}" if suffix else self._ref + + def split( + self, + *, + horizontal: bool = False, + shell: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + ) -> ForwardHandle: + """Split this handle's active pane; return a handle to the new pane.""" + self._require("split", "pane", "window") + return self._plan._create( + self._parent(), + "pane", + "split-window", + _split_args(horizontal, shell, start_directory, environment), + ) + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + window_shell: str | None = None, + ) -> ForwardHandle: + """Create a window in this session handle; return a window handle. + + Targets the session as ``-t $N:`` -- the (captured or concrete) session + id with a ``:`` suffix, so it addresses a new window in that session. + """ + self._require("new_window", "session") + args: list[str] = [] + if name is not None: + args += ["-n", name] + args.extend(_location_args(start_directory, environment)) + if window_shell is not None: + args.append(window_shell) + return self._plan._create( + self._parent(":"), "window", "new-window", tuple(args) + ) + + @property + def initial_pane(self) -> ForwardHandle: + """Return a pane handle on this session's initial (default) pane. + + A detached ``new-session`` is born with one window and pane that the + plan otherwise can't address; this hands back a pane handle bound to the + session (which resolves to its active pane), so the default window can be + decorated or split instead of orphaned. Session handles only. + """ + if self._kind != "session": + msg = f"initial_pane is only on a session handle, not a {self._kind} handle" + raise TypeError(msg) + return ForwardHandle(self._plan, self._ref, "pane") + + @property + def initial_window(self) -> ForwardHandle: + """Return a window handle on this session's initial (default) window.""" + if self._kind != "session": + msg = ( + f"initial_window is only on a session handle, not a {self._kind} handle" + ) + raise TypeError(msg) + return ForwardHandle(self._plan, self._ref, "window") + + def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: + """Decorate this handle via its namespaces (reused, no new vocabulary).""" + calls = _to_calls(build(self)) + for call in calls: + ensure_chainable(call.name) + self._plan._steps.extend(_Decorate(call) for call in calls) + return self + + def _require(self, verb: str, *kinds: Kind) -> None: + """Reject a creation verb used on a handle of the wrong tmux scope.""" + if self._kind not in kinds: + allowed = " or ".join(kinds) + msg = f"{verb}() needs a {allowed} handle, not a {self._kind} handle" + raise TypeError(msg) + + +class ForwardPlan: + r"""A builder for a multi-handle forward plan, resolved over N dispatches. + + Hand out independent handles across every tmux scope -- :meth:`new_session`, + then :meth:`ForwardHandle.new_window`, then :meth:`split` -- decorate each + through its reused namespaces, then :meth:`run_resolving` (sync) or + :meth:`run_resolving_async`: one creation per dispatch (``-P -F`` capture), + the downstream commands folded into one trailing ``\;`` chain with the + captured ids substituted in. + + Examples + -------- + Two independent panes, resolved over three dispatches against a fake server + that hands back fabricated pane ids: + + >>> from libtmux._experimental.chain.plan import PaneTarget + >>> class _FakeServer: + ... count = 6 + ... def cmd(self, *argv): + ... _FakeServer.count += 1 + ... line = [f"%{_FakeServer.count}"] + ... return type("R", (), {"stdout": line, "stderr": [], "returncode": 0})() + >>> plan = ForwardPlan(PaneTarget("%1")) + >>> left, right = plan.split(horizontal=True), plan.split() + >>> _ = left.do(lambda h: h.cmd.send_keys("vim", enter=True)) + >>> _ = right.do(lambda h: h.cmd.send_keys("htop", enter=True)) + >>> plan.run_resolving(_FakeServer()).bindings + {0: '%7', 1: '%8'} + """ + + def __init__(self, seed: AnyTarget | None = None) -> None: + self._steps: list[_Step] = [] + self._n = 0 + self._seed = seed + self._seed_query: PaneQuery | None = None + + @classmethod + def from_pane(cls, pane: PaneSeed) -> ForwardPlan: + """Seed from an existing pane (a live ``Pane``, a ref, a target, or an id).""" + return cls(seed=PaneTarget(_id_of(pane, "pane_id"))) + + @classmethod + def from_window(cls, window: WindowSeed) -> ForwardPlan: + """Seed from an existing window -- ``split`` splits its active pane.""" + return cls(seed=WindowTarget(_id_of(window, "window_id"))) + + @classmethod + def from_session(cls, session: SessionSeed) -> ForwardPlan: + """Seed from an existing session -- ``new_window`` adds windows to it.""" + return cls(seed=SessionTarget(_id_of(session, "session_id"))) + + @classmethod + def from_query(cls, query: PaneQuery) -> ForwardPlan: + """Seed the plan from the first row of a live query (read at run time).""" + plan = cls(seed=None) + plan._seed_query = query + return plan + + @property + def seed(self) -> ForwardHandle: + """A handle to the existing seed object -- decorate it or create off it. + + Lets the pre-existing seed take part in the plan like a created handle: + ``plan.seed.do(...)`` decorates it, and (by scope) ``plan.seed.split()`` / + ``plan.seed.new_window()`` create children of it. + """ + if self._seed is None: + msg = "plan has no concrete seed (use from_pane/from_window/from_session)" + raise ValueError(msg) + return ForwardHandle(self, str(_target_arg(self._seed)), _kind_of(self._seed)) + + def _seed_target(self) -> str | int | None | SlotRef: + if self._seed_query is not None: + return SlotRef(_SEED) + if self._seed is None: + return None + return _target_arg(self._seed) + + def _create( + self, + parent: str | int | None | SlotRef, + kind: Kind, + name: str, + args: tuple[str, ...], + ) -> ForwardHandle: + slot = self._n + self._n += 1 + self._steps.append(_Create(slot, kind, CommandCall(name, args, target=parent))) + return ForwardHandle(self, SlotRef(slot), kind) + + def split( + self, + *, + horizontal: bool = False, + shell: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + ) -> ForwardHandle: + """Split the seed (root); return a handle to the new pane.""" + return self._create( + self._seed_target(), + "pane", + "split-window", + _split_args(horizontal, shell, start_directory, environment), + ) + + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + width: int | None = None, + height: int | None = None, + ) -> ForwardHandle: + """Create a detached session; return a session handle.""" + args: list[str] = ["-d"] + if name is not None: + args += ["-s", name] + args.extend(_location_args(start_directory, environment)) + if width is not None: + args += ["-x", str(width)] + if height is not None: + args += ["-y", str(height)] + return self._create(None, "session", "new-session", tuple(args)) + + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + window_shell: str | None = None, + ) -> ForwardHandle: + """Create a window in the seed session (requires :meth:`from_session`).""" + return self.seed.new_window( + name=name, + start_directory=start_directory, + environment=environment, + window_shell=window_shell, + ) + + def run_resolving( + self, runner: PlanRunner, *, preserve_mark: bool = False + ) -> Resolved: + """Resolve over N dispatches against a live server (sync). + + ``preserve_mark=True`` skips the single-dispatch ``{marked}`` fold (which + transiently sets then clears tmux's server-wide marked pane), so resolving + against a user's live server does not clobber a mark they had set. + """ + gen = drive( + tuple(self._steps), + seed_query=self._seed_query, + allow_marked=not preserve_mark, + ) + return run_sync(gen, runner) + + async def run_resolving_async( + self, runner: AsyncPlanRunner, *, preserve_mark: bool = False + ) -> Resolved: + """Resolve over N dispatches against a live server (async, same core).""" + gen = drive( + tuple(self._steps), + seed_query=self._seed_query, + allow_marked=not preserve_mark, + ) + return await run_async(gen, runner) diff --git a/src/libtmux/_experimental/chain/chain.py b/src/libtmux/_experimental/chain/chain.py new file mode 100644 index 000000000..f2319442c --- /dev/null +++ b/src/libtmux/_experimental/chain/chain.py @@ -0,0 +1,222 @@ +"""The chainability contract: what may fold into one dispatch. + +A tmux command sequence is dispatched once, so a command may only join a chain +if its output is **not** consumed mid-chain. This module wires the two halves of +that rule together: + +- *static* -- a :class:`~libtmux._experimental.chain.ir.CommandSpec` + per command declares ``chainable`` (see :data:`COMMAND_SPECS` / + :func:`is_chainable`). Output commands such as ``show-option`` are + ``chainable=False``. +- *dynamic* -- :class:`DeferredCommandResult` stands in for a call folded into a + chain. It raises :class:`DeferredOutputUnavailable` if its output is read + before the chain runs, and resolves to the chain's merged result afterwards. + :meth:`~libtmux._experimental.chain.plan.CommandPlan.run_deferred` (and its + async counterpart) dispatch once and hand back resolved deferred results. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandResultLike, + CommandScope, + CommandSpec, +) + +COMMAND_SPECS: dict[str, CommandSpec] = { + "new-session": CommandSpec("new-session", "server"), + "new-window": CommandSpec("new-window", "session"), + "split-window": CommandSpec("split-window", "pane"), + "break-pane": CommandSpec("break-pane", "pane"), + "rename-window": CommandSpec("rename-window", "window"), + "rename-session": CommandSpec("rename-session", "session"), + "select-layout": CommandSpec("select-layout", "window"), + "select-pane": CommandSpec("select-pane", "pane"), + "select-window": CommandSpec("select-window", "window"), + "send-keys": CommandSpec("send-keys", "pane"), + "resize-pane": CommandSpec("resize-pane", "pane"), + "set-option": CommandSpec("set-option", "server"), + "set-environment": CommandSpec("set-environment", "session"), + # Output commands cannot fold into a chain -- they need stdout immediately. + "show-option": CommandSpec("show-option", "server", chainable=False), + "capture-pane": CommandSpec("capture-pane", "pane", chainable=False), + "display-message": CommandSpec("display-message", "server", chainable=False), +} +"""Known command metadata, including each command's ``chainable`` flag.""" + + +COMMAND_TARGET_SCOPES: dict[str, frozenset[CommandScope]] = { + "display-message": frozenset(("server", "session", "window", "pane")), + "set-option": frozenset(("server", "session", "window", "pane")), + "show-option": frozenset(("server", "session", "window", "pane")), + "split-window": frozenset(("window", "pane")), +} +"""Commands whose accepted typed target scopes differ from their primary scope.""" + + +class DeferredOutputUnavailable(RuntimeError): + """Raised when a deferred command result is inspected before dispatch.""" + + +class ChainabilityError(RuntimeError): + """Raised when a non-chainable command is added to a chain.""" + + +class CommandScopeError(RuntimeError): + """Raised when a known command is bound to the wrong typed target scope.""" + + +def is_chainable(name: str) -> bool: + """Return whether a command may fold into a one-dispatch chain. + + Unknown commands fail closed. Commands in :data:`COMMAND_SPECS` use their + declared ``chainable`` flag. + + Examples + -------- + >>> is_chainable("rename-window") + True + >>> is_chainable("show-option") + False + >>> is_chainable("some-unknown-command") + False + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + return False + return spec.chainable + + +def ensure_chainable(name: str) -> None: + """Raise unless ``name`` is a known command that may fold into a chain. + + Examples + -------- + >>> ensure_chainable("rename-window") + >>> ensure_chainable("show-option") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...ChainabilityError: command 'show-option' is not chainable... + >>> ensure_chainable("some-unknown-command") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...ChainabilityError: unknown tmux command 'some-unknown-command'... + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + msg = ( + f"unknown tmux command {name!r} cannot be folded into " + "a one-dispatch sequence" + ) + raise ChainabilityError(msg) + if not spec.chainable: + msg = ( + f"command {name!r} is not chainable and cannot be " + f"folded into a one-dispatch sequence" + ) + raise ChainabilityError(msg) + + +def validate_command_scope(name: str, target_scope: CommandScope) -> None: + """Raise if a known command cannot target ``target_scope``. + + Unknown commands are left to :func:`ensure_chainable`; raw + :class:`CommandCall` targets are opaque and intentionally not parsed. + + Examples + -------- + >>> validate_command_scope("rename-window", "window") + >>> validate_command_scope("rename-window", "pane") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...CommandScopeError: command 'rename-window' cannot target pane... + >>> validate_command_scope("some-unknown-command", "pane") + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + return + allowed = COMMAND_TARGET_SCOPES.get(name, frozenset((spec.scope,))) + if target_scope not in allowed: + msg = f"command {name!r} cannot target {target_scope} scope" + raise CommandScopeError(msg) + + +@dataclass(frozen=True, slots=True) +class DeferredCommandResult: + r"""A result handle for a call folded into a one-dispatch chain. + + A chained call has no result of its own until the chain runs: a ``\\;`` + sequence dispatches once and returns a single merged result. While + ``result`` is ``None`` the handle is *unresolved* and reading output raises + :class:`DeferredOutputUnavailable`; once resolved it hands back the chain's + merged result -- the same result for every call in the chain, since a + ``\\;`` dispatch is not separable per command. + + Examples + -------- + Unresolved -- the value does not exist yet: + + >>> pending = DeferredCommandResult(CommandCall("rename-window", ("work",))) + >>> try: + ... pending.stdout + ... except DeferredOutputUnavailable: + ... print("unavailable until the chain runs") + unavailable until the chain runs + + Resolved -- the chain's merged result is handed back: + + >>> class _Merged: + ... stdout, stderr, returncode = [], [], 0 + >>> done = pending.resolve(_Merged()) + >>> done.returncode + 0 + """ + + call: CommandCall + result: CommandResultLike | None = None + + def resolve(self, result: CommandResultLike) -> DeferredCommandResult: + """Return a resolved copy bound to the chain's merged result. + + Examples + -------- + >>> class _Merged: + ... stdout, stderr, returncode = ["ok"], [], 0 + >>> DeferredCommandResult( + ... CommandCall("rename-window", ("work",)) + ... ).resolve(_Merged()).stdout + ['ok'] + """ + return DeferredCommandResult(self.call, result) + + @property + def stdout(self) -> list[str]: + """Chain stdout once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command output is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.stdout + + @property + def stderr(self) -> list[str]: + """Chain stderr once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command errors are unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.stderr + + @property + def returncode(self) -> int: + """Chain return code once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command status is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.returncode diff --git a/src/libtmux/_experimental/chain/control.py b/src/libtmux/_experimental/chain/control.py new file mode 100644 index 000000000..6ffb007ba --- /dev/null +++ b/src/libtmux/_experimental/chain/control.py @@ -0,0 +1,475 @@ +"""Control-mode runner for experimental chain commands. + +This module keeps the control-mode surface scoped to +``libtmux._experimental.chain``. It borrows the persistent ``tmux -C`` + +batched-stdin shape from the protocol-engines experiments without installing a +general engine registry into this branch. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +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.chain.ir import Arg, CommandCall, CommandChain + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from libtmux.server import Server + +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 +_NOTIFICATION_PREFIXES = ( + b"%extended-output ", + b"%output ", + b"%pause ", + b"%continue ", + b"%session-changed ", + b"%client-session-changed ", + b"%session-renamed ", + b"%sessions-changed", + b"%session-window-changed ", + b"%window-add ", + b"%window-close ", + b"%window-renamed ", + b"%window-pane-changed ", + b"%pane-mode-changed ", + b"%unlinked-window-add ", + b"%unlinked-window-close ", + b"%unlinked-window-renamed ", + b"%paste-buffer-changed ", + b"%paste-buffer-deleted ", + b"%client-detached ", + b"%subscription-changed ", + b"%exit", + b"%message ", +) + + +class ControlModeError(exc.LibTmuxException): + """The experimental control-mode runner failed.""" + + +@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: + """I/O-free parser for the subset of control mode needed by chains.""" + + __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 + + prefix = _ERROR_PREFIX if line.startswith(_ERROR_PREFIX) else _END_PREFIX + number, _flags = _parse_guard(line, prefix) + if number is not None and number != pending.number: + logger.warning( + "control-mode close guard number mismatch", + extra={ + "tmux_cm_block_id": pending.number, + "tmux_cm_close_id": number, + }, + ) + + self._blocks.append( + ControlModeBlock( + number=pending.number, + flags=pending.flags, + is_error=line.startswith(_ERROR_PREFIX), + body=tuple(pending.body), + ), + ) + + +@dataclasses.dataclass(slots=True) +class ControlModeResult: + """Result shape returned by :class:`ControlModeRunner`.""" + + stdout: list[str] + stderr: list[str] + returncode: int + + +class ControlModeRunner: + """Persistent ``tmux -C`` runner for experimental command chains. + + The runner batches independent command lines over one control-mode + connection. Unlike a native ``;`` chain, each line returns its own + ``%begin``/``%end`` block, so callers can keep per-command stdout and + return codes while avoiding per-command subprocess startup. + """ + + def __init__( + self, + server: Server, + *, + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self.server = server + 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 cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> ControlModeResult: + """Dispatch one command through the persistent control client.""" + call = CommandCall(name=cmd, args=tuple(args), target=target) + return self.run_calls([call])[0] + + def run_calls(self, calls: Sequence[CommandCall]) -> list[ControlModeResult]: + """Dispatch command calls as one control-mode batch.""" + return self.run_argvs([call.argv() for call in calls]) + + def run_chain(self, chain: CommandChain) -> list[ControlModeResult]: + """Dispatch a ``CommandChain`` with one result per contained call.""" + return self.run_calls(chain.calls) + + def run_argvs(self, argvs: Sequence[Sequence[Arg]]) -> list[ControlModeResult]: + """Dispatch rendered tmux argv rows as one control-mode batch.""" + if not argvs: + return [] + + rendered = [tuple(str(arg) for arg in argv) for argv in argvs] + with self._lock: + self._ensure_started() + payload = b"".join( + (shlex.join(argv) + "\n").encode("utf-8") for argv in rendered + ) + self._write(payload) + blocks = self._read_blocks(len(rendered)) + + return [_result_from_block(block) for block in blocks] + + def close(self) -> None: + """Close the control-mode subprocess. + + Acquires the run lock so teardown never closes the selector out from + under an in-flight :meth:`run_argvs` reading on another thread. + """ + 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.write(b"\n") + proc.stdin.flush() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + 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) -> ControlModeRunner: + """Return this runner.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the control-mode subprocess.""" + 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.server.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 _server_args(self) -> list[str]: + args: list[str] = [] + if self.server.socket_name: + args.extend(("-L", str(self.server.socket_name))) + if self.server.socket_path: + args.extend(("-S", str(self.server.socket_path))) + if self.server.config_file: + args.extend(("-f", str(self.server.config_file))) + if self.server.colors == 256: + args.append("-2") + elif self.server.colors == 88: + args.append("-8") + elif self.server.colors is not None: + raise exc.UnknownColorOption + return args + + 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 = ( + "tmux control-mode timed out after " + f"{self.timeout}s 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 + assert proc is not None + assert proc.stdout is not None + 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 + assert proc is not None + assert proc.stderr is not None + 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("utf-8", errors="replace")]}, + ) + + +def _parse_guard( + line: bytes, + prefix: bytes, +) -> tuple[int | None, int | None]: + rest = line[len(prefix) :] + parts = rest.split() + if len(parts) < 3: + return (None, None) + try: + number = int(parts[1]) + flags = int(parts[2]) + except ValueError: + return (None, None) + return (number, flags) + + +def _matches_pending_close(line: bytes, pending_number: int) -> bool: + if line.startswith(_END_PREFIX): + number, _flags = _parse_guard(line, _END_PREFIX) + return number == pending_number + if line.startswith(_ERROR_PREFIX): + number, _flags = _parse_guard(line, _ERROR_PREFIX) + return number == pending_number + return False + + +def _result_from_block(block: ControlModeBlock) -> ControlModeResult: + lines = [line.decode("utf-8", errors="replace") for line in block.body] + if block.is_error: + return ControlModeResult(stdout=[], stderr=_trim_lines(lines), returncode=1) + return ControlModeResult(stdout=_trim_lines(lines), stderr=[], returncode=0) + + +def _trim_lines(lines: list[str]) -> list[str]: + trimmed = list(lines) + while trimmed and not trimmed[-1].strip(): + trimmed.pop() + return trimmed + + +def _wait_for_exit(proc: subprocess.Popen[bytes], timeout: float) -> bool: + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return False + return True + + +__all__ = [ + "ControlModeBlock", + "ControlModeError", + "ControlModeParser", + "ControlModeResult", + "ControlModeRunner", +] diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py new file mode 100644 index 000000000..8bf9d4f0f --- /dev/null +++ b/src/libtmux/_experimental/chain/ir.py @@ -0,0 +1,382 @@ +r"""Immutable argv intermediate representation for tmux command sequences. + +This module is the substrate for the chainable-commands API. A +:class:`CommandCall` is one typed tmux command before dispatch; a +:class:`CommandChain` is an ordered group of calls that renders to a single +native ``tmux ... \\; ...`` invocation and dispatches once through a +:class:`CommandRunner` (which a live :class:`libtmux.Server` already satisfies). + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import logging +import typing as t +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +Arg: t.TypeAlias = "str | int" +"""A single tmux argument token. Integers are rendered with :func:`str`.""" + +CommandScope: t.TypeAlias = t.Literal["server", "session", "window", "pane"] +"""The tmux object scope a command targets.""" + + +class CommandResultLike(t.Protocol): + """Result protocol matching the libtmux command-result surface. + + A live :class:`libtmux.common.tmux_cmd` satisfies this protocol, as does + any object exposing ``stdout``/``stderr`` line lists and a ``returncode``. + """ + + stdout: list[str] + stderr: list[str] + returncode: int + + +class CommandRunner(t.Protocol): + """Object capable of dispatching one tmux command argv. + + A live :class:`libtmux.Server` already matches this protocol via its + ``cmd()`` method, so sequences can be dispatched without an adapter for the + common case. Object-level ``cmd()`` wrappers such as ``Session.cmd()`` add + their own target context; use ``session.server`` or + :class:`~libtmux._experimental.chain._connection.SessionPlanExecutor` for + composed command sequences. + """ + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch a single tmux command and return its result.""" + ... + + +@dataclass(frozen=True, slots=True) +class CommandSpec: + """Static metadata describing a tmux command. + + Parameters + ---------- + name : str + The tmux command name, e.g. ``"rename-window"``. + scope : CommandScope + The tmux object scope the command targets. + chainable : bool + Whether the command may be folded into a one-dispatch sequence. A + command is chainable only when its output is not consumed mid-chain; + commands that must return output immediately (e.g. ``show-option``) set + this to ``False``. + + Examples + -------- + >>> CommandSpec(name="rename-window", scope="window") + CommandSpec(name='rename-window', scope='window', chainable=True) + >>> CommandSpec(name="show-option", scope="server", chainable=False).chainable + False + """ + + name: str + scope: CommandScope + chainable: bool = True + + +@dataclass(frozen=True, slots=True) +class SlotRef: + r"""An unresolved target: "the id of forward slot N", filled at resolve time. + + Carried by a :class:`CommandCall` built against a forward handle in a + multi-dispatch plan. The resolver replaces it with the captured concrete id + (``%N``/``@N``/``$N``) plus ``suffix`` before the call is rendered; rendering + an unresolved SlotRef is a planner bug and raises. ``suffix`` lets a command + that needs 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 = "" + + +@dataclass(frozen=True, slots=True) +class CommandCall: + """One typed tmux command call before subprocess dispatch. + + Parameters + ---------- + name : str + The tmux command name. + args : tuple[Arg, ...] + Positional argument tokens, rendered in order after the target. + target : str | int | None + Optional ``-t`` target inserted immediately after the command name. + + Examples + -------- + >>> CommandCall("new-window", ("-d", "-n", "work")).argv() + ('new-window', '-d', '-n', 'work') + + A target is rendered as a ``-t`` flag right after the command name: + + >>> CommandCall("split-window", ("-h",), target="%1").argv() + ('split-window', '-t', '%1', '-h') + """ + + name: str + args: tuple[Arg, ...] = () + target: str | int | None | SlotRef = None + + def __post_init__(self) -> None: + """Reject an empty-string target (fail closed). + + An empty ``-t ''`` resolves to tmux's *current/attached* target, which + silently defeats the typed-target guarantee. ``None`` means "no target" + and is allowed; an integer target such as ``0`` is allowed. + + Examples + -------- + >>> CommandCall("kill-window", target="") + Traceback (most recent call last): + ... + ValueError: CommandCall target must be a non-empty string or None + >>> CommandCall("select-window", target=0).argv() + ('select-window', '-t', '0') + """ + if self.target == "": + msg = "CommandCall target must be a non-empty string or None" + raise ValueError(msg) + + def argv(self) -> tuple[str, ...]: + """Render this call as tmux argv tokens. + + Returns + ------- + tuple[str, ...] + The command name, optional ``-t ``, then each argument. + + Examples + -------- + >>> CommandCall("kill-window", target="@1").argv() + ('kill-window', '-t', '@1') + """ + rendered: list[str] = [self.name] + if isinstance(self.target, SlotRef): + msg = "cannot render an unresolved SlotRef; resolve the plan first" + raise TypeError(msg) + if self.target is not None: + rendered.extend(("-t", str(self.target))) + rendered.extend(_render_arg(arg) for arg in self.args) + return tuple(rendered) + + def then(self, other: CommandCall | CommandChain) -> CommandChain: + """Return a sequence with ``other`` appended after this call. + + Parameters + ---------- + other : CommandCall | CommandChain + The call or sequence to append. + + Returns + ------- + CommandChain + + Examples + -------- + >>> seq = CommandCall("new-window").then(CommandCall("split-window")) + >>> seq.argvs() + (('new-window',), ('split-window',)) + """ + if isinstance(other, CommandCall): + return CommandChain((self, other)) + return CommandChain((self, *other.calls)) + + def __rshift__(self, other: CommandCall | CommandChain) -> CommandChain: + """Compose command calls with ``>>``. + + Examples + -------- + >>> (CommandCall("new-window") >> CommandCall("split-window")).argv() + ('new-window', ';', 'split-window') + """ + return self.then(other) + + +@dataclass(frozen=True, slots=True) +class CommandChain: + r"""An ordered tmux command sequence dispatched as one invocation. + + A sequence renders to a single argv list using standalone ``;`` separator + tokens, mirroring tmux's native command-sequence syntax + (``tmux cmd-a \\; cmd-b``). Later commands do not run if an earlier command + in the sequence errors -- the same semantics tmux applies to ``;`` chains. + + Parameters + ---------- + calls : tuple[CommandCall, ...] + The ordered, non-empty calls in the sequence. + + Examples + -------- + >>> seq = CommandCall("new-window", ("-d",)) >> CommandCall("split-window", ("-h",)) + >>> seq.argv() + ('new-window', '-d', ';', 'split-window', '-h') + """ + + calls: tuple[CommandCall, ...] + + def __post_init__(self) -> None: + """Reject empty sequences. + + Examples + -------- + >>> CommandChain(()) + Traceback (most recent call last): + ... + ValueError: CommandChain requires at least one call + """ + if not self.calls: + msg = "CommandChain requires at least one call" + raise ValueError(msg) + + def argv(self) -> tuple[str, ...]: + """Render the full sequence with tmux ``;`` separators. + + Returns + ------- + tuple[str, ...] + One flat argv list, with a standalone ``";"`` token between calls. + + Examples + -------- + >>> seq = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + >>> seq.argv() + ('rename-window', 'work', ';', 'split-window') + """ + rendered: list[str] = [] + for index, call in enumerate(self.calls): + if index: + rendered.append(";") + rendered.extend(call.argv()) + return tuple(rendered) + + def argvs(self) -> tuple[tuple[str, ...], ...]: + """Render each call independently, without separators. + + Returns + ------- + tuple[tuple[str, ...], ...] + One argv tuple per call. Useful for asserting the compiled commands + in tests without reasoning about ``;`` placement. + + Examples + -------- + >>> seq = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + >>> seq.argvs() + (('rename-window', 'work'), ('split-window',)) + """ + return tuple(call.argv() for call in self.calls) + + def then(self, other: CommandCall | CommandChain) -> CommandChain: + """Return a sequence with ``other`` appended. + + Parameters + ---------- + other : CommandCall | CommandChain + + Returns + ------- + CommandChain + + Examples + -------- + >>> base = CommandChain((CommandCall("new-window"),)) + >>> base.then(CommandCall("split-window")).argvs() + (('new-window',), ('split-window',)) + """ + if isinstance(other, CommandCall): + return CommandChain((*self.calls, other)) + return CommandChain((*self.calls, *other.calls)) + + def __rshift__(self, other: CommandCall | CommandChain) -> CommandChain: + """Compose sequences with ``>>``. + + Examples + -------- + >>> seq = CommandChain((CommandCall("new-window"),)) + >>> (seq >> CommandCall("kill-window")).argvs() + (('new-window',), ('kill-window',)) + """ + return self.then(other) + + def run(self, runner: CommandRunner) -> CommandResultLike: + """Dispatch the whole sequence through one runner call. + + Parameters + ---------- + runner : CommandRunner + Any object with a ``Server.cmd``-shaped ``cmd()`` method. A live + :class:`libtmux.Server` works directly. + + Returns + ------- + CommandResultLike + The single result of the one-shot dispatch. + + Examples + -------- + Two server options set in a single tmux invocation: + + >>> seq = ( + ... CommandCall("set-option", ("-g", "@cc_demo_a", "1")) + ... >> CommandCall("set-option", ("-g", "@cc_demo_b", "2")) + ... ) + >>> result = seq.run(session.server) + >>> result.returncode + 0 + >>> session.server.cmd("show-option", "-gv", "@cc_demo_b").stdout + ['2'] + """ + argv = self.argv() + logger.debug( + "tmux command sequence dispatched", + extra={"tmux_cmd": " ".join(argv), "tmux_subcommand": argv[0]}, + ) + result = runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence complete", + extra={"tmux_exit_code": result.returncode}, + ) + return result + + +def _render_arg(arg: Arg) -> str: + r"""Render one argument token, escaping a trailing tmux separator. + + A literal argument that ends in ``;`` is escaped to ``\;`` so tmux does not + mistake it for a command separator inside a sequence. + + Examples + -------- + >>> _render_arg(50) + '50' + >>> _render_arg("echo hi;") + 'echo hi\\;' + """ + text = str(arg) + if text.endswith(";"): + return f"{text[:-1]}\\;" + return text diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py new file mode 100644 index 000000000..4ebe229cf --- /dev/null +++ b/src/libtmux/_experimental/chain/plan.py @@ -0,0 +1,1294 @@ +"""Typed, target-safe deferred query-command plans. + +A plan starts from a lazy :class:`PaneQuery`, resolves it against a pure +:class:`TmuxSnapshot`, maps each typed :class:`PaneRef` row to one or more +commands, and compiles the result into a single +:class:`~libtmux._experimental.chain.ir.CommandChain` -- which +dispatches once. Targets are typed (:class:`PaneTarget`, :class:`WindowTarget`, +:class:`SessionTarget`), so a row-bound command namespace cannot mis-target a +command. + +Compilation (:meth:`CommandPlan.to_chain`) is a pure function of the +snapshot, so a plan can be inspected in memory -- no tmux required -- and only +:meth:`CommandPlan.run` and :meth:`CommandPlan.run_deferred` touch a live +server. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux._experimental.chain.chain import ( + DeferredCommandResult, + ensure_chainable, + validate_command_scope, +) +from libtmux._experimental.chain.ir import ( + Arg, + CommandCall, + CommandChain, + CommandResultLike, + CommandRunner, + SlotRef, +) + +if t.TYPE_CHECKING: + from typing_extensions import Self + +OrderField: t.TypeAlias = t.Literal["pane_id", "pane_index", "title"] +"""A :class:`PaneRef` field a query may order by.""" + +MappedT = t.TypeVar("MappedT") + + +class NoCommandsResolved(RuntimeError): + """Raised when a deferred plan resolves to no concrete commands.""" + + +@dataclass(frozen=True, slots=True) +class PaneTarget: + """A typed tmux pane target (e.g. ``%1``). + + Examples + -------- + >>> PaneTarget("%1") + PaneTarget(value='%1') + >>> str(PaneTarget("%1")) + '%1' + """ + + value: str + + @classmethod + def coerce(cls, target: str | PaneTarget) -> PaneTarget: + """Normalize raw pane-target text into a typed target. + + Examples + -------- + >>> PaneTarget.coerce("%2") + PaneTarget(value='%2') + >>> PaneTarget.coerce(PaneTarget("%2")) + PaneTarget(value='%2') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class WindowTarget: + """A typed tmux window target (e.g. ``@1``). + + Examples + -------- + >>> str(WindowTarget("@1")) + '@1' + """ + + value: str + + @classmethod + def coerce(cls, target: str | WindowTarget) -> WindowTarget: + """Normalize raw window-target text into a typed target. + + Examples + -------- + >>> WindowTarget.coerce("@1") + WindowTarget(value='@1') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class SessionTarget: + """A typed tmux session target (e.g. ``$0``). + + Examples + -------- + >>> str(SessionTarget("$0")) + '$0' + """ + + value: str + + @classmethod + def coerce(cls, target: str | SessionTarget) -> SessionTarget: + """Normalize raw session-target text into a typed target. + + Examples + -------- + >>> SessionTarget.coerce("$0") + SessionTarget(value='$0') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class PendingTarget: + """A tmux runtime token for an object that does not exist yet. + + A *forward* ref -- the pane/window/session a creation verb will make -- has + no id until tmux runs, so it is addressed at dispatch via a runtime token: + ``"active"`` renders to ``None`` (no ``-t``), since a non-detached + split/new-window/new-session activates what it creates and the next commands + hit it; ``"marked"`` renders to ``"{marked}"`` (the ``select-pane -m`` pane). + + Examples + -------- + >>> PendingTarget().render() is None + True + >>> PendingTarget("marked").render() + '{marked}' + """ + + slot: t.Literal["active", "marked"] = "active" + + def render(self) -> str | None: + """Render as the tmux runtime token (``None`` for active, else marked).""" + return None if self.slot == "active" else "{marked}" + + @property + def value(self) -> str: + """Display form (``""`` for active) so a ``*TargetT`` union is uniform.""" + return self.render() or "" + + +PaneTargetT: t.TypeAlias = "PaneTarget | PendingTarget" +WindowTargetT: t.TypeAlias = "WindowTarget | PendingTarget" +SessionTargetT: t.TypeAlias = "SessionTarget | PendingTarget" +AnyTarget: t.TypeAlias = ( + "PaneTarget | WindowTarget | SessionTarget | PendingTarget | SlotRef" +) + + +def _target_arg(target: AnyTarget) -> str | int | None | SlotRef: + """Render a target as a concrete id or a pending token (the single seam). + + Examples + -------- + >>> _target_arg(PaneTarget("%1")) + '%1' + >>> _target_arg(PendingTarget()) is None + True + """ + if isinstance(target, PendingTarget): + return target.render() + if isinstance(target, SlotRef): + return target # deferred; the multi-dispatch resolver substitutes it + return target.value + + +class ForwardDataUnavailable(RuntimeError): + """Raised when forward-ref metadata is read before tmux creates the object.""" + + +def _forward_data(field: str) -> t.NoReturn: + """Raise: a forward ref has no metadata until tmux creates the object.""" + msg = f"{field} is unavailable on a forward ref until tmux creates the object" + raise ForwardDataUnavailable(msg) + + +class _ForwardRef: + """Shared forward-chaining surface for the typed refs. + + A ref accumulates a one-dispatch ``_lineage`` of creation/decoration calls; + this mixin compiles and dispatches it. Defining it once keeps + :class:`PaneRef`/:class:`WindowRef`/:class:`SessionRef` free of repetition. + """ + + __slots__ = () + + if t.TYPE_CHECKING: + _lineage: tuple[CommandCall, ...] + + def do(self, build: cabc.Callable[[Self], IntoCommands]) -> Self: + """Append commands built from this ref via its own namespaces. + + The fluent way to act on a forward ref with no new vocabulary: ``build`` + uses the existing ``.cmd``/``.window``/``.session`` namespaces; the + cursor (this ref) is unchanged. + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> ref.do(lambda p: p.cmd.send_keys("vim", enter=True)).to_chain().argvs() + (('send-keys', '-t', '%1', 'vim', 'Enter'),) + """ + extra = _to_calls(build(self)) + return dataclasses.replace(self, _lineage=(*self._lineage, *extra)) # type: ignore[type-var] + + def to_chain(self) -> CommandChain: + """Fold the accumulated lineage into one chain (chainability-checked). + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> ref.split().to_chain().argvs() + (('split-window', '-t', '%1', '-v'),) + """ + return _compile_lineage(self._lineage) + + def run(self, runner: CommandRunner) -> CommandResultLike: + """Dispatch the accumulated lineage in one tmux invocation. + + Examples + -------- + Dispatch the lineage against a live server in one invocation: + + >>> ref = PaneRef.concrete( + ... pane_id=pane.pane_id, window_id=pane.window_id, + ... session_id=pane.session_id, pane_index=0, active=True, title="", + ... ) + >>> built = ref.do(lambda p: p.cmd.send_keys("echo hi", enter=True)) + >>> built.run(pane.server).returncode + 0 + """ + return self.to_chain().run(runner) + + +def _compile_lineage(calls: tuple[CommandCall, ...]) -> CommandChain: + """Chainability-check an accumulated lineage and fold it into one chain.""" + for call in calls: + ensure_chainable(call.name) + return CommandChain(calls) + + +class CommandValue: + """Base for typed command values produced by a deferred plan. + + Subclasses carry their own typed target and compile to an + :class:`~libtmux._experimental.chain.ir.CommandCall`. + """ + + def to_call(self) -> CommandCall: + """Compile this command value into a shared command call.""" + raise NotImplementedError + + def argv(self) -> tuple[str, ...]: + """Render this command value as tmux argv tokens. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + return self.to_call().argv() + + +CommandLike: t.TypeAlias = "CommandValue | CommandCall" +IntoCommands: t.TypeAlias = "CommandLike | cabc.Iterable[CommandLike]" + + +@dataclass(frozen=True, slots=True) +class SendKeys(CommandValue): + """A typed ``send-keys`` command bound to a pane. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + + target: PaneTargetT | SlotRef + command: str + enter: bool = False + + def to_call(self) -> CommandCall: + """Compile to a shared command call. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).to_call().argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + args: list[Arg] = [self.command] + if self.enter: + args.append("Enter") + return CommandCall("send-keys", tuple(args), target=_target_arg(self.target)) + + +@dataclass(frozen=True, slots=True) +class ResizePane(CommandValue): + """A typed ``resize-pane`` command bound to a pane. + + Examples + -------- + >>> ResizePane(PaneTarget("%1"), height=20).argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ + + target: PaneTargetT | SlotRef + height: int + + def to_call(self) -> CommandCall: + """Compile to a shared command call. + + Examples + -------- + >>> ResizePane(PaneTarget("%1"), height=20).to_call().argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ + return CommandCall( + "resize-pane", + ("-y", self.height), + target=_target_arg(self.target), + ) + + +@dataclass(frozen=True, slots=True) +class SelectLayout(CommandValue): + """A typed ``select-layout`` command bound to a window. + + Examples + -------- + >>> SelectLayout(WindowTarget("@1"), "even-horizontal").argv() + ('select-layout', '-t', '@1', 'even-horizontal') + """ + + target: WindowTargetT | SlotRef + layout: str + + def to_call(self) -> CommandCall: + """Compile to a shared command call. + + Examples + -------- + >>> SelectLayout(WindowTarget("@1"), "tiled").to_call().argv() + ('select-layout', '-t', '@1', 'tiled') + """ + return CommandCall( + "select-layout", (self.layout,), target=_target_arg(self.target) + ) + + +class BoundPaneCommands: + """Pane command namespace bound to one typed pane target. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).send_keys("clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + + def __init__(self, target: PaneTargetT | SlotRef) -> None: + self.target = target + + def send_keys(self, command: str, *, enter: bool = False) -> SendKeys: + """Build a target-bound ``send-keys`` command. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).send_keys("clear").argv() + ('send-keys', '-t', '%1', 'clear') + """ + return SendKeys(target=self.target, command=command, enter=enter) + + def resize_pane(self, *, height: int) -> ResizePane: + """Build a target-bound ``resize-pane`` command. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).resize_pane(height=20).argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ + return ResizePane(target=self.target, height=height) + + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a pane-scoped ``set-option -p`` bound to this pane. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).set_option("@x", "1").argv() + ('set-option', '-t', '%1', '-p', '@x', '1') + """ + return self.raw("set-option", "-p", name, value) + + def select(self) -> CommandCall: + """Build a ``select-pane`` bound to this pane. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).select().argv() + ('select-pane', '-t', '%1') + """ + return self.raw("select-pane") + + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary pane-scoped command bound to this pane. + + The typed escape hatch: any tmux command, with the pane target + pre-bound, for commands without a first-class builder. Still subject to + the chainability check when compiled in a plan. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).raw("pipe-pane", "-o").argv() + ('pipe-pane', '-t', '%1', '-o') + """ + validate_command_scope(name, "pane") + return CommandCall(name, args, target=_target_arg(self.target)) + + +class BoundWindowCommands: + """Window command namespace bound to one typed window target. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + """ + + def __init__(self, target: WindowTargetT | SlotRef) -> None: + self.target = target + + def select_layout(self, layout: str) -> SelectLayout: + """Build a target-bound ``select-layout`` command. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + """ + return SelectLayout(target=self.target, layout=layout) + + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a window-scoped ``set-option -w`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).set_option("mode-keys", "vi").argv() + ('set-option', '-t', '@1', '-w', 'mode-keys', 'vi') + """ + return self.raw("set-option", "-w", name, value) + + def rename(self, name: str) -> CommandCall: + """Build a ``rename-window`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).rename("editor").argv() + ('rename-window', '-t', '@1', 'editor') + """ + return self.raw("rename-window", name) + + def select(self) -> CommandCall: + """Build a ``select-window`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select().argv() + ('select-window', '-t', '@1') + """ + return self.raw("select-window") + + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary window-scoped command bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).raw("set-option", "@x", "1").argv() + ('set-option', '-t', '@1', '@x', '1') + """ + validate_command_scope(name, "window") + return CommandCall(name, args, target=_target_arg(self.target)) + + +class BoundSessionCommands: + """Session command namespace bound to one typed session target. + + The session scope exists mainly for the ``raw`` escape hatch -- e.g. the + per-session ``set-option`` loops that workspace builders issue. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).raw("set-option", "@x", "1").argv() + ('set-option', '-t', '$0', '@x', '1') + """ + + def __init__(self, target: SessionTargetT | SlotRef) -> None: + self.target = target + + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a session-scoped ``set-option`` bound to this session. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).set_option("status", "on").argv() + ('set-option', '-t', '$0', 'status', 'on') + """ + return self.raw("set-option", name, value) + + def set_environment(self, name: str, value: str) -> CommandCall: + """Build a session-scoped ``set-environment`` bound to this session. + + Examples + -------- + >>> cmds = BoundSessionCommands(SessionTarget("$0")) + >>> cmds.set_environment("EDITOR", "vim").argv() + ('set-environment', '-t', '$0', 'EDITOR', 'vim') + """ + return self.raw("set-environment", name, value) + + def rename(self, name: str) -> CommandCall: + """Build a ``rename-session`` bound to this session. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).rename("work").argv() + ('rename-session', '-t', '$0', 'work') + """ + return self.raw("rename-session", name) + + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary session-scoped command bound to this session.""" + validate_command_scope(name, "session") + return CommandCall(name, args, target=_target_arg(self.target)) + + +@dataclass(frozen=True, slots=True) +class PaneRef(_ForwardRef): + r"""A typed pane handle -- concrete (a snapshot row) or forward, one type. + + A *concrete* ref comes from a query/snapshot: a real ``%id`` and metadata + (``pane_index``/``active``/``title``). A *forward* ref is declared before the + pane exists (``pane.split()``); its id is a :class:`PendingTarget` resolved + at dispatch and reading its metadata raises until tmux creates it. The + ``.cmd``/``.window``/``.session`` namespaces work identically on both. + + Examples + -------- + Concrete (from a snapshot): build a command bound to this pane's real ids. + + >>> pane = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> pane.cmd.send_keys("clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + >>> pane.title + 'editor' + >>> pane.is_forward + False + + Forward: split it, then split the pane that split just created -- one chain. + + >>> pane.split(horizontal=True).split().to_chain().argvs() + (('split-window', '-t', '%1', '-h'), ('split-window', '-v')) + """ + + pane_id: PaneTargetT + window_id: WindowTargetT + session_id: SessionTargetT + _pane_index: int | None = None + _active: bool | None = None + _title: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, + *, + pane_id: str | PaneTarget, + window_id: str | WindowTarget, + session_id: str | SessionTarget, + pane_index: int, + active: bool, + title: str, + ) -> PaneRef: + """Build a concrete pane row (real ids and metadata). + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> (ref.is_forward, ref.title) + (False, 'editor') + """ + return cls( + pane_id=PaneTarget.coerce(pane_id), + window_id=WindowTarget.coerce(window_id), + session_id=SessionTarget.coerce(session_id), + _pane_index=pane_index, + _active=active, + _title=title, + ) + + @property + def is_forward(self) -> bool: + """Whether this ref's id resolves at dispatch (vs. a known ``%id``).""" + return isinstance(self.pane_id, PendingTarget) + + @property + def pane_index(self) -> int: + """Pane index (raises on a forward ref -- the pane does not exist yet).""" + if self._pane_index is None: + _forward_data("pane_index") + return self._pane_index + + @property + def active(self) -> bool: + """Whether the pane is active (raises on a forward ref).""" + if self._active is None: + _forward_data("active") + return self._active + + @property + def title(self) -> str: + """Pane title (raises on a forward ref).""" + if self._title is None: + _forward_data("title") + return self._title + + @property + def cmd(self) -> BoundPaneCommands: + """Pane-scoped commands bound to this pane (concrete id or pending token).""" + return BoundPaneCommands(self.pane_id) + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands bound to this pane's window.""" + return BoundWindowCommands(self.window_id) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this pane's session.""" + return BoundSessionCommands(self.session_id) + + def split(self, *, horizontal: bool = False, shell: str | None = None) -> PaneRef: + r"""Split this pane; return a FORWARD ref to the new (active) pane. + + The new pane stays in this pane's window/session; its own id is pending + until dispatch -- a non-detached split activates it, so later commands + hit it with no ``-t``. + """ + args: list[Arg] = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + call = CommandCall( + "split-window", tuple(args), target=_target_arg(self.pane_id) + ) + return PaneRef( + pane_id=PendingTarget("active"), + window_id=self.window_id, + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + def break_pane(self, *, name: str | None = None) -> WindowRef: + r"""Break this pane into a new window; return a FORWARD :class:`WindowRef`.""" + args: list[Arg] = ["-s", _require_id(self.pane_id)] + args += ["-t", f"{_require_id(self.session_id)}:"] # scope to owning session + if name is not None: + args += ["-n", name] + call = CommandCall("break-pane", tuple(args)) + return WindowRef( + window_id=PendingTarget("active"), + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +CommandMapper: t.TypeAlias = cabc.Callable[[PaneRef], IntoCommands] + + +def _require_id(target: AnyTarget) -> str: + """Return a concrete id, or raise -- creation verbs need a real source id.""" + if isinstance(target, (PendingTarget, SlotRef)): + msg = "a creation verb needs a concrete source id, not a forward ref" + raise ForwardDataUnavailable(msg) + return target.value + + +@dataclass(frozen=True, slots=True) +class WindowRef(_ForwardRef): + r"""A typed window handle -- concrete or forward, mirroring :class:`PaneRef`. + + Created by ``session.new_window()`` or ``pane.break_pane()``. Reuses the + ``.window``/``.session`` namespaces; ``.split()`` descends into a forward + :class:`PaneRef`. + + Examples + -------- + >>> win = WindowRef.concrete( + ... window_id="@1", session_id="$0", window_index=1, window_name="editor" + ... ) + >>> win.window.select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + >>> win.split().is_forward + True + """ + + window_id: WindowTargetT + session_id: SessionTargetT + _window_index: int | None = None + _window_name: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, + *, + window_id: str | WindowTarget, + session_id: str | SessionTarget, + window_index: int, + window_name: str, + ) -> WindowRef: + """Build a concrete window row (real ids and metadata). + + Examples + -------- + >>> ref = WindowRef.concrete( + ... window_id="@1", session_id="$0", window_index=1, window_name="editor" + ... ) + >>> (ref.is_forward, ref.window_name) + (False, 'editor') + """ + return cls( + window_id=WindowTarget.coerce(window_id), + session_id=SessionTarget.coerce(session_id), + _window_index=window_index, + _window_name=window_name, + ) + + @property + def is_forward(self) -> bool: + """Whether this window resolves at dispatch.""" + return isinstance(self.window_id, PendingTarget) + + @property + def window_index(self) -> int: + """Window index (raises on a forward ref).""" + if self._window_index is None: + _forward_data("window_index") + return self._window_index + + @property + def window_name(self) -> str: + """Window name (raises on a forward ref).""" + if self._window_name is None: + _forward_data("window_name") + return self._window_name + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands bound to this window.""" + return BoundWindowCommands(self.window_id) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this window's session.""" + return BoundSessionCommands(self.session_id) + + def split(self, *, horizontal: bool = False, shell: str | None = None) -> PaneRef: + r"""Split this window's active pane; return a forward :class:`PaneRef`.""" + args: list[Arg] = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + call = CommandCall( + "split-window", tuple(args), target=_target_arg(self.window_id) + ) + return PaneRef( + pane_id=PendingTarget("active"), + window_id=self.window_id, + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +@dataclass(frozen=True, slots=True) +class SessionRef(_ForwardRef): + r"""A typed session handle -- concrete or forward, mirroring :class:`PaneRef`. + + Created by :func:`new_session`. Reuses the ``.session`` namespace; + ``.new_window()`` descends into a forward :class:`WindowRef`. + + Examples + -------- + >>> SessionRef.concrete(session_id="$0", session_name="ci").new_window( + ... name="build" + ... ).is_forward + True + """ + + session_id: SessionTargetT + _session_name: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, *, session_id: str | SessionTarget, session_name: str + ) -> SessionRef: + """Build a concrete session row (real id and name). + + Examples + -------- + >>> ref = SessionRef.concrete(session_id="$0", session_name="ci") + >>> (ref.is_forward, ref.session_name) + (False, 'ci') + """ + return cls( + session_id=SessionTarget.coerce(session_id), + _session_name=session_name, + ) + + @property + def is_forward(self) -> bool: + """Whether this session resolves at dispatch.""" + return isinstance(self.session_id, PendingTarget) + + @property + def session_name(self) -> str: + """Session name (raises on a forward ref).""" + if self._session_name is None: + _forward_data("session_name") + return self._session_name + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this session.""" + return BoundSessionCommands(self.session_id) + + def new_window(self, *, name: str | None = None) -> WindowRef: + r"""Create a window in this session; return a forward :class:`WindowRef`.""" + args: list[Arg] = [] + target = _target_arg(self.session_id) + if target is not None: + args += ["-t", f"{target}:"] + if name is not None: + args += ["-n", name] + call = CommandCall("new-window", tuple(args)) + return WindowRef( + window_id=PendingTarget("active"), + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +def new_session(*, name: str | None = None) -> SessionRef: + r"""Create a detached session; return a forward :class:`SessionRef`. + + Examples + -------- + >>> new_session(name="ci").new_window(name="build").to_chain().argvs() + (('new-session', '-d', '-s', 'ci'), ('new-window', '-n', 'build')) + """ + args: list[Arg] = ["-d"] + if name is not None: + args += ["-s", name] + call = CommandCall("new-session", tuple(args)) + return SessionRef(session_id=PendingTarget("active"), _lineage=(call,)) + + +@dataclass(frozen=True, slots=True) +class TmuxSnapshot: + """A pure snapshot of tmux pane state used to resolve plans. + + Examples + -------- + >>> snapshot = TmuxSnapshot(panes=()) + >>> snapshot.panes + () + """ + + panes: tuple[PaneRef, ...] + + +class SnapshotProvider(t.Protocol): + """Object that can provide a pure tmux snapshot.""" + + def snapshot(self) -> TmuxSnapshot: + """Return a tmux snapshot.""" + ... + + +class PlanRunner(CommandRunner, SnapshotProvider, t.Protocol): + """A runner that can both resolve snapshots and dispatch commands.""" + + +SnapshotSource: t.TypeAlias = "TmuxSnapshot | SnapshotProvider" + + +@dataclass(frozen=True, slots=True) +class PaneQuery: + """A lazy pane query that can become a deferred command plan. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=False, title="logs"), + ... ), + ... ) + >>> [p.pane_id.value for p in panes().filter(active=True).all(snapshot)] + ['%1'] + """ + + active_filter: bool | None = None + ordering: OrderField | None = None + limit_count: int | None = None + + def filter(self, *, active: bool) -> PaneQuery: + """Return a query filtered by active state. + + Examples + -------- + >>> panes().filter(active=True) + PaneQuery(active_filter=True, ordering=None, limit_count=None) + """ + return dataclasses.replace(self, active_filter=active) + + def order_by(self, field: OrderField) -> PaneQuery: + """Return a query ordered by a known pane field. + + Examples + -------- + >>> panes().order_by("pane_index") + PaneQuery(active_filter=None, ordering='pane_index', limit_count=None) + """ + return dataclasses.replace(self, ordering=field) + + def limit(self, count: int) -> PaneQuery: + """Return a query capped to ``count`` rows. + + Examples + -------- + >>> panes().limit(2) + PaneQuery(active_filter=None, ordering=None, limit_count=2) + """ + return dataclasses.replace(self, limit_count=count) + + def all(self, source: SnapshotSource) -> list[PaneRef]: + """Evaluate the query against a snapshot source. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> [p.pane_id.value for p in panes().order_by("pane_index").all(snapshot)] + ['%1', '%2'] + """ + rows = list(_resolve_snapshot(source).panes) + if self.active_filter is not None: + rows = [row for row in rows if row.active == self.active_filter] + if self.ordering is not None: + ordering = self.ordering + rows.sort(key=lambda row: _order_value(row, ordering)) + if self.limit_count is not None: + rows = rows[: self.limit_count] + return rows + + def first(self, source: SnapshotSource) -> PaneRef | None: + """Evaluate the query and return its first row, or ``None``.""" + rows = self.limit(1).all(source) + if not rows: + return None + return rows[0] + + def map( + self, + mapper: cabc.Callable[[PaneRef], MappedT], + ) -> MappedPaneQuery[MappedT]: + """Return a data-only transformation query (no commands).""" + return MappedPaneQuery(query=self, mapper=mapper) + + def commands(self, mapper: CommandMapper) -> CommandPlan: + """Return a deferred plan where each row maps to one or more commands. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> plan = panes().commands( + ... lambda pane: ( + ... pane.cmd.resize_pane(height=10), + ... pane.window.select_layout("tiled"), + ... ), + ... ) + >>> compiled = plan.to_chain(snapshot).argvs() + >>> compiled[0] + ('resize-pane', '-t', '%1', '-y', '10') + >>> compiled[1] + ('select-layout', '-t', '@1', 'tiled') + """ + return CommandPlan(_CommandPlanNode(query=self, mapper=mapper)) + + +@dataclass(frozen=True, slots=True) +class MappedPaneQuery(t.Generic[MappedT]): + """A data-only query transformation over pane rows. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).all(snapshot) + ['editor'] + """ + + query: PaneQuery + mapper: cabc.Callable[[PaneRef], MappedT] + + def all(self, source: SnapshotSource) -> list[MappedT]: + """Evaluate the query and transform every row. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).all(snapshot) + ['editor', 'logs'] + """ + return [self.mapper(row) for row in self.query.all(source)] + + def first(self, source: SnapshotSource) -> MappedT | None: + """Evaluate the query and transform the first row, or ``None``. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).first(snapshot) + 'editor' + >>> panes().filter(active=False).map(lambda p: p.title).first(snapshot) is None + True + """ + row = self.query.first(source) + if row is None: + return None + return self.mapper(row) + + +@dataclass(frozen=True, slots=True) +class _CommandPlanNode: + """A deferred query plus a command mapper (an unresolved plan node).""" + + query: PaneQuery + mapper: CommandMapper + + +@dataclass(frozen=True, slots=True) +class CommandPlan: + """A lazy command plan that resolves a query into a command sequence. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> plan = ( + ... panes() + ... .filter(active=True) + ... .order_by("pane_index") + ... .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ... ) + >>> plan.to_chain(snapshot).argvs() + (('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) + """ + + node: _CommandPlanNode + + def to_chain(self, source: SnapshotSource) -> CommandChain: + """Resolve the query and compile mapped commands (pure). + + Parameters + ---------- + source : SnapshotSource + A :class:`TmuxSnapshot` or a :class:`SnapshotProvider`. + + Returns + ------- + CommandChain + + Raises + ------ + NoCommandsResolved + If the resolved query produced no commands. + ChainabilityError + If a mapped command is non-chainable -- its output would be + consumed mid-chain (e.g. ``show-option``). Raw ``CommandCall`` + composition via ``>>`` is the explicit escape hatch and is not + checked. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().commands( + ... lambda p: p.cmd.resize_pane(height=10) + ... ).to_chain(snapshot).argvs() + (('resize-pane', '-t', '%1', '-y', '10'),) + """ + calls: list[CommandCall] = [] + for row in self.node.query.all(source): + calls.extend(_to_calls(self.node.mapper(row))) + if not calls: + msg = "command plan resolved to no commands" + raise NoCommandsResolved(msg) + for call in calls: + ensure_chainable(call.name) + return CommandChain(tuple(calls)) + + def run(self, runner: PlanRunner) -> None: + """Resolve, compile, and dispatch the plan in one tmux invocation. + + An empty plan is a no-op (it does not raise), mirroring libtmux's + lenient list-accessor contract. + + Examples + -------- + Dispatch ``send-keys`` to every active pane in one invocation, against + a live tmux server: + + >>> from libtmux._experimental.chain import SessionPlanExecutor + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> plan.run(SessionPlanExecutor(session)) + """ + try: + sequence = self.to_chain(runner) + except NoCommandsResolved: + return None + sequence.run(runner) + return None + + def run_deferred(self, runner: PlanRunner) -> tuple[DeferredCommandResult, ...]: + r"""Dispatch once and return a resolved deferred result per command. + + The chain dispatches a single time; each returned + :class:`~libtmux._experimental.chain.chain.DeferredCommandResult` is + resolved with the chain's merged result (a ``\\;`` dispatch is not + separable per command, so every handle reflects the same result). An + empty plan returns an empty tuple. + + Examples + -------- + >>> from libtmux._experimental.chain import SessionPlanExecutor + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> results = plan.run_deferred(SessionPlanExecutor(session)) + >>> all(r.returncode == 0 for r in results) + True + """ + try: + sequence = self.to_chain(runner) + except NoCommandsResolved: + return () + result = sequence.run(runner) + return tuple( + DeferredCommandResult(call).resolve(result) for call in sequence.calls + ) + + +def panes() -> PaneQuery: + """Start a lazy pane query. + + Examples + -------- + >>> panes() + PaneQuery(active_filter=None, ordering=None, limit_count=None) + """ + return PaneQuery() + + +def _resolve_snapshot(source: SnapshotSource) -> TmuxSnapshot: + if isinstance(source, TmuxSnapshot): + return source + return source.snapshot() + + +def _order_value(row: PaneRef, field: OrderField) -> str | int: + if field == "pane_id": + return row.pane_id.value + if field == "pane_index": + return row.pane_index + return row.title + + +def _to_calls(value: IntoCommands) -> tuple[CommandCall, ...]: + if isinstance(value, CommandCall): + return (value,) + if isinstance(value, CommandValue): + return (value.to_call(),) + if isinstance(value, str | bytes): + msg = "command mapper must return a command or iterable of commands" + raise TypeError(msg) + + calls: list[CommandCall] = [] + try: + iterator = iter(value) + except TypeError as exc: + msg = "command mapper must return a command or iterable of commands" + raise TypeError(msg) from exc + for item in iterator: + calls.extend(_to_calls(item)) + return tuple(calls) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e5833111f..3531d2a81 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2078,6 +2078,8 @@ def break_pane( tmux_args += ("-n", window_name) tmux_args += ("-s", str(self.pane_id)) + if self.session_id is not None: + tmux_args += ("-t", f"{self.session_id}:") # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("break-pane", *tmux_args) diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052..6726b3062 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -167,7 +167,9 @@ def server( >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) @@ -212,7 +214,9 @@ def session_params() -> dict[str, t.Any]: >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) @@ -246,7 +250,9 @@ def session( >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index ff9a851e7..70231cbd7 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -854,19 +854,31 @@ def kill_window(self, target_window: str | int | None = None) -> None: Parameters ---------- target_window : str | int, optional - Window to kill. + Window to kill. A bare window name or index is scoped to this + session so it does not resolve against the server's current + session. Raises ------ :exc:`libtmux.exc.LibTmuxException` If tmux returns an error. + + Notes + ----- + A ``target_window`` string that contains ``:`` (or starts with ``@``) is + treated as an already-qualified tmux target and passed through + unchanged. tmux target syntax cannot distinguish a ``session:window`` + specifier from a window *name* that itself contains ``:``, so a window + whose name contains a colon must be killed via its ``@`` window id. """ target: str | int | None = target_window if target_window is not None: if isinstance(target_window, int): - target = f"{self.session_name}:{target_window}" + target = f"{self.session_id}:{target_window}" + elif target_window.startswith("@") or ":" in target_window: + target = target_window else: - target = f"{target_window}" + target = f"{self.session_id}:{target_window}" proc = self.cmd("kill-window", target=target) diff --git a/tests/_experimental/__init__.py b/tests/_experimental/__init__.py new file mode 100644 index 000000000..3b1f242bf --- /dev/null +++ b/tests/_experimental/__init__.py @@ -0,0 +1 @@ +"""Tests for experimental libtmux APIs.""" diff --git a/tests/_experimental/chain/__init__.py b/tests/_experimental/chain/__init__.py new file mode 100644 index 000000000..0569ad661 --- /dev/null +++ b/tests/_experimental/chain/__init__.py @@ -0,0 +1 @@ +"""Tests for the experimental chainable-commands API.""" diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py new file mode 100644 index 000000000..9e397a687 --- /dev/null +++ b/tests/_experimental/chain/test_async.py @@ -0,0 +1,218 @@ +"""Tests for the async facade over deferred command plans.""" + +from __future__ import annotations + +import asyncio +import typing as t +from dataclasses import dataclass, field + +import pytest +from typing_extensions import assert_type + +from libtmux._experimental.chain import _async as api, plan as sync_plan +from libtmux._experimental.chain._connection import AsyncSessionPlanExecutor +from libtmux._experimental.chain.ir import CommandChain + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + from libtmux.session import Session + +# Strict asyncio_mode: mark every coroutine test in this module explicitly. +pytestmark = pytest.mark.asyncio + + +@dataclass +class _FakeResult: + """Minimal async command result.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _AsyncFakeRunner: + """Async runner that exposes a snapshot and records dispatches.""" + + snapshot_value: sync_plan.TmuxSnapshot + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + snapshot_calls: int = 0 + + async def snapshot(self) -> sync_plan.TmuxSnapshot: + """Return the fixed tmux snapshot asynchronously.""" + await asyncio.sleep(0) + self.snapshot_calls += 1 + return self.snapshot_value + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one async command dispatch.""" + await asyncio.sleep(0) + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["async ok"]) + + +def _snapshot() -> sync_plan.TmuxSnapshot: + return sync_plan.TmuxSnapshot( + panes=( + sync_plan.PaneRef.concrete( + pane_id=sync_plan.PaneTarget("%2"), + window_id=sync_plan.WindowTarget("@1"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=2, + active=True, + title="shell", + ), + sync_plan.PaneRef.concrete( + pane_id=sync_plan.PaneTarget("%1"), + window_id=sync_plan.WindowTarget("@1"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=1, + active=True, + title="editor", + ), + sync_plan.PaneRef.concrete( + pane_id=sync_plan.PaneTarget("%3"), + window_id=sync_plan.WindowTarget("@2"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=3, + active=False, + title="logs", + ), + ), + ) + + +async def test_async_to_chain_awaits_snapshot_without_dispatching() -> None: + """Async plan inspection preserves the pure command-assertion workflow.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands( + lambda pane: [ + pane.cmd.send_keys("clear", enter=True), + pane.window.select_layout("even-horizontal"), + ], + ) + ) + + sequence = await plan.to_chain(runner) + + assert_type(plan, api.CommandPlan) + assert_type(sequence, CommandChain) + assert sequence.argvs() == ( + ("send-keys", "-t", "%1", "clear", "Enter"), + ("select-layout", "-t", "@1", "even-horizontal"), + ("send-keys", "-t", "%2", "clear", "Enter"), + ("select-layout", "-t", "@1", "even-horizontal"), + ) + assert runner.snapshot_calls == 1 + assert runner.calls == [] + + +async def test_async_run_dispatches_one_native_tmux_sequence() -> None: + """Async execution still chains concrete commands into one tmux call.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ) + + await plan.run(runner) + + assert runner.calls == [ + ( + "resize-pane", + ( + "-t", + "%1", + "-y", + "20", + ";", + "resize-pane", + "-t", + "%2", + "-y", + "20", + ), + None, + ), + ] + + +async def test_async_map_and_first_are_data_only() -> None: + """Async row transforms stay separate from command construction.""" + runner = _AsyncFakeRunner(_snapshot()) + query = api.panes().filter(active=True).order_by("pane_index") + + titles = await query.map(lambda pane: pane.title).all(runner) + first = await query.first(runner) + + assert_type(titles, list[str]) + assert titles == ["editor", "shell"] + assert first is not None + assert first.pane_id.value == "%1" + assert runner.calls == [] + + +async def test_async_empty_plan_to_chain_raises_but_run_is_noop() -> None: + """Async empty plans match the sync no-op execution behavior.""" + runner = _AsyncFakeRunner(sync_plan.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=20)) + + with pytest.raises(api.NoCommandsResolved): + await plan.to_chain(runner) + + await plan.run(runner) + assert runner.calls == [] + + +async def test_async_session_plan_runner_dispatches_against_live_tmux( + session: Session, +) -> None: + """The async live adapter resolves and dispatches against a real server.""" + session.active_window.split() + runner = AsyncSessionPlanExecutor(session) + + snapshot = await runner.snapshot() + assert len(snapshot.panes) >= 2 + + plan = api.panes().commands( + lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ) + sequence = await plan.to_chain(runner) + + # Every active and inactive pane in the snapshot is targeted. + targeted = {argv[2] for argv in sequence.argvs()} + assert targeted == {pane.pane_id.value for pane in snapshot.panes} + + # Dispatches once through the worker thread without raising. + await plan.run(runner) + + +async def test_async_run_deferred_resolves_one_handle_per_command() -> None: + """Async ``run_deferred`` dispatches once and resolves a handle per command.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("clear", enter=True), + ) + ) + + results = await plan.run_deferred(runner) + + assert len(runner.calls) == 1 # the whole chain dispatched once + assert [r.returncode for r in results] == [0, 0] + assert results[0].stdout == ["async ok"] # the chain's merged result diff --git a/tests/_experimental/chain/test_chain.py b/tests/_experimental/chain/test_chain.py new file mode 100644 index 000000000..4c8813886 --- /dev/null +++ b/tests/_experimental/chain/test_chain.py @@ -0,0 +1,139 @@ +"""Tests for the chainability contract.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import sys +import typing as t + +import pytest + +from libtmux._experimental.chain.chain import ( + DeferredCommandResult, + DeferredOutputUnavailable, + is_chainable, +) +from libtmux._experimental.chain.ir import CommandCall + + +class MinimalImportCase(t.NamedTuple): + """A module import that must work without optional dependency groups.""" + + test_id: str + module: str + + +MINIMAL_IMPORT_CASES = ( + MinimalImportCase( + test_id="chain-package", + module="libtmux._experimental.chain", + ), +) + + +class ChainabilityCase(t.NamedTuple): + """A command name and expected chainability.""" + + test_id: str + command: str + expected: bool + + +CHAINABILITY_CASES = ( + ChainabilityCase( + test_id="known-chainable", + command="rename-window", + expected=True, + ), + ChainabilityCase( + test_id="layout-chainable", + command="select-layout", + expected=True, + ), + ChainabilityCase( + test_id="output-show-option", + command="show-option", + expected=False, + ), + ChainabilityCase( + test_id="output-capture-pane", + command="capture-pane", + expected=False, + ), + ChainabilityCase( + test_id="unknown-fail-closed", + command="some-unknown-command", + expected=False, + ), +) + + +@pytest.mark.parametrize( + "case", + MINIMAL_IMPORT_CASES, + ids=[case.test_id for case in MINIMAL_IMPORT_CASES], +) +def test_minimal_import_without_dev_dependency_groups( + case: MinimalImportCase, +) -> None: + """The experimental chain package imports with only stdlib dependencies.""" + project_root = pathlib.Path(__file__).parents[3] + env = os.environ.copy() + env["PYTHONPATH"] = str(project_root / "src") + + proc = subprocess.run( + [sys.executable, "-S", "-c", f"import {case.module}"], + check=False, + capture_output=True, + env=env, + text=True, + ) + + assert proc.returncode == 0, proc.stderr + + +@pytest.mark.parametrize( + "case", + CHAINABILITY_CASES, + ids=[case.test_id for case in CHAINABILITY_CASES], +) +def test_is_chainable_uses_static_spec(case: ChainabilityCase) -> None: + """The static ``chainable`` flag decides what may fold into a chain.""" + assert is_chainable(case.command) is case.expected + + +def test_deferred_result_rejects_output_access() -> None: + """An unresolved deferred result has no output until the chain runs.""" + result = DeferredCommandResult(CommandCall("rename-window", ("work",))) + + with pytest.raises(DeferredOutputUnavailable): + _ = result.stdout + with pytest.raises(DeferredOutputUnavailable): + _ = result.stderr + with pytest.raises(DeferredOutputUnavailable): + _ = result.returncode + + +class _MergedResult: + """Minimal merged chain result for resolution tests.""" + + def __init__(self) -> None: + self.stdout = ["ok"] + self.stderr: list[str] = [] + self.returncode = 0 + + +def test_deferred_result_resolves_to_chain_result() -> None: + """A resolved deferred result hands back the chain's merged result.""" + pending = DeferredCommandResult(CommandCall("rename-window", ("work",))) + + resolved = pending.resolve(_MergedResult()) + + assert resolved.returncode == 0 + assert resolved.stdout == ["ok"] + assert resolved.stderr == [] + # The original handle stays unresolved (immutable). + with pytest.raises(DeferredOutputUnavailable): + _ = pending.returncode diff --git a/tests/_experimental/chain/test_connection.py b/tests/_experimental/chain/test_connection.py new file mode 100644 index 000000000..d0b6a014b --- /dev/null +++ b/tests/_experimental/chain/test_connection.py @@ -0,0 +1,73 @@ +"""Live-tmux integration tests for the chain connection layer.""" + +from __future__ import annotations + +import typing as t + +from libtmux._experimental.chain._connection import ( + SessionPlanExecutor, + snapshot_from_session, +) +from libtmux._experimental.chain.plan import PaneTarget, panes + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_snapshot_from_session_reads_live_panes(session: Session) -> None: + """A snapshot reflects the session's real panes with typed targets.""" + session.active_window.split() + + snapshot = snapshot_from_session(session) + + assert len(snapshot.panes) >= 2 + for pane in snapshot.panes: + assert isinstance(pane.pane_id, PaneTarget) + assert pane.pane_id.value.startswith("%") + assert pane.window_id.value.startswith("@") + assert pane.session_id.value.startswith("$") + + +def test_session_plan_runner_compiles_real_targets(session: Session) -> None: + """A plan resolved through the runner targets the session's real panes.""" + session.active_window.split() + runner = SessionPlanExecutor(session) + snapshot = runner.snapshot() + + plan = panes().commands(lambda pane: pane.cmd.send_keys("echo cc", enter=True)) + sequence = plan.to_chain(runner) + + # Every compiled command targets a real pane id from the live snapshot. + targeted = {argv[2] for argv in sequence.argvs()} + assert targeted == {pane.pane_id.value for pane in snapshot.panes} + + +def test_session_plan_runner_dispatches_plan_once(session: Session) -> None: + """Running a plan dispatches against the live server without error.""" + session.active_window.split() + runner = SessionPlanExecutor(session) + + plan = ( + panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ) + ) + + # Resolves the live snapshot and dispatches as one native tmux invocation. + # ``run`` returns ``None``; this asserts the dispatch raises nothing. + plan.run(runner) + + +def test_empty_plan_run_is_noop_against_live_session(session: Session) -> None: + """A plan that resolves to no commands is a live no-op.""" + runner = SessionPlanExecutor(session) + + # ``limit(0)`` yields no rows, so the plan resolves to no commands. + plan = ( + panes().limit(0).commands(lambda pane: pane.cmd.send_keys("echo x", enter=True)) + ) + + # No commands resolved -> a silent no-op (does not raise). + plan.run(runner) diff --git a/tests/_experimental/chain/test_control.py b/tests/_experimental/chain/test_control.py new file mode 100644 index 000000000..56219f3da --- /dev/null +++ b/tests/_experimental/chain/test_control.py @@ -0,0 +1,166 @@ +"""Tests for the experimental chain control-mode runner.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._experimental.chain.control import ( + ControlModeBlock, + ControlModeParser, + ControlModeRunner, +) +from libtmux._experimental.chain.ir import CommandCall + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class ParserCase(t.NamedTuple): + """A control-mode wire payload and expected blocks.""" + + test_id: str + wire: bytes + expected: tuple[ControlModeBlock, ...] + + +PARSER_CASES = ( + ParserCase( + test_id="success-block", + wire=b"%begin 1 7 1\nhello\n%end 1 7 1\n", + expected=( + ControlModeBlock( + number=7, + flags=1, + is_error=False, + body=(b"hello",), + ), + ), + ), + ParserCase( + test_id="error-block", + wire=b"%begin 1 8 1\nbad command\n%error 1 8 1\n", + expected=( + ControlModeBlock( + number=8, + flags=1, + is_error=True, + body=(b"bad command",), + ), + ), + ), + ParserCase( + test_id="pane-id-output", + wire=b"%begin 1 9 1\n%42\n%end 1 9 1\n", + expected=( + ControlModeBlock( + number=9, + flags=1, + is_error=False, + body=(b"%42",), + ), + ), + ), + ParserCase( + test_id="event-shaped-output", + wire=b"%begin 1 10 1\n%output literal\n%message literal\n%end 1 10 1\n", + expected=( + ControlModeBlock( + number=10, + flags=1, + is_error=False, + body=(b"%output literal", b"%message literal"), + ), + ), + ), + ParserCase( + test_id="mismatched-end-shaped-output", + wire=b"%begin 1 11 1\n%end 1 12 1\nstill here\n%end 1 11 1\n", + expected=( + ControlModeBlock( + number=11, + flags=1, + is_error=False, + body=(b"%end 1 12 1", b"still here"), + ), + ), + ), +) + + +@pytest.mark.parametrize( + "case", + PARSER_CASES, + ids=[case.test_id for case in PARSER_CASES], +) +def test_control_mode_parser_emits_blocks(case: ParserCase) -> None: + """The parser preserves block bodies and error status.""" + parser = ControlModeParser() + + parser.feed(case.wire) + + assert tuple(parser.blocks()) == case.expected + + +def test_control_mode_runner_empty_batch_does_not_spawn(session: Session) -> None: + """An empty control-mode batch is a no-op.""" + runner = ControlModeRunner(session.server) + try: + assert runner.run_argvs([]) == [] + assert runner._proc is None + finally: + runner.close() + + +def test_control_mode_runner_batch_returns_per_command_stdout( + session: Session, +) -> None: + """A control-mode batch returns one output result per command.""" + with ControlModeRunner(session.server) as runner: + results = runner.run_argvs( + [ + ("display-message", "-p", "first"), + ("display-message", "-p", "second"), + ], + ) + + assert [result.returncode for result in results] == [0, 0] + assert [result.stdout for result in results] == [["first"], ["second"]] + assert [result.stderr for result in results] == [[], []] + + +def test_control_mode_runner_chain_returns_per_call_stdout( + session: Session, +) -> None: + """A ``CommandChain`` can run over control mode without merged output.""" + chain = CommandCall("display-message", ("-p", "left")).then( + CommandCall("display-message", ("-p", "right")), + ) + + with ControlModeRunner(session.server) as runner: + results = runner.run_chain(chain) + + assert [result.stdout for result in results] == [["left"], ["right"]] + + +def test_control_mode_runner_mid_batch_error_keeps_later_results( + session: Session, +) -> None: + """A bad command in a control-mode batch does not consume later results.""" + with ControlModeRunner(session.server) as runner: + before, bad, after = runner.run_argvs( + [ + ("display-message", "-p", "before"), + ("no-such-command",), + ("display-message", "-p", "after"), + ], + ) + + assert before.returncode == 0 + assert before.stdout == ["before"] + assert bad.returncode == 1 + assert bad.stdout == [] + assert bad.stderr + assert after.returncode == 0 + assert after.stdout == ["after"] diff --git a/tests/_experimental/chain/test_forward.py b/tests/_experimental/chain/test_forward.py new file mode 100644 index 000000000..dba3116da --- /dev/null +++ b/tests/_experimental/chain/test_forward.py @@ -0,0 +1,153 @@ +"""Forward (lazily-resolved) refs: the dual-purpose PaneRef/WindowRef/SessionRef.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._experimental.chain import ( + ForwardDataUnavailable, + PaneRef, + SessionPlanExecutor, + new_session, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _seed(pane: t.Any) -> PaneRef: + return PaneRef.concrete( + pane_id=pane.pane_id, + window_id=pane.window_id, + session_id=pane.session_id, + pane_index=int(pane.pane_index or 0), + active=pane.pane_active == "1", + title=pane.pane_title or "", + ) + + +def test_forward_is_same_type_reuses_namespaces_and_guards_metadata() -> None: + """A forward ref is a PaneRef; .cmd is reused; metadata is guarded.""" + seed = PaneRef.concrete( + pane_id="%1", + window_id="@1", + session_id="$0", + pane_index=0, + active=True, + title="editor", + ) + child = seed.split(horizontal=True) + + assert isinstance(child, PaneRef) # SAME type, not a parallel object + assert child.is_forward and not seed.is_forward + # The pane id is pending (no -t = active), but the window is still the + # concrete @1 the split happened in -- propagated, so it stays addressable: + assert child.cmd.send_keys("htop").argv() == ("send-keys", "htop") + assert child.window.select_layout("tiled").argv() == ( + "select-layout", + "-t", + "@1", + "tiled", + ) + + assert seed.title == "editor" # concrete metadata: typed str + with pytest.raises(ForwardDataUnavailable): + _ = child.title # forward metadata: guarded (pending-attribute pattern) + + +def test_forward_do_threads_commands_and_compiles_one_chain() -> None: + """`.do()` reuses the namespaces fluently and compiles to one chain (pure).""" + seed = PaneRef.concrete( + pane_id="%1", + window_id="@1", + session_id="$0", + pane_index=0, + active=True, + title="x", + ) + plan = ( + seed.split(horizontal=True) # split %1 -> forward B + .split() # split B -> forward C + .do(lambda p: p.cmd.send_keys("htop", enter=True)) # reuse .cmd + ) + assert plan.to_chain().argvs() == ( + ("split-window", "-t", "%1", "-h"), + ("split-window", "-v"), + ("send-keys", "htop", "Enter"), + ) + + +def test_forward_pane_resolves_to_newly_created_pane(session: Session) -> None: + """Live: split a pane, split that pane, mark the deepest -- one dispatch.""" + window = session.new_window(window_name="fwd_pane") + seed_pane = window.active_pane + assert seed_pane is not None + + ( + _seed(seed_pane) + .split(horizontal=True) + .split() + .do(lambda p: p.cmd.raw("set-option", "-p", "@cc_mark", "DEEP")) + .run(session.server) + ) + + window.refresh() + assert len(window.panes) == 3 + marked = [ + p + for p in window.panes + if "DEEP" in p.cmd("display-message", "-p", "#{@cc_mark}").stdout + ] + assert len(marked) == 1 + assert marked[0].pane_active == "1" + assert marked[0].pane_id != seed_pane.pane_id # forward ref resolved + + +def test_forward_window_scope_creates_window_then_splits(session: Session) -> None: + """Live: session -> new_window (forward WindowRef) -> split inside it.""" + from libtmux._experimental.chain import SessionRef, SessionTarget + + assert session.session_id is not None + sref = SessionRef.concrete( + session_id=SessionTarget(session.session_id), + session_name=session.session_name or "", + ) + n_before = len(session.windows) + + sref.new_window(name="fwd_win").split(horizontal=True).run(session.server) + + session.refresh() + assert len(session.windows) == n_before + 1 + new_win = next(w for w in session.windows if w.window_name == "fwd_win") + new_win.refresh() + assert len(new_win.panes) == 2 # the new window's pane was split + + +def test_forward_session_scope_creates_session(session: Session) -> None: + """Live: new_session (forward SessionRef) -> new_window, one dispatch.""" + server = session.server + name = "cc_v2_fwd_sess" + try: + new_session(name=name).new_window(name="built").run(server) + + created = next((s for s in server.sessions if s.session_name == name), None) + assert created is not None # the forward session was created + assert "built" in {w.window_name for w in created.windows} + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_forward_paneref_runs_through_executor(session: Session) -> None: + """A forward plan also dispatches through SessionPlanExecutor.""" + window = session.new_window(window_name="fwd_exec") + seed_pane = window.active_pane + assert seed_pane is not None + + _seed(seed_pane).split().split().run(SessionPlanExecutor(session)) + + window.refresh() + assert len(window.panes) == 3 diff --git a/tests/_experimental/chain/test_ir.py b/tests/_experimental/chain/test_ir.py new file mode 100644 index 000000000..4bfac2a00 --- /dev/null +++ b/tests/_experimental/chain/test_ir.py @@ -0,0 +1,203 @@ +"""Tests for the chainable-commands argv intermediate representation.""" + +from __future__ import annotations + +import logging +import typing as t +from dataclasses import dataclass, field + +import pytest + +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandChain, + CommandSpec, +) + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + from libtmux.session import Session + + +@dataclass +class _FakeResult: + """Minimal command result for runner tests.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _FakeRunner: + """Runner that records dispatches instead of touching tmux.""" + + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one command dispatch.""" + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["ok"]) + + +def _read_global_option(session: Session, name: str) -> list[str]: + """Read a global tmux option through the experimental IR. + + Dogfoods :class:`CommandChain` for the read-back so the assertion goes + through the typed ``CommandRunner`` protocol rather than a raw + ``server.cmd`` call. + """ + readback = CommandChain((CommandCall("show-option", ("-gv", name)),)) + return readback.run(session.server).stdout + + +def test_command_call_renders_argv() -> None: + """A call renders its name and positional arguments in order.""" + call = CommandCall("new-window", ("-d", "-n", "work")) + + assert call.argv() == ("new-window", "-d", "-n", "work") + + +def test_command_call_injects_target_after_name() -> None: + """A target renders as a ``-t`` flag immediately after the command name.""" + call = CommandCall("split-window", ("-h",), target="%1") + + assert call.argv() == ("split-window", "-t", "%1", "-h") + + +def test_command_call_renders_integer_arguments() -> None: + """Integer argument tokens render via :func:`str`.""" + call = CommandCall("resize-pane", ("-y", 20), target="%1") + + assert call.argv() == ("resize-pane", "-t", "%1", "-y", "20") + + +def test_command_call_rejects_empty_string_target() -> None: + """An empty-string target is rejected; ``None`` and ints are allowed.""" + with pytest.raises(ValueError, match="non-empty string or None"): + CommandCall("kill-window", target="") + + # None (no target) and integer targets remain valid. + assert CommandCall("list-panes").argv() == ("list-panes",) + assert CommandCall("select-window", target=0).argv() == ( + "select-window", + "-t", + "0", + ) + + +def test_command_sequence_renders_tmux_semicolon_sequence() -> None: + """Composed calls render with standalone ``;`` separator tokens.""" + sequence = CommandCall("new-window", ("-d",)) >> CommandCall( + "split-window", + ("-h",), + ) + + assert sequence.argv() == ( + "new-window", + "-d", + ";", + "split-window", + "-h", + ) + + +def test_command_sequence_argvs_renders_each_call_independently() -> None: + """``argvs`` keeps per-call argv tuples for easy assertions.""" + sequence = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + + assert sequence.argvs() == ( + ("rename-window", "work"), + ("split-window",), + ) + + +def test_command_sequence_escapes_literal_semicolon_arguments() -> None: + """A literal trailing ``;`` is escaped so tmux does not split on it.""" + sequence = CommandChain( + (CommandCall("send-keys", ("echo hi;",), target="%1"),), + ) + + assert sequence.argv() == ("send-keys", "-t", "%1", "echo hi\\;") + + +def test_command_sequence_rejects_empty() -> None: + """An empty sequence is a programming error.""" + with pytest.raises(ValueError, match="at least one call"): + CommandChain(()) + + +def test_command_sequence_runs_as_single_runner_call() -> None: + """``run`` dispatches the whole sequence through one runner call.""" + runner = _FakeRunner() + sequence = CommandCall("new-window", ("-d",)) >> CommandCall("split-window") + + sequence.run(runner) + + assert runner.calls == [ + ("new-window", ("-d", ";", "split-window"), None), + ] + + +def test_command_sequence_run_logs_structured_dispatch( + caplog: pytest.LogCaptureFixture, +) -> None: + """``run`` emits a debug record carrying the rendered tmux command.""" + runner = _FakeRunner() + sequence = CommandCall("new-window", ("-d",)) >> CommandCall("split-window") + + with caplog.at_level(logging.DEBUG, logger="libtmux._experimental.chain.ir"): + sequence.run(runner) + + dispatched = [r for r in caplog.records if hasattr(r, "tmux_cmd")] + assert dispatched + record = t.cast("t.Any", dispatched[0]) + assert record.tmux_cmd == "new-window -d ; split-window" + assert record.tmux_subcommand == "new-window" + + completed = [r for r in caplog.records if hasattr(r, "tmux_exit_code")] + assert completed + assert t.cast("t.Any", completed[0]).tmux_exit_code == 0 + + +def test_command_spec_defaults_to_chainable() -> None: + """Specs are chainable unless a command must return output immediately.""" + assert CommandSpec(name="rename-window", scope="window").chainable is True + assert ( + CommandSpec(name="show-option", scope="server", chainable=False).chainable + is False + ) + + +def test_tmux_executes_native_command_sequence(session: Session) -> None: + """A sequence dispatches as one native tmux invocation against a server.""" + sequence = CommandCall( + "set-option", + ("-g", "@cc_ir_a", "1"), + ) >> CommandCall("set-option", ("-g", "@cc_ir_b", "2")) + + result = sequence.run(session.server) + + assert result.returncode == 0 + assert _read_global_option(session, "@cc_ir_b") == ["2"] + + +def test_tmux_stops_native_sequence_after_error(session: Session) -> None: + """Tmux skips later commands in a sequence once one errors.""" + sequence = CommandCall( + "set-option", + ("-g", "@cc_ir_marker", "set"), + ) >> CommandCall("has-session", ("-t", "cc_definitely_missing_session")) + + result = sequence.run(session.server) + + assert result.returncode != 0 + # The first command ran before the erroring one stopped the rest. + assert _read_global_option(session, "@cc_ir_marker") == ["set"] diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py new file mode 100644 index 000000000..19624e2f6 --- /dev/null +++ b/tests/_experimental/chain/test_plan.py @@ -0,0 +1,394 @@ +"""Tests for the deferred query-command plan layer.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +import pytest +from typing_extensions import assert_type + +from libtmux._experimental.chain import plan as api +from libtmux._experimental.chain.chain import ( + ChainabilityError, + CommandScopeError, + DeferredCommandResult, +) +from libtmux._experimental.chain.ir import CommandCall, CommandChain + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + + +@dataclass +class _FakeResult: + """Minimal command result for plan runner tests.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _FakeRunner: + """Runner exposing a fixed snapshot and recording dispatches.""" + + snapshot_value: api.TmuxSnapshot + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + + def snapshot(self) -> api.TmuxSnapshot: + """Return the fixed tmux snapshot.""" + return self.snapshot_value + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one command dispatch.""" + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["ok"]) + + +def _snapshot() -> api.TmuxSnapshot: + return api.TmuxSnapshot( + panes=( + api.PaneRef.concrete( + pane_id=api.PaneTarget("%2"), + window_id=api.WindowTarget("@1"), + session_id=api.SessionTarget("$0"), + pane_index=2, + active=True, + title="shell", + ), + api.PaneRef.concrete( + pane_id=api.PaneTarget("%1"), + window_id=api.WindowTarget("@1"), + session_id=api.SessionTarget("$0"), + pane_index=1, + active=True, + title="editor", + ), + api.PaneRef.concrete( + pane_id=api.PaneTarget("%3"), + window_id=api.WindowTarget("@2"), + session_id=api.SessionTarget("$0"), + pane_index=3, + active=False, + title="logs", + ), + ), + ) + + +def test_typed_targets_and_bound_commands_render_targets() -> None: + """Bound command namespaces keep pane and window targets typed.""" + pane = _snapshot().panes[0] + + pane_call = pane.cmd.send_keys("clear", enter=True) + window_call = pane.window.select_layout("even-horizontal") + + assert_type(pane.pane_id, api.PaneTargetT) + assert_type(pane.window_id, api.WindowTargetT) + assert_type(pane.session_id, api.SessionTargetT) + assert pane_call.argv() == ("send-keys", "-t", "%2", "clear", "Enter") + assert window_call.argv() == ("select-layout", "-t", "@1", "even-horizontal") + + +def test_commands_defers_mapper_until_sequence_resolution() -> None: + """``commands`` stores a plan node instead of eagerly calling the mapper.""" + mapper_calls: list[api.PaneRef] = [] + + def mapper(pane: api.PaneRef) -> api.CommandValue: + mapper_calls.append(pane) + return pane.cmd.resize_pane(height=20) + + plan = api.panes().filter(active=True).commands(mapper) + + assert_type(plan, api.CommandPlan) + assert mapper_calls == [] + + sequence = plan.to_chain(_snapshot()) + + assert [pane.pane_id.value for pane in mapper_calls] == ["%2", "%1"] + assert sequence.argvs() == ( + ("resize-pane", "-t", "%2", "-y", "20"), + ("resize-pane", "-t", "%1", "-y", "20"), + ) + + +def test_snapshot_sequence_filters_orders_and_flattens_commands() -> None: + """Snapshot compilation gives pure assertions without touching tmux.""" + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands( + lambda pane: [ + pane.cmd.send_keys("clear", enter=True), + pane.cmd.resize_pane(height=20), + ], + ) + ) + + sequence = plan.to_chain(_snapshot()) + + assert_type(sequence, CommandChain) + assert sequence.argvs() == ( + ("send-keys", "-t", "%1", "clear", "Enter"), + ("resize-pane", "-t", "%1", "-y", "20"), + ("send-keys", "-t", "%2", "clear", "Enter"), + ("resize-pane", "-t", "%2", "-y", "20"), + ) + assert sequence.argv()[:6] == ( + "send-keys", + "-t", + "%1", + "clear", + "Enter", + ";", + ) + + +def test_commands_supports_multiple_commands_per_row() -> None: + """``commands`` exposes the explicit multi-command row expansion.""" + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: ( + pane.cmd.resize_pane(height=10), + pane.window.select_layout("even-horizontal"), + ), + ) + ) + + assert plan.to_chain(_snapshot()).argvs() == ( + ("resize-pane", "-t", "%2", "-y", "10"), + ("select-layout", "-t", "@1", "even-horizontal"), + ("resize-pane", "-t", "%1", "-y", "10"), + ("select-layout", "-t", "@1", "even-horizontal"), + ) + + +def test_map_transforms_rows_without_creating_commands() -> None: + """``map`` remains data-oriented and separate from command construction.""" + query = api.panes().filter(active=True).order_by("pane_index") + + titles = query.map(lambda pane: pane.title).all(_snapshot()) + + assert_type(titles, list[str]) + assert titles == ["editor", "shell"] + + +def test_run_resolves_live_snapshot_and_dispatches_once() -> None: + """``run`` resolves the query and executes one native tmux sequence.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands(lambda pane: pane.cmd.send_keys("clear", enter=True)) + ) + + plan.run(runner) + + assert runner.calls == [ + ( + "send-keys", + ( + "-t", + "%1", + "clear", + "Enter", + ";", + "send-keys", + "-t", + "%2", + "clear", + "Enter", + ), + None, + ), + ] + + +def test_to_chain_uses_runner_snapshot_without_dispatching() -> None: + """Resolving against a runner still keeps execution explicit.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands(lambda pane: pane.cmd.resize_pane(height=12)) + ) + + sequence = plan.to_chain(runner) + + assert sequence.argvs() == ( + ("resize-pane", "-t", "%2", "-y", "12"), + ("resize-pane", "-t", "%1", "-y", "12"), + ) + assert runner.calls == [] + + +def test_empty_query_to_chain_raises_but_run_is_noop() -> None: + """Empty query plans are inspectably empty and executable as no-ops.""" + runner = _FakeRunner(api.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=20)) + + with pytest.raises(api.NoCommandsResolved): + plan.to_chain(runner) + + plan.run(runner) + assert runner.calls == [] + + +def test_commands_rejects_string_iterable_command_results() -> None: + """String-like mapper results are not accepted as command iterables.""" + plan = api.panes().commands(lambda pane: t.cast("t.Any", pane.title)) + + with pytest.raises(TypeError, match="command mapper"): + plan.to_chain(_snapshot()) + + +def test_to_chain_rejects_nonchainable_command() -> None: + """A plan mapping a row to a non-chainable command raises. + + ``show-option`` returns output that would be consumed mid-chain, so folding + it into a one-dispatch sequence is rejected at compile time -- the + chainability contract is enforced, not merely advertised. + """ + plan = api.panes().commands( + lambda pane: CommandCall( + "show-option", + ("-gv", "@x"), + target=pane.pane_id.value, + ), + ) + + with pytest.raises(ChainabilityError, match="not chainable"): + plan.to_chain(_snapshot()) + + +def test_to_chain_rejects_unknown_raw_command() -> None: + """The raw escape hatch may not fold unregistered commands.""" + plan = ( + api.panes().limit(1).commands(lambda pane: pane.cmd.raw("some-unknown-command")) + ) + + with pytest.raises(ChainabilityError, match="unknown tmux command"): + plan.to_chain(_snapshot()) + + +def test_to_chain_allows_chainable_command() -> None: + """A chainable raw command compiles without raising.""" + plan = ( + api.panes() + .limit(1) + .commands( + lambda pane: CommandCall("rename-window", ("work",)), + ) + ) + + assert plan.to_chain(_snapshot()).argvs() == (("rename-window", "work"),) + + +def test_raw_escape_hatch_binds_typed_targets() -> None: + """``raw`` issues an arbitrary command bound to each scope's typed target.""" + pane = _snapshot().panes[0] + + assert pane.cmd.raw("pipe-pane", "-o").argv() == ("pipe-pane", "-t", "%2", "-o") + assert pane.window.raw("set-option", "@x", "1").argv() == ( + "set-option", + "-t", + "@1", + "@x", + "1", + ) + assert pane.session.raw("set-option", "automatic-rename", "on").argv() == ( + "set-option", + "-t", + "$0", + "automatic-rename", + "on", + ) + + +class ScopeRejectionCase(t.NamedTuple): + """A known command bound to a wrong typed target scope.""" + + test_id: str + build: t.Callable[[api.PaneRef], object] + + +SCOPE_REJECTION_CASES = ( + ScopeRejectionCase( + test_id="pane-target-window-command", + build=lambda pane: pane.cmd.raw("rename-window", "bad"), + ), + ScopeRejectionCase( + test_id="window-target-pane-command", + build=lambda pane: pane.window.raw("send-keys", "bad"), + ), + ScopeRejectionCase( + test_id="session-target-pane-command", + build=lambda pane: pane.session.raw("resize-pane", "-y", 20), + ), +) + + +@pytest.mark.parametrize( + "case", + SCOPE_REJECTION_CASES, + ids=[case.test_id for case in SCOPE_REJECTION_CASES], +) +def test_raw_escape_hatch_rejects_wrong_known_scope( + case: ScopeRejectionCase, +) -> None: + """Known commands cannot bind to the wrong typed target namespace.""" + pane = _snapshot().panes[0] + + with pytest.raises(CommandScopeError, match="cannot target"): + case.build(pane) + + +def test_raw_escape_hatch_still_enforces_chainability() -> None: + """A non-chainable command via the escape hatch is still rejected.""" + plan = ( + api.panes().limit(1).commands(lambda pane: pane.cmd.raw("capture-pane", "-p")) + ) + + with pytest.raises(ChainabilityError, match="not chainable"): + plan.to_chain(_snapshot()) + + +def test_run_deferred_resolves_one_handle_per_command() -> None: + """``run_deferred`` dispatches once and resolves a handle per command.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("clear", enter=True), + ) + ) + + results = plan.run_deferred(runner) + + assert len(runner.calls) == 1 # the whole chain dispatched once + assert len(results) == 2 # one handle per active pane + assert all(isinstance(r, DeferredCommandResult) for r in results) + assert [r.returncode for r in results] == [0, 0] + assert results[0].stdout == ["ok"] # the chain's merged result + + +def test_run_deferred_empty_plan_returns_empty() -> None: + """An empty plan dispatches nothing and returns no handles.""" + runner = _FakeRunner(api.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=10)) + + assert plan.run_deferred(runner) == () + assert runner.calls == [] diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py new file mode 100644 index 000000000..33491e2bd --- /dev/null +++ b/tests/_experimental/chain/test_resolve.py @@ -0,0 +1,646 @@ +"""Multi-dispatch resolution: independent forward handles, sync + async.""" + +from __future__ import annotations + +import pathlib +import typing as t +from dataclasses import dataclass, field + +import pytest + +from libtmux._experimental.chain import ( + AsyncSessionPlanExecutor, + ChainabilityError, + ForwardPlan, + ServerPlanRunner, + SessionPlanExecutor, + panes, +) +from libtmux._experimental.chain._resolve import ( + Dispatch, + ForwardDispatchError, + _marked_eligible, + drive, +) +from libtmux._experimental.chain.plan import PaneTarget + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@dataclass +class _FakeResult: + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +def _mark(value: str) -> t.Callable[[t.Any], t.Any]: + return lambda h: h.cmd.raw("set-option", "-p", "@m", value) + + +class ForwardDecorateChainabilityCase(t.NamedTuple): + """A forward decoration that must fail chainability validation.""" + + test_id: str + build: t.Callable[[t.Any], t.Any] + match: str + + +FORWARD_DECORATE_CHAINABILITY_CASES = ( + ForwardDecorateChainabilityCase( + test_id="output-command", + build=lambda h: h.cmd.raw("capture-pane", "-p"), + match="not chainable", + ), + ForwardDecorateChainabilityCase( + test_id="unknown-command", + build=lambda h: h.cmd.raw("some-unknown-command"), + match="unknown tmux command", + ), +) + + +def test_drive_core_is_sans_io_and_substitutes_ids() -> None: + """The core yields Dispatch, captures ids, and substitutes them -- no tmux. + + A hand-rolled driver feeds fake ids back, proving the resolution logic is a + pure generator independent of any I/O (just like the sync/async drivers). + """ + plan = ForwardPlan(PaneTarget("%1")) + left, right = plan.split(horizontal=True), plan.split() + left.do(_mark("L")) + right.do(_mark("R")) + + gen = drive(tuple(plan._steps)) + dispatched: list[tuple[str, ...]] = [] + fake_ids = iter(["%7", "%8"]) + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + dispatched.append(request.argv) + out = [next(fake_ids)] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=out)) + except StopIteration as stop: + resolved = stop.value + + assert dispatched == [ + # each creation dispatched alone with id capture (both split the seed %1): + ("split-window", "-t", "%1", "-h", "-P", "-F", "#{pane_id}"), + ("split-window", "-t", "%1", "-v", "-P", "-F", "#{pane_id}"), + # downstream commands fold into one trailing \; chain, ids substituted: + ( + "set-option", + "-t", + "%7", + "-p", + "@m", + "L", + ";", + "set-option", + "-t", + "%8", + "-p", + "@m", + "R", + ), + ] + assert resolved.bindings == {0: "%7", 1: "%8"} + + +def _mark_of(session: Session, pane_id: str) -> list[str]: + return session.server.cmd("display-message", "-p", "-t", pane_id, "#{@m}").stdout + + +def test_multidispatch_two_handles_sync(session: Session) -> None: + """Live: two independent forward panes, each captured + decorated correctly.""" + window = session.new_window(window_name="resolve_sync") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + left, right = plan.split(horizontal=True), plan.split() + left.do(_mark("LEFT")) + right.do(_mark("RIGHT")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert set(resolved.bindings) == {0, 1} + assert resolved.bindings[0] != resolved.bindings[1] + window.refresh() + assert len(window.panes) == 3 # seed + two independent panes + assert _mark_of(session, resolved.bindings[0]) == ["LEFT"] + assert _mark_of(session, resolved.bindings[1]) == ["RIGHT"] + + +@pytest.mark.asyncio +async def test_multidispatch_two_handles_async(session: Session) -> None: + """Live async: the same resolution core, driven with await.""" + window = session.new_window(window_name="resolve_async") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(_mark("AL")) + plan.split().do(_mark("AR")) + + resolved = await plan.run_resolving_async(AsyncSessionPlanExecutor(session)) + + window.refresh() + assert len(window.panes) == 3 + assert _mark_of(session, resolved.bindings[0]) == ["AL"] + assert _mark_of(session, resolved.bindings[1]) == ["AR"] + + +def test_multidispatch_from_query_seed(session: Session) -> None: + """Live: seed the plan from the first row of a query (resolved at run).""" + window = session.new_window(window_name="resolve_seed") + assert window.active_pane is not None + + plan = ForwardPlan.from_query(panes().filter(active=True)) + plan.split(horizontal=True).do(_mark("A")) + plan.split().do(_mark("B")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert _mark_of(session, resolved.bindings[0]) == ["A"] + assert _mark_of(session, resolved.bindings[1]) == ["B"] + + +def test_handle_scope_guard() -> None: + """A creation verb on the wrong tmux scope fails fast at build time (pure).""" + plan = ForwardPlan() + sess = plan.new_session(name="g") + win = sess.new_window(name="w") + pane = win.split() # window -> split is allowed + + with pytest.raises(TypeError): + sess.split() # a session has no single pane to split + with pytest.raises(TypeError): + win.new_window() # only a session creates windows + with pytest.raises(TypeError): + pane.new_window() # ditto for a pane + + +@pytest.mark.parametrize( + "case", + FORWARD_DECORATE_CHAINABILITY_CASES, + ids=[case.test_id for case in FORWARD_DECORATE_CHAINABILITY_CASES], +) +def test_forward_handle_do_enforces_chainability( + case: ForwardDecorateChainabilityCase, +) -> None: + """Forward decorations cannot bypass the chainability contract.""" + plan = ForwardPlan(PaneTarget("%1")) + handle = plan.split() + + with pytest.raises(ChainabilityError, match=case.match): + handle.do(case.build) + + +def test_multidispatch_session_window_pane(session: Session) -> None: + """Live: one plan builds a session, two independent windows, a split in each. + + Five creations resolve over five dispatches -- a session (``$N``), two + windows captured via ``new-window -t $N:`` (``@M``), and a split inside each + window (``%K``) -- proving the builder spans all three tmux scopes. + """ + server = session.server + name = "cc_md_swp" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + w_left = sess.new_window(name="left") + w_right = sess.new_window(name="right") + w_left.split(horizontal=True).do(_mark("WL")) + w_right.split().do(_mark("WR")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert set(resolved.bindings) == {0, 1, 2, 3, 4} + assert resolved.bindings[0].startswith("$") # session + assert resolved.bindings[1].startswith("@") # left window + assert resolved.bindings[2].startswith("@") # right window + assert resolved.bindings[3].startswith("%") # pane split into left + assert resolved.bindings[4].startswith("%") # pane split into right + + created = next(s for s in server.sessions if s.session_name == name) + wins = {w.window_name: w for w in created.windows} + assert {"left", "right"} <= set(wins) + wins["left"].refresh() + wins["right"].refresh() + assert len(wins["left"].panes) == 2 # each window's pane was split + assert len(wins["right"].panes) == 2 + assert _mark_of(session, resolved.bindings[3]) == ["WL"] + assert _mark_of(session, resolved.bindings[4]) == ["WR"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +@pytest.mark.asyncio +async def test_multidispatch_session_window_async(session: Session) -> None: + """Live async: the same session/window/pane span, driven with await.""" + server = session.server + name = "cc_md_swp_async" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + w_a = sess.new_window(name="a") + w_b = sess.new_window(name="b") + w_a.split().do(_mark("A")) + w_b.split().do(_mark("B")) + + resolved = await plan.run_resolving_async(AsyncSessionPlanExecutor(session)) + + assert resolved.bindings[0].startswith("$") + assert _mark_of(session, resolved.bindings[3]) == ["A"] + assert _mark_of(session, resolved.bindings[4]) == ["B"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_creation_options_render() -> None: + """split/new_session/new_window render -c/-e/size onto the create argv.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split(start_directory="/tmp", environment={"FOO": "bar"}) + assert plan._steps[0].call.args == ("-v", "-c/tmp", "-eFOO=bar") + + plan2 = ForwardPlan() + sess = plan2.new_session( + name="x", start_directory="/tmp", environment={"A": "1"}, width=200, height=50 + ) + sess.new_window( + name="w", start_directory="/srv", environment={"B": "2"}, window_shell="zsh" + ) + assert plan2._steps[0].call.args == ( + "-d", + "-s", + "x", + "-c/tmp", + "-eA=1", + "-x", + "200", + "-y", + "50", + ) + assert plan2._steps[1].call.args == ("-n", "w", "-c/srv", "-eB=2", "zsh") + + +def test_creation_start_directory_live( + session: Session, tmp_path: pathlib.Path +) -> None: + """Live: a forward split honours start_directory.""" + window = session.new_window(window_name="cc_cwd") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(start_directory=str(tmp_path)) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + cwd = session.server.cmd( + "display-message", "-p", "-t", resolved.bindings[0], "#{pane_current_path}" + ).stdout + assert cwd + assert pathlib.Path(cwd[0]).resolve() == tmp_path.resolve() + + +def test_typed_verbs_in_forward_plan(session: Session) -> None: + """Live: the new typed bound-namespace verbs work as decorates in a plan.""" + window = session.new_window(window_name="cc_verbs") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split().do(lambda h: h.cmd.set_option("@cc_v", "ok")) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + val = session.server.cmd( + "display-message", "-p", "-t", resolved.bindings[0], "#{@cc_v}" + ).stdout + assert val == ["ok"] + + +def test_server_plan_runner_creates_session_from_scratch(session: Session) -> None: + """ServerPlanRunner runs a create-from-scratch plan without a seed session.""" + server = session.server + name = "cc_server_runner" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) # slot 0 + sess.new_window(name="w").split().do(_mark("SR")) # slots 1, 2 + resolved = plan.run_resolving(ServerPlanRunner(server)) + + assert resolved.session(0, server).session_name == name + assert _mark_of(session, resolved.bindings[2]) == ["SR"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_resolved_maps_slots_to_live_objects(session: Session) -> None: + """Resolved.pane/window/session(slot, server) return the created libtmux objects.""" + server = session.server + name = "cc_resolved_objs" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) # slot 0 (session) + win = sess.new_window(name="w") # slot 1 (window) + win.split() # slot 2 (pane) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert resolved.session(0, server).session_id == resolved.bindings[0] + assert resolved.window(1, server).window_id == resolved.bindings[1] + assert resolved.pane(2, server).pane_id == resolved.bindings[2] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_preserve_mark_skips_marked_fold() -> None: + """allow_marked=False forces multi-dispatch -- the mark is untouched (pure).""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split().do(lambda h: h.cmd.raw("set-option", "-p", "@m", "X")) + + gen = drive(tuple(plan._steps), allow_marked=False) + argvs: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + argvs.append(request.argv) + request = gen.send(_FakeResult(stdout=["%9"])) + except StopIteration: + pass + + flat = [tok for argv in argvs for tok in argv] + assert "select-pane" not in flat # mark register untouched + assert len(argvs) == 2 # multi-dispatch: create + decorate + + +def test_marked_fold_preserves_pre_create_decorate_order() -> None: + """Seed decorations authored before a split must run before the split.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.seed.do(_mark("seed")) + plan.split().do(_mark("child")) + + gen = drive(tuple(plan._steps)) + argvs: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + argvs.append(request.argv) + stdout = ["%9"] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=stdout)) + except StopIteration: + pass + + assert argvs == [ + ("set-option", "-t", "%1", "-p", "@m", "seed"), + ("split-window", "-t", "%1", "-v", "-P", "-F", "#{pane_id}"), + ("set-option", "-t", "%9", "-p", "@m", "child"), + ] + + +def test_preserve_mark_keeps_user_mark(session: Session) -> None: + """Live: preserve_mark=True leaves a pre-existing user mark intact.""" + window = session.new_window(window_name="cc_mark") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + session.server.cmd("select-pane", "-m", "-t", seed.pane_id) # user marks a pane + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split().do(_mark("M")) + plan.run_resolving(SessionPlanExecutor(session), preserve_mark=True) + + marked = session.server.cmd( + "display-message", "-p", "-t", seed.pane_id, "#{pane_marked}" + ).stdout + assert marked == ["1"] # default marked-fold would have cleared it + + +def test_initial_handles_require_session() -> None: + """initial_pane/initial_window are only available on a session handle (pure).""" + pane = ForwardPlan(PaneTarget("%1")).split() # a pane handle + with pytest.raises(TypeError): + _ = pane.initial_pane + with pytest.raises(TypeError): + _ = pane.initial_window + + +def test_initial_pane_builds_onto_default_window(session: Session) -> None: + """Live: a new session's default window is built onto, not orphaned.""" + server = session.server + name = "cc_initial" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + sess.initial_pane.do(lambda h: h.window.rename("main")) + sess.initial_pane.split().do(_mark("IP")) + resolved = plan.run_resolving(ServerPlanRunner(server)) + + created = resolved.session(0, server) + created.refresh() + assert len(created.windows) == 1 # no orphan window + main = created.windows[0] + assert main.window_name == "main" + main.refresh() + assert len(main.panes) == 2 # default pane was split + assert _mark_of(session, resolved.bindings[1]) == ["IP"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_seed_from_existing_scopes_render() -> None: + """from_session/from_window/from_pane build creates targeting the seed id.""" + splan = ForwardPlan.from_session("$0") + splan.new_window(name="w") + assert splan._steps[0].call.argv() == ("new-window", "-t", "$0:", "-n", "w") + + wplan = ForwardPlan.from_window("@1") + wplan.split(horizontal=True) + assert wplan._steps[0].call.argv() == ("split-window", "-t", "@1", "-h") + + pplan = ForwardPlan.from_pane("%5") + pplan.split() + assert pplan._steps[0].call.argv() == ("split-window", "-t", "%5", "-v") + + +def test_seed_handle_decorates_existing_object() -> None: + """plan.seed exposes the pre-existing seed as a decoratable handle.""" + plan = ForwardPlan.from_pane("%1") + assert plan.seed.cmd.send_keys("clear", enter=True).argv() == ( + "send-keys", + "-t", + "%1", + "clear", + "Enter", + ) + # a query-seeded plan has no concrete seed to hand back: + qplan = ForwardPlan.from_query(panes().filter(active=True)) + with pytest.raises(ValueError, match="no concrete seed"): + _ = qplan.seed + + +def test_seed_from_live_session_adds_window(session: Session) -> None: + """Live: from_session(live) adds a window + split to the existing session.""" + n_before = len(session.windows) + + plan = ForwardPlan.from_session(session) + plan.new_window(name="cc_seeded").split().do(_mark("SEED")) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + session.refresh() + assert len(session.windows) == n_before + 1 + new_win = next(w for w in session.windows if w.window_name == "cc_seeded") + new_win.refresh() + assert len(new_win.panes) == 2 # the window's pane was split + assert _mark_of(session, resolved.bindings[1]) == ["SEED"] # slot 1 = split pane + + +def test_forward_dispatch_error_on_failed_create() -> None: + """A failed creation dispatch raises ForwardDispatchError, not IndexError.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split() + + gen = drive(tuple(plan._steps)) + next(gen) # the split's capturing dispatch + with pytest.raises(ForwardDispatchError) as excinfo: + gen.send(_FakeResult(stdout=[], stderr=["no space for new pane"], returncode=1)) + + assert "no space for new pane" in str(excinfo.value) + assert excinfo.value.argv[0] == "split-window" + + +def test_forward_dispatch_error_live(session: Session) -> None: + """Live: splitting against a bogus target fails loudly with the tmux error.""" + plan = ForwardPlan(PaneTarget("%nonexistent999")) + plan.split() + with pytest.raises(ForwardDispatchError): + plan.run_resolving(SessionPlanExecutor(session)) + + +def test_marked_eligible_classifies_plan_shapes() -> None: + """The analyzer picks single-dispatch only for a lone pane creation.""" + one_pane = ForwardPlan(PaneTarget("%1")) + one_pane.split().do(_mark("x")) + assert _marked_eligible(tuple(one_pane._steps)) is not None + + two_panes = ForwardPlan(PaneTarget("%1")) + two_panes.split() + two_panes.split() + assert _marked_eligible(tuple(two_panes._steps)) is None # one mark slot only + + one_session = ForwardPlan() + one_session.new_session(name="x") + # a detached session has no active pane to mark: + assert _marked_eligible(tuple(one_session._steps)) is None + + +def test_marked_single_dispatch_folds_one_handle() -> None: + """A lone pane handle resolves in ONE ``{marked}`` invocation (pure, no tmux). + + The split captures its id, marks the new (active) pane, the decorate + addresses it via ``{marked}``, and the mark is cleared -- all one dispatch. + """ + plan = ForwardPlan(PaneTarget("%1")) + plan.split(horizontal=True).do(_mark("L")) + + gen = drive(tuple(plan._steps)) + dispatched: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + dispatched.append(request.argv) + out = ["%7"] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=out)) + except StopIteration as stop: + resolved = stop.value + + assert dispatched == [ + ( + "split-window", + "-t", + "%1", + "-h", + "-P", + "-F", + "#{pane_id}", + ";", + "select-pane", + "-m", + ";", + "set-option", + "-t", + "{marked}", + "-p", + "@m", + "L", + ";", + "select-pane", + "-M", + ), + ] + assert len(dispatched) == 1 # one invocation, not create + decorate + assert resolved.bindings == {0: "%7"} + + +def test_marked_single_dispatch_live(session: Session) -> None: + """Live: a lone forward pane resolves in one dispatch and leaks no mark.""" + window = session.new_window(window_name="marked_solo") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(_mark("SOLO")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert len(resolved.results) == 1 # single invocation, not create + decorate + new_pane_id = resolved.bindings[0] + window.refresh() + assert len(window.panes) == 2 + assert _mark_of(session, new_pane_id) == ["SOLO"] + # the server-wide mark register is cleared afterward, not leaked: + marked = session.server.cmd( + "display-message", "-p", "-t", new_pane_id, "#{pane_marked}" + ).stdout + assert marked == ["0"] + + +def test_marked_single_dispatch_failure_clears_mark(session: Session) -> None: + """Live: a failed marked dispatch still clears tmux's mark register.""" + window = session.new_window(window_name="marked_fail") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + assert window.window_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(lambda h: h.window.select_layout("not-a-layout")) + + with pytest.raises(ForwardDispatchError): + plan.run_resolving(SessionPlanExecutor(session)) + + marked = session.server.cmd( + "list-panes", "-t", window.window_id, "-F", "#{pane_id}:#{pane_marked}" + ).stdout + assert marked + assert all(row.endswith(":0") for row in marked) diff --git a/tests/test/test_retry.py b/tests/test/test_retry.py index c2a0f9255..61b5134a4 100644 --- a/tests/test/test_retry.py +++ b/tests/test/test_retry.py @@ -11,13 +11,14 @@ def test_retry_three_times() -> None: - """Test retry_until().""" - ini = time() + """retry_until retries until the callable succeeds.""" + calls = 0 value = 0 def call_me_three_times() -> bool: - nonlocal value - sleep(0.3) # Sleep for 0.3 seconds to simulate work + nonlocal value, calls + calls += 1 + sleep(0.3) # simulate work if value == 2: return True @@ -25,73 +26,76 @@ def call_me_three_times() -> bool: value += 1 return False - retry_until(call_me_three_times, 1) - - end = time() - - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # Generous budget so all three calls fit even under load; assert on behavior + # (call count + success), not wall-clock, to stay deterministic. + assert retry_until(call_me_three_times, 5) is True + assert calls == 3 def test_function_times_out() -> None: - """Test time outs with retry_until().""" + """retry_until raises WaitTimeout after exhausting its budget.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False with pytest.raises(exc.WaitTimeout): retry_until(never_true, 1) - end = time() - - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # It retried for the full budget before timing out. The lower bound is + # deterministic (retry_until only times out once elapsed >= the budget); + # no fragile upper bound that load can blow past. + assert (time() - ini) >= 0.9 + assert calls > 1 def test_function_times_out_no_raise() -> None: - """Tests retry_until() with exception raising disabled.""" + """retry_until returns instead of raising when raises=False.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False retry_until(never_true, 1, raises=False) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert (time() - ini) >= 0.9 + assert calls > 1 def test_function_times_out_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, returning False.""" + """retry_until returns False on timeout when raises=False.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False assert not retry_until(never_true, 1, raises=False) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert (time() - ini) >= 0.9 + assert calls > 1 def test_retry_three_times_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, with closure variable.""" - ini = time() + """retry_until returns True on success even with raises=False.""" + calls = 0 value = 0 def call_me_three_times() -> bool: - nonlocal value - sleep( - 0.3, - ) # Sleep for 0.3 seconds to simulate work (called 3 times in ~0.9 seconds) + nonlocal value, calls + calls += 1 + sleep(0.3) # simulate work if value == 2: return True @@ -99,7 +103,6 @@ def call_me_three_times() -> bool: value += 1 return False - assert retry_until(call_me_three_times, 1, raises=False) - - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # Behavior-based, generous budget: deterministic even under load. + assert retry_until(call_me_three_times, 5, raises=False) is True + assert calls == 3 diff --git a/tests/test_pane.py b/tests/test_pane.py index c77eb34f1..dcfc3f1a2 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -17,6 +17,7 @@ if t.TYPE_CHECKING: from libtmux._internal.types import StrPath from libtmux.pane import Pane + from libtmux.server import Server from libtmux.session import Session logger = logging.getLogger(__name__) @@ -1335,6 +1336,27 @@ def test_break_pane_basic(session: Session) -> None: assert new_window.window_id is not None +def test_break_pane_targets_owning_session(server: Server) -> None: + """``break_pane`` creates the new window in the pane's own session. + + Regression for the bundled targeting fix: a bare break-pane destination + resolves against the server's *current* session, which can differ from the + pane's session. A second session created afterwards becomes the current one, + so the broken-out window must still land in the owning session. + """ + owning = server.new_session(session_name="cc_break_owner") + server.new_session(session_name="cc_break_other") + + window = owning.new_window(window_name="to_break") + pane = window.active_pane + assert pane is not None + new_pane = pane.split(shell="sleep 1m") + + new_window = new_pane.break_pane() + + assert new_window.session_id == owning.session_id + + def test_break_pane_with_name(session: Session) -> None: """Test Pane.break_pane() with window_name.""" window = session.new_window(window_name="test_break_name") diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 23acbcfe9..eebc505f8 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -76,7 +76,10 @@ def test_repo_git_remote_checkout( ) # Test - result = pytester.runpytest(str(first_test_filename)) + # Inner pytester runs are isolated and don't read this project's + # asyncio_default_fixture_loop_scope, so pytest-asyncio would emit its + # loop-scope deprecation. Disable the plugin for these synchronous runs. + result = pytester.runpytest(str(first_test_filename), "-p", "no:asyncio") result.assert_outcomes(passed=1) diff --git a/tests/test_session.py b/tests/test_session.py index d68d6d42c..153643830 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -103,6 +103,28 @@ def test_active_pane(session: Session) -> None: assert isinstance(session.active_pane, Pane) +def test_kill_window_targets_owning_session(server: Server) -> None: + """``kill_window`` scopes a bare window name to its owning session. + + Regression for the bundled targeting fix: a bare window name resolves + against the server's current session, so two sessions holding a window of + the same name could cross-target. Killing the owning session's window must + leave the other session's same-named window untouched. + """ + owning = server.new_session(session_name="cc_killwin_owner") + other = server.new_session(session_name="cc_killwin_other") + + owning.new_window(window_name="dup") + other.new_window(window_name="dup") + + owning.kill_window("dup") + + owning.refresh() + other.refresh() + assert "dup" not in [w.window_name for w in owning.windows] + assert "dup" in [w.window_name for w in other.windows] + + def test_session_rename(session: Session) -> None: """Session.rename_session renames session.""" session_name = session.session_name diff --git a/uv.lock b/uv.lock index 96f2ff9d7..bf4ca3f5b 100644 --- a/uv.lock +++ b/uv.lock @@ -114,6 +114,15 @@ 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-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -634,6 +643,7 @@ dev = [ { name = "gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -662,6 +672,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -683,6 +694,7 @@ dev = [ { name = "gp-sphinx", specifier = "==0.0.1a31" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -709,6 +721,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -1028,6 +1041,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0"