diff --git a/.agents/skills/benchmarking-engine-builds/SKILL.md b/.agents/skills/benchmarking-engine-builds/SKILL.md new file mode 100644 index 000000000..b98bfd85b --- /dev/null +++ b/.agents/skills/benchmarking-engine-builds/SKILL.md @@ -0,0 +1,85 @@ +--- +name: benchmarking-engine-builds +description: Use when measuring or profiling how fast libtmux.experimental engines build tmux workspaces — comparing classic vs subprocess/control_mode/imsg/mock/pipelined, chasing a build-latency regression, reading percentile grids, or finding where a control-mode build spends its time (cProfile). Runs scripts/bench_engines.py hermetically on throwaway sockets. +--- + +# Benchmarking engine builds + +## Overview + +`scripts/bench_engines.py` times how long each experimental engine takes to +build a tmux session structure (`W` windows × `P` panes-per-window), sweeping +shapes × engines × wait-modes and reporting min/avg/median/p90/p95/p99/max. + +**Hermetic and safe to run beside a live tmux session:** every server gets its +own socket under a throwaway `mkdtemp` dir, `TMUX` is unset before libtmux is +imported, and an `atexit` hook kills every spawned server. The default tmux +server is never contacted. + +It is a PEP 723 script — **always launch it with `uv run`**, never `python`, or +its inline deps (`rich`, `typer`, editable `libtmux`) won't resolve. + +## When to use + +- Comparing engine build cost (which engine is fastest for a given shape). +- Checking whether a change to the ops/plan/engine layer moved build latency. +- Reading percentile spread (is p99 blowing out?) rather than a single number. +- Locating the hot path inside one engine's build (`profile` → cProfile cumtime). + +## Quick reference + +Run from the repo root. + +| Command | What it does | +|---|---| +| `uv run scripts/bench_engines.py run` | full engine grid (the clean signal) | +| `uv run scripts/bench_engines.py matrix --shapes 1x4,3x3,5x4` | 4-axis factorial: which choice drives build cost | +| `uv run scripts/bench_engines.py concurrency --transport control_mode --k 4` | K builds sync-serial vs async-`gather` | +| `uv run scripts/bench_engines.py contract` | mock-parity ops-language check only (for CI) | +| `uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4` | cProfile one engine, print slowest by cumtime | +| `uv run scripts/bench_engines.py cell control_mode 8x4` | one isolated build (for wrapping in hyperfine) | + +`run` flags: `--shapes 1x1,1x4,3x3,5x4,8x4`, `--engines classic,subprocess,control_mode,imsg,mock,pipelined`, +`--wait` (ALSO measure with shell-readiness wait), `--runs 20`, `--warmup 3`, +`--json-out grid.json`. Shape is `windows x panes-per-window`. + +`matrix` sweeps five expression layers (`imperative`, `plan-seq`, `plan-fold`, +`ws-seq`, `ws-fold`) × transport {subprocess, control_mode} × mode {sync, async} +against a `classic` reference. `mock` is the offline correctness **oracle**, not +a results row: `matrix --check` (default on) and `contract` assert every layer × +mode renders identical tmux argv to it, so the benchmark doubles as an +ops-language contract test. + +Engines: `classic` (Server/Session/Window/Pane API) · `subprocess` (one fork +per op) · `control_mode` (one persistent `tmux -C`) · `imsg` (AF_UNIX one-shot) · +`mock` (offline, in-memory Python floor) · `pipelined` (prototype: batch +independent creates via `run_batch`). + +## Reading the results + +- **`control_mode` is the fastest shipped engine** (~21× classic at 32 panes) + because it avoids a per-op `tmux` fork. `pipelined` edges it (~1.4×) by + batching independent creates into ~3 round-trips. +- **Builds are tmux-server-bound, not round-trip-bound** — one shell fork per + pane dominates, so cutting round-trips helps less than the count implies. + `mock` (~1–2 ms) is the pure-Python floor: the plan/compile layer is + negligible; the time is tmux. +- **`profile` shows ~68% in `select.epoll.poll`** inside `_read_blocks`: each + created id is read back before the next op targets it. Latency-bound. + +## Common mistakes + +- Running with `python` instead of `uv run` — PEP 723 deps don't resolve. +- **Comparing `--wait` against no-wait across engines.** Shell startup + (~0.8–2.1 s) dwarfs a fast build, so the ~20× engine win collapses to ~1.5× + once both sides wait. Compare engines with matching readiness policies. +- Trusting hyperfine whole-process wall time over the in-process grid — Python + startup + import dwarfs a 3 ms build and understates the builder. The + in-process `run` grid is the clean signal. +- Expecting `mock` under `--wait`: it has no real panes and is skipped. + +## Results & reproduction + +Committed results live in `scripts/bench-results/`: `RESULTS.md` (narrative + +tables), `grid.json` (no-wait grid), `wait.json` (wait comparison). Regenerate +the raw JSON with `--json-out`. diff --git a/.agents/skills/testing-mcp-with-cli-agents/SKILL.md b/.agents/skills/testing-mcp-with-cli-agents/SKILL.md new file mode 100644 index 000000000..ba29cdad1 --- /dev/null +++ b/.agents/skills/testing-mcp-with-cli-agents/SKILL.md @@ -0,0 +1,216 @@ +--- +name: testing-mcp-with-cli-agents +description: >- + Test an MCP server by driving real CLI agents (Claude, Codex, Cursor, Gemini, + Grok, agy) against it — isolating each CLI's config and the server's own + backend state instead of trusting unit tests alone. Use this whenever + verifying MCP-server behavior end-to-end, checking that a local branch or + checkout works across installed agent CLIs, comparing trunk-vs-branch + behavior, driving an interactive agent TUI to exercise approval flows or + cancellation, or reproducing a bug through a live client. Reach for it even + when the user only says "test the MCP", "does the branch work in the agents", + "drive the CLI to call the tool", or "check it across Codex/Gemini/Cursor". +--- + +# Testing an MCP server through real CLI agents + +Unit tests prove the server's internals; they don't prove a real agent can +discover a tool, clear its approval gate, call it, and survive cancelling it +mid-flight. This skill exercises that whole path by pointing installed CLI +agents at a checkout and driving them. Here the server is libtmux's +tmux-control MCP (`libtmux-engine-mcp`, registered under the `libtmux` slug), +and its backend-isolation lever is a scratch tmux socket +(`LIBTMUX_SOCKET=` → an isolated `tmux -L ` server) — the +thing that scratches every side effect a tool call would otherwise make. + +## The core idea: isolate two things, never zero + +Driving an MCP server through a real CLI mutates two things you don't want +touched. Isolate both and the whole exercise is safe and observable: + +1. **The CLI's config.** Use a throwaway config-home or project config so the + real `~/.codex`, `~/.claude.json`, etc. are never written. Each CLI's lever + is in `references/cli-matrix.md`. +2. **The server's backend / side effects.** Point the server at a *scratch* + backend via its own env var or flag, so tool calls never touch real state + and you can assert against that scratch backend as independent ground truth. + +What "scratch backend" means depends on the server: + +| Server kind | Scratch-backend lever | Ground-truth check | +|---|---|---| +| tmux control (libtmux-mcp) | `LIBTMUX_SOCKET=` → an isolated `tmux -L ` server | `tmux -L list-windows` | +| search / index (agentgrep) | a scratch index/store dir via the server's data-dir env/flag | inspect the scratch index, not the real store | +| filesystem | a temp working root | check the temp tree | +| external API | a sandbox/base-URL override or a recording | the sandbox's own state | + +The principle is identical everywhere: the server writes only to scratch, and +you verify against scratch — so "the agent said it worked" is separated from +"the tool actually did it," and a destructive tool can't harm anything real. + +## Climb only as high as the question needs — three fidelity layers + +### Layer 0 — Direct MCP smoke, no CLI at all + +Fastest and most deterministic. Drive the server over stdio from a tiny FastMCP +client against a scratch backend and assert the wire contract directly: the tool +list, a couple of representative calls, an error path. Use this to answer "is +the tool surface and result shape correct?" before spending a CLI on it. +Normalize result shapes before asserting — `structuredContent` is often +`{"result": [...]}`, and single-value returns can arrive as a bare string. + +### Layer 1 — Headless CLI one-shot + +Proves the real client can discover and call the tools, scriptably, with no +send-keys. Every CLI has a non-interactive mode. Run a cheap discovery proof +first (does the client *see* the server?) — but the cheapest proof differs +sharply per CLI: grok's `mcp doctor` does a real handshake, codex's `mcp get` +only parses config, some CLIs have nothing short of a model call. +`references/cli-matrix.md` has the verified per-CLI invocation, isolation lever, +and approval-bypass flag. Two recurring surprises: some `mcp list`/`list-tools` +subcommands read the *ambient* config and ignore your isolated one, and a +mutating tool call needs a per-CLI approval-bypass flag or it hangs on a no-TTY +prompt. + +### Layer 2 — Interactive, driven by tmux send-keys + +The high-fidelity path, and the only one that exercises approval flows, live +streaming, multi-turn, and cancellation. Run the agent's TUI in a **harness** +tmux socket (`tmux -L cli-harness`, separate from any socket the server itself +uses) and drive it. Create a wide harness so the TUI does not wrap: + +```console +$ tmux -L cli-harness new-session -d -s agent -x 220 -y 50 +``` + +Launch the CLI with its isolated backend: + +```console +$ tmux -L cli-harness send-keys -t agent 'cd /repo && ' Enter +``` + +Poll until the prompt renders: + +```console +$ tmux -L cli-harness capture-pane -p -t agent | tail -5 +``` + +Type the task: + +```console +$ tmux -L cli-harness send-keys -t agent 'Use the libtmux MCP to ' +``` + +Submit it as a separate event: + +```console +$ tmux -L cli-harness send-keys -t agent Enter +``` + +If prompted, answer the approval gate: + +```console +$ tmux -L cli-harness send-keys -t agent 'y' Enter +``` + +Capture what the agent rendered: + +```console +$ tmux -L cli-harness capture-pane -p -t agent | tail -30 +``` + +Finally, assert ground truth against the scratch backend, not the transcript. +Layers 0 and 1 can be fooled by a hallucinated success line; the scratch backend +cannot. + +## Two failure modes that waste the most time + +**Approval gates hang naive harnesses.** The first tool use pops an approval +dialog. A driver that types the prompt and immediately waits for output waits +forever. Pre-approve with the CLI's trust/approval flags (see the matrix), or +detect the prompt via `capture-pane` and answer its keystroke before waiting. + +**Sleeping instead of waiting is flaky, and blind typing doesn't submit.** Poll +`capture-pane` for a stable completion marker rather than `sleep N`. Send the +prompt text and `Enter` as **separate** `send-keys` events — then one Enter +submits; batching text+Enter in one call is what leaves the prompt unsent. And a +CLI launched inside a `-L` harness pane runs in a non-login shell that lacks your +mise/node/uv shims, so `export` the needed bin dirs before launching it. + +## High-value test: cancellation / teardown + +Cancellation is invisible to the tool list and only reachable through Layer 2. +With a long-running tool (a wait, a big scan): start it, then while the TUI shows +"working / esc to interrupt" send `Escape` to that pane. `Esc` during the working +phase cancels the in-flight tool call while keeping the MCP server subprocess +alive — the exact client-cancellation a server's teardown path must survive; +`Esc` after a turn finishes just enters edit-previous mode. Then assert the +scratch backend is clean and no child process leaked. + +## Comparing two versions (trunk vs a branch) + +Two worktrees, two scratch backends, same prompt. Diff three things: the **tool +surface** (a Layer-0 `tools/list` dump or `mcp list-tools`, diffed), the +**rendered agent behavior** for the same prompt (capture-pane transcripts), and +the **scratch-backend state** afterward. + +## Wiring a checkout into the CLIs: mcp_swap + +`scripts/mcp_swap.py` rewrites each CLI's config to run a local checkout, with +backup/revert. Detect installed CLIs: + +```console +$ uv run scripts/mcp_swap.py detect +``` + +Inspect the effective environment and configuration hazards: + +```console +$ uv run scripts/mcp_swap.py doctor --server libtmux-engine +``` + +Check the current swap state: + +```console +$ uv run scripts/mcp_swap.py status --server libtmux-engine +``` + +Preview a local swap: + +```console +$ uv run scripts/mcp_swap.py use-local --server libtmux-engine --env KEY=VALUE --dry-run +``` + +Apply it: + +```console +$ uv run scripts/mcp_swap.py use-local --server libtmux-engine --env KEY=VALUE +``` + +Revert the latest swap: + +```console +$ uv run scripts/mcp_swap.py revert +``` + +Run `doctor` first — it reports which server name each CLI points at (and warns +when the repo is registered under a name other than the derived default), +un-reverted swaps and orphaned backups, missing backups (revert would fail), and +auth-overriding env vars like `OPENAI_API_KEY`. Use `--env` to inject the +backend-isolation var (e.g. an isolated socket or data dir) at swap time. + +**Prefer zero-mutation isolation for a test.** mcp_swap is for a swap you *want* +to persist. To just exercise a checkout, use each CLI's throwaway +config-home / project-config lever (`references/cli-matrix.md`) — all were +verified to drive the server with the real config confirmed byte-identical +afterward, and no swap state touched. `use-local` mutates real configs, so +dry-run first and always `revert` at the end; and the machine may already carry +an un-reverted swap, so `revert` returns you to *that* state, not a pristine one +(check `doctor` first). + +## When NOT to reach for the full harness + +If the question is purely "is the tool surface correct?" stay at Layer 0 — +booting six CLIs to answer a wire-contract question is wasted effort. Escalate to +Layers 1 and 2 only when the client's discovery, approval, streaming, or +cancellation behavior is what's actually in doubt. diff --git a/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md b/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md new file mode 100644 index 000000000..d8171b69a --- /dev/null +++ b/.agents/skills/testing-mcp-with-cli-agents/references/cli-matrix.md @@ -0,0 +1,131 @@ +# CLI matrix — per-CLI isolation, proofs, and gotchas + +Verified 2026-07-24 by driving all six CLIs against a local MCP server on +throwaway configs. **Every real config was confirmed byte-identical +before/after** — no CLI needs `mcp_swap` or a real-config write to be tested. +Full model-driven tool-call proof was reached on **codex, cursor, grok, agy**; +**claude** and **gemini** were blocked at account tier/credit, not by the +harness. Flags drift — re-verify with ` --help` before trusting any +invocation. Throughout, `libtmux` is the server's registered slug and +`LIBTMUX_SOCKET` is the env var that scratches its backend — pointing the +server at an isolated `tmux -L ` control server. + +## Cross-cutting lessons (the transferable part) + +1. **Isolate the config, never mutate it.** Every CLI exposes a config-home or + project-config lever (table below). None requires `mcp_swap` for a test. +2. **The wall is auth/account tier, not the harness.** claude → `Credit balance + is too low`; gemini → `IneligibleTierError` (free tier unsupported). Treat + these as findings and stop spending; they are not harness failures. +3. **Name the throwaway server distinctively.** CLIs commonly already carry an + entry under a shared slug pointing elsewhere. An identical name silently + collides — it merges (cursor), shadows (gemini), or gets resolved instead of + yours (claude `mcp list`). A unique name makes leakage obvious. (This is also + why `mcp_swap doctor` warns when the repo's derived server name isn't the one + the CLIs are actually registered under.) +4. **Config leaks across CLIs.** grok merges Claude Code's `~/.claude.json` *and* + any cwd `.mcp.json`; agy and gemini share the `~/.gemini` tree; codex's daemon + is keyed to `CODEX_HOME`. Assume ambient servers are present unless you + override `HOME`/config-home fully. +5. **"Cheapest proof" is not uniform.** grok's `mcp doctor` does a real + handshake; codex's `mcp get` only parses config; agy has nothing short of a + model call. Pick per CLI (table). +6. **PATH:** for a **headless** run, export the node + uv dirs once before + invoking — the CLI inherits them. The alternate-socket-pane PATH gap (a `-L` + pane's non-login shell lacks the mise shims) only bites when you launch a CLI + **TUI inside a harness pane** (Layer 2). +7. **Non-interactive mutating tool calls need an approval-bypass flag** — + different per CLI (table). Without it, a mutating call blocks on a no-TTY + prompt and the harness hangs. +8. **Interactive send-keys submit:** send the prompt text and `Enter` as + **separate** `send-keys` events — then one Enter submits. `Esc` cancels only + *during* the working/tool phase; after a turn completes it enters + edit-previous mode. + +## Quick matrix + +| CLI | headless one-shot | config-isolation lever | cheapest discovery proof | approval bypass (non-interactive) | full model proof reached | +|---|---|---|---|---|---| +| claude | `claude -p` | `--mcp-config --strict-mcp-config` (session only) | `-p --output-format stream-json` init event | `--permission-mode bypassPermissions` | no — credit blocked | +| codex | `codex exec` | `CODEX_HOME` throwaway **or** `-c` overrides | `codex mcp get libtmux-engine` (parses config, no spawn) | `--dangerously-bypass-approvals-and-sandbox` | yes | +| cursor | `cursor-agent --print` | project `.cursor/mcp.json` (merged, not isolating) | headless `--approve-mcps` run (see trap) | `--force --approve-mcps` (omit `--mode`) | yes | +| gemini | `gemini -p` | project `.gemini/settings.json` from cwd | `gemini mcp list` | `--approval-mode yolo` (`--skip-trust`) | no — free tier | +| grok | `grok -p` / `--single` | `GROK_HOME` **or** `mcp add --scope project` | `grok mcp doctor libtmux --json` (real handshake) | `--permission-mode bypassPermissions` | yes | +| agy | `agy -p` | hidden `--gemini_dir ` | none short of a model call | `--dangerously-skip-permissions` | yes | + +## Per-CLI detail + +### codex — two isolation styles +- **Config-less (leanest):** a home dir with only a symlink to real `auth.json`, + no `config.toml`, plus `-c` overrides: + `-c 'mcp_servers.libtmux-engine.command="..."' -c 'mcp_servers.libtmux-engine.args=[...]' -c 'mcp_servers.libtmux-engine.env.LIBTMUX_SOCKET="..."'`. +- **Copy-config:** `cp ~/.codex/config.toml /`; symlink `auth.json`; + rewrite `[mcp_servers.libtmux-engine]`. Downside: **drags in the user's hooks/output + style** — prefer the `-c` style. +- Run: `env -u OPENAI_API_KEY CODEX_HOME= codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -C ''`. +- Gotchas: **`OPENAI_API_KEY` hijacks auth** to API-key billing even with a + ChatGPT `auth.json` — always `env -u OPENAI_API_KEY`. **No `codex mcp + list-tools`**; `mcp list`/`get`/`doctor` only parse config — real enumeration + needs a model turn. Subcommand flags are position-sensitive (after the + subcommand; `--skip-git-repo-check` is exec-only). Env values show masked as + `*****` in `mcp get`. The `/tmp` `CODEX_HOME` helper-binary warning is harmless. + +### cursor — and it CORRECTS common prior art +- Project `/.cursor/mcp.json` with a **distinct** server name; run with + cwd=`` or `--workspace `: + `cursor-agent --print --output-format stream-json --trust --approve-mcps --force --workspace ''`. +- **`mcp list-tools ` FAILS** on build 2026.07.23 (`has not been + approved`) — the older "list-tools bypasses approval" note is reversed. Prove + via a headless `--approve-mcps` run + the backend, not `list-tools`. +- **`--mode ask`/`--mode plan` are read-only**, so a mutating call is + suppressed. Omit `--mode` and add `--force`. +- Project config is **merged** with global (all global servers still load) — + isolate by unique name + the backend env var, not by expecting override. No + `mcp add`; config is a JSON file only. + +### grok — best cheap proof +- `GROK_HOME=/.grok grok mcp add libtmux-engine -e LIBTMUX_SOCKET=... -- `, + then `cp ~/.grok/auth.json ~/.grok/agent_id /.grok/` (**auth does not + follow `GROK_HOME`**), then + `GROK_HOME=/.grok grok -p '' --permission-mode bypassPermissions --cwd --output-format plain`. +- `grok mcp doctor libtmux --json` is the **best cheap proof of any CLI** — a + real handshake reporting tool count, no model turn. Alternative: `mcp add + --scope project` writes `./.grok/config.toml` (keeps real `$HOME`/auth). +- Gotchas: **grok merges `~/.claude.json` + cwd `.mcp.json`**, so `GROK_HOME` + alone doesn't fully isolate — override `HOME` for a clean set. `grok models` + says "not authenticated" even when valid (trust `doctor` and the run). + +### agy (Antigravity) — no `mcp` verb +- **Hidden `--gemini_dir `** flag (not in `--help`) relocates the entire + `~/.gemini` tree. Symlink the real auth/state files into ``, but make + `/config/mcp_config.json` your own `{"mcpServers":{"libtmux-engine":{...}}}`. +- Run: `PATH=::$PATH agy --gemini_dir --log-file --dangerously-skip-permissions --print-timeout 3m -p ''`. +- Gotchas: **no `mcp` verb at all** — configure by editing `mcp_config.json`; + only a model call enumerates. `--gemini_dir` does **not** isolate auth (symlink + it). `--print-timeout` default is 5m — set it low and wrap in `timeout`. + +### claude — isolation proven, model turn often credit-blocked +- `--mcp-config --strict-mcp-config` scopes which MCP servers a + `-p`/interactive **session** sees — but the server sits at `status:"pending"` + and connects lazily on the **first model turn**, so you can't enumerate its + tools without spending one. +- **`claude mcp list`/`get` ignore `--mcp-config`** and inspect the *ambient* + config. Use a `-p --output-format stream-json` run and read the `init` event's + `mcp_servers` array. +- `--strict-mcp-config` scopes MCP **only**: a `-p` run still writes + `~/.claude.json` and creates `~/.claude/projects//`, and ambient + hooks/skills fire. Add **`--bare`** to strip them. `mcp list` alone leaves + `~/.claude.json` untouched. +- Auth: `ANTHROPIC_API_KEY` (if set) takes precedence over the claude.ai OAuth + login; `env -u ANTHROPIC_API_KEY claude …` forces subscription auth. + +### gemini — isolation proven, model turn often tier-blocked +- Project `/.gemini/settings.json` (`{"mcpServers":{"libtmux-engine":{"command","args","env"}}}`) + read from **cwd**; a project-scoped server **shadows** a same-named user + server. `gemini mcp add [args] -s project -e K=V` defaults to + project scope. +- Run: `gemini --skip-trust --allowed-mcp-server-names libtmux-engine --approval-mode yolo --output-format json -p ''` (verified against gemini 0.52.0). +- Gotchas: **untrusted folders disable ALL MCP** — pass `--skip-trust` (`mcp + list` shows Disabled without it — expected, not a failure). A failed headless + run **still mutates `~/.gemini/projects.json`** (appends the cwd); full + isolation needs a `HOME`/config-dir override (which discards real OAuth). diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index bb7094c28..cc23a85a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,25 +250,40 @@ type """ ``` -**Classes with fields** — `NamedTuple`, dataclasses — document every field in -an `Attributes` section: +**Structured classes** — Every first-party declarative record under +`src/libtmux` must document its effective fields in a NumPy `Attributes` +section. This includes dataclasses, class and functional `NamedTuple` +declarations, `TypedDict`, and future record-style models: ```python @dataclasses.dataclass() -class Pane(Obj, OptionsMixin, HooksMixin): - """:term:`tmux(1)` :term:`Pane`. +class PaneRef: + """Identity of a pane on a server. Attributes ---------- server : Server Server the pane belongs to. + pane_id : str + Stable tmux pane identifier. """ + + server: Server + pane_id: str ``` -Autodoc renders every field whether or not you describe it, so an -undocumented `NamedTuple` field ships to the API docs as "Alias for field -number 0" and a dataclass field ships bare. Document all of them — a class -with three fields and two documented still ships a stub for the third. +Document fields in runtime order with non-empty semantic descriptions. +Inherited prose counts toward subclass completeness. A subclass that +redeclares a field may provide a more specific description, and a +multiple-inheritance composite may inherit same-named prose from distinct +defining bases; normal MRO precedence selects the effective description. +Autodoc renders every field whether or not it is described, so a partial +section still ships bare fields or generated text such as "Alias for field +number 0". + +Test-local parameter containers and `libtmux._vendor` records are exempt unless +they are intentionally published through autodoc. Behavioral classes that are +not declarative records are outside this rule. ### Doctests diff --git a/CHANGES b/CHANGES index 7a691b0cc..93af35023 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,96 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### Dependencies + +`PyYAML>=6.0` is now a runtime dependency for declarative YAML workspace +loading. + +### What's new + +#### Experimental operations and engines (#690) + +Operations describe tmux commands as data. Each renders its argv against a tmux +version (dropping flags an older tmux cannot accept), adapts raw output into a +typed result, and serializes to and from plain dicts -- all without a running +tmux server. The set spans the read seam (``list-*``, ``has-session``, +``display-message``, ``show-options``, ``show-buffer``) and the +mutating/creating surface for panes, windows, the server, options, environment, +hooks, and paste buffers. A registry-generated catalog on the {ref}`experimental` +page always matches the code. + +Engines run those operations behind one protocol, so the same operation returns +the same typed result whether it goes through a subprocess (the classic path +that reproduces today's libtmux behavior), an in-memory simulator for tests and +dry runs, a persistent ``tmux -C`` control connection, an async transport, or +tmux's native binary peer protocol. Results never raise on construction; +raising is opt-in via ``raise_for_status()``, and how a failed result is handled +is each engine's policy. + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations and yields +forward references so a later operation can target an object that does not exist +yet, resolved against captured ids at execution time. How a plan becomes tmux +dispatches is a pluggable {class}`~libtmux.experimental.ops.planner.Planner` +(sequential, ``;``-folding, or ``{marked}``-folding), so dispatch strategies can +be A/B tested against the same plan with identical results. + +#### Declarative workspace builds fold to a few tmux calls (#690) + +A {class}`~libtmux.experimental.workspace.ir.Workspace` declares a session as a +tree of windows and panes and lowers to a Core +{class}`~libtmux.experimental.ops.plan.LazyPlan`, so a tmuxp-style spec can be +analyzed, inspected, and built over any engine. +{meth}`~libtmux.experimental.workspace.ir.Workspace.build` and its async twin +{meth}`~libtmux.experimental.workspace.ir.Workspace.abuild` fold the build's +dispatches by default: a multi-pane window collapses from one tmux call per +operation into a handful of ``;``-chained and ``{marked}`` dispatches, so a +session renders in a few round-trips instead of dozens. + +The resulting {class}`~libtmux.experimental.ops.plan.PlanResult` is identical to +an unfolded build -- only the dispatch count changes -- because host-side steps +(per-command sleeps, the ``wait_pane`` anti-race, ``before_script``) stay hard +fold boundaries that a fold never crosses. Pass a +{class}`~libtmux.experimental.ops.planner.SequentialPlanner` to ``build`` for one +legible tmux call per operation when debugging. + +#### Floating panes on tmux 3.7 (#690) + +On tmux 3.7, the operations create floating panes -- overlays with an absolute +size, position, and optional zoom. A ``new-pane`` operation, ``new_pane()`` on +the pane wrappers, and a curated MCP tool each open one, and a +{class}`~libtmux.experimental.workspace.ir.Workspace` can declare floating panes +on a window, including a pane that overlays a different window. + +#### Query and command live panes with `panes()` (#690) + +{func}`~libtmux.experimental.query.panes` is a lazy, chainable query over the +panes a running server has: ``filter``, ``order_by``, ``limit``, and ``map`` +compose and read nothing until a terminal call. The same query commands what it +selects -- ``commands()`` attaches per-pane actions (send keys, resize, select, +respawn, clear history, kill) that run as one folded tmux dispatch. A query +resolves against a live engine or a plain list of pane snapshots, so the same +code runs offline in tests. + +#### Drive tmux from an MCP server (#690) + +An optional Model Context Protocol server exposes tmux as tools an AI agent can +call, installed with the ``libtmux[mcp]`` extra and launched as +``libtmux-engine-mcp``. It offers a curated vocabulary of intuitive verbs +(``send_input``, ``wait_for_output``, ``split_pane``, ``capture_pane``, +``new_pane``, and the session, window, and pane lifecycle), a tool for every +individual operation, and plan tools that preview or build a whole workspace in +one call. + +The typed tools are caller-aware: because a control-mode agent shares the server +with the panes it drives, they know which pane launched it and refuse to kill or +respawn its own pane, window, or session. The explicitly destructive raw, +shell, and configuration escape hatches cannot provide target-aware liveness +checks for commands they interpret later. By default, +``LIBTMUX_SAFETY`` permits mutations while destructive tools and payload +variants require an explicit opt-in; it can also restrict the server to reads. +A needle-free ``wait_for_output`` reports when a pane goes quiet after a +command -- no sentinel string -- and whether its process exited. + ### Documentation #### Cleaner `from_env` examples (#719) @@ -87,6 +177,14 @@ parsers, {meth}`Server.is_alive() `, and the Sphinx config carry scoped per-file ignores where catching everything is the intended contract. +#### Inspect and isolate MCP config swaps (#690) + +The development config-swap helper's read-only ``doctor`` command reports the +effective server name, registration mismatches, active swaps, orphaned or +missing backups, and auth-overriding environment variables. Its ``use-local +--env KEY=VALUE`` option writes an isolated environment into the server entry +without manual config edits. + ## libtmux 0.62.0 (2026-07-12) libtmux 0.62.0 teaches libtmux objects to locate themselves and to resolve diff --git a/conftest.py b/conftest.py index 88a2656d2..6e1e38557 100644 --- a/conftest.py +++ b/conftest.py @@ -11,7 +11,9 @@ from __future__ import annotations import functools +import pathlib import shutil +import tempfile import typing as t import pytest @@ -25,12 +27,46 @@ from libtmux.session import Session from libtmux.window import Window -if t.TYPE_CHECKING: - import pathlib - pytest_plugins = ["pytester"] +def _is_prose_doctest(request: pytest.FixtureRequest) -> bool: + """Whether the active item is an executable Markdown or reST block.""" + item = request._pyfuncitem + return isinstance(item, DoctestItem) and item.path.suffix.lower() in { + ".md", + ".rst", + } + + +@pytest.fixture +def docs_doctest_sandbox( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> pathlib.Path: + """Give one prose doctest a private home and tmux socket base.""" + # tmux's Unix socket path has a small platform limit. Nested pytest + # temporary paths can exceed it before the socket name is appended. + sandbox = pathlib.Path(tempfile.mkdtemp(prefix="libtmux-doc-")) + request.addfinalizer(functools.partial(shutil.rmtree, sandbox, ignore_errors=True)) + home = sandbox / "home" + tmux_tmpdir = sandbox / "tmux" + home.mkdir(parents=True) + tmux_tmpdir.mkdir() + tmux_tmpdir.chmod(0o700) + (home / ".tmux.conf").write_text( + "set -g base-index 1\n", + encoding="utf-8", + ) + (home / ".zshrc").touch() + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("TMUX_TMPDIR", str(tmux_tmpdir)) + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + return sandbox + + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, @@ -39,6 +75,8 @@ def add_doctest_fixtures( """Configure doctest fixtures for pytest-doctest.""" if isinstance(request._pyfuncitem, DoctestItem) and shutil.which("tmux"): request.getfixturevalue("set_home") + if _is_prose_doctest(request): + request.getfixturevalue("docs_doctest_sandbox") doctest_namespace["Server"] = Server doctest_namespace["Session"] = Session doctest_namespace["Window"] = Window @@ -50,6 +88,7 @@ def add_doctest_fixtures( doctest_namespace["session"] = session doctest_namespace["window"] = session.active_window doctest_namespace["pane"] = session.active_pane + doctest_namespace["tmp_path"] = request.getfixturevalue("tmp_path") doctest_namespace["request"] = request doctest_namespace["ControlMode"] = ControlMode doctest_namespace["control_mode"] = functools.partial( diff --git a/docs/AGENTS.md b/docs/AGENTS.md index e4e6176bb..d18012c6b 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -67,7 +67,7 @@ than after paragraphs of prose; libtmux is code-first. hand. `conftest.py` seeds the namespace with `server`, `session`, `window`, `pane`, the `Server` / `Session` / `Window` / `Pane` / `Client` classes, `ControlMode` and `control_mode`, `monkeypatch`, - and `request`. + `tmp_path`, and `request`. - Fence a `>>>` session as a ```` ```python ```` block, and reach for `# doctest: +ELLIPSIS` when output varies (ids like `@1`, `$2`, socket names). Use a ```` ```console ```` block for shell commands at @@ -88,6 +88,33 @@ than after paragraphs of prose; libtmux is code-first. so `server.socket_path` is `None` — an example that needs the path must ask tmux for it with `display-message -p '#{socket_path}'`. +## Experimental operation examples + +Every operation page under `experimental/operations/` owns one visible Python +doctest before its API card. That block is the user example and the executable +proof; do not duplicate it in hidden setup or a separate generated scenario. + +- Drive the isolated fixture server through + `SubprocessEngine.for_server(server)`. Mocks do not prove command rendering, + target resolution, parsing, or the operation's effect on tmux. +- Show the typed result and the smallest observable outcome that distinguishes + success from an acknowledgement. Use one of these proof shapes: typed read, + creation, durable mutation, relationship change, destruction, transient + acknowledgement, or version-gated behavior. +- Prefer a direct before/after query for mutations and destruction. For + terminal output, synchronize with tmux or `Server.wait_for`; do not add an + arbitrary sleep. +- Use a live control-mode client for client operations. A server with no + attached client cannot demonstrate client behavior. +- Exercise a supported version-gated operation directly. A composed plan may + report a skipped step, but the operation itself must expose its documented + version error. +- Keep the primary proof compact. Put reusable setup, async forms, batching, + and transport-specific behavior in an engine tutorial and link to it. + +Operation examples must not contain `MockEngine`, `AsyncMockEngine`, +`# doctest: +SKIP`, or hidden `testsetup` directives. + ## What stays precise Warm the framing, never the facts. Resolution-order lists, value diff --git a/docs/_ext/tmuxop/__init__.py b/docs/_ext/tmuxop/__init__.py new file mode 100644 index 000000000..c06264366 --- /dev/null +++ b/docs/_ext/tmuxop/__init__.py @@ -0,0 +1,37 @@ +"""Semantic Sphinx reference for experimental tmux operations.""" + +from __future__ import annotations + +import typing as t + +from .domain import ( + TmuxOperationCatalogDirective, + TmuxOperationDirective, + TmuxOperationDomain, + filter_catalog, + operation_anchor, +) + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata + +__all__ = [ + "TmuxOperationCatalogDirective", + "TmuxOperationDirective", + "TmuxOperationDomain", + "filter_catalog", + "operation_anchor", +] + + +def setup(app: Sphinx) -> ExtensionMetadata: + """Register the tmux operation domain.""" + app.setup_extension("sphinx_ux_badges") + app.setup_extension("sphinx_ux_autodoc_layout") + app.add_domain(TmuxOperationDomain) + return { + "version": "0.2", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_ext/tmuxop/domain.py b/docs/_ext/tmuxop/domain.py new file mode 100644 index 000000000..408b0ad86 --- /dev/null +++ b/docs/_ext/tmuxop/domain.py @@ -0,0 +1,336 @@ +"""Domain lifecycle and semantic roles for tmux operations.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.parsers.rst import directives, states +from sphinx import addnodes +from sphinx.domains import Domain, ObjType +from sphinx.errors import SphinxError +from sphinx.roles import XRefRole +from sphinx.util import docname_join +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import make_refnode + +from libtmux.experimental.ops import CatalogEntry, catalog, registry +from libtmux.experimental.ops.exc import UnknownOperation + +from .render import ( + build_catalog_table, + build_operation_description, + operation_init_target, + operation_python_target, +) + +if t.TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence, Set as AbstractSet + + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + +_SCOPES = frozenset({"server", "session", "window", "pane", "client"}) +_SAFETY_TIERS = frozenset({"readonly", "mutating", "destructive"}) + + +def operation_anchor(kind: str) -> str: + """Return the stable document target for an operation kind.""" + return f"tmuxop-operation-{nodes.make_id(kind)}" + + +def filter_catalog( + entries: Sequence[CatalogEntry], + options: Mapping[str, str | None], +) -> list[CatalogEntry]: + """Filter registry catalog entries using validated directive options.""" + scope = options.get("scope") + if scope is not None and scope not in _SCOPES: + msg = f"tmuxop catalog has unknown scope {scope!r}" + raise SphinxError(msg) + + safety = options.get("safety") + if safety is not None and safety not in _SAFETY_TIERS: + msg = f"tmuxop catalog has unknown safety {safety!r}" + raise SphinxError(msg) + + return [ + entry + for entry in entries + if (scope is None or entry.scope == scope) + and (safety is None or entry.safety == safety) + and ("primitive-only" not in options or entry.primitive) + ] + + +class TmuxOperationDirective(SphinxDirective): + """Describe one registry operation.""" + + required_arguments = 1 + has_content = True + option_spec: t.ClassVar[dict[str, t.Any]] = { + "no-index": directives.flag, + } + + def run(self) -> list[nodes.Node]: + """Render one operation.""" + kind = self.arguments[0].strip() + try: + spec = registry.get(kind) + except UnknownOperation as error: + msg = f"tmuxop operation references unknown kind {kind!r}" + raise SphinxError(msg) from error + + entry = next(item for item in catalog() if item.kind == kind) + node_id = operation_anchor(kind) + # Registry summaries are reStructuredText docstrings on every page type. + inliner = states.Inliner() + inliner.init_customizations(self.state.document.settings) + summary_nodes, messages = inliner.parse( + entry.summary, + self.lineno, + self.state.memo, + t.cast("nodes.Element", self.state_machine.node), + ) + for pending in nodes.container("", *summary_nodes).findall( + addnodes.pending_xref + ): + pending["refwarn"] = False + + body = nodes.container() + if self.content: + self.state.nested_parse(self.content, self.content_offset, body) + + description = build_operation_description( + self, + spec, + entry, + node_id=node_id, + summary_nodes=summary_nodes, + body_nodes=body.children, + ) + if "no-index" not in self.options: + domain = self.env.domains["tmuxop"] + t.cast("TmuxOperationDomain", domain).note_operation( + kind, + self.env.docname, + node_id, + ) + python_domain = self.env.domains["py"] + python_domain.note_object( + operation_python_target(spec), + "class", + node_id, + location=description, + ) + implementation_target = ( + f"{spec.operation_cls.__module__}.{spec.operation_cls.__qualname__}" + ) + python_domain.note_object( + implementation_target, + "class", + node_id, + aliased=True, + location=description, + ) + init_node_id = f"{node_id}-init" + python_domain.note_object( + operation_init_target(spec), + "method", + init_node_id, + location=description, + ) + python_domain.note_object( + f"{implementation_target}.__init__", + "method", + init_node_id, + aliased=True, + location=description, + ) + + return [description, *messages] + + +class TmuxOperationCatalogDirective(SphinxDirective): + """List registry operations.""" + + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "scope": directives.unchanged, + "safety": directives.unchanged, + "primitive-only": directives.flag, + "toctree": directives.flag, + } + + def _hidden_toctree( + self, + entries: Sequence[CatalogEntry], + ) -> nodes.compound: + """Build a native hidden toctree for the filtered operation pages.""" + docnames = [docname_join(self.env.docname, entry.kind) for entry in entries] + missing = sorted(set(docnames) - self.env.found_docs) + if missing: + msg = "tmuxop catalog toctree pages do not exist: " + ", ".join(missing) + raise SphinxError(msg) + + toctree = addnodes.toctree() + toctree["parent"] = self.env.docname + toctree["entries"] = [(None, docname) for docname in docnames] + toctree["includefiles"] = docnames + toctree["maxdepth"] = -1 + toctree["caption"] = None + toctree["glob"] = False + toctree["hidden"] = True + toctree["includehidden"] = False + toctree["numbered"] = 0 + toctree["titlesonly"] = False + self.set_source_info(toctree) + + wrapper = nodes.compound(classes=["toctree-wrapper"]) + wrapper += toctree + return wrapper + + def run(self) -> list[nodes.Node]: + """Render a registry catalog.""" + entries = filter_catalog(catalog(), self.options) + if not entries: + msg = "tmuxop catalog filters matched no registered operations" + raise SphinxError(msg) + + summaries: dict[str, Sequence[nodes.Node]] = {} + messages: list[nodes.system_message] = [] + # Registry summaries are reStructuredText docstrings on every page type. + inliner = states.Inliner() + inliner.init_customizations(self.state.document.settings) + for entry in entries: + summary_nodes, summary_messages = inliner.parse( + entry.summary, + self.lineno, + self.state.memo, + t.cast("nodes.Element", self.state_machine.node), + ) + for pending in nodes.container("", *summary_nodes).findall( + addnodes.pending_xref + ): + pending["refwarn"] = False + summaries[entry.kind] = summary_nodes + messages.extend(summary_messages) + + rendered: list[nodes.Node] = [ + build_catalog_table(entries, summaries=summaries), + *messages, + ] + if "toctree" in self.options: + rendered.append(self._hidden_toctree(entries)) + return rendered + + +class TmuxOperationDomain(Domain): + """Sphinx domain for registry-backed tmux operations.""" + + name = "tmuxop" + label = "tmux operation" + object_types = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + "operation": ObjType("operation", "op") + } + directives = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + "operation": TmuxOperationDirective, + "catalog": TmuxOperationCatalogDirective, + } + roles = { # noqa: RUF012 — XRefRole instances are safe to share across domains + "op": XRefRole(warn_dangling=True) + } + initial_data = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + "operations": {} + } + + @property + def operations(self) -> dict[str, tuple[str, str]]: + """Return registered documentation targets keyed by operation kind.""" + return t.cast("dict[str, tuple[str, str]]", self.data["operations"]) + + def note_operation(self, kind: str, docname: str, node_id: str) -> None: + """Register one operation documentation target.""" + if kind in self.operations: + existing_docname, _ = self.operations[kind] + msg = ( + f"tmux operation {kind!r} is already documented in {existing_docname!r}" + ) + raise SphinxError(msg) + self.operations[kind] = (docname, node_id) + + def clear_doc(self, docname: str) -> None: + """Remove targets belonging to a document before it is reread.""" + self.data["operations"] = { + kind: location + for kind, location in self.operations.items() + if location[0] != docname + } + + def merge_domaindata( + self, + docnames: AbstractSet[str], + otherdata: dict[str, t.Any], + ) -> None: + """Merge targets read by one parallel Sphinx worker.""" + other_operations = t.cast( + "dict[str, tuple[str, str]]", + otherdata.get("operations", {}), + ) + for kind, (docname, node_id) in other_operations.items(): + if docname in docnames: + self.note_operation(kind, docname, node_id) + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: addnodes.pending_xref, + contnode: nodes.Element, + ) -> nodes.reference | None: + """Resolve an operation role to its documented target.""" + location = self.operations.get(target) + if location is None: + return None + docname, node_id = location + return make_refnode( + builder, + fromdocname, + docname, + node_id, + contnode, + target, + ) + + def resolve_any_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + target: str, + node: addnodes.pending_xref, + contnode: nodes.Element, + ) -> list[tuple[str, nodes.reference]]: + """Resolve an operation through Sphinx's any role.""" + reference = self.resolve_xref( + env, + fromdocname, + builder, + "operation", + target, + node, + contnode, + ) + if reference is None: + return [] + return [("tmuxop:op", reference)] + + def get_objects( + self, + ) -> Iterator[tuple[str, str, str, str, str, int]]: + """Yield operation objects for the Sphinx inventory.""" + for kind, (docname, node_id) in sorted(self.operations.items()): + yield kind, kind, "operation", docname, node_id, 1 diff --git a/docs/_ext/tmuxop/render.py b/docs/_ext/tmuxop/render.py new file mode 100644 index 000000000..b6888dd73 --- /dev/null +++ b/docs/_ext/tmuxop/render.py @@ -0,0 +1,436 @@ +"""gp-sphinx presentation helpers for tmux operation reference.""" + +from __future__ import annotations + +import inspect +import pathlib +import re +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.errors import SphinxError +from sphinx.ext.napoleon.docstring import NumpyDocstring +from sphinx_ux_autodoc_layout import ( + ApiFactRow, + build_api_facts_section, + build_api_summary_section, + build_chip_paragraph, + build_linked_literal, + inject_signature_slots, + iter_desc_nodes, + parse_generated_markup, +) +from sphinx_ux_badges import SAB, BadgeSpec, build_badge_from_spec + +from libtmux.experimental.ops import CatalogEntry +from libtmux.experimental.ops.operation import Operation +from libtmux.experimental.ops.registry import OpSpec + +if t.TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from sphinx.util.docutils import SphinxDirective + + +def operation_python_target(spec: OpSpec) -> str: + """Return the public Python API target for an operation class.""" + return f"libtmux.experimental.ops.{spec.operation_cls.__name__}" + + +def operation_init_target(spec: OpSpec) -> str: + """Return the public Python API target for an operation constructor.""" + return f"{operation_python_target(spec)}.__init__" + + +def _constructor_signature(operation_cls: type) -> inspect.Signature: + """Return a gp-sphinx-style constructor signature.""" + signature = inspect.signature(operation_cls) + parameters = [ + parameter.replace(annotation=inspect.Parameter.empty) + for parameter in signature.parameters.values() + ] + return signature.replace( + parameters=parameters, + return_annotation=inspect.Signature.empty, + ) + + +_ATTRIBUTE_FIELD = re.compile(r"^\.\. attribute:: ([^:]+)$") +_ATTRIBUTE_TYPE = re.compile(r"^\s*:type:\s*(.*)$") +_DIRECTIVE = re.compile(r"^\.\. \S+::") + + +def _attribute_parameter_blocks(lines: list[str]) -> dict[str, tuple[str, ...]]: + """Convert Napoleon ``Attributes`` markup into constructor field lists. + + Examples + -------- + >>> _attribute_parameter_blocks( + ... [ + ... ".. attribute:: value", + ... "", + ... " Field prose.", + ... " :type: str", + ... ] + ... ) + {'value': (':param value:', ' Field prose.', ':type value: str')} + """ + fields: dict[str, tuple[str, ...]] = {} + index = 0 + while index < len(lines): + match = _ATTRIBUTE_FIELD.match(lines[index]) + if match is None: + index += 1 + continue + name = match.group(1) + index += 1 + block: list[str] = [] + while index < len(lines) and _DIRECTIVE.match(lines[index]) is None: + block.append(lines[index]) + index += 1 + + description: list[str] = [] + type_name: str | None = None + for line in block: + type_match = _ATTRIBUTE_TYPE.match(line) + if type_match is not None: + type_name = type_match.group(1) + else: + description.append(line) + while description and not description[0].strip(): + description.pop(0) + while description and not description[-1].strip(): + description.pop() + if not description or type_name is None: + continue + + parameter = [f":param {name}:"] + parameter.extend( + "" if not line.strip() else line.rstrip() for line in description + ) + fields[name] = (*parameter, f":type {name}: {type_name}") + return fields + + +def _constructor_parameter_fields( + operation_cls: type[Operation[t.Any]], +) -> dict[str, tuple[str, ...]]: + """Return ``Attributes`` prose as fields for every constructor parameter.""" + fields: dict[str, tuple[str, ...]] = {} + for base in reversed(operation_cls.__mro__): + if not issubclass(base, Operation): + continue + docstring = inspect.getdoc(base) + if not docstring: + continue + lines = str(NumpyDocstring(docstring)).splitlines() + fields.update(_attribute_parameter_blocks(lines)) + + parameter_names = tuple(_constructor_signature(operation_cls).parameters) + missing = [name for name in parameter_names if name not in fields] + if missing: + joined = ", ".join(missing) + msg = ( + f"{operation_cls.__name__} has undocumented constructor " + f"parameter(s): {joined}" + ) + raise SphinxError(msg) + + return {name: fields[name] for name in parameter_names} + + +def _constructor_field_markup(operation_cls: type[Operation[t.Any]]) -> str: + """Return ordered parameter fields for the generated constructor.""" + return "\n".join( + line + for block in _constructor_parameter_fields(operation_cls).values() + for line in block + ) + + +def _operation_api_markup(directive: SphinxDirective, spec: OpSpec) -> str: + """Return parser-native Python API markup for an operation.""" + python_target = operation_python_target(spec) + constructor = _constructor_signature(spec.operation_cls) + parameter_fields = _constructor_field_markup(spec.operation_cls) + source, _ = directive.get_source_info() + if pathlib.Path(source).suffix.lower() in {".md", ".markdown", ".myst"}: + return ( + f"::::{{py:class}} {python_target}\n" + ":no-index:\n" + "\n" + f":::{{py:method}} __init__{constructor}\n" + ":no-index:\n" + "\n" + f"{parameter_fields}\n" + ":::\n" + "::::\n" + ) + indented_fields = "\n".join( + f" {line}" if line else "" for line in parameter_fields.splitlines() + ) + return ( + f".. py:class:: {python_target}\n" + " :no-index:\n" + "\n" + f" .. py:method:: __init__{constructor}\n" + " :no-index:\n" + "\n" + f"{indented_fields}\n" + ) + + +def _literal_fact(value: str) -> nodes.paragraph: + """Wrap one literal value for a facts grid.""" + return build_chip_paragraph([value]) + + +def _boolean_fact(value: bool) -> nodes.paragraph: + """Render a boolean as user-facing reference text.""" + return _literal_fact("yes" if value else "no") + + +def _effect_labels(entry: CatalogEntry) -> list[str]: + """Return compact labels for enabled operation effects.""" + labels = [ + key.replace("_", " ") + for key, enabled in entry.effects.items() + if enabled is True + ] + creates = entry.effects.get("creates") + if creates: + labels.append(f"creates {creates}") + return labels + + +def build_operation_badges(entry: CatalogEntry) -> nodes.inline: + """Build text-complete safety, scope, and shape badges.""" + shape = "primitive" if entry.primitive else "composed" + shared_classes = (SAB.DENSE, SAB.NO_UNDERLINE) + specs = [ + BadgeSpec( + entry.safety, + tooltip=f"Safety: {entry.safety}", + classes=( + *shared_classes, + "tmuxop-badge--safety", + f"tmuxop-badge--safety-{entry.safety}", + ), + ), + BadgeSpec( + entry.scope, + tooltip=f"Scope: {entry.scope}", + classes=( + *shared_classes, + "tmuxop-badge--scope", + f"tmuxop-badge--scope-{entry.scope}", + ), + ), + BadgeSpec( + shape, + tooltip=( + "One tmux command" + if entry.primitive + else "Composed from multiple operations" + ), + classes=( + *shared_classes, + "tmuxop-badge--shape", + f"tmuxop-badge--shape-{shape}", + ), + ), + ] + group = nodes.inline(classes=[SAB.BADGE_GROUP, "tmuxop-badge-group"]) + group.extend(build_badge_from_spec(spec) for spec in specs) + return group + + +def build_operation_description( + directive: SphinxDirective, + spec: OpSpec, + entry: CatalogEntry, + *, + node_id: str, + summary_nodes: Sequence[nodes.Node], + body_nodes: Sequence[nodes.Node] = (), +) -> addnodes.desc: + """Render one operation from canonical registry metadata.""" + operation_cls = spec.operation_cls + rendered = parse_generated_markup(directive, _operation_api_markup(directive, spec)) + descriptions = [ + desc + for desc in iter_desc_nodes(rendered) + if desc.get("domain") == "py" and desc.get("objtype") == "class" + ] + if len(descriptions) != 1: + msg = ( + f"Expected one Python class description for {operation_cls.__name__}, " + f"found {len(descriptions)}" + ) + raise SphinxError(msg) + description = descriptions[0] + description["classes"].append("tmuxop-operation-card") + + signature_node = next( + ( + child + for child in description.children + if isinstance(child, addnodes.desc_signature) + ), + None, + ) + if signature_node is None: + msg = f"Missing Python class signature for {operation_cls.__name__}" + raise SphinxError(msg) + signature_node["ids"] = [node_id] + # Registry pages can describe operations newer than the package release + # tag used by linkcode. An absent link is preferable to a durable 404. + signature_node["module"] = "" + signature_node["fullname"] = "" + inject_signature_slots( + signature_node, + marker_attr="tmuxop_badges_injected", + badge_node=build_operation_badges(entry), + extract_source_link=False, + ) + + result_target = ( + f"{entry.result_type.__module__}.{entry.result_type.__qualname__}" + if isinstance(entry.result_type, type) + else f"libtmux.experimental.ops.results.{entry.result_type}" + ) + facts = [ + ApiFactRow( + "Python class", + build_chip_paragraph( + [ + build_linked_literal( + operation_python_target(spec), + operation_cls.__name__, + ) + ] + ), + ), + ApiFactRow("tmux command", _literal_fact(entry.command)), + ApiFactRow( + "Result", + build_chip_paragraph( + [build_linked_literal(result_target, entry.result_type)] + ), + ), + ApiFactRow( + "Minimum tmux", + _literal_fact(entry.min_version or "any supported version"), + ), + ApiFactRow("Chainable", _boolean_fact(entry.chainable)), + ApiFactRow( + "Version-gated flags", + build_chip_paragraph( + [ + f"{flag} >= {version}" + for flag, version in sorted(entry.flag_version_gates.items()) + ] + ), + ), + ApiFactRow("Effects", build_chip_paragraph(_effect_labels(entry))), + ] + + summary = build_api_summary_section( + nodes.paragraph("", "", *[node.deepcopy() for node in summary_nodes]) + ) + content_node = next( + ( + child + for child in description.children + if isinstance(child, addnodes.desc_content) + ), + None, + ) + if content_node is None: + msg = f"Missing Python class content for {operation_cls.__name__}" + raise SphinxError(msg) + member_descriptions = [ + child.deepcopy() + for child in content_node.children + if isinstance(child, addnodes.desc) + ] + if not member_descriptions: + msg = f"Missing __init__ API description for {operation_cls.__name__}" + raise SphinxError(msg) + member_signature = next( + member_descriptions[0].findall(addnodes.desc_signature), + None, + ) + if member_signature is None: + msg = f"Missing __init__ signature for {operation_cls.__name__}" + raise SphinxError(msg) + member_signature["ids"] = [f"{node_id}-init"] + + content_node.children.clear() + content_node += summary + content_node += build_api_facts_section(facts) + content_node.extend(node.deepcopy() for node in body_nodes) + content_node.extend(member_descriptions) + return description + + +def operation_xref(kind: str) -> addnodes.pending_xref: + """Build one explicit operation cross-reference.""" + literal = nodes.literal("", kind) + return addnodes.pending_xref( + "", + literal, + refdomain="tmuxop", + reftype="op", + reftarget=kind, + refexplicit=True, + refwarn=True, + ) + + +def _table_row(cells: Sequence[nodes.Node]) -> nodes.row: + """Build one catalog table row.""" + row = nodes.row() + for cell in cells: + table_entry = nodes.entry() + table_entry += cell + row += table_entry + return row + + +def build_catalog_table( + entries: Sequence[CatalogEntry], + *, + summaries: Mapping[str, Sequence[nodes.Node]], +) -> nodes.table: + """Build a linked registry catalog table.""" + headers = ("Operation", "Command", "Safety", "Result", "Min tmux", "Summary") + table = nodes.table(classes=["tmuxop-catalog"]) + group = nodes.tgroup(cols=len(headers)) + table += group + for width in (2, 2, 1, 2, 1, 5): + group += nodes.colspec(colwidth=width) + + head = nodes.thead() + head += _table_row([nodes.paragraph(text=header) for header in headers]) + group += head + body = nodes.tbody() + for entry in entries: + summary = nodes.paragraph( + "", + "", + *[node.deepcopy() for node in summaries[entry.kind]], + ) + body += _table_row( + [ + nodes.paragraph("", "", operation_xref(entry.kind)), + _literal_fact(entry.command), + nodes.paragraph(text=entry.safety), + _literal_fact(entry.result_type), + nodes.paragraph(text=entry.min_version or "—"), + summary, + ] + ) + group += body + return table diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 903277352..59cddc58a 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -232,3 +232,79 @@ img[src*="codecov.io"] { ::view-transition-new(root) { animation-duration: 150ms; } +/* Keep producer-supplied operation badges on the same compact rhythm as + * gp-sphinx's native Python type badge. */ +.tmuxop-operation-card .gp-sphinx-api-badge-container { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + min-width: 0; + max-width: 100%; +} + +.tmuxop-badge-group { + flex-wrap: wrap; + max-width: 100%; + white-space: normal; +} + +.tmuxop-badge--safety-readonly { + --gp-sphinx-badge-bg: #1f7a3f; + --gp-sphinx-badge-fg: #f3fff7; + --gp-sphinx-badge-border: #2a8d4d; +} + +.tmuxop-badge--safety-mutating { + --gp-sphinx-badge-bg: #a85b13; + --gp-sphinx-badge-fg: #fff8ef; + --gp-sphinx-badge-border: #cf7a23; +} + +.tmuxop-badge--safety-destructive { + --gp-sphinx-badge-bg: #b4232c; + --gp-sphinx-badge-fg: #fff5f5; + --gp-sphinx-badge-border: #cb3640; +} + +.tmuxop-badge--scope-server { + --gp-sphinx-badge-bg: #155e75; + --gp-sphinx-badge-fg: #ecfeff; + --gp-sphinx-badge-border: #0891b2; +} + +.tmuxop-badge--scope-session { + --gp-sphinx-badge-bg: #1e40af; + --gp-sphinx-badge-fg: #eff6ff; + --gp-sphinx-badge-border: #3b82f6; +} + +.tmuxop-badge--scope-window { + --gp-sphinx-badge-bg: #5b21b6; + --gp-sphinx-badge-fg: #f5f3ff; + --gp-sphinx-badge-border: #8b5cf6; +} + +.tmuxop-badge--scope-pane { + --gp-sphinx-badge-bg: #3730a3; + --gp-sphinx-badge-fg: #eef2ff; + --gp-sphinx-badge-border: #6366f1; +} + +.tmuxop-badge--scope-client { + --gp-sphinx-badge-bg: #0e7490; + --gp-sphinx-badge-fg: #ecfeff; + --gp-sphinx-badge-border: #06b6d4; +} + +.tmuxop-badge--shape-primitive { + --gp-sphinx-badge-bg: #374151; + --gp-sphinx-badge-fg: #f9fafb; + --gp-sphinx-badge-border: #6b7280; +} + +.tmuxop-badge--shape-composed { + --gp-sphinx-badge-bg: #6b21a8; + --gp-sphinx-badge-fg: #faf5ff; + --gp-sphinx-badge-border: #a855f7; +} diff --git a/docs/conf.py b/docs/conf.py index 379d0b3c6..a09b8a0d2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,8 +4,10 @@ import pathlib import sys +import typing as t from gp_sphinx.config import make_linkcode_resolve, merge_sphinx_config +from sphinx.application import Sphinx import libtmux @@ -15,6 +17,7 @@ project_src = project_root / "src" sys.path.insert(0, str(project_src)) +sys.path.insert(0, str(cwd / "_ext")) # package data about: dict[str, str] = {} @@ -34,6 +37,7 @@ "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx.ext.todo", + "tmuxop", ], intersphinx_mapping={ "python": ("https://docs.python.org/", None), @@ -52,8 +56,71 @@ html_css_files=["css/custom.css"], html_extra_path=["manifest.json"], rediraffe_redirects="redirects.txt", + api_layout_enabled=True, # AGENTS.md (+ its CLAUDE.md symlink) is agent guidance, not a site # page; keep Sphinx from treating it as an orphan document. - exclude_patterns=["_build", "AGENTS.md", "CLAUDE.md"], + exclude_patterns=["_build", "AGENTS.md", "CLAUDE.md", "superpowers/**"], ) +conf["myst_enable_extensions"].append("fieldlist") + +_shared_setup = t.cast("t.Callable[[Sphinx], None]", conf["setup"]) + + +def _omit_removed_inline_tabs_script( + app: Sphinx, + _pagename: str, + _templatename: str, + context: dict[str, t.Any], + _doctree: object | None, +) -> None: + """Keep rendered pages consistent with gp-sphinx's static assets. + + gp-sphinx removes ``tabs.js`` after a successful HTML build because + sphinx-inline-tabs needs only its CSS behavior here. Remove the matching + page-context entry before Sphinx renders a stale ``script`` reference. + + Parameters + ---------- + app : sphinx.application.Sphinx + Active Sphinx application. + _pagename : str + Name of the page being rendered. + _templatename : str + Template used to render the page. + context : dict[str, Any] + Template context containing Sphinx's script assets. + _doctree : object | None + Page doctree, when available. + """ + if "sphinx_inline_tabs" not in app.extensions: + return + + script_files = context.get("script_files") + if not isinstance(script_files, list): + return + + context["script_files"] = [ + asset + for asset in script_files + if getattr(asset, "filename", None) != "_static/tabs.js" + ] + + +def setup(app: Sphinx) -> None: + """Configure shared gp-sphinx hooks and project asset consistency. + + Parameters + ---------- + app : sphinx.application.Sphinx + Active Sphinx application. + """ + _shared_setup(app) + app.connect( + "html-page-context", + _omit_removed_inline_tabs_script, + priority=900, + ) + + +conf["setup"] = setup globals().update(conf) diff --git a/docs/experimental/engines.md b/docs/experimental/engines.md new file mode 100644 index 000000000..652ce6396 --- /dev/null +++ b/docs/experimental/engines.md @@ -0,0 +1,99 @@ +# Execution engines + +Operations declare tmux commands and convert their output to typed results. +Engines own dispatch: transport, concurrency, process or connection lifetime, +batching, and transport-level failures. Changing the engine does not change the +operation or its result type, but it does change the resources and failure +boundary around execution. + +## Choose an engine + +| Engine | Dispatch model | Use it for | Worked example | +| ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------ | +| {class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` ([reference](engines/subprocess.md)) | One tmux CLI process per request | Straightforward synchronous live-server work | {doc}`tutorials/live-operation` | +| {class}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine` ([reference](engines/async-subprocess.md)) | One asyncio subprocess per request | Async applications that do not need a persistent connection | {doc}`tutorials/async-subprocess` | +| {class}`~libtmux.experimental.engines.mock.MockEngine` ([reference](engines/mock.md)) | Stateless in-memory simulation | Offline rendering and result-conversion tests | {doc}`tutorials/offline-testing` | +| {class}`~libtmux.experimental.engines.mock.AsyncMockEngine` ([reference](engines/async-mock.md)) | Async stateless in-memory simulation | Offline tests of async callers | {doc}`tutorials/offline-testing` | +| {class}`~libtmux.experimental.engines.control_mode.ControlModeEngine` ([reference](engines/control-mode.md)) | Subprocess bootstrap, then persistent `tmux -C` | Pipelined command batches | {doc}`tutorials/control-mode` | +| {class}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine` ([reference](engines/async-control-mode.md)) | Async subprocess bootstrap, then supervised `tmux -C` | Async batches and control-mode notifications | {doc}`tutorials/async-control-plans` | +| {class}`~libtmux.experimental.engines.imsg.base.ImsgEngine` ([reference](engines/imsg.md)) | Native socket with required CLI fallbacks | POSIX protocol experiments and narrow parity checks | {doc}`tutorials/imsg-parity` | + +The control-mode engines open a persistent client only when an existing +session has `destroy-unattached` set to `off`. Until then, they use their +matching subprocess engine so a user command can create the first safe session. + +## Select by name + +The registry exposes the synchronous engine families. +Async engines are constructed directly. Their lifecycle belongs to the caller's +event loop. + +```python +>>> from libtmux.experimental.engines import available_engines, create_engine +>>> available_engines() +('control_mode', 'imsg', 'mock', 'subprocess') +>>> type(create_engine("mock")).__name__ +'MockEngine' +``` + +## Engine boundary + +A synchronous engine satisfies +{class}`~libtmux.experimental.engines.base.TmuxEngine`; an async engine +satisfies {class}`~libtmux.experimental.engines.base.AsyncTmuxEngine`. Both +accept a {class}`~libtmux.experimental.engines.base.CommandRequest` and produce +a raw {class}`~libtmux.experimental.engines.base.CommandResult`. +{func}`~libtmux.experimental.ops.run` and +{func}`~libtmux.experimental.ops.arun` own the next boundary: they render an +operation and convert the raw command outcome to its declared typed result. + +A tmux command rejection is normally result data. Missing executables, dead +persistent connections, protocol mismatches, and similar transport failures +raise at the engine boundary. + +## Tutorials + +Each engine row links to its tested workflow. Start with +{doc}`tutorials/live-operation`, or go directly to the transport you need: + +- {doc}`tutorials/async-control-plans` +- {doc}`tutorials/async-subprocess` +- {doc}`tutorials/control-mode` +- {doc}`tutorials/offline-testing` +- {doc}`tutorials/imsg-parity` + +{doc}`tutorials/results-and-failures` is the shared result guide rather than an +engine-owned workflow. + +## Shared API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.base.TmuxEngine + :members: + +.. autoclass:: libtmux.experimental.engines.base.AsyncTmuxEngine + :members: + +.. autoclass:: libtmux.experimental.engines.base.CommandRequest + :members: + +.. autoclass:: libtmux.experimental.engines.base.CommandResult + :members: + +.. autofunction:: libtmux.experimental.engines.registry.available_engines + +.. autofunction:: libtmux.experimental.engines.registry.create_engine +``` + +```{toctree} +:hidden: + +engines/subprocess +engines/async-subprocess +engines/mock +engines/async-mock +engines/control-mode +engines/async-control-mode +engines/imsg +tutorials/index +``` diff --git a/docs/experimental/engines/async-control-mode.md b/docs/experimental/engines/async-control-mode.md new file mode 100644 index 000000000..b17a88d8d --- /dev/null +++ b/docs/experimental/engines/async-control-mode.md @@ -0,0 +1,97 @@ +# Async control-mode engine + +{class}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine` +combines hybrid async dispatch with supervised control-mode reconnection and +notification fan-out. + +## Use it when + +Use it for async command batches, desired subscription replay across +reconnections, or consumers of raw control-mode notifications. + +## Avoid it when + +Choose async subprocess execution when each request should have an independent +child lifetime. Do not choose this engine on the assumption that notification +subscription has a public readiness handshake. + +## Construction and cleanup + +Bind the engine with +{meth}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.for_server`. +Use the async context manager or call +{meth}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.aclose`. + +Async context entry looks for an existing session whose effective +`destroy-unattached` value is `off`. When it finds one, it starts the +supervisor eagerly and attaches to that exact session ID with +`attach-session -E`; tmux neither chooses a different session nor applies +`update-environment`. If no safe session exists, context entry stays lazy and +the first batch uses +{class}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine`. This lets +`new-session` bootstrap an empty server without creating a private control +session. A later batch can open the persistent connection. + +Within the context, pass typed operations to +{func}`~libtmux.experimental.ops.arun` as you would with any asynchronous +engine. + +```python +>>> import asyncio +>>> from libtmux.experimental.engines import AsyncControlModeEngine +>>> from libtmux.experimental.ops import DisplayMessage, PaneId, arun +>>> assert pane is not None and pane.pane_id is not None +>>> async def read_pane_id(): +... async with AsyncControlModeEngine.for_server(server) as engine: +... result = await arun( +... DisplayMessage(target=PaneId(pane.pane_id), message="#{pane_id}"), +... engine, +... ) +... return result.raise_for_status().text +>>> asyncio.run(read_pane_id()) == pane.pane_id +True +``` + +## Lifecycle and failure boundary + +A supervisor owns the connection, reader, reconnection backoff, and replay of +state registered with +{meth}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.add_subscription` +and +{meth}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.set_attach_targets`. +Per-subscriber queues are bounded; +{attr}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.dropped_notifications` +reports overflow. Connection failures and timeouts raise at the engine +boundary, while tmux command errors remain result data. Sequence anomalies are +logged. + +The persistent process has normal tmux client semantics: `list-clients` shows +it, `session_attached` includes it, and client attach and detach hooks can +observe it. The engine never changes `destroy-unattached` and does not issue a +session-deletion command during cleanup. If that option changes while the +client is attached, closing the engine detaches normally and tmux applies the +new policy. + +{meth}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.subscribe` +is an async generator. Its queue is registered only when iteration advances, +not when the generator object is created, and it does not start the engine. +If context entry stayed lazy, or the caller does not use the context manager, a +notification-only consumer must call +{meth}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.start` +after a safe session exists; direct startup raises +{exc}`~libtmux.experimental.engines.control_mode.ControlModeError` when none is +available. The API has no subscriber-readiness signal, so code must not assume +a notification emitted before the first iteration will be delivered. + +## API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine + :members: + :special-members: __aenter__, __aexit__ +``` + +## Related tutorial + +See {doc}`../tutorials/async-control-plans` to compose forward-referenced +operations and fold them into control-mode dispatches. diff --git a/docs/experimental/engines/async-mock.md b/docs/experimental/engines/async-mock.md new file mode 100644 index 000000000..ba3b4782e --- /dev/null +++ b/docs/experimental/engines/async-mock.md @@ -0,0 +1,55 @@ +# Async mock engine + +{class}`~libtmux.experimental.engines.mock.AsyncMockEngine` provides the mock +simulation through the async engine protocol. + +## Use it when + +Use it to exercise async application and plan code offline while preserving the +same operation rendering and typed result conversion as a live async engine. + +## Avoid it when + +Do not use it to test scheduling, cancellation, subprocess cleanup, control-mode +reconnection, notifications, or real tmux state. + +## Construction and cleanup + +Pass optional canned `capture_lines`. Create operations receive fabricated IDs. +The engine owns no task, process, or connection, so no cleanup is required. + +The `%1` below is fabricated. The engine neither inspects `@404` nor creates a +pane. + +```python +>>> import asyncio +>>> from libtmux.experimental.engines import AsyncMockEngine +>>> from libtmux.experimental.ops import SplitWindow, WindowId, arun +>>> operation = SplitWindow(target=WindowId("@404")) +>>> async def create_offline(): +... return await arun(operation, AsyncMockEngine()) +>>> fabricated = asyncio.run(create_offline()).raise_for_status() +>>> fabricated.new_pane_id, fabricated.argv == operation.render() +('%1', True) +``` + +## Lifecycle and failure boundary + +The async methods complete from memory, and +{meth}`~libtmux.experimental.engines.mock.AsyncMockEngine.run_batch` executes +requests in order. The simulation fabricates create IDs and canned captures but +does not model server objects or transport failure. It also cannot report a +tmux version, so operation rendering assumes the latest behavior unless the +caller supplies `version`. + +## API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.mock.AsyncMockEngine + :members: +``` + +## Related tutorial + +See {doc}`../tutorials/offline-testing` for synchronous and asynchronous +offline test patterns. diff --git a/docs/experimental/engines/async-subprocess.md b/docs/experimental/engines/async-subprocess.md new file mode 100644 index 000000000..04b1af252 --- /dev/null +++ b/docs/experimental/engines/async-subprocess.md @@ -0,0 +1,60 @@ +# Async subprocess engine + +{class}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine` runs each +request with native asyncio subprocess I/O. + +## Use it when + +Use this engine for typed tmux operations inside an async application when each +request may own a short-lived child process. + +## Avoid it when + +Choose async control mode when batches should be pipelined over one connection +or the application consumes control-mode notifications. + +## Construction and cleanup + +Construct it with explicit connection arguments, or bind it with +{meth}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine.for_server`. +It has no persistent resource or close method; each +{meth}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine.run` call +reaps its child. + +```python +>>> import asyncio +>>> from libtmux.experimental.engines import AsyncSubprocessEngine +>>> from libtmux.experimental.ops import DisplayMessage, PaneId, arun +>>> assert pane is not None and pane.pane_id is not None +>>> async def read_pane_id(): +... engine = AsyncSubprocessEngine.for_server(server) +... result = await arun( +... DisplayMessage(target=PaneId(pane.pane_id), message="#{pane_id}"), +... engine, +... ) +... return result.raise_for_status().text +>>> asyncio.run(read_pane_id()) == pane.pane_id +True +``` + +## Lifecycle and failure boundary + +{meth}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine.run_batch` +awaits requests sequentially to preserve command order. Separate +{meth}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine.run` or +{func}`~libtmux.experimental.ops.arun` calls may be scheduled concurrently by +the caller. If a task is cancelled while the child is running, the engine +terminates and reaps that child before propagating cancellation. Cancellation +cannot roll back a tmux mutation the server already accepted. + +## API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.asyncio.AsyncSubprocessEngine + :members: +``` + +## Related tutorial + +See {doc}`../tutorials/async-subprocess` for concurrent operation dispatch and +cancellation boundaries. diff --git a/docs/experimental/engines/control-mode.md b/docs/experimental/engines/control-mode.md new file mode 100644 index 000000000..2d421bd57 --- /dev/null +++ b/docs/experimental/engines/control-mode.md @@ -0,0 +1,76 @@ +# Control-mode engine + +{class}`~libtmux.experimental.engines.control_mode.ControlModeEngine` pipelines +requests over one synchronous `tmux -C` connection once it can attach to a +session safely. Before then, it dispatches through subprocesses. + +## Use it when + +Use it for synchronous workloads where several commands should avoid one +process start per request. + +## Avoid it when + +Choose subprocess execution for isolated one-shot commands. Choose async control +mode when the event loop or notification stream is part of the application. + +## Construction and cleanup + +Bind the engine to a live server with +{meth}`~libtmux.experimental.engines.control_mode.ControlModeEngine.for_server`, +and always call +{meth}`~libtmux.experimental.engines.control_mode.ControlModeEngine.close` or +use the context manager. The context is lazy: the first batch looks for an +existing session whose effective `destroy-unattached` value is `off`. The +engine attaches to that exact session ID with `attach-session -E`, so tmux does +not choose a different session and does not apply `update-environment`. + +If no safe session exists, the batch uses +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` instead. +This lets `new-session` bootstrap an empty server without creating a private +control session. A later batch can open the persistent connection. + +Within the context, pass typed operations to +{func}`~libtmux.experimental.ops.run` as you would with any synchronous engine. + +```python +>>> from libtmux.experimental.engines import ControlModeEngine +>>> from libtmux.experimental.ops import DisplayMessage, PaneId, run +>>> assert pane is not None and pane.pane_id is not None +>>> with ControlModeEngine.for_server(server) as engine: +... result = run( +... DisplayMessage(target=PaneId(pane.pane_id), message="#{pane_id}"), +... engine, +... ).raise_for_status() +>>> result.text == pane.pane_id +True +``` + +## Lifecycle and failure boundary + +{meth}`~libtmux.experimental.engines.control_mode.ControlModeEngine.run_batch` +writes the batch before collecting correlated control-mode result blocks. The +engine drains unsolicited notifications so they are not mistaken for command +replies. Timeouts, connection death, and write failures raise +{exc}`~libtmux.experimental.engines.control_mode.ControlModeError`; tmux +`%error` blocks remain command-result data. Sequence anomalies are logged. + +The persistent process has normal tmux client semantics: `list-clients` shows +it, `session_attached` includes it, and client attach and detach hooks can +observe it. The engine never changes `destroy-unattached` and does not issue a +session-deletion command during cleanup. If that option changes while the +client is attached, closing the engine detaches normally and tmux applies the +new policy. + +## API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.control_mode.ControlModeEngine + :members: + :special-members: __enter__, __exit__ +``` + +## Related tutorial + +See {doc}`../tutorials/control-mode` for ordered pipelining over one synchronous +control client. diff --git a/docs/experimental/engines/imsg.md b/docs/experimental/engines/imsg.md new file mode 100644 index 000000000..f8d361924 --- /dev/null +++ b/docs/experimental/engines/imsg.md @@ -0,0 +1,64 @@ +# Imsg engine + +{class}`~libtmux.experimental.engines.imsg.base.ImsgEngine` speaks tmux's native +binary peer protocol over a Unix socket. + +## Use it when + +Use it on POSIX systems for narrow protocol experiments and direct comparisons +against the CLI engine at a known tmux protocol version. + +## Avoid it when + +Do not treat it as a portable replacement for the tmux CLI or as a universal +parity guarantee. The implementation currently supports protocol v8 and relies +on POSIX sockets, file descriptors, and process APIs. + +## Construction and cleanup + +Construct it directly, optionally with `protocol_version`. Socket-dispatched +requests open and close their own connection, so the engine has no close +method. Local queries and commands that must start a missing server use the +tmux binary instead. Unlike the subprocess and control-mode engines, it has no +`for_server()` helper. Put a private server's raw `-L` or `-S` global argument +in every +{class}`~libtmux.experimental.engines.base.CommandRequest`. + +```python +>>> from libtmux.experimental.engines import CommandRequest, ImsgEngine +>>> assert server.socket_name is not None +>>> assert session.session_id is not None +>>> request = CommandRequest.from_args( +... f"-L{server.socket_name}", +... "display-message", +... "-p", +... "-t", +... session.session_id, +... "#{session_id}", +... ) +>>> result = ImsgEngine().run(request) +>>> result.returncode, result.stdout == (session.session_id,) +(0, True) +``` + +## Lifecycle and failure boundary + +{meth}`~libtmux.experimental.engines.imsg.base.ImsgEngine.run_batch` calls +{meth}`~libtmux.experimental.engines.imsg.base.ImsgEngine.run` for each request +in order. Each socket-dispatched command gets a fresh connection; local +queries, `start-server`, and server-starting commands such as `new-session` +against a missing server use the tmux binary. Missing or lost servers become +failed command results. Unsupported protocol negotiation, framing, codec, and +unexpected socket setup failures raise at the engine boundary. + +## API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.imsg.base.ImsgEngine + :members: +``` + +## Related tutorial + +See {doc}`../tutorials/imsg-parity` for an explicit private-server binding and a +bounded CLI comparison. diff --git a/docs/experimental/engines/mock.md b/docs/experimental/engines/mock.md new file mode 100644 index 000000000..07c458530 --- /dev/null +++ b/docs/experimental/engines/mock.md @@ -0,0 +1,55 @@ +# Mock engine + +{class}`~libtmux.experimental.engines.mock.MockEngine` is a synchronous, +in-memory command simulator. + +## Use it when + +Use it to test operation rendering, typed result conversion, fabricated create +IDs, and deterministic captured pane content without starting tmux. + +## Avoid it when + +Do not use it to prove target existence, server state, version behavior, +permissions, shell execution, transport failures, or tmux output parsing. + +## Construction and cleanup + +Pass optional canned `capture_lines`. An instance keeps only monotonic ID +counters and needs no cleanup. + +The result below is canned. The engine does not contact tmux or inspect whether +the target pane exists. + +```python +>>> from libtmux.experimental.engines import MockEngine +>>> from libtmux.experimental.ops import CapturePane, PaneId, run +>>> engine = MockEngine(capture_lines=("canned, no tmux",)) +>>> canned = run( +... CapturePane(target=PaneId("%404")), +... engine, +... ).raise_for_status() +>>> canned.lines +('canned, no tmux',) +``` + +## Lifecycle and failure boundary + +Create commands that request formatted IDs receive fabricated IDs. +`capture-pane` receives the configured lines; every other command succeeds with +empty output. The simulator does not track whether fabricated sessions, +windows, or panes exist. +{meth}`~libtmux.experimental.engines.mock.MockEngine.run_batch` is an ordered +loop with no batching benefit. + +## API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.mock.MockEngine + :members: +``` + +## Related tutorial + +See {doc}`../tutorials/offline-testing` for the claims an offline engine can and +cannot support. diff --git a/docs/experimental/engines/subprocess.md b/docs/experimental/engines/subprocess.md new file mode 100644 index 000000000..c224199a0 --- /dev/null +++ b/docs/experimental/engines/subprocess.md @@ -0,0 +1,52 @@ +# Subprocess engine + +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` runs each +request through a new tmux CLI process. + +## Use it when + +Use this engine for synchronous live-server work, especially when independent +process lifetime and the classic tmux CLI path are useful defaults. + +## Avoid it when + +Choose control mode when a large batch should share one connection. Choose an +async engine when blocking process I/O would stall an event loop. + +## Construction and cleanup + +Construct the engine with explicit `tmux_bin` and `server_args`, or use +{meth}`~libtmux.experimental.engines.subprocess.SubprocessEngine.for_server` to +copy a live {class}`~libtmux.Server` connection. The engine retains no child +process and has no cleanup method. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListSessions, run +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> result = run(ListSessions(), engine).raise_for_status() +>>> any(item.session_id == session.session_id for item in result.sessions) +True +``` + +## Lifecycle and failure boundary + +{meth}`~libtmux.experimental.engines.subprocess.SubprocessEngine.run` starts, +communicates with, and reaps one child. +{meth}`~libtmux.experimental.engines.subprocess.SubprocessEngine.run_batch` +repeats that sequence in request order, so it does not pipeline. A tmux +rejection is returned as raw command data and becomes a failed operation result. +A missing tmux executable raises {exc}`libtmux.exc.TmuxCommandNotFound`. + +## API + +```{eval-rst} +.. autoclass:: libtmux.experimental.engines.subprocess.SubprocessEngine + :members: +``` + +## Related tutorial + +See {doc}`../tutorials/live-operation` for standalone and test-fixture server +setup. diff --git a/docs/experimental/index.md b/docs/experimental/index.md new file mode 100644 index 000000000..0fcf402f7 --- /dev/null +++ b/docs/experimental/index.md @@ -0,0 +1,53 @@ +(experimental)= + +# Experimental API + +```{warning} +Everything under {mod}`libtmux.experimental` is **not** covered by the +versioning policy and may change or be removed between any two releases. +``` + +`libtmux.experimental` separates an inert, typed operation from the engine that +executes it. An operation renders tmux arguments and declares its result and +effects; an engine returns the raw command outcome, and +{func}`~libtmux.experimental.ops.run` or +{func}`~libtmux.experimental.ops.arun` converts it to the operation's typed +result. + +## Start here + +- [Operations](operations/index.md) documents every registered command by + Server, Session, Window, Pane, and Client scope. +- [Results](results.md) documents shared status and output fields plus each + operation payload type. +- [Engines](engines.md) explains how to choose a transport without changing the + operation contract. +- [Plans](plans.md) covers deferred execution, planners, and the fluent builder. + +## Run one operation + +{func}`~libtmux.experimental.ops.run` passes an operation value to an engine. +Inspect the returned result, or opt into an exception with +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status`: + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> result = run(HasSession(target=SessionId(session.session_id)), engine) +>>> result.status, result.exists +('complete', True) +>>> result.raise_for_status() is result +True +``` + +```{toctree} +:hidden: + +operations/index +results +engines +plans +``` diff --git a/docs/experimental/operations/client/detach_client.md b/docs/experimental/operations/client/detach_client.md new file mode 100644 index 000000000..46c3c9721 --- /dev/null +++ b/docs/experimental/operations/client/detach_client.md @@ -0,0 +1,45 @@ +# Detach a client + +{class}`~libtmux.experimental.ops.DetachClient` disconnects one attached tmux +client without stopping its session. + +## Example + +This executable example uses an isolated live tmux server. `control_mode` +creates a real attached client that the example owns. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DetachClient, ListClients, run +>>> from libtmux.experimental.ops._types import ClientName +>>> engine = SubprocessEngine.for_server(server) +>>> with control_mode() as attached: +... client_name = attached.client_name +... before = run(ListClients(), engine).raise_for_status() +... result = run( +... DetachClient(target=ClientName(client_name)), +... engine, +... ).raise_for_status() +... after = run(ListClients(), engine).raise_for_status() +>>> ( +... type(result).__name__, +... client_name in {item.name for item in before.clients}, +... client_name not in {item.name for item in after.clients}, +... ) +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} detach_client +``` + +## Failure and effects + +The target must name a currently attached client. Detaching it invalidates +snapshots that assumed the client remained connected. + +## Related operations + +- {tmuxop:op}`switch_client` +- {tmuxop:op}`refresh_client` diff --git a/docs/experimental/operations/client/index.md b/docs/experimental/operations/client/index.md new file mode 100644 index 000000000..8d86dcffc --- /dev/null +++ b/docs/experimental/operations/client/index.md @@ -0,0 +1,8 @@ +# Client operations + +Client operations control tmux clients attached to a server. + +```{tmuxop:catalog} +:scope: client +:toctree: +``` diff --git a/docs/experimental/operations/client/refresh_client.md b/docs/experimental/operations/client/refresh_client.md new file mode 100644 index 000000000..dfd94148c --- /dev/null +++ b/docs/experimental/operations/client/refresh_client.md @@ -0,0 +1,52 @@ +# Refresh a client + +{class}`~libtmux.experimental.ops.RefreshClient` asks tmux to redraw one +attached client and returns an acknowledgement. + +## Example + +This executable example uses an isolated live tmux server. `control_mode` +creates a real attached client that the example owns. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListClients, RefreshClient, run +>>> from libtmux.experimental.ops._types import ClientName +>>> engine = SubprocessEngine.for_server(server) +>>> with control_mode() as attached: +... before = run(ListClients(), engine).raise_for_status() +... client = next( +... item for item in before.clients if item.name == attached.client_name +... ) +... result = run( +... RefreshClient(target=ClientName(client.name)), +... engine, +... ).raise_for_status() +... after = run(ListClients(), engine).raise_for_status() +... observed = next(item for item in after.clients if item.name == client.name) +... proof = ( +... type(client).__name__, +... type(result).__name__, +... observed.pid == client.pid, +... ) +>>> proof +('ClientSnapshot', 'AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} refresh_client +``` + +## Failure and effects + +This operation changes tmux state. It is safe to repeat. + +tmux exposes redraw as an acknowledgement, not a durable value that can be read +back. The example therefore proves the exact live client target and successful +typed acknowledgement without inventing a visual postcondition. + +## Related operations + +- {tmuxop:op}`detach_client` +- {tmuxop:op}`suspend_client` diff --git a/docs/experimental/operations/client/suspend_client.md b/docs/experimental/operations/client/suspend_client.md new file mode 100644 index 000000000..d01c91837 --- /dev/null +++ b/docs/experimental/operations/client/suspend_client.md @@ -0,0 +1,53 @@ +# Suspend a client + +{class}`~libtmux.experimental.ops.SuspendClient` suspends one attached client +while leaving its tmux session running. + +## Example + +This executable example uses the injected live `server` and `session`. +`control_mode` creates a real attached client that the example owns. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import HasSession, SuspendClient, run +>>> from libtmux.experimental.ops._types import ClientName, SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> session_target = SessionId(session.session_id) +>>> with control_mode() as attached: +... result = run( +... SuspendClient(target=ClientName(attached.client_name)), +... engine, +... ).raise_for_status() +... session_alive = run( +... HasSession(target=session_target), +... engine, +... ).raise_for_status() +>>> ( +... type(result).__name__, +... result.status, +... result.returncode, +... result.stdout, +... result.stderr, +... session_alive.exists, +... ) +('AckResult', 'complete', 0, (), (), True) +``` + +## Operation reference + +```{tmuxop:operation} suspend_client +``` + +## Failure and effects + +Suspension leaves the tmux session alive. Depending on the tmux release, +`list-clients` may retain or filter the suspended client, so list membership is +not a portable suspension signal. Resuming terminal job control belongs to the +client process, not the operation result. + +## Related operations + +- {tmuxop:op}`refresh_client` +- {tmuxop:op}`switch_client` diff --git a/docs/experimental/operations/client/switch_client.md b/docs/experimental/operations/client/switch_client.md new file mode 100644 index 000000000..c2d7a2f91 --- /dev/null +++ b/docs/experimental/operations/client/switch_client.md @@ -0,0 +1,55 @@ +# Switch a client to another session + +{class}`~libtmux.experimental.ops.SwitchClient` moves one attached client to a +different session without changing either session. + +## Example + +This executable example uses the injected live `server` and `session`. +`control_mode` creates a real attached client that the example owns. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListClients, NewSession, SwitchClient, run +>>> engine = SubprocessEngine.for_server(server) +>>> destination = run( +... NewSession(session_name="docs-destination"), +... engine, +... ).raise_for_status() +>>> assert destination.new_id is not None +>>> with control_mode() as attached: +... before = run(ListClients(), engine).raise_for_status() +... original = next( +... item for item in before.clients if item.name == attached.client_name +... ) +... result = run( +... SwitchClient( +... client=original.name, +... to_session=destination.new_id, +... ), +... engine, +... ).raise_for_status() +... after = run(ListClients(), engine).raise_for_status() +... moved = next(item for item in after.clients if item.name == original.name) +>>> ( +... type(result).__name__, +... original.session == session.session_name, +... moved.session == "docs-destination", +... ) +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} switch_client +``` + +## Failure and effects + +The command identifies the client with `client` and the destination with +`to_session`; it does not use the operation's generic target field. + +## Related operations + +- {tmuxop:op}`suspend_client` +- {tmuxop:op}`detach_client` diff --git a/docs/experimental/operations/index.md b/docs/experimental/operations/index.md new file mode 100644 index 000000000..241c2aca8 --- /dev/null +++ b/docs/experimental/operations/index.md @@ -0,0 +1,41 @@ +# Operations + +Operations are immutable command descriptions. Each page below documents the +Python constructor, tmux command, result type, version gates, safety, and +effects directly from the operation registry. + +## Execution contract + +An engine emits a raw +{class}`~libtmux.experimental.engines.base.CommandResult`. +{func}`~libtmux.experimental.ops.run` converts it to the operation's declared +{class}`~libtmux.experimental.ops.results.Result` subtype. Results preserve +failures as data; call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` when the +calling boundary requires an exception. + +Each operation page runs its visible example against an isolated tmux server +through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine`. The +example shows the typed result and an observable tmux outcome, so the same code +that teaches the operation also verifies its public behavior. + +## Browse by tmux object + +- [Server operations](server/index.md) manage server-wide state, buffers, and + hierarchy discovery. +- [Session operations](session/index.md) manage a session and its windows. +- [Window operations](window/index.md) manage windows, layouts, and pane + placement. +- [Pane operations](pane/index.md) manage terminal input, output, and geometry. +- [Client operations](client/index.md) manage attached tmux clients. + +```{toctree} +:hidden: + +server/index +session/index +window/index +pane/index +client/index +``` diff --git a/docs/experimental/operations/pane/capture_pane.md b/docs/experimental/operations/pane/capture_pane.md new file mode 100644 index 000000000..576b7f38e --- /dev/null +++ b/docs/experimental/operations/pane/capture_pane.md @@ -0,0 +1,48 @@ +# Capture pane output + +{class}`~libtmux.experimental.ops.CapturePane` reads the visible pane and +scrollback into typed output lines. + +## Example + +This executable example uses the injected live `server` and `pane` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import CapturePane, SendKeys, run +>>> from libtmux.experimental.ops._types import PaneId +>>> assert pane.pane_id is not None +>>> target = PaneId(pane.pane_id) +>>> engine = SubprocessEngine.for_server(server) +>>> _ = run( +... SendKeys( +... target=target, +... keys="printf 'docs-captured\\n'; tmux wait-for -S docs-capture", +... enter=True, +... ), +... engine, +... ).raise_for_status() +>>> server.wait_for("docs-capture") +>>> result = run(CapturePane(target=target, start=-10), engine).raise_for_status() +>>> type(result).__name__, any("docs-captured" in line for line in result.lines) +('CapturePaneResult', True) +``` + +## Operation reference + +```{tmuxop:operation} capture_pane +``` + +## Failure and effects + +This operation reads terminal output without changing tmux state. It is safe to +repeat. + +The `trim_trailing` flag is available on tmux 3.4 and newer; `mode_screen` +requires tmux 3.6. The operation omits an unavailable optional flag when you +provide an older tmux version. + +## Related operations + +- {tmuxop:op}`swap_pane` +- {tmuxop:op}`clear_history` diff --git a/docs/experimental/operations/pane/clear_history.md b/docs/experimental/operations/pane/clear_history.md new file mode 100644 index 000000000..43bd47750 --- /dev/null +++ b/docs/experimental/operations/pane/clear_history.md @@ -0,0 +1,72 @@ +# Clear a pane's scrollback history + +{class}`~libtmux.experimental.ops.ClearHistory` discards a pane's accumulated +scrollback without closing the pane. + +## Example + +This executable example uses the injected live `server` and `session` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... ClearHistory, +... DisplayMessage, +... NewWindow, +... SendKeys, +... run, +... ) +>>> from libtmux.experimental.ops._types import PaneId, SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow( +... target=SessionId(session.session_id), +... name="docs-clear-history", +... capture_pane=True, +... ), +... engine, +... ).raise_for_status() +>>> assert created.first_pane_id is not None +>>> target = PaneId(created.first_pane_id) +>>> _ = run( +... SendKeys( +... target=target, +... keys=( +... "for i in $(seq 1 50); do echo docs-history-$i; done; " +... "tmux wait-for -S docs-history" +... ), +... enter=True, +... ), +... engine, +... ).raise_for_status() +>>> server.wait_for("docs-history") +>>> before = run( +... DisplayMessage(target=target, message="#{history_size}"), +... engine, +... ).raise_for_status() +>>> result = run(ClearHistory(target=target), engine).raise_for_status() +>>> after = run( +... DisplayMessage(target=target, message="#{history_size}"), +... engine, +... ).raise_for_status() +>>> int(before.text) > 0, type(result).__name__, after.text +(True, 'AckResult', '0') +``` + +## Operation reference + +```{tmuxop:operation} clear_history +``` + +## Failure and effects + +Clearing scrollback is irreversible for that pane. It does not erase the lines +currently visible on screen, and repeating the operation is safe. + +The example creates the pane whose history it clears. + +## Related operations + +- {tmuxop:op}`capture_pane` +- {tmuxop:op}`display_message` diff --git a/docs/experimental/operations/pane/display_message.md b/docs/experimental/operations/pane/display_message.md new file mode 100644 index 000000000..c1dbeceff --- /dev/null +++ b/docs/experimental/operations/pane/display_message.md @@ -0,0 +1,44 @@ +# Evaluate a tmux format + +{class}`~libtmux.experimental.ops.DisplayMessage` evaluates a tmux format +against a target and returns its text. + +## Example + +This executable example uses the injected live `server`, `session`, `window`, +and `pane` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DisplayMessage, run +>>> from libtmux.experimental.ops._types import PaneId +>>> assert pane.pane_id is not None +>>> result = run( +... DisplayMessage( +... target=PaneId(pane.pane_id), +... message="#{session_id}:#{window_id}:#{pane_id}", +... ), +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> result.text.split(":") == [ +... session.session_id, +... window.window_id, +... pane.pane_id, +... ] +True +``` + +## Operation reference + +```{tmuxop:operation} display_message +``` + +## Failure and effects + +This operation reads tmux state without changing it. An unknown or unavailable +format expands to an empty value; a missing target produces a failed result. + +## Related operations + +- {tmuxop:op}`clear_history` +- {tmuxop:op}`join_pane` diff --git a/docs/experimental/operations/pane/index.md b/docs/experimental/operations/pane/index.md new file mode 100644 index 000000000..134ac2079 --- /dev/null +++ b/docs/experimental/operations/pane/index.md @@ -0,0 +1,9 @@ +# Pane operations + +Pane operations send terminal input, capture output, move panes, and control +pane geometry and lifecycle. + +```{tmuxop:catalog} +:scope: pane +:toctree: +``` diff --git a/docs/experimental/operations/pane/join_pane.md b/docs/experimental/operations/pane/join_pane.md new file mode 100644 index 000000000..630730c49 --- /dev/null +++ b/docs/experimental/operations/pane/join_pane.md @@ -0,0 +1,56 @@ +# Join a pane into another window + +{class}`~libtmux.experimental.ops.JoinPane` moves a source pane into a +destination window and splits the destination around it. + +## Example + +This executable example uses the injected live `server`, `session`, and +`window` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import JoinPane, ListPanes, NewWindow, run +>>> from libtmux.experimental.ops._types import PaneId, SessionId, WindowId +>>> assert session.session_id is not None and window.window_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow( +... target=SessionId(session.session_id), +... name="docs-join-source", +... capture_pane=True, +... ), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None and created.first_pane_id is not None +>>> before = run(ListPanes(), engine).raise_for_status() +>>> source = next( +... item for item in before.panes if item.pane_id == created.first_pane_id +... ) +>>> result = run( +... JoinPane( +... target=WindowId(window.window_id), +... src_target=PaneId(source.pane_id), +... ), +... engine, +... ).raise_for_status() +>>> after = run(ListPanes(), engine).raise_for_status() +>>> moved = next(item for item in after.panes if item.pane_id == source.pane_id) +>>> type(result).__name__, source.window_id == created.new_id, moved.window_id == window.window_id +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} join_pane +``` + +## Failure and effects + +Moving the only pane out of its source window removes that empty window. Both +targets must belong to the same tmux server. + +## Related operations + +- {tmuxop:op}`display_message` +- {tmuxop:op}`kill_pane` diff --git a/docs/experimental/operations/pane/kill_pane.md b/docs/experimental/operations/pane/kill_pane.md new file mode 100644 index 000000000..cf152e83b --- /dev/null +++ b/docs/experimental/operations/pane/kill_pane.md @@ -0,0 +1,48 @@ +# Kill a pane + +{class}`~libtmux.experimental.ops.KillPane` stops the process in one pane and +removes the pane from its window. + +## Example + +This executable example uses the injected live `server` and `window` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import KillPane, ListPanes, SplitWindow, run +>>> from libtmux.experimental.ops._types import PaneId, WindowId +>>> assert window.window_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... SplitWindow(target=WindowId(window.window_id)), +... engine, +... ).raise_for_status() +>>> assert created.new_pane_id is not None +>>> before = run(ListPanes(), engine).raise_for_status() +>>> result = run( +... KillPane(target=PaneId(created.new_pane_id)), +... engine, +... ).raise_for_status() +>>> after = run(ListPanes(), engine).raise_for_status() +>>> ( +... type(result).__name__, +... created.new_pane_id in {item.pane_id for item in before.panes}, +... created.new_pane_id not in {item.pane_id for item in after.panes}, +... ) +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} kill_pane +``` + +## Failure and effects + +This operation is destructive and cannot be undone. The example creates a +disposable second pane instead of targeting the fixture's primary pane. + +## Related operations + +- {tmuxop:op}`join_pane` +- {tmuxop:op}`move_pane` diff --git a/docs/experimental/operations/pane/move_pane.md b/docs/experimental/operations/pane/move_pane.md new file mode 100644 index 000000000..3bab59ea9 --- /dev/null +++ b/docs/experimental/operations/pane/move_pane.md @@ -0,0 +1,56 @@ +# Move a pane into another window + +{class}`~libtmux.experimental.ops.MovePane` relocates a source pane into a +destination window while preserving the pane identifier. + +## Example + +This executable example uses the injected live `server`, `session`, and +`window` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, MovePane, NewWindow, run +>>> from libtmux.experimental.ops._types import PaneId, SessionId, WindowId +>>> assert session.session_id is not None and window.window_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow( +... target=SessionId(session.session_id), +... name="docs-move-source", +... capture_pane=True, +... ), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None and created.first_pane_id is not None +>>> before = run(ListPanes(), engine).raise_for_status() +>>> source = next( +... item for item in before.panes if item.pane_id == created.first_pane_id +... ) +>>> result = run( +... MovePane( +... target=WindowId(window.window_id), +... src_target=PaneId(source.pane_id), +... ), +... engine, +... ).raise_for_status() +>>> after = run(ListPanes(), engine).raise_for_status() +>>> moved = next(item for item in after.panes if item.pane_id == source.pane_id) +>>> type(result).__name__, source.window_id == created.new_id, moved.window_id == window.window_id +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} move_pane +``` + +## Failure and effects + +The operation changes pane ownership and layout. Moving the only pane out of a +window removes the empty source window. + +## Related operations + +- {tmuxop:op}`kill_pane` +- {tmuxop:op}`paste_buffer` diff --git a/docs/experimental/operations/pane/paste_buffer.md b/docs/experimental/operations/pane/paste_buffer.md new file mode 100644 index 000000000..fac73fb9d --- /dev/null +++ b/docs/experimental/operations/pane/paste_buffer.md @@ -0,0 +1,55 @@ +# Paste a buffer into a pane + +{class}`~libtmux.experimental.ops.PasteBuffer` writes a tmux paste buffer into +one pane as terminal input. + +## Example + +This executable example uses the injected live `server` and `pane` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import CapturePane, PasteBuffer, SetBuffer, run +>>> from libtmux.experimental.ops._types import PaneId +>>> assert pane.pane_id is not None +>>> target = PaneId(pane.pane_id) +>>> engine = SubprocessEngine.for_server(server) +>>> _ = run( +... SetBuffer( +... buffer_name="docs-paste", +... data=( +... "printf 'docs-pasted\\n'; " +... "tmux wait-for -S docs-paste-buffer\n" +... ), +... ), +... engine, +... ).raise_for_status() +>>> result = run( +... PasteBuffer( +... target=target, +... buffer_name="docs-paste", +... delete=True, +... ), +... engine, +... ).raise_for_status() +>>> server.wait_for("docs-paste-buffer") +>>> captured = run(CapturePane(target=target, start=-10), engine).raise_for_status() +>>> type(result).__name__, any("docs-pasted" in line for line in captured.lines) +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} paste_buffer +``` + +## Failure and effects + +This operation writes input. `delete=True` removes the buffer after a successful +paste; `bracket=True` asks applications that support bracketed paste to treat +the text as one paste event. + +## Related operations + +- {tmuxop:op}`move_pane` +- {tmuxop:op}`pipe_pane` diff --git a/docs/experimental/operations/pane/pipe_pane.md b/docs/experimental/operations/pane/pipe_pane.md new file mode 100644 index 000000000..4f38f608c --- /dev/null +++ b/docs/experimental/operations/pane/pipe_pane.md @@ -0,0 +1,61 @@ +# Pipe pane output to a command + +{class}`~libtmux.experimental.ops.PipePane` streams new output from one pane to +a shell command until you close or replace the pipe. + +## Example + +This executable example uses the injected live `server`, `pane`, and +`tmp_path` context. The output file belongs to the documentation sandbox. + +```python +>>> import shlex +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import PipePane, SendKeys, run +>>> from libtmux.experimental.ops._types import PaneId +>>> from libtmux.test.retry import retry_until +>>> output = tmp_path / "pane-output.txt" +>>> assert pane.pane_id is not None +>>> target = PaneId(pane.pane_id) +>>> engine = SubprocessEngine.for_server(server) +>>> opened = run( +... PipePane( +... target=target, +... command_line=f"cat > {shlex.quote(str(output))}", +... ), +... engine, +... ).raise_for_status() +>>> _ = run( +... SendKeys( +... target=target, +... keys="printf 'docs-pipe\\n'; tmux wait-for -S docs-pipe-pane", +... enter=True, +... ), +... engine, +... ).raise_for_status() +>>> server.wait_for("docs-pipe-pane") +>>> closed = run(PipePane(target=target), engine).raise_for_status() +>>> observed = retry_until( +... lambda: output.exists() +... and "docs-pipe" in output.read_text(encoding="utf-8"), +... seconds=2, +... ) +>>> type(opened).__name__, type(closed).__name__, observed +('AckResult', 'AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} pipe_pane +``` + +## Failure and effects + +Calling the operation without `command_line` closes the current pipe. The +consumer command controls buffering and file semantics; the example closes the +pipe before reading its owned file. + +## Related operations + +- {tmuxop:op}`paste_buffer` +- {tmuxop:op}`resize_pane` diff --git a/docs/experimental/operations/pane/resize_pane.md b/docs/experimental/operations/pane/resize_pane.md new file mode 100644 index 000000000..3f53a1036 --- /dev/null +++ b/docs/experimental/operations/pane/resize_pane.md @@ -0,0 +1,44 @@ +# Resize a pane + +{class}`~libtmux.experimental.ops.ResizePane` changes a pane's width or height, +moves one boundary, or toggles zoom. + +## Example + +This executable example uses the injected live `server` and `window` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, ResizePane, SplitWindow, run +>>> from libtmux.experimental.ops._types import PaneId, WindowId +>>> assert window.window_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... SplitWindow(target=WindowId(window.window_id)), +... engine, +... ).raise_for_status() +>>> assert created.new_pane_id is not None +>>> result = run( +... ResizePane(target=PaneId(created.new_pane_id), height=6), +... engine, +... ).raise_for_status() +>>> panes = run(ListPanes(), engine).raise_for_status().panes +>>> observed = next(item for item in panes if item.pane_id == created.new_pane_id) +>>> type(result).__name__, observed.height +('AckResult', 6) +``` + +## Operation reference + +```{tmuxop:operation} resize_pane +``` + +## Failure and effects + +tmux may constrain the requested size when neighboring panes or the terminal +make it impossible. Read the resulting geometry when the exact size matters. + +## Related operations + +- {tmuxop:op}`pipe_pane` +- {tmuxop:op}`respawn_pane` diff --git a/docs/experimental/operations/pane/respawn_pane.md b/docs/experimental/operations/pane/respawn_pane.md new file mode 100644 index 000000000..50e35de97 --- /dev/null +++ b/docs/experimental/operations/pane/respawn_pane.md @@ -0,0 +1,59 @@ +# Restart a pane command + +{class}`~libtmux.experimental.ops.RespawnPane` replaces the process running in +an existing pane while keeping the pane identifier. + +## Example + +This executable example uses the injected live `server` and `session` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, NewWindow, RespawnPane, run +>>> from libtmux.experimental.ops._types import PaneId, SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow( +... target=SessionId(session.session_id), +... name="docs-respawn-pane", +... capture_pane=True, +... window_shell="sleep 30", +... ), +... engine, +... ).raise_for_status() +>>> assert created.first_pane_id is not None +>>> target = PaneId(created.first_pane_id) +>>> before = run(ListPanes(), engine).raise_for_status() +>>> original = next( +... item for item in before.panes if item.pane_id == created.first_pane_id +... ) +>>> assert original.pid is not None +>>> result = run( +... RespawnPane(target=target, kill=True, shell="sleep 30"), +... engine, +... ).raise_for_status() +>>> after = run(ListPanes(), engine).raise_for_status() +>>> restarted = next( +... item for item in after.panes if item.pane_id == created.first_pane_id +... ) +>>> type(result).__name__, restarted.pane_id == original.pane_id, restarted.pid != original.pid +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} respawn_pane +``` + +## Failure and effects + +Use `kill=True` when the current process is still running. The optional +environment flag requires tmux 3.0 or newer. + +The example creates the pane and process it replaces. + +## Related operations + +- {tmuxop:op}`resize_pane` +- {tmuxop:op}`select_pane` diff --git a/docs/experimental/operations/pane/select_pane.md b/docs/experimental/operations/pane/select_pane.md new file mode 100644 index 000000000..92c9bd80d --- /dev/null +++ b/docs/experimental/operations/pane/select_pane.md @@ -0,0 +1,46 @@ +# Select a pane + +{class}`~libtmux.experimental.ops.SelectPane` makes one pane active or moves the +selection in a direction. + +## Example + +This executable example uses the injected live `server`, `window`, and `pane` +context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, SelectPane, SplitWindow, run +>>> from libtmux.experimental.ops._types import PaneId, WindowId +>>> assert pane.pane_id is not None and window.window_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... SplitWindow(target=WindowId(window.window_id)), +... engine, +... ).raise_for_status() +>>> assert created.new_pane_id is not None +>>> result = run( +... SelectPane(target=PaneId(pane.pane_id)), +... engine, +... ).raise_for_status() +>>> panes = run(ListPanes(), engine).raise_for_status().panes +>>> selected = next(item for item in panes if item.pane_id == pane.pane_id) +>>> other = next(item for item in panes if item.pane_id == created.new_pane_id) +>>> type(result).__name__, selected.active, other.active +('AckResult', True, False) +``` + +## Operation reference + +```{tmuxop:operation} select_pane +``` + +## Failure and effects + +Selection changes the active pane for clients viewing the window. Repeating an +explicit selection is safe. + +## Related operations + +- {tmuxop:op}`respawn_pane` +- {tmuxop:op}`send_keys` diff --git a/docs/experimental/operations/pane/send_keys.md b/docs/experimental/operations/pane/send_keys.md new file mode 100644 index 000000000..2e18fce5d --- /dev/null +++ b/docs/experimental/operations/pane/send_keys.md @@ -0,0 +1,45 @@ +# Send input to a pane + +{class}`~libtmux.experimental.ops.SendKeys` types keys into a pane, optionally +pressing Enter after the input. + +## Example + +This executable example uses the injected live `server` and `pane` context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import CapturePane, SendKeys, run +>>> from libtmux.experimental.ops._types import PaneId +>>> assert pane.pane_id is not None +>>> target = PaneId(pane.pane_id) +>>> engine = SubprocessEngine.for_server(server) +>>> result = run( +... SendKeys( +... target=target, +... keys="printf 'docs-ready\\n'; tmux wait-for -S docs-send-keys", +... enter=True, +... ), +... engine, +... ).raise_for_status() +>>> server.wait_for("docs-send-keys") +>>> captured = run(CapturePane(target=target, start=-10), engine).raise_for_status() +>>> type(result).__name__, any("docs-ready" in line for line in captured.lines) +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} send_keys +``` + +## Failure and effects + +This operation changes tmux state and writes input. `literal=True` cannot be +combined with `enter=True`: tmux's literal mode would type the word `Enter` +instead of pressing Return. + +## Related operations + +- {tmuxop:op}`select_pane` +- {tmuxop:op}`swap_pane` diff --git a/docs/experimental/operations/pane/swap_pane.md b/docs/experimental/operations/pane/swap_pane.md new file mode 100644 index 000000000..37df62b46 --- /dev/null +++ b/docs/experimental/operations/pane/swap_pane.md @@ -0,0 +1,58 @@ +# Swap two panes + +{class}`~libtmux.experimental.ops.SwapPane` exchanges the positions of two +panes without changing their identifiers. + +## Example + +This executable example uses the injected live `server`, `window`, and `pane` +context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, SplitWindow, SwapPane, run +>>> from libtmux.experimental.ops._types import PaneId, WindowId +>>> assert pane.pane_id is not None and window.window_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... SplitWindow(target=WindowId(window.window_id)), +... engine, +... ).raise_for_status() +>>> assert created.new_pane_id is not None +>>> before = { +... item.pane_id: item.pane_index +... for item in run(ListPanes(), engine).raise_for_status().panes +... } +>>> result = run( +... SwapPane( +... target=PaneId(pane.pane_id), +... src_target=PaneId(created.new_pane_id), +... ), +... engine, +... ).raise_for_status() +>>> after = { +... item.pane_id: item.pane_index +... for item in run(ListPanes(), engine).raise_for_status().panes +... } +>>> ( +... type(result).__name__, +... after[pane.pane_id] == before[created.new_pane_id], +... after[created.new_pane_id] == before[pane.pane_id], +... ) +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} swap_pane +``` + +## Failure and effects + +The panes must exist on the same server. Use `detach=True` when the current +selection should remain with the original position. + +## Related operations + +- {tmuxop:op}`send_keys` +- {tmuxop:op}`capture_pane` diff --git a/docs/experimental/operations/server/delete_buffer.md b/docs/experimental/operations/server/delete_buffer.md new file mode 100644 index 000000000..16d1a9d06 --- /dev/null +++ b/docs/experimental/operations/server/delete_buffer.md @@ -0,0 +1,48 @@ +# Delete a paste buffer + +{class}`~libtmux.experimental.ops.DeleteBuffer` removes one paste buffer through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +`window`, and `pane` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DeleteBuffer, SetBuffer, ShowBuffer, run +>>> engine = SubprocessEngine.for_server(server) +>>> _ = run( +... SetBuffer(buffer_name="docs-delete", data="temporary"), +... engine, +... ).raise_for_status() +>>> before = run( +... ShowBuffer(buffer_name="docs-delete"), +... engine, +... ).raise_for_status() +>>> deleted = run( +... DeleteBuffer(buffer_name="docs-delete"), +... engine, +... ).raise_for_status() +>>> after = run(ShowBuffer(buffer_name="docs-delete"), engine) +>>> type(deleted).__name__, before.text, after.ok, bool(after.stderr) +('AckResult', 'temporary', False, True) +``` + +## Operation reference + +```{tmuxop:operation} delete_buffer +``` + +## Failure and effects + +Omitting `buffer_name` deletes tmux's most recent buffer. Prefer an explicit, +owned name when cleanup must be predictable. Deleting or showing a missing +named buffer returns a failed result; call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` to raise it. + +## Related operations + +- {tmuxop:op}`start_server` +- {tmuxop:op}`kill_server` diff --git a/docs/experimental/operations/server/index.md b/docs/experimental/operations/server/index.md new file mode 100644 index 000000000..145da38a0 --- /dev/null +++ b/docs/experimental/operations/server/index.md @@ -0,0 +1,9 @@ +# Server operations + +Server operations address the tmux server as a whole: lifecycle, buffers, +shell commands, configuration files, and hierarchy-wide listings. + +```{tmuxop:catalog} +:scope: server +:toctree: +``` diff --git a/docs/experimental/operations/server/kill_server.md b/docs/experimental/operations/server/kill_server.md new file mode 100644 index 000000000..d1699541d --- /dev/null +++ b/docs/experimental/operations/server/kill_server.md @@ -0,0 +1,45 @@ +# Kill the tmux server and all its sessions + +{class}`~libtmux.experimental.ops.KillServer` destroys one tmux server through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example creates and owns a second live tmux server so the +documentation fixture remains intact. + +```python +>>> import uuid +>>> from libtmux.server import Server +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import KillServer, run +>>> socket_name = f"libtmux-doc-{uuid.uuid4().hex}" +>>> with Server(socket_name=socket_name) as doomed: +... _ = doomed.new_session(session_name="docs-doomed") +... before = doomed.is_alive() +... result = run( +... KillServer(), +... SubprocessEngine.for_server(doomed), +... ).raise_for_status() +... proof = before, type(result).__name__, doomed.is_alive() +>>> proof +(True, 'AckResult', False) +``` + +## Operation reference + +```{tmuxop:operation} kill_server +``` + +## Failure and effects + +This operation ends every session on its target server and cannot be undone. +The example creates and destroys a second server, leaving the primary +documentation server intact. A later operation through the dead server's +engine fails because no server remains. + +## Related operations + +- {tmuxop:op}`delete_buffer` +- {tmuxop:op}`list_clients` diff --git a/docs/experimental/operations/server/list_clients.md b/docs/experimental/operations/server/list_clients.md new file mode 100644 index 000000000..b8a4a7785 --- /dev/null +++ b/docs/experimental/operations/server/list_clients.md @@ -0,0 +1,48 @@ +# List attached clients and return typed snapshots + +{class}`~libtmux.experimental.ops.ListClients` reads attached clients through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +their snapshots in +{class}`~libtmux.experimental.ops.results.ListClientsResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +and `control_mode` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListClients, run +>>> engine = SubprocessEngine.for_server(server) +>>> with control_mode() as attached: +... result = run(ListClients(), engine).raise_for_status() +... client = next( +... item +... for item in result.clients +... if item.name == attached.client_name +... ) +... proof = ( +... type(client).__name__, +... client.session == session.session_name, +... client.pid is not None, +... ) +>>> proof +('ClientSnapshot', True, True) +``` + +## Operation reference + +```{tmuxop:operation} list_clients +``` + +## Failure and effects + +An unattached server legitimately returns no rows. The example creates an +attachment so its assertion cannot pass vacuously. Client snapshots are +point-in-time values; a client may detach after the read. The operation does not +change tmux state and is safe to repeat. + +## Related operations + +- {tmuxop:op}`kill_server` +- {tmuxop:op}`list_panes` diff --git a/docs/experimental/operations/server/list_panes.md b/docs/experimental/operations/server/list_panes.md new file mode 100644 index 000000000..dd8c7e603 --- /dev/null +++ b/docs/experimental/operations/server/list_panes.md @@ -0,0 +1,45 @@ +# List panes and return typed snapshots + +{class}`~libtmux.experimental.ops.ListPanes` reads panes through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +their snapshots in {class}`~libtmux.experimental.ops.results.ListPanesResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +`window`, and `pane` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, run +>>> assert pane.pane_id is not None +>>> result = run( +... ListPanes(), +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> observed = next(item for item in result.panes if item.pane_id == pane.pane_id) +>>> ( +... type(observed).__name__, +... observed.window_id == window.window_id, +... observed.session_id == session.session_id, +... observed.active, +... ) +('PaneSnapshot', True, True, True) +``` + +## Operation reference + +```{tmuxop:operation} list_panes +``` + +## Failure and effects + +The snapshots capture one read of the server and do not refresh themselves. +The operation does not change tmux state and is safe to repeat. Call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` when a failed +server read should raise. + +## Related operations + +- {tmuxop:op}`list_clients` +- {tmuxop:op}`list_sessions` diff --git a/docs/experimental/operations/server/list_sessions.md b/docs/experimental/operations/server/list_sessions.md new file mode 100644 index 000000000..b42c01f3a --- /dev/null +++ b/docs/experimental/operations/server/list_sessions.md @@ -0,0 +1,47 @@ +# List the server's sessions and return typed snapshots + +{class}`~libtmux.experimental.ops.ListSessions` reads sessions through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +their snapshots in +{class}`~libtmux.experimental.ops.results.ListSessionsResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +`window`, and `pane` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListSessions, run +>>> assert session.session_id is not None +>>> result = run( +... ListSessions(), +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> observed = next( +... item for item in result.sessions if item.session_id == session.session_id +... ) +>>> ( +... type(observed).__name__, +... observed.name == session.session_name, +... observed.session_id == session.session_id, +... ) +('SessionSnapshot', True, True) +``` + +## Operation reference + +```{tmuxop:operation} list_sessions +``` + +## Failure and effects + +The snapshots capture one read of the server and do not refresh themselves. +The operation does not change tmux state and is safe to repeat. Call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` when a failed +server read should raise. + +## Related operations + +- {tmuxop:op}`list_panes` +- {tmuxop:op}`list_windows` diff --git a/docs/experimental/operations/server/list_windows.md b/docs/experimental/operations/server/list_windows.md new file mode 100644 index 000000000..9b467d24b --- /dev/null +++ b/docs/experimental/operations/server/list_windows.md @@ -0,0 +1,47 @@ +# List windows and return typed snapshots + +{class}`~libtmux.experimental.ops.ListWindows` reads windows through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +their snapshots in +{class}`~libtmux.experimental.ops.results.ListWindowsResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +`window`, and `pane` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListWindows, run +>>> assert window.window_id is not None +>>> result = run( +... ListWindows(), +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> observed = next( +... item for item in result.windows if item.window_id == window.window_id +... ) +>>> ( +... type(observed).__name__, +... observed.name == window.window_name, +... observed.session_id == session.session_id, +... ) +('WindowSnapshot', True, True) +``` + +## Operation reference + +```{tmuxop:operation} list_windows +``` + +## Failure and effects + +The snapshots capture one read of the server and do not refresh themselves. +The operation does not change tmux state and is safe to repeat. Call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` when a failed +server read should raise. + +## Related operations + +- {tmuxop:op}`list_sessions` +- {tmuxop:op}`load_buffer` diff --git a/docs/experimental/operations/server/load_buffer.md b/docs/experimental/operations/server/load_buffer.md new file mode 100644 index 000000000..e3276ab39 --- /dev/null +++ b/docs/experimental/operations/server/load_buffer.md @@ -0,0 +1,47 @@ +# Load a paste buffer from a file + +{class}`~libtmux.experimental.ops.LoadBuffer` reads a file into a paste buffer +through {class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and +returns {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`tmp_path` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DeleteBuffer, LoadBuffer, ShowBuffer, run +>>> source = tmp_path / "buffer.txt" +>>> _ = source.write_text("loaded from file", encoding="utf-8") +>>> engine = SubprocessEngine.for_server(server) +>>> loaded = run( +... LoadBuffer(path=str(source), buffer_name="docs-load"), +... engine, +... ).raise_for_status() +>>> observed = run( +... ShowBuffer(buffer_name="docs-load"), +... engine, +... ).raise_for_status() +>>> proof = type(loaded).__name__, observed.text +>>> _ = run(DeleteBuffer(buffer_name="docs-load"), engine).raise_for_status() +>>> proof +('AckResult', 'loaded from file') +``` + +## Operation reference + +```{tmuxop:operation} load_buffer +``` + +## Failure and effects + +A missing or unreadable path returns a failed result. Call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` to raise it. +The loaded buffer persists until it is replaced or deleted; the example owns +and removes its named buffer. + +## Related operations + +- {tmuxop:op}`list_windows` +- {tmuxop:op}`new_session` diff --git a/docs/experimental/operations/server/new_session.md b/docs/experimental/operations/server/new_session.md new file mode 100644 index 000000000..b879dd1dd --- /dev/null +++ b/docs/experimental/operations/server/new_session.md @@ -0,0 +1,50 @@ +# Create a detached session and capture its ID + +{class}`~libtmux.experimental.ops.NewSession` creates a detached session through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and captures +its ID in {class}`~libtmux.experimental.ops.results.CreateResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +`window`, and `pane` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import KillSession, ListSessions, NewSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewSession(session_name="docs-created"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> observed = run(ListSessions(), engine).raise_for_status() +>>> snapshot = next( +... item for item in observed.sessions if item.session_id == created.new_id +... ) +>>> proof = type(created).__name__, snapshot.name, snapshot.session_id == created.new_id +>>> _ = run( +... KillSession(target=SessionId(created.new_id)), +... engine, +... ).raise_for_status() +>>> proof +('CreateResult', 'docs-created', True) +``` + +## Operation reference + +```{tmuxop:operation} new_session +``` + +## Failure and effects + +The captured ID resolves the session without relying on its generated numeric +value. A duplicate session name returns a failed result. Call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` to raise that +error. The example removes the session it creates. + +## Related operations + +- {tmuxop:op}`load_buffer` +- {tmuxop:op}`run_shell` diff --git a/docs/experimental/operations/server/run_shell.md b/docs/experimental/operations/server/run_shell.md new file mode 100644 index 000000000..0211452e9 --- /dev/null +++ b/docs/experimental/operations/server/run_shell.md @@ -0,0 +1,42 @@ +# Run a shell command via tmux + +{class}`~libtmux.experimental.ops.RunShell` asks tmux to execute a shell command +through {class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and +returns {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`tmp_path` are injected documentation context. + +```python +>>> import shlex +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import RunShell, run +>>> destination = tmp_path / "run-shell.txt" +>>> command = f"printf ready > {shlex.quote(str(destination))}" +>>> result = run( +... RunShell(command_line=command), +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> type(result).__name__, destination.read_text(encoding="utf-8") +('AckResult', 'ready') +``` + +## Operation reference + +```{tmuxop:operation} run_shell +``` + +## Failure and effects + +The command runs with the tmux server process's privileges and may change +external state. Without `background=True`, the acknowledgement arrives after +the command finishes, so the example can read its private file directly. +Background execution needs a bounded state poll rather than an arbitrary +delay. + +## Related operations + +- {tmuxop:op}`new_session` +- {tmuxop:op}`save_buffer` diff --git a/docs/experimental/operations/server/save_buffer.md b/docs/experimental/operations/server/save_buffer.md new file mode 100644 index 000000000..7b93b8ea7 --- /dev/null +++ b/docs/experimental/operations/server/save_buffer.md @@ -0,0 +1,46 @@ +# Save a paste buffer to a file + +{class}`~libtmux.experimental.ops.SaveBuffer` writes a paste buffer to a file +through {class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and +returns {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`tmp_path` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DeleteBuffer, SaveBuffer, SetBuffer, run +>>> destination = tmp_path / "saved.txt" +>>> engine = SubprocessEngine.for_server(server) +>>> _ = run( +... SetBuffer(buffer_name="docs-save", data="saved by tmux"), +... engine, +... ).raise_for_status() +>>> saved = run( +... SaveBuffer(path=str(destination), buffer_name="docs-save"), +... engine, +... ).raise_for_status() +>>> proof = type(saved).__name__, destination.read_text(encoding="utf-8") +>>> _ = run(DeleteBuffer(buffer_name="docs-save"), engine).raise_for_status() +>>> proof +('AckResult', 'saved by tmux') +``` + +## Operation reference + +```{tmuxop:operation} save_buffer +``` + +## Failure and effects + +Saving reads tmux state but writes to the filesystem. It replaces the +destination by default; `append=True` extends it instead. A missing buffer or +unwritable path returns a failed result. The example confines both resources to +an owned buffer and pytest's private temporary directory. + +## Related operations + +- {tmuxop:op}`run_shell` +- {tmuxop:op}`set_buffer` diff --git a/docs/experimental/operations/server/set_buffer.md b/docs/experimental/operations/server/set_buffer.md new file mode 100644 index 000000000..640ac927b --- /dev/null +++ b/docs/experimental/operations/server/set_buffer.md @@ -0,0 +1,45 @@ +# Set the contents of a paste buffer + +{class}`~libtmux.experimental.ops.SetBuffer` creates or updates a paste buffer +through {class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and +returns {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +`window`, and `pane` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DeleteBuffer, SetBuffer, ShowBuffer, run +>>> engine = SubprocessEngine.for_server(server) +>>> changed = run( +... SetBuffer(buffer_name="docs-set", data="live buffer"), +... engine, +... ).raise_for_status() +>>> observed = run( +... ShowBuffer(buffer_name="docs-set"), +... engine, +... ).raise_for_status() +>>> proof = type(changed).__name__, observed.text +>>> _ = run(DeleteBuffer(buffer_name="docs-set"), engine).raise_for_status() +>>> proof +('AckResult', 'live buffer') +``` + +## Operation reference + +```{tmuxop:operation} set_buffer +``` + +## Failure and effects + +Setting a named buffer replaces its contents unless `append=True`. The +acknowledgement proves dispatch; the independent +{class}`~libtmux.experimental.ops.ShowBuffer` read proves the stored text. The +example removes the buffer it creates. + +## Related operations + +- {tmuxop:op}`save_buffer` +- {tmuxop:op}`show_buffer` diff --git a/docs/experimental/operations/server/show_buffer.md b/docs/experimental/operations/server/show_buffer.md new file mode 100644 index 000000000..752996cc3 --- /dev/null +++ b/docs/experimental/operations/server/show_buffer.md @@ -0,0 +1,42 @@ +# Show the contents of a paste buffer + +{class}`~libtmux.experimental.ops.ShowBuffer` reads one paste buffer through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +its text in {class}`~libtmux.experimental.ops.results.ShowBufferResult`. + +## Example + +This executable example uses an isolated live tmux server. `server`, `session`, +`window`, and `pane` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DeleteBuffer, SetBuffer, ShowBuffer, run +>>> engine = SubprocessEngine.for_server(server) +>>> _ = run( +... SetBuffer(buffer_name="docs-show", data="buffer contents"), +... engine, +... ).raise_for_status() +>>> result = run(ShowBuffer(buffer_name="docs-show"), engine).raise_for_status() +>>> proof = type(result).__name__, result.text +>>> _ = run(DeleteBuffer(buffer_name="docs-show"), engine).raise_for_status() +>>> proof +('ShowBufferResult', 'buffer contents') +``` + +## Operation reference + +```{tmuxop:operation} show_buffer +``` + +## Failure and effects + +Showing a missing named buffer returns a failed result. Call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` to raise that +error. The read itself does not change tmux state; the example owns and removes +its setup buffer. + +## Related operations + +- {tmuxop:op}`set_buffer` +- {tmuxop:op}`source_file` diff --git a/docs/experimental/operations/server/source_file.md b/docs/experimental/operations/server/source_file.md new file mode 100644 index 000000000..1277ee7c6 --- /dev/null +++ b/docs/experimental/operations/server/source_file.md @@ -0,0 +1,47 @@ +# Execute tmux commands from a file + +{class}`~libtmux.experimental.ops.SourceFile` executes tmux commands from a file +through {class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and +returns {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`tmp_path` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import DeleteBuffer, ShowBuffer, SourceFile, run +>>> config = tmp_path / "source.conf" +>>> _ = config.write_text( +... "set-buffer -b docs-source sourced\n", +... encoding="utf-8", +... ) +>>> engine = SubprocessEngine.for_server(server) +>>> sourced = run(SourceFile(path=str(config)), engine).raise_for_status() +>>> observed = run( +... ShowBuffer(buffer_name="docs-source"), +... engine, +... ).raise_for_status() +>>> proof = type(sourced).__name__, observed.text +>>> _ = run(DeleteBuffer(buffer_name="docs-source"), engine).raise_for_status() +>>> proof +('AckResult', 'sourced') +``` + +## Operation reference + +```{tmuxop:operation} source_file +``` + +## Failure and effects + +Commands in the file can change any state available to tmux. A missing file or +invalid command can return a failed result; call +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` to raise it. +The example confines the effect to an owned buffer and removes it afterward. + +## Related operations + +- {tmuxop:op}`show_buffer` +- {tmuxop:op}`start_server` diff --git a/docs/experimental/operations/server/start_server.md b/docs/experimental/operations/server/start_server.md new file mode 100644 index 000000000..b445325d2 --- /dev/null +++ b/docs/experimental/operations/server/start_server.md @@ -0,0 +1,46 @@ +# Start the tmux server if it is not already running + +{class}`~libtmux.experimental.ops.StartServer` asks tmux to ensure its server is +running through +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` and returns +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example creates a disposable server that is initially stopped. +`tmp_path` provides a private temporary configuration file. + +```python +>>> import uuid +>>> from libtmux.server import Server +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import StartServer, run +>>> config = tmp_path / "start-server.conf" +>>> _ = config.write_text("set -g exit-empty off\n", encoding="utf-8") +>>> socket_name = f"libtmux-doc-{uuid.uuid4().hex}" +>>> with Server(socket_name=socket_name, config_file=str(config)) as candidate: +... before = candidate.is_alive() +... result = run( +... StartServer(), +... SubprocessEngine.for_server(candidate), +... ).raise_for_status() +... proof = before, type(result).__name__, candidate.is_alive() +>>> proof +(False, 'AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} start_server +``` + +## Failure and effects + +The command is idempotent when the target server is already running. The +example disables `exit-empty` so the new empty server remains alive long enough +for a live check; the context manager stops that owned server afterward. + +## Related operations + +- {tmuxop:op}`source_file` +- {tmuxop:op}`delete_buffer` diff --git a/docs/experimental/operations/session/has_session.md b/docs/experimental/operations/session/has_session.md new file mode 100644 index 000000000..20235485c --- /dev/null +++ b/docs/experimental/operations/session/has_session.md @@ -0,0 +1,39 @@ +# Check whether a session exists + +{class}`~libtmux.experimental.ops.HasSession` asks the server whether a +session target exists and returns a typed boolean result. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context; the standalone setup tutorial +shows the equivalent public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import NameRef, SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> present = run(HasSession(target=SessionId(session.session_id)), engine) +>>> missing = run(HasSession(target=NameRef("docs-missing-session")), engine) +>>> present.status, present.exists, missing.status, missing.exists +('complete', True, 'complete', False) +``` + +## Operation reference + +```{tmuxop:operation} has_session +``` + +## Failure and effects + +This operation reads tmux state without changing it. It is safe to repeat. + +A missing session is a successful existence query with `exists=False`; it is +not a failed command result. + +## Related operations + +- {tmuxop:op}`show_options` +- {tmuxop:op}`kill_session` diff --git a/docs/experimental/operations/session/index.md b/docs/experimental/operations/session/index.md new file mode 100644 index 000000000..e629353ad --- /dev/null +++ b/docs/experimental/operations/session/index.md @@ -0,0 +1,9 @@ +# Session operations + +Session operations inspect or change one session and the windows or +configuration owned by it. + +```{tmuxop:catalog} +:scope: session +:toctree: +``` diff --git a/docs/experimental/operations/session/kill_session.md b/docs/experimental/operations/session/kill_session.md new file mode 100644 index 000000000..1cd910ebd --- /dev/null +++ b/docs/experimental/operations/session/kill_session.md @@ -0,0 +1,45 @@ +# Kill a session + +{class}`~libtmux.experimental.ops.KillSession` removes one session and all of +its windows and panes. + +## Example + +This executable example uses an isolated live tmux server. `server` is injected +documentation context; the standalone setup tutorial shows the equivalent +public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import HasSession, KillSession, NewSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewSession(session_name="docs-disposable"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> target = SessionId(created.new_id) +>>> run(HasSession(target=target), engine).exists +True +>>> result = run(KillSession(target=target), engine).raise_for_status() +>>> type(result).__name__, run(HasSession(target=target), engine).exists +('AckResult', False) +``` + +## Operation reference + +```{tmuxop:operation} kill_session +``` + +## Failure and effects + +This operation is destructive and cannot be undone. + +The example creates and removes a disposable session so the documentation +fixture remains available for cleanup and later assertions. + +## Related operations + +- {tmuxop:op}`has_session` +- {tmuxop:op}`new_window` diff --git a/docs/experimental/operations/session/new_window.md b/docs/experimental/operations/session/new_window.md new file mode 100644 index 000000000..15cb68d51 --- /dev/null +++ b/docs/experimental/operations/session/new_window.md @@ -0,0 +1,43 @@ +# Create a window in a session + +{class}`~libtmux.experimental.ops.NewWindow` creates a window and captures its +tmux identifier in a typed creation result. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context; the standalone setup tutorial +shows the equivalent public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListWindows, NewWindow, run +>>> from libtmux.experimental.ops._types import SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-build"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> next(item.name for item in observed.windows if item.window_id == created.new_id) +'docs-build' +``` + +## Operation reference + +```{tmuxop:operation} new_window +``` + +## Failure and effects + +This operation changes tmux state. It creates a window. + +The captured identifier is resolved through a second live operation; a +non-`None` identifier alone would not prove that tmux created the window. + +## Related operations + +- {tmuxop:op}`kill_session` +- {tmuxop:op}`rename_session` diff --git a/docs/experimental/operations/session/rename_session.md b/docs/experimental/operations/session/rename_session.md new file mode 100644 index 000000000..3256d6a58 --- /dev/null +++ b/docs/experimental/operations/session/rename_session.md @@ -0,0 +1,48 @@ +# Rename a session + +{class}`~libtmux.experimental.ops.RenameSession` changes the name of an +existing session and returns an acknowledgement. + +## Example + +This executable example uses an isolated live tmux server. `server` is injected +documentation context; the standalone setup tutorial shows the equivalent +public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListSessions, NewSession, RenameSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewSession(session_name="docs-before"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> changed = run( +... RenameSession(target=SessionId(created.new_id), name="docs-after"), +... engine, +... ).raise_for_status() +>>> observed = run(ListSessions(), engine).raise_for_status() +>>> type(changed).__name__, next( +... item.name for item in observed.sessions if item.session_id == created.new_id +... ) +('AckResult', 'docs-after') +``` + +## Operation reference + +```{tmuxop:operation} rename_session +``` + +## Failure and effects + +This operation changes tmux state. It is safe to repeat. + +The session identifier remains stable across the rename, so a live session +listing can read the new name back without relying on command output. + +## Related operations + +- {tmuxop:op}`new_window` +- {tmuxop:op}`set_environment` diff --git a/docs/experimental/operations/session/set_environment.md b/docs/experimental/operations/session/set_environment.md new file mode 100644 index 000000000..9e69fc211 --- /dev/null +++ b/docs/experimental/operations/session/set_environment.md @@ -0,0 +1,45 @@ +# Set or unset a session environment variable + +{class}`~libtmux.experimental.ops.SetEnvironment` changes the environment +inherited by new processes in a session. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context; the standalone setup tutorial +shows the equivalent public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import SetEnvironment, run +>>> from libtmux.experimental.ops._types import SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> changed = run( +... SetEnvironment( +... target=SessionId(session.session_id), +... name="LIBTMUX_DOC_MODE", +... value="live", +... ), +... engine, +... ).raise_for_status() +>>> type(changed).__name__, session.getenv("LIBTMUX_DOC_MODE") +('AckResult', 'live') +``` + +## Operation reference + +```{tmuxop:operation} set_environment +``` + +## Failure and effects + +This operation changes tmux state. + +The typed operation has no matching typed read operation, so the example uses +{meth}`~libtmux.common.EnvironmentMixin.getenv` for the independent readback. + +## Related operations + +- {tmuxop:op}`rename_session` +- {tmuxop:op}`set_hook` diff --git a/docs/experimental/operations/session/set_hook.md b/docs/experimental/operations/session/set_hook.md new file mode 100644 index 000000000..c77ab5b28 --- /dev/null +++ b/docs/experimental/operations/session/set_hook.md @@ -0,0 +1,46 @@ +# Set or unset a tmux hook + +{class}`~libtmux.experimental.ops.SetHook` configures a command that tmux runs +when a named session event occurs. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context; the standalone setup tutorial +shows the equivalent public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import SetHook, run +>>> from libtmux.experimental.ops._types import SessionId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> changed = run( +... SetHook( +... target=SessionId(session.session_id), +... name="after-new-window", +... hook_command="display-message docs-ready", +... ), +... engine, +... ).raise_for_status() +>>> hook = session.show_hooks()["after-new-window[0]"] +>>> type(changed).__name__, "display-message docs-ready" in hook +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} set_hook +``` + +## Failure and effects + +This operation changes tmux state. + +The typed catalog does not include a show-hooks operation. The example reads +the configured hook back through {meth}`~libtmux.hooks.HooksMixin.show_hooks`. + +## Related operations + +- {tmuxop:op}`set_environment` +- {tmuxop:op}`set_option` diff --git a/docs/experimental/operations/session/set_option.md b/docs/experimental/operations/session/set_option.md new file mode 100644 index 000000000..8d5b9ab5a --- /dev/null +++ b/docs/experimental/operations/session/set_option.md @@ -0,0 +1,43 @@ +# Set a session option + +{class}`~libtmux.experimental.ops.SetOption` changes one tmux option at the +session, server, window, or pane scope. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context; the standalone setup tutorial +shows the equivalent public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import SetOption, ShowOptions, run +>>> from libtmux.experimental.ops._types import SessionId +>>> assert session.session_id is not None +>>> target = SessionId(session.session_id) +>>> engine = SubprocessEngine.for_server(server) +>>> changed = run( +... SetOption(target=target, option="@docs_mode", value="live"), +... engine, +... ).raise_for_status() +>>> observed = run(ShowOptions(target=target), engine).raise_for_status() +>>> type(changed).__name__, observed.options["@docs_mode"] +('AckResult', 'live') +``` + +## Operation reference + +```{tmuxop:operation} set_option +``` + +## Failure and effects + +This operation changes tmux state. + +An acknowledgement confirms dispatch. The separate {tmuxop:op}`show_options` +operation proves the stored value. + +## Related operations + +- {tmuxop:op}`set_hook` +- {tmuxop:op}`show_options` diff --git a/docs/experimental/operations/session/show_options.md b/docs/experimental/operations/session/show_options.md new file mode 100644 index 000000000..ad844c91e --- /dev/null +++ b/docs/experimental/operations/session/show_options.md @@ -0,0 +1,43 @@ +# Read session options + +{class}`~libtmux.experimental.ops.ShowOptions` parses tmux option output into a +typed name-to-value mapping. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context; the standalone setup tutorial +shows the equivalent public setup and cleanup. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import SetOption, ShowOptions, run +>>> from libtmux.experimental.ops._types import SessionId +>>> assert session.session_id is not None +>>> target = SessionId(session.session_id) +>>> engine = SubprocessEngine.for_server(server) +>>> _ = run( +... SetOption(target=target, option="@docs_visible", value="yes"), +... engine, +... ).raise_for_status() +>>> result = run(ShowOptions(target=target), engine).raise_for_status() +>>> type(result).__name__, result.options["@docs_visible"] +('ShowOptionsResult', 'yes') +``` + +## Operation reference + +```{tmuxop:operation} show_options +``` + +## Failure and effects + +This operation reads tmux state without changing it. It is safe to repeat. + +Without `include_inherited=True`, the mapping contains only options set at the +selected scope. + +## Related operations + +- {tmuxop:op}`set_option` +- {tmuxop:op}`has_session` diff --git a/docs/experimental/operations/window/break_pane.md b/docs/experimental/operations/window/break_pane.md new file mode 100644 index 000000000..d63dfdc7a --- /dev/null +++ b/docs/experimental/operations/window/break_pane.md @@ -0,0 +1,69 @@ +# Break a pane out into a new window + +{class}`~libtmux.experimental.ops.BreakPane` breaks a pane into a new window +and returns its identifier in a +{class}`~libtmux.experimental.ops.results.CreateResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... BreakPane, +... ListWindows, +... NewWindow, +... SplitWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import PaneId, SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> owned = run( +... NewWindow(target=SessionId(session.session_id), name="docs-break-source"), +... engine, +... ).raise_for_status() +>>> assert owned.new_id is not None +>>> split = run(SplitWindow(target=WindowId(owned.new_id)), engine).raise_for_status() +>>> assert split.new_pane_id is not None +>>> broken = run( +... BreakPane(src_target=PaneId(split.new_pane_id), name="docs-broken"), +... engine, +... ).raise_for_status() +>>> assert broken.new_id is not None +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> any( +... item.window_id == broken.new_id +... and item.session_id == session.session_id +... and item.name == "docs-broken" +... for item in observed.windows +... ) +True +``` + +## Operation reference + +```{tmuxop:operation} break_pane +``` + +## Failure and effects + +This operation changes tmux state. It creates a window. + +On exactly tmux 3.7, every request receives the placeholder `-n libtmux` to +avoid an upstream null dereference. tmux 3.7 also ignores the requested name, +so {func}`~libtmux.experimental.ops.run` and +{func}`~libtmux.experimental.ops.arun` apply it with a typed `rename-window` +follow-up. A failed follow-up makes the complete operation fail instead of +reporting a name that was not applied. + +The captured identifier is resolved through +{class}`~libtmux.experimental.ops.ListWindows`; a non-`None` identifier alone +would not prove that tmux created the window. + +## Related operations + +- {tmuxop:op}`unlink_window` +- {tmuxop:op}`kill_window` diff --git a/docs/experimental/operations/window/index.md b/docs/experimental/operations/window/index.md new file mode 100644 index 000000000..9f95b9fb1 --- /dev/null +++ b/docs/experimental/operations/window/index.md @@ -0,0 +1,9 @@ +# Window operations + +Window operations control window selection, layout, pane placement, and +window-level lifecycle. + +```{tmuxop:catalog} +:scope: window +:toctree: +``` diff --git a/docs/experimental/operations/window/kill_window.md b/docs/experimental/operations/window/kill_window.md new file mode 100644 index 000000000..7e3824211 --- /dev/null +++ b/docs/experimental/operations/window/kill_window.md @@ -0,0 +1,53 @@ +# Kill a window + +{class}`~libtmux.experimental.ops.KillWindow` destroys one window and returns +an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import KillWindow, ListWindows, NewWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-kill-window"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> before = run(ListWindows(), engine).raise_for_status() +>>> result = run( +... KillWindow(target=WindowId(created.new_id)), +... engine, +... ).raise_for_status() +>>> after = run(ListWindows(), engine).raise_for_status() +>>> ( +... type(result).__name__, +... created.new_id in {item.window_id for item in before.windows}, +... created.new_id not in {item.window_id for item in after.windows}, +... ) +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} kill_window +``` + +## Failure and effects + +This operation is destructive and cannot be undone. + +The example creates the window it destroys and proves both its presence and +absence with independent live listings. A missing target produces a failed +result; {meth}`~libtmux.experimental.ops.results.Result.raise_for_status` +raises the underlying error. + +## Related operations + +- {tmuxop:op}`break_pane` +- {tmuxop:op}`last_pane` diff --git a/docs/experimental/operations/window/last_pane.md b/docs/experimental/operations/window/last_pane.md new file mode 100644 index 000000000..2e9757ae4 --- /dev/null +++ b/docs/experimental/operations/window/last_pane.md @@ -0,0 +1,68 @@ +# Select the previously active pane in a window + +{class}`~libtmux.experimental.ops.LastPane` selects a window's previously +active pane and returns an +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... LastPane, +... ListPanes, +... NewWindow, +... SelectPane, +... SplitWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import PaneId, SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-last-pane"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> initial = run(ListPanes(), engine).raise_for_status() +>>> first_id = next( +... item.pane_id for item in initial.panes if item.window_id == created.new_id +... ) +>>> split = run(SplitWindow(target=WindowId(created.new_id)), engine).raise_for_status() +>>> assert split.new_pane_id is not None +>>> _ = run(SelectPane(target=PaneId(split.new_pane_id)), engine).raise_for_status() +>>> _ = run(SelectPane(target=PaneId(first_id)), engine).raise_for_status() +>>> result = run( +... LastPane(target=WindowId(created.new_id)), +... engine, +... ).raise_for_status() +>>> observed = run(ListPanes(), engine).raise_for_status() +>>> active_pane_id = next( +... item.pane_id +... for item in observed.panes +... if item.window_id == created.new_id and item.active +... ) +>>> type(result).__name__, active_pane_id == split.new_pane_id +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} last_pane +``` + +## Failure and effects + +This operation changes tmux state. It is safe to repeat. + +The acknowledgement carries no pane state, so the example establishes a pane +history and reads the active pane back with +{class}`~libtmux.experimental.ops.ListPanes`. + +## Related operations + +- {tmuxop:op}`kill_window` +- {tmuxop:op}`last_window` diff --git a/docs/experimental/operations/window/last_window.md b/docs/experimental/operations/window/last_window.md new file mode 100644 index 000000000..c191337c7 --- /dev/null +++ b/docs/experimental/operations/window/last_window.md @@ -0,0 +1,64 @@ +# Select the previously active window + +{class}`~libtmux.experimental.ops.LastWindow` selects a session's previously +active window and returns an +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... LastWindow, +... ListWindows, +... NewWindow, +... SelectWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> first = run( +... NewWindow(target=SessionId(session.session_id), name="docs-last-first"), +... engine, +... ).raise_for_status() +>>> second = run( +... NewWindow(target=SessionId(session.session_id), name="docs-last-second"), +... engine, +... ).raise_for_status() +>>> assert first.new_id is not None and second.new_id is not None +>>> _ = run(SelectWindow(target=WindowId(first.new_id)), engine).raise_for_status() +>>> _ = run(SelectWindow(target=WindowId(second.new_id)), engine).raise_for_status() +>>> result = run( +... LastWindow(target=SessionId(session.session_id)), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> active_window_id = next( +... item.window_id +... for item in observed.windows +... if item.session_id == session.session_id and item.active +... ) +>>> type(result).__name__, active_window_id == first.new_id +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} last_window +``` + +## Failure and effects + +This operation changes tmux state. It is safe to repeat. + +The example owns both history entries, then resolves the active window through +{class}`~libtmux.experimental.ops.ListWindows`. + +## Related operations + +- {tmuxop:op}`last_pane` +- {tmuxop:op}`link_window` diff --git a/docs/experimental/operations/window/link_window.md b/docs/experimental/operations/window/link_window.md new file mode 100644 index 000000000..b711f5170 --- /dev/null +++ b/docs/experimental/operations/window/link_window.md @@ -0,0 +1,66 @@ +# Link a window into another session + +{class}`~libtmux.experimental.ops.LinkWindow` links one window into another +session and returns an +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... LinkWindow, +... ListWindows, +... NewSession, +... NewWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> source = run( +... NewWindow(target=SessionId(session.session_id), name="docs-link-source"), +... engine, +... ).raise_for_status() +>>> destination = run( +... NewSession(session_name="docs-link-destination"), +... engine, +... ).raise_for_status() +>>> assert source.new_id is not None and destination.new_id is not None +>>> result = run( +... LinkWindow( +... target=SessionId(destination.new_id), +... src_target=WindowId(source.new_id), +... ), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> locations = { +... item.session_id +... for item in observed.windows +... if item.window_id == source.new_id +... } +>>> type(result).__name__, session.session_id in locations, destination.new_id in locations +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} link_window +``` + +## Failure and effects + +This operation changes tmux state. + +The source window and destination session are local to the example. +{class}`~libtmux.experimental.ops.ListWindows` proves that the same window +identifier belongs to both sessions after the link. + +## Related operations + +- {tmuxop:op}`last_window` +- {tmuxop:op}`move_window` diff --git a/docs/experimental/operations/window/move_window.md b/docs/experimental/operations/window/move_window.md new file mode 100644 index 000000000..f38e89a83 --- /dev/null +++ b/docs/experimental/operations/window/move_window.md @@ -0,0 +1,66 @@ +# Move a window to a new index/session + +{class}`~libtmux.experimental.ops.MoveWindow` transfers a window to another +session or index and returns an +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... ListWindows, +... MoveWindow, +... NewSession, +... NewWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> source = run( +... NewWindow(target=SessionId(session.session_id), name="docs-move-source"), +... engine, +... ).raise_for_status() +>>> destination = run( +... NewSession(session_name="docs-move-destination"), +... engine, +... ).raise_for_status() +>>> assert source.new_id is not None and destination.new_id is not None +>>> result = run( +... MoveWindow( +... target=SessionId(destination.new_id), +... src_target=WindowId(source.new_id), +... ), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> destination_session_id = next( +... item.session_id +... for item in observed.windows +... if item.window_id == source.new_id +... ) +>>> type(result).__name__, destination_session_id == destination.new_id +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} move_window +``` + +## Failure and effects + +This operation changes tmux state. + +The source window and destination session are local to the example. The final +listing resolves the moved identifier under its new session rather than +trusting the acknowledgement alone. + +## Related operations + +- {tmuxop:op}`link_window` +- {tmuxop:op}`new_pane` diff --git a/docs/experimental/operations/window/new_pane.md b/docs/experimental/operations/window/new_pane.md new file mode 100644 index 000000000..fceea8c78 --- /dev/null +++ b/docs/experimental/operations/window/new_pane.md @@ -0,0 +1,55 @@ +# Create a floating pane + +{class}`~libtmux.experimental.ops.NewPane` creates a floating pane on tmux 3.7 +or newer and returns a +{class}`~libtmux.experimental.ops.results.SplitWindowResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and `pane` +are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import NewPane, VersionUnsupported, run +>>> from libtmux.experimental.ops._types import PaneId +>>> assert pane.pane_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> try: +... result = run( +... NewPane(target=PaneId(pane.pane_id), width=40, height=10), +... engine, +... ).raise_for_status() +... except VersionUnsupported as error: +... proof = ( +... error.kind == "new_pane" +... and error.need == "3.7" +... and bool(error.have) +... ) +... else: +... proof = ( +... result.new_pane_id is not None +... and server.panes.get(pane_id=result.new_pane_id) is not None +... ) +>>> proof +True +``` + +## Operation reference + +```{tmuxop:operation} new_pane +``` + +## Failure and effects + +This operation changes tmux state. It creates a pane. + +On tmux older than 3.7, rendering raises +{exc}`~libtmux.experimental.ops.VersionUnsupported` before dispatch. The same +doctest proves that exact gate on older CI jobs and resolves the new pane on +supported versions; it does not skip either branch. + +## Related operations + +- {tmuxop:op}`move_window` +- {tmuxop:op}`next_window` diff --git a/docs/experimental/operations/window/next_window.md b/docs/experimental/operations/window/next_window.md new file mode 100644 index 000000000..05ba8b409 --- /dev/null +++ b/docs/experimental/operations/window/next_window.md @@ -0,0 +1,63 @@ +# Select the next window in a session + +{class}`~libtmux.experimental.ops.NextWindow` selects the next window in a +session and returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... ListWindows, +... NewWindow, +... NextWindow, +... SelectWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> first = run( +... NewWindow(target=SessionId(session.session_id), name="docs-next-first"), +... engine, +... ).raise_for_status() +>>> second = run( +... NewWindow(target=SessionId(session.session_id), name="docs-next-second"), +... engine, +... ).raise_for_status() +>>> assert first.new_id is not None and second.new_id is not None +>>> _ = run(SelectWindow(target=WindowId(first.new_id)), engine).raise_for_status() +>>> result = run( +... NextWindow(target=SessionId(session.session_id)), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> active_window_id = next( +... item.window_id +... for item in observed.windows +... if item.session_id == session.session_id and item.active +... ) +>>> type(result).__name__, active_window_id == second.new_id +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} next_window +``` + +## Failure and effects + +This operation changes tmux state. + +The example creates adjacent windows, selects the first, and resolves the +active window after advancing. The final relationship is stronger evidence +than the acknowledgement alone. + +## Related operations + +- {tmuxop:op}`new_pane` +- {tmuxop:op}`previous_window` diff --git a/docs/experimental/operations/window/previous_window.md b/docs/experimental/operations/window/previous_window.md new file mode 100644 index 000000000..cab3f04af --- /dev/null +++ b/docs/experimental/operations/window/previous_window.md @@ -0,0 +1,64 @@ +# Select the previous window in a session + +{class}`~libtmux.experimental.ops.PreviousWindow` selects the preceding window +in a session and returns an +{class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... ListWindows, +... NewWindow, +... PreviousWindow, +... SelectWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> first = run( +... NewWindow(target=SessionId(session.session_id), name="docs-previous-first"), +... engine, +... ).raise_for_status() +>>> second = run( +... NewWindow(target=SessionId(session.session_id), name="docs-previous-second"), +... engine, +... ).raise_for_status() +>>> assert first.new_id is not None and second.new_id is not None +>>> _ = run(SelectWindow(target=WindowId(second.new_id)), engine).raise_for_status() +>>> result = run( +... PreviousWindow(target=SessionId(session.session_id)), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> active_window_id = next( +... item.window_id +... for item in observed.windows +... if item.session_id == session.session_id and item.active +... ) +>>> type(result).__name__, active_window_id == first.new_id +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} previous_window +``` + +## Failure and effects + +This operation changes tmux state. + +The example creates adjacent windows, selects the second, and resolves the +active window after moving backward. The final relationship is stronger +evidence than the acknowledgement alone. + +## Related operations + +- {tmuxop:op}`next_window` +- {tmuxop:op}`rename_window` diff --git a/docs/experimental/operations/window/rename_window.md b/docs/experimental/operations/window/rename_window.md new file mode 100644 index 000000000..1d0b07122 --- /dev/null +++ b/docs/experimental/operations/window/rename_window.md @@ -0,0 +1,50 @@ +# Rename a window + +{class}`~libtmux.experimental.ops.RenameWindow` changes a window's name and +returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListWindows, NewWindow, RenameWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-before"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> result = run( +... RenameWindow(target=WindowId(created.new_id), name="docs-after"), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> renamed = next( +... item.name for item in observed.windows if item.window_id == created.new_id +... ) +>>> type(result).__name__, renamed +('AckResult', 'docs-after') +``` + +## Operation reference + +```{tmuxop:operation} rename_window +``` + +## Failure and effects + +This operation changes tmux state. It is safe to repeat. + +The operation returns no name payload. The example resolves the same window +identifier through {class}`~libtmux.experimental.ops.ListWindows` and reads its +new name independently. + +## Related operations + +- {tmuxop:op}`previous_window` +- {tmuxop:op}`resize_window` diff --git a/docs/experimental/operations/window/resize_window.md b/docs/experimental/operations/window/resize_window.md new file mode 100644 index 000000000..595613122 --- /dev/null +++ b/docs/experimental/operations/window/resize_window.md @@ -0,0 +1,53 @@ +# Resize a window + +{class}`~libtmux.experimental.ops.ResizeWindow` changes a window's dimensions +and returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListWindows, NewWindow, ResizeWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-resize"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> result = run( +... ResizeWindow(target=WindowId(created.new_id), width=90, height=30), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> resized = next( +... item for item in observed.windows if item.window_id == created.new_id +... ) +>>> ( +... type(result).__name__, +... resized.fields["window_width"], +... resized.fields["window_height"], +... ) +('AckResult', '90', '30') +``` + +## Operation reference + +```{tmuxop:operation} resize_window +``` + +## Failure and effects + +This operation changes tmux state. It changes layout. + +The acknowledgement has no geometry payload, so the example reads +`window_width` and `window_height` back from a live typed window snapshot. + +## Related operations + +- {tmuxop:op}`rename_window` +- {tmuxop:op}`respawn_window` diff --git a/docs/experimental/operations/window/respawn_window.md b/docs/experimental/operations/window/respawn_window.md new file mode 100644 index 000000000..ec0f9392f --- /dev/null +++ b/docs/experimental/operations/window/respawn_window.md @@ -0,0 +1,65 @@ +# Restart the command in a (usually dead) window + +{class}`~libtmux.experimental.ops.RespawnWindow` replaces the process in a +window and returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, NewWindow, RespawnWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow( +... target=SessionId(session.session_id), +... name="docs-respawn", +... window_shell="sleep 300", +... ), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> initial = run(ListPanes(), engine).raise_for_status() +>>> before_pid = next( +... item.pid for item in initial.panes if item.window_id == created.new_id +... ) +>>> result = run( +... RespawnWindow( +... target=WindowId(created.new_id), +... kill=True, +... shell="sleep 300", +... ), +... engine, +... ).raise_for_status() +>>> observed = run(ListPanes(), engine).raise_for_status() +>>> after_pid = next( +... item.pid for item in observed.panes if item.window_id == created.new_id +... ) +>>> ( +... type(result).__name__, +... before_pid is not None and after_pid is not None and after_pid != before_pid, +... ) +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} respawn_window +``` + +## Failure and effects + +This operation changes tmux state. + +The example creates the process it replaces and compares the pane PID before +and after the acknowledgement. `kill=True` is required while the original +process is still running. + +## Related operations + +- {tmuxop:op}`resize_window` +- {tmuxop:op}`rotate_window` diff --git a/docs/experimental/operations/window/rotate_window.md b/docs/experimental/operations/window/rotate_window.md new file mode 100644 index 000000000..ebde9461c --- /dev/null +++ b/docs/experimental/operations/window/rotate_window.md @@ -0,0 +1,70 @@ +# Rotate the panes in a window + +{class}`~libtmux.experimental.ops.RotateWindow` rotates pane positions within +a window and returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... ListPanes, +... NewWindow, +... RotateWindow, +... SplitWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-rotate"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> _ = run(SplitWindow(target=WindowId(created.new_id)), engine).raise_for_status() +>>> _ = run(SplitWindow(target=WindowId(created.new_id)), engine).raise_for_status() +>>> initial = run(ListPanes(), engine).raise_for_status() +>>> before = tuple( +... item.pane_id +... for item in sorted( +... (item for item in initial.panes if item.window_id == created.new_id), +... key=lambda item: item.pane_index, +... ) +... ) +>>> result = run( +... RotateWindow(target=WindowId(created.new_id), down=True), +... engine, +... ).raise_for_status() +>>> observed = run(ListPanes(), engine).raise_for_status() +>>> after = tuple( +... item.pane_id +... for item in sorted( +... (item for item in observed.panes if item.window_id == created.new_id), +... key=lambda item: item.pane_index, +... ) +... ) +>>> type(result).__name__, len(before) == 3 and set(after) == set(before) and after != before +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} rotate_window +``` + +## Failure and effects + +This operation changes tmux state. It changes layout. + +The same three pane identifiers remain after rotation, but their index order +changes. The before-and-after snapshots prove that relationship without +depending on volatile identifiers. + +## Related operations + +- {tmuxop:op}`respawn_window` +- {tmuxop:op}`select_layout` diff --git a/docs/experimental/operations/window/select_layout.md b/docs/experimental/operations/window/select_layout.md new file mode 100644 index 000000000..ced3958bf --- /dev/null +++ b/docs/experimental/operations/window/select_layout.md @@ -0,0 +1,63 @@ +# Apply a layout to a window + +{class}`~libtmux.experimental.ops.SelectLayout` applies a named or serialized +layout and returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... ListPanes, +... NewWindow, +... SelectLayout, +... SplitWindow, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-layout"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> _ = run(SplitWindow(target=WindowId(created.new_id)), engine).raise_for_status() +>>> result = run( +... SelectLayout( +... target=WindowId(created.new_id), +... layout="even-horizontal", +... ), +... engine, +... ).raise_for_status() +>>> observed = run(ListPanes(), engine).raise_for_status() +>>> widths = [ +... item.width for item in observed.panes if item.window_id == created.new_id +... ] +>>> ( +... type(result).__name__, +... len(widths) == 2 and None not in widths and max(widths) - min(widths) <= 1, +... ) +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} select_layout +``` + +## Failure and effects + +This operation changes tmux state. It changes layout, is safe to repeat. + +`even-horizontal` divides the owned window into equal-width panes. The live +pane widths provide a stable layout postcondition; the serialized +`window_layout` string itself is intentionally opaque. + +## Related operations + +- {tmuxop:op}`rotate_window` +- {tmuxop:op}`select_window` diff --git a/docs/experimental/operations/window/select_window.md b/docs/experimental/operations/window/select_window.md new file mode 100644 index 000000000..922eefefe --- /dev/null +++ b/docs/experimental/operations/window/select_window.md @@ -0,0 +1,50 @@ +# Make a window active + +{class}`~libtmux.experimental.ops.SelectWindow` makes a window active and +returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListWindows, NewWindow, SelectWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-select"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> result = run( +... SelectWindow(target=WindowId(created.new_id)), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> selected = next( +... item.active for item in observed.windows if item.window_id == created.new_id +... ) +>>> type(result).__name__, selected +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} select_window +``` + +## Failure and effects + +This operation changes tmux state. It is safe to repeat. + +The example reads the selected window back through +{class}`~libtmux.experimental.ops.ListWindows`; the acknowledgement itself +contains no active-window state. + +## Related operations + +- {tmuxop:op}`select_layout` +- {tmuxop:op}`set_window_option` diff --git a/docs/experimental/operations/window/set_window_option.md b/docs/experimental/operations/window/set_window_option.md new file mode 100644 index 000000000..6f4232711 --- /dev/null +++ b/docs/experimental/operations/window/set_window_option.md @@ -0,0 +1,60 @@ +# Set a window option + +{class}`~libtmux.experimental.ops.SetWindowOption` changes a window-scoped +option and returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... NewWindow, +... SetWindowOption, +... ShowOptions, +... run, +... ) +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-window-option"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> target = WindowId(created.new_id) +>>> result = run( +... SetWindowOption( +... target=target, +... option="@libtmux_docs_window", +... value="enabled", +... ), +... engine, +... ).raise_for_status() +>>> observed = run( +... ShowOptions(target=target, window=True), +... engine, +... ).raise_for_status() +>>> type(result).__name__, observed.options["@libtmux_docs_window"] +('AckResult', 'enabled') +``` + +## Operation reference + +```{tmuxop:operation} set_window_option +``` + +## Failure and effects + +This operation changes tmux state. + +The custom option is scoped to a window created by the example and read back +with {class}`~libtmux.experimental.ops.ShowOptions`. Use `unset=True` to remove +an option instead of assigning a value. + +## Related operations + +- {tmuxop:op}`select_window` +- {tmuxop:op}`split_window` diff --git a/docs/experimental/operations/window/split_window.md b/docs/experimental/operations/window/split_window.md new file mode 100644 index 000000000..d0165688a --- /dev/null +++ b/docs/experimental/operations/window/split_window.md @@ -0,0 +1,53 @@ +# Split a pane, creating a new pane + +{class}`~libtmux.experimental.ops.SplitWindow` adds a tiled pane and returns +its identifier in a +{class}`~libtmux.experimental.ops.results.SplitWindowResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListPanes, NewWindow, SplitWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-split"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> split = run( +... SplitWindow(target=WindowId(created.new_id)), +... engine, +... ).raise_for_status() +>>> assert split.new_pane_id is not None +>>> observed = run(ListPanes(), engine).raise_for_status() +>>> any( +... item.pane_id == split.new_pane_id +... and item.window_id == created.new_id +... for item in observed.panes +... ) +True +``` + +## Operation reference + +```{tmuxop:operation} split_window +``` + +## Failure and effects + +This operation changes tmux state. It creates a pane. + +The example resolves the captured pane identifier through +{class}`~libtmux.experimental.ops.ListPanes` and verifies that it belongs to the +owned window. + +## Related operations + +- {tmuxop:op}`set_window_option` +- {tmuxop:op}`swap_window` diff --git a/docs/experimental/operations/window/swap_window.md b/docs/experimental/operations/window/swap_window.md new file mode 100644 index 000000000..072cc241a --- /dev/null +++ b/docs/experimental/operations/window/swap_window.md @@ -0,0 +1,69 @@ +# Swap two windows + +{class}`~libtmux.experimental.ops.SwapWindow` exchanges two window positions +and returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListWindows, NewWindow, SwapWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> first = run( +... NewWindow(target=SessionId(session.session_id), name="docs-swap-first"), +... engine, +... ).raise_for_status() +>>> second = run( +... NewWindow(target=SessionId(session.session_id), name="docs-swap-second"), +... engine, +... ).raise_for_status() +>>> assert first.new_id is not None and second.new_id is not None +>>> initial = run(ListWindows(), engine).raise_for_status() +>>> before = { +... item.window_id: item.window_index +... for item in initial.windows +... if item.window_id in {first.new_id, second.new_id} +... } +>>> result = run( +... SwapWindow( +... target=WindowId(first.new_id), +... src_target=WindowId(second.new_id), +... detach=True, +... ), +... engine, +... ).raise_for_status() +>>> observed = run(ListWindows(), engine).raise_for_status() +>>> after = { +... item.window_id: item.window_index +... for item in observed.windows +... if item.window_id in {first.new_id, second.new_id} +... } +>>> ( +... type(result).__name__, +... after[first.new_id] == before[second.new_id] +... and after[second.new_id] == before[first.new_id] +... ) +('AckResult', True) +``` + +## Operation reference + +```{tmuxop:operation} swap_window +``` + +## Failure and effects + +This operation changes tmux state. + +The before-and-after listings prove that the owned window identifiers exchange +indices. `detach=True` prevents the proof from changing the active window. + +## Related operations + +- {tmuxop:op}`split_window` +- {tmuxop:op}`unlink_window` diff --git a/docs/experimental/operations/window/unlink_window.md b/docs/experimental/operations/window/unlink_window.md new file mode 100644 index 000000000..615461d9e --- /dev/null +++ b/docs/experimental/operations/window/unlink_window.md @@ -0,0 +1,52 @@ +# Unlink a window from a session + +{class}`~libtmux.experimental.ops.UnlinkWindow` removes a window link and +returns an {class}`~libtmux.experimental.ops.results.AckResult`. + +## Example + +This executable example uses an isolated live tmux server. `server` and +`session` are injected documentation context. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListWindows, NewWindow, UnlinkWindow, run +>>> from libtmux.experimental.ops._types import SessionId, WindowId +>>> assert session.session_id is not None +>>> engine = SubprocessEngine.for_server(server) +>>> created = run( +... NewWindow(target=SessionId(session.session_id), name="docs-unlink"), +... engine, +... ).raise_for_status() +>>> assert created.new_id is not None +>>> before = run(ListWindows(), engine).raise_for_status() +>>> result = run( +... UnlinkWindow(target=WindowId(created.new_id), kill=True), +... engine, +... ).raise_for_status() +>>> after = run(ListWindows(), engine).raise_for_status() +>>> ( +... type(result).__name__, +... created.new_id in {item.window_id for item in before.windows}, +... created.new_id not in {item.window_id for item in after.windows}, +... ) +('AckResult', True, True) +``` + +## Operation reference + +```{tmuxop:operation} unlink_window +``` + +## Failure and effects + +This operation changes tmux state. + +The example owns the link it removes and uses `kill=True`, so no other session +can retain the disposable window. Independent listings prove presence before +the operation and absence afterward. + +## Related operations + +- {tmuxop:op}`swap_window` +- {tmuxop:op}`break_pane` diff --git a/docs/experimental/plans.md b/docs/experimental/plans.md new file mode 100644 index 000000000..690eeaa99 --- /dev/null +++ b/docs/experimental/plans.md @@ -0,0 +1,183 @@ +# Build operation plans + +A {class}`~libtmux.experimental.ops.plan.LazyPlan` records operations without +running them. Create operations return forward slot references, allowing later +operations to target objects that do not exist until execution. + +## Record and execute + +{meth}`~libtmux.experimental.ops.plan.LazyPlan.execute` resolves forward +references against captured identifiers as each operation completes. This live +plan creates a pane, queries its captured ID through the forward reference, +then removes it: + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... DisplayMessage, +... KillPane, +... LazyPlan, +... SplitWindow, +... WindowId, +... ) +>>> assert window.window_id is not None +>>> operation_plan = LazyPlan() +>>> new_pane = operation_plan.add( +... SplitWindow(target=WindowId(window.window_id)), +... ) +>>> _ = operation_plan.add( +... DisplayMessage(target=new_pane, message="#{pane_id}"), +... ) +>>> _ = operation_plan.add(KillPane(target=new_pane)) +>>> outcome = operation_plan.execute( +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> created_id = outcome.results[0].created_id +>>> created_id is not None +True +>>> outcome.results[1].text == created_id +True +>>> len(server.panes.filter(pane_id=created_id)) == 0 +True +``` + +## Choose a planner + +A {class}`~libtmux.experimental.ops.planner.Planner` turns a plan into +dispatches: + +- {class}`~libtmux.experimental.ops.planner.SequentialPlanner` sends one + dispatch per operation. +- {class}`~libtmux.experimental.ops.planner.FoldingPlanner` combines adjacent + chainable operations. +- {class}`~libtmux.experimental.ops.planner.MarkedPlanner` folds creation and + follow-up work by using tmux's `{marked}` register. + +All planners preserve per-operation results. They differ only in dispatch +shape. The callback in this live example records the dispatches: two chainable +option writes fold, while the output-bearing read stays separate. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ( +... FoldingPlanner, +... LazyPlan, +... SessionId, +... SetOption, +... ShowOptions, +... ) +>>> assert session.session_id is not None +>>> target = SessionId(session.session_id) +>>> operation_plan = LazyPlan() +>>> _ = operation_plan.add( +... SetOption(target=target, option="@docs_first", value="one"), +... ) +>>> _ = operation_plan.add( +... SetOption(target=target, option="@docs_second", value="two"), +... ) +>>> _ = operation_plan.add(ShowOptions(target=target)) +>>> steps = [] +>>> outcome = operation_plan.execute( +... SubprocessEngine.for_server(server), +... planner=FoldingPlanner(), +... on_step=lambda report: steps.append(report.step.indices), +... ).raise_for_status() +>>> steps +[(0, 1), (2,)] +>>> type(outcome.results[2]).__name__ +'ShowOptionsResult' +>>> ( +... outcome.results[2].options["@docs_first"], +... outcome.results[2].options["@docs_second"], +... ) +('one', 'two') +``` + +## Build fluently + +{func}`~libtmux.experimental.fluent.plan` records a hierarchy without requiring +the caller to thread newly created identifiers through each step. Nothing +touches tmux until {meth}`~libtmux.experimental.fluent.PlanBuilder.run`: + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.fluent import plan +>>> workspace_name = f"{session.session_name}-planned" +>>> workspace = plan() +>>> _ = workspace.new_session(workspace_name).new_window("logs") +>>> outcome = workspace.run( +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> outcome.ok, server.has_session(workspace_name) +(True, True) +>>> built = server.sessions.get(session_name=workspace_name) +>>> built is not None and len(built.windows) == 2 +True +>>> _ = server.kill_session(workspace_name) +``` + +`split()` returns a forward handle so callers can continue composing work. +Execution records the handle's concrete pane identifier in +{attr}`~libtmux.experimental.ops.plan.PlanResult.bindings`. + +See {doc}`tutorials/async-control-plans` to compose chainable operations, inspect +their compiled tmux sequence, and execute the plan over one persistent async +control-mode client. + +## API reference + +### Composition and references + +```{eval-rst} +.. autoclass:: libtmux.experimental.ops.operation.Operation + :members: then + +.. autoclass:: libtmux.experimental.ops._chain.OpChain + :members: + +.. autoclass:: libtmux.experimental.ops._types.SlotRef + :members: + +.. autoclass:: libtmux.experimental.ops._types.PaneId + :members: +``` + +### Plans and planners + +```{eval-rst} +.. autoclass:: libtmux.experimental.ops.plan.LazyPlan + :members: + +.. autoclass:: libtmux.experimental.ops.plan.PlanResult + :members: + +.. autoclass:: libtmux.experimental.ops.planner.Planner + :members: + +.. autoclass:: libtmux.experimental.ops.planner.SequentialPlanner + :members: + +.. autoclass:: libtmux.experimental.ops.planner.FoldingPlanner + :members: + +.. autoclass:: libtmux.experimental.ops.planner.MarkedPlanner + :members: + +.. autoclass:: libtmux.experimental.ops.planner.BoundedPlanner + :members: +``` + +### Fluent builder + +```{eval-rst} +.. autofunction:: libtmux.experimental.fluent.plan + +.. autoclass:: libtmux.experimental.fluent.PlanBuilder + :members: + +.. autoclass:: libtmux.experimental.fluent.SessionRef + :members: + +.. autoclass:: libtmux.experimental.fluent.WindowRef + :members: +``` diff --git a/docs/experimental/results.md b/docs/experimental/results.md new file mode 100644 index 000000000..da672a94c --- /dev/null +++ b/docs/experimental/results.md @@ -0,0 +1,121 @@ +# Results + +Every operation returns an immutable +{class}`~libtmux.experimental.ops.results.Result` subtype. You inspect the same +status and tmux output fields for every command, then use the subtype's payload +when the command returns structured data. Most callers need only that contract +and {meth}`~libtmux.experimental.ops.results.Result.raise_for_status`. + +## Read one result + +{func}`~libtmux.experimental.ops.run` preserves a tmux rejection as result data. +Call {meth}`~libtmux.experimental.ops.results.Result.raise_for_status` where +your application wants a failed or unknown status to become an exception. +Successful list operations add typed snapshot views, such as +{attr}`~libtmux.experimental.ops.results.ListSessionsResult.sessions`. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListSessions, run +>>> assert session.session_id is not None +>>> result = run( +... ListSessions(), +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> type(result).__name__, result.status, result.ok +('ListSessionsResult', 'complete', True) +>>> any(item.session_id == session.session_id for item in result.sessions) +True +``` + +All results retain the operation, rendered `argv`, `status`, `returncode`, +`stdout`, and `stderr`. `ok` is true only for `complete`; `failed` identifies a +tmux rejection or an incomplete composed operation. +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status` raises for +`failed` and `unknown`, but returns both `complete` and `skipped` results. See +{doc}`tutorials/results-and-failures` for those paths in context. + +## Choose the payload + +- {class}`~libtmux.experimental.ops.results.AckResult` adds no payload; the + status is the acknowledgement. +- {class}`~libtmux.experimental.ops.results.CreateResult` captures a created + session or window and optional child IDs. +- {class}`~libtmux.experimental.ops.results.SplitWindowResult` captures a + created pane ID. +- {class}`~libtmux.experimental.ops.results.CapturePaneResult`, + {class}`~libtmux.experimental.ops.results.DisplayMessageResult`, and + {class}`~libtmux.experimental.ops.results.ShowBufferResult` expose captured + text. +- {class}`~libtmux.experimental.ops.results.HasSessionResult` turns expected + absence into the boolean `exists` payload. +- {class}`~libtmux.experimental.ops.results.ShowOptionsResult` exposes parsed + option values. +- {class}`~libtmux.experimental.ops.results.ListClientsResult`, + {class}`~libtmux.experimental.ops.results.ListPanesResult`, + {class}`~libtmux.experimental.ops.results.ListSessionsResult`, and + {class}`~libtmux.experimental.ops.results.ListWindowsResult` expose typed + snapshots over their parsed rows. + +## API reference + +### Shared contract + +```{eval-rst} +.. autoclass:: libtmux.experimental.ops.results.Result + :members: +``` + +### Acknowledgement and creation + +```{eval-rst} +.. autoclass:: libtmux.experimental.ops.results.AckResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.CreateResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.SplitWindowResult + :members: +``` + +### Text, existence, and options + +```{eval-rst} +.. autoclass:: libtmux.experimental.ops.results.CapturePaneResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.DisplayMessageResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.HasSessionResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.ShowBufferResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.ShowOptionsResult + :members: +``` + +### Snapshot collections + +```{eval-rst} +.. autoclass:: libtmux.experimental.ops.results.ListClientsResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.ListPanesResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.ListSessionsResult + :members: + +.. autoclass:: libtmux.experimental.ops.results.ListWindowsResult + :members: +``` + +### Navigation failures + +```{eval-rst} +.. autoexception:: libtmux.experimental.ops.exc.MissingCreateIdError +``` diff --git a/docs/experimental/tutorials/async-control-plans.md b/docs/experimental/tutorials/async-control-plans.md new file mode 100644 index 000000000..4304da1e5 --- /dev/null +++ b/docs/experimental/tutorials/async-control-plans.md @@ -0,0 +1,221 @@ +# Master async control-mode plans + +Build a typed operation plan synchronously, then execute it asynchronously over +one persistent tmux client. The useful mental model has three distinct layers: + +| Layer | Owns | Does not imply | +| --- | --- | --- | +| Python composition | Operation order and forward references | Any tmux I/O | +| Planning | Which operations share one tmux command sequence | Process reuse | +| Control mode | One persistent `tmux -C` client and framed replies | One reply per plan | + +Keeping those layers separate lets you change dispatch policy or transport +without rewriting the operations. + +## Compose and run one live plan + +This plan creates a pane, assigns two pane-local user options, then reads them +back. The pane does not exist when Python records the option operations, so +{meth}`~libtmux.experimental.ops.plan.LazyPlan.add` returns a +{class}`~libtmux.experimental.ops._types.SlotRef` that stands in for its future +ID. + +The same deterministic story gives an agent tool three explicit phases: + +- Observe: preview the unresolved plan and explain its two dispatch steps + without contacting tmux. +- Act: execute the marked fold through + {class}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine`. +- Verify: inspect four typed statuses, the captured `worker:ready` value, and + the live pane lookup. + +The two tabs show the same work at the Python and tmux boundaries. `@WINDOW` +stands for the live window ID. `%PANE` stands for the pane ID captured by +`split-window`. + +`````{tab} Python plan +```python +>>> import asyncio +>>> from libtmux.experimental.engines import AsyncControlModeEngine +>>> from libtmux.experimental.ops import ( +... DisplayMessage, +... LazyPlan, +... MarkedPlanner, +... PaneId, +... SetOption, +... SplitWindow, +... WindowId, +... ) +>>> assert window.window_id is not None +>>> operation_plan = LazyPlan() +>>> worker = operation_plan.add( +... SplitWindow(target=WindowId(window.window_id)), +... ) +>>> worker +SlotRef(slot=0, suffix='', part='self') +>>> operation_plan.add_chain( +... SetOption( +... target=worker, +... pane=True, +... option="@role", +... value="worker", +... ) +... >> SetOption( +... target=worker, +... pane=True, +... option="@state", +... value="ready", +... ), +... ) +>>> _ = operation_plan.add( +... DisplayMessage( +... target=worker, +... message="#{@role}:#{@state}", +... ), +... ) +>>> operation_plan.preview()[1:] +[None, None, None] +>>> [ +... (step.kinds, step.reason) +... for step in operation_plan.explain(MarkedPlanner()) +... ] +[(('split_window', 'set_option', 'set_option'), 'marked-fold'), + (('display_message',), 'capture')] +>>> async def configure_worker(): +... async with AsyncControlModeEngine.for_server(server) as engine: +... return await operation_plan.aexecute( +... engine, +... planner=MarkedPlanner(), +... ) +>>> outcome = asyncio.run(configure_worker()) +>>> [result.status for result in outcome.results] +['complete', 'complete', 'complete', 'complete'] +>>> pane_id = outcome.bindings[worker.slot] +>>> outcome.results[1].operation.target == PaneId(pane_id) +True +>>> outcome.results[-1].text +'worker:ready' +>>> server.panes.get(pane_id=pane_id) is not None +True +``` +````` + +`````{tab} Compiled tmux sequence +The pane creation and its two decorations compile into one tmux command +sequence: + +```console +$ tmux split-window \ + -t @WINDOW \ + -v \ + -P \ + -F '#{pane_id}' \ + \; select-pane -m \ + \; set-option -t '{marked}' -p -- @role worker \ + \; set-option -t '{marked}' -p -- @state ready \ + \; select-pane -M +``` + +After tmux returns the new pane ID, the output-bearing query becomes a second +dispatch: + +```console +$ tmux display-message \ + -t %PANE \ + -p \ + -- '#{@role}:#{@state}' +``` +````` + +These console commands are the direct-CLI equivalents. The control-mode engine +does not start `tmux` for either line. It removes the leading executable and +writes each command sequence to the standard input of the existing `tmux -C` +client. + +## Layer one: compose Python values + +{meth}`~libtmux.experimental.ops.operation.Operation.then` and `>>` create an +ordered {class}`~libtmux.experimental.ops._chain.OpChain`. +{meth}`~libtmux.experimental.ops.plan.LazyPlan.add_chain` records that order in +the plan. Both operations are inert: they neither contact tmux nor promise that +the operations will share a dispatch. + +That distinction matters because +{meth}`~libtmux.experimental.ops.plan.LazyPlan.aexecute` defaults to +{class}`~libtmux.experimental.ops.planner.SequentialPlanner`. Pass a folding +planner explicitly when dispatch shape matters. + +## Layer two: fold safe tmux sequences + +{class}`~libtmux.experimental.ops.planner.MarkedPlanner` recognizes a focused +pane creator followed immediately by chainable operations targeting that +creator's exact forward reference. It emits: + +1. `split-window -P -F '#{pane_id}'`; +2. `select-pane -m` to mark the newly focused pane; +3. each decoration retargeted to tmux's `{marked}` special target; and +4. `select-pane -M` to clear the mark. + +The first planner step therefore contains three user operations but becomes one +engine request. The mark and unmark commands are implementation details and do +not add operation results. + +The final +{class}`~libtmux.experimental.ops._ops.display_message.DisplayMessage` remains +separate because it produces output. Combining its stdout with the creator's +captured pane ID would make typed result attribution ambiguous. Creators, +capturing reads, and any operation declaring `chainable = False` remain hard +boundaries unless a specialized planner has a safe attribution rule. + +## Resolve references at the last responsible moment + +The worker target passes through three representations: + +1. Python records `SlotRef(slot=0)` before a pane exists. +2. The marked fold addresses the new pane as `{marked}` inside one tmux + sequence. +3. The captured `%N` binds slot `0`; subsequent operations render a concrete + {class}`~libtmux.experimental.ops._types.PaneId`. + +The plan awaits each planner step before rendering a dependent step. That is why +the `display-message` request contains `%PANE`, while the two option writes can +run earlier against `{marked}`. + +## Reuse one asynchronous transport + +{class}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine` +owns one long-lived `tmux -C attach-session -E` child while its async context is +open. Each planner step becomes one newline-terminated control-mode request. +Tmux frames every subcommand reply with `%begin` and `%end` or `%error`; the +engine correlates those blocks into raw command results, and the plan maps the +raw outcomes back to typed operation results. + +This example has four operations and two planner steps, but only one persistent +control client process. Control mode avoids one process start per request; it +does not eliminate processes entirely. + +Use +{meth}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine.run_batch` +for independent, already-rendered requests. A forward-reference plan cannot +pipeline dependent steps that way because it must capture the first result +before it can render the next target. + +## Know the boundaries + +- Enter the async context only after a safe session exists. Without an + attachable session, the engine uses async subprocess execution to bootstrap + the server. +- Keep output-bearing reads and failure-sensitive validation in their own + planner steps. Tmux stops a command sequence after an error, and a folded + result cannot provide independent stdout or exact failure attribution for + every subcommand. +- A detached pane creator cannot use the marked-pane optimization because it + does not focus the new pane. +- Host-side waits, sleeps, or callbacks are dispatch boundaries. Use a bounded + planner when host work must occur between operations. +- Scope the engine with `async with` so its reader, supervisor, and control + client close before the event loop exits. + +See {doc}`../engines/async-control-mode` for attachment, notification, and +reconnection lifecycle details. See {doc}`results-and-failures` for typed +command failures and skipped work. diff --git a/docs/experimental/tutorials/async-subprocess.md b/docs/experimental/tutorials/async-subprocess.md new file mode 100644 index 000000000..aefee7e74 --- /dev/null +++ b/docs/experimental/tutorials/async-subprocess.md @@ -0,0 +1,69 @@ +# Run async subprocess operations + +{class}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine` uses +{func}`asyncio.create_subprocess_exec` for command process I/O. +{func}`~libtmux.experimental.ops.arun` preserves the same operation and typed +result contract as synchronous execution. The workflow below returns two typed, +independent reads from one live server. + +## Dispatch independent reads concurrently + +The first implicit engine-version lookup is a synchronous, memoized `tmux -V` +probe. Resolve it before entering the event loop, then pass it to +{func}`~libtmux.experimental.ops.arun` when the loop must remain nonblocking. +Each call below then owns one async command child. {func}`asyncio.gather` makes +the two independent reads concurrent; +{meth}`~libtmux.experimental.engines.asyncio.AsyncSubprocessEngine.run_batch` +would deliberately await them in order. + +```python +>>> import asyncio +>>> from libtmux.experimental.engines import AsyncSubprocessEngine +>>> from libtmux.experimental.ops import DisplayMessage, PaneId, SessionId, arun +>>> assert session.session_id is not None +>>> assert pane is not None and pane.pane_id is not None +>>> engine = AsyncSubprocessEngine.for_server(server) +>>> version = engine.tmux_version() +>>> assert version is not None +>>> async def read_ids(): +... session_result, pane_result = await asyncio.gather( +... arun( +... DisplayMessage( +... target=SessionId(session.session_id), +... message="#{session_id}", +... ), +... engine, +... version=version, +... ), +... arun( +... DisplayMessage( +... target=PaneId(pane.pane_id), +... message="#{pane_id}", +... ), +... engine, +... version=version, +... ), +... ) +... return ( +... session_result.raise_for_status(), +... pane_result.raise_for_status(), +... ) +>>> session_result, pane_result = asyncio.run(read_ids()) +>>> [type(result).__name__ for result in (session_result, pane_result)] +['DisplayMessageResult', 'DisplayMessageResult'] +>>> session_result.status, pane_result.status +('complete', 'complete') +>>> session_result.text == session.session_id, pane_result.text == pane.pane_id +(True, True) +``` + +## Cancellation boundary + +If cancellation interrupts subprocess communication, the engine terminates and +reaps the child before re-raising {exc}`asyncio.CancelledError`. That guarantee +is about local process lifetime. It cannot undo a command tmux accepted before +the cancellation arrived. + +The engine has no persistent connection and therefore no close method. Keep the +owning private server or test fixture alive until all scheduled operations have +finished. diff --git a/docs/experimental/tutorials/control-mode.md b/docs/experimental/tutorials/control-mode.md new file mode 100644 index 000000000..bcd5e5f5d --- /dev/null +++ b/docs/experimental/tutorials/control-mode.md @@ -0,0 +1,74 @@ +# Use control mode + +{class}`~libtmux.experimental.engines.control_mode.ControlModeEngine` keeps one +`tmux -C` client alive once connected and correlates tmux's framed replies with +submitted +{class}`~libtmux.experimental.engines.base.CommandRequest` values. +{meth}`~libtmux.experimental.engines.control_mode.ControlModeEngine.run_batch` +pipelines an ordered batch instead of starting one process per request. + +## How attachment stays safe + +Before opening a control connection, the engine looks for an existing session +whose effective `destroy-unattached` value is `off`. It attaches to that exact +session ID with `attach-session -E`. The exact target avoids tmux's implicit +session selection, while `-E` leaves the session's `update-environment` state +unchanged. + +If no safe session exists, the engine uses the corresponding subprocess engine +for that batch. `new-session` can therefore bootstrap an empty server without a +private control session. A later batch can switch to the persistent client once +a safe session exists. + +The control process is still a normal attached client. It appears in +`list-clients`, contributes to `session_attached`, and participates in client +attach and detach hooks. The engine does not change `destroy-unattached` or +issue a session-deletion command during cleanup. If you change that option +while the engine is connected, tmux applies the new policy when the client +detaches. + +## Pipeline one live batch + +Use {class}`~libtmux.experimental.engines.control_mode.ControlModeEngine` as a +context manager so the persistent control client is closed on every exit path. +Context entry is lazy; the first batch chooses persistent or subprocess +dispatch. + +```python +>>> from libtmux.experimental.engines import CommandRequest, ControlModeEngine +>>> assert session.session_id is not None +>>> assert window.window_id is not None +>>> requests = [ +... CommandRequest.from_args( +... "display-message", "-p", "-t", session.session_id, "#{session_id}" +... ), +... CommandRequest.from_args( +... "display-message", "-p", "-t", window.window_id, "#{window_id}" +... ), +... ] +>>> with ControlModeEngine.for_server(server) as engine: +... results = engine.run_batch(requests) +>>> [type(result).__name__ for result in results] +['CommandResult', 'CommandResult'] +>>> [result.returncode for result in results] +[0, 0] +>>> [result.stdout[0] for result in results] == [ +... session.session_id, +... window.window_id, +... ] +True +``` + +The values are live raw +{class}`~libtmux.experimental.engines.base.CommandResult` instances because +`run_batch` is the engine boundary. Use {func}`~libtmux.experimental.ops.run` or +a plan when the caller needs operation-specific result subtypes. + +## Async variation + +For +{class}`~libtmux.experimental.engines.async_control_mode.AsyncControlModeEngine`, +{doc}`async-control-plans` shows typed results, a forward-referenced pane, and +two control-mode dispatches. The engine reference documents its supervisor, +reconnection, and subscription boundaries without implying a public subscriber +readiness handshake. diff --git a/docs/experimental/tutorials/imsg-parity.md b/docs/experimental/tutorials/imsg-parity.md new file mode 100644 index 000000000..601b3fe62 --- /dev/null +++ b/docs/experimental/tutorials/imsg-parity.md @@ -0,0 +1,50 @@ +# Compare imsg with subprocess execution + +{class}`~libtmux.experimental.engines.imsg.base.ImsgEngine` uses tmux's native +binary protocol on POSIX. A useful parity check binds it and +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` to the same +private server, sends the same raw request, and compares one observable: +standard output. + +## Bind both engines explicitly + +`ImsgEngine` has no `for_server()` helper. Include the server's `-L` socket name +or `-S` socket path in every +{class}`~libtmux.experimental.engines.base.CommandRequest`. The subprocess +engine accepts the same global argument in the request, which keeps the +comparison exact. + +```python +>>> from libtmux.experimental.engines import CommandRequest, ImsgEngine +>>> from libtmux.experimental.engines import SubprocessEngine +>>> assert session.session_id is not None +>>> if server.socket_name is not None: +... prefix = (f"-L{server.socket_name}",) +... else: +... socket_path = server.cmd( +... "display-message", "-p", "#{socket_path}" +... ).stdout[0] +... prefix = (f"-S{socket_path}",) +>>> request = CommandRequest.from_args( +... *prefix, +... "display-message", +... "-p", +... "-t", +... session.session_id, +... "#{session_id}", +... ) +>>> imsg_result = ImsgEngine().run(request) +>>> cli_result = SubprocessEngine().run(request) +>>> imsg_result.stdout == cli_result.stdout == (session.session_id,) +True +``` + +## Interpret the result narrowly + +This proves stdout parity for one query against one server. It does not prove +return-code or stderr parity, command-wide parity, cross-version compatibility, +or portability. The current codec supports +protocol v8; unsupported protocol negotiation, socket framing, and descriptor +handling have their own failure paths. Local queries and commands that must +start a missing server use the tmux binary; commands dispatched to an existing +server still use the socket, including commands that spawn a shell there. diff --git a/docs/experimental/tutorials/index.md b/docs/experimental/tutorials/index.md new file mode 100644 index 000000000..18d696de0 --- /dev/null +++ b/docs/experimental/tutorials/index.md @@ -0,0 +1,27 @@ +# Engine tutorials + +Each concrete engine has one tested workflow: + +| Engine | Tutorial outcome | +| --- | --- | +| `SubprocessEngine` | {doc}`live-operation` returns a typed result from an isolated live server. | +| `AsyncSubprocessEngine` | {doc}`async-subprocess` runs two independent live reads concurrently. | +| `ControlModeEngine` | {doc}`control-mode` pipelines an ordered request batch over one synchronous connection. | +| `AsyncControlModeEngine` | {doc}`async-control-plans` resolves a forward reference and verifies the live pane in two dispatches. | +| `MockEngine` and `AsyncMockEngine` | {doc}`offline-testing` distinguishes canned output and fabricated IDs from live tmux state. | +| `ImsgEngine` | {doc}`imsg-parity` compares one live stdout value with the subprocess transport. | + +{doc}`results-and-failures` is the shared guide to typed success, command +failure, expected absence, version rejection, and skipped work. + +```{toctree} +:hidden: + +live-operation +results-and-failures +async-control-plans +async-subprocess +control-mode +offline-testing +imsg-parity +``` diff --git a/docs/experimental/tutorials/live-operation.md b/docs/experimental/tutorials/live-operation.md new file mode 100644 index 000000000..1c0691a62 --- /dev/null +++ b/docs/experimental/tutorials/live-operation.md @@ -0,0 +1,48 @@ +# Run a live operation + +{class}`~libtmux.experimental.engines.subprocess.SubprocessEngine` is the +synchronous live-server baseline. Bind it to an isolated +{class}`~libtmux.server.Server`, return a typed operation result, and leave +cleanup with the server owner. Operations stay unchanged when the engine +changes. + +## Application-owned server + +Use the public server context manager when the calling application owns tmux +setup. A unique socket name isolates the example from the user's normal server; +context exit kills the private server. + +```python +>>> import uuid +>>> from libtmux.server import Server as TmuxServer +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListSessions, run +>>> socket_name = f"libtmux-doc-{uuid.uuid4().hex}" +>>> with TmuxServer(socket_name=socket_name) as private_server: +... private_session = private_server.new_session(session_name="docs-live") +... engine = SubprocessEngine.for_server(private_server) +... result = run(ListSessions(), engine).raise_for_status() +... found = any( +... item.session_id == private_session.session_id +... for item in result.sessions +... ) +>>> type(result).__name__, result.status +('ListSessionsResult', 'complete') +>>> found +True +>>> private_server.is_alive() +False +``` + +## Injected-server variation + +The documentation test environment injects isolated `server`, `session`, +`window`, and `pane` values. The library's pytest fixtures provide the same +style of object hierarchy. Bind the same engine with +`SubprocessEngine.for_server(server)` instead of creating another server, then +run the same operation and assert against the injected objects. + +{meth}`~libtmux.experimental.engines.subprocess.SubprocessEngine.for_server` +copies the server's tmux binary and `-L` or `-S` connection arguments. It does +not transfer ownership: cleaning up the server remains the responsibility of +the context or fixture that created it. diff --git a/docs/experimental/tutorials/offline-testing.md b/docs/experimental/tutorials/offline-testing.md new file mode 100644 index 000000000..a9c24dd49 --- /dev/null +++ b/docs/experimental/tutorials/offline-testing.md @@ -0,0 +1,56 @@ +# Test operations offline + +{class}`~libtmux.experimental.engines.mock.MockEngine` and +{class}`~libtmux.experimental.engines.mock.AsyncMockEngine` execute no tmux +process. Use them to prove rendering and typed result conversion without +claiming that a real server accepted a command. + +## Read canned output and fabricated IDs + +{class}`~libtmux.experimental.engines.mock.MockEngine` fabricates IDs for create +operations, returns configured lines for `capture-pane`, and reports success +for other commands. + +```python +>>> from libtmux.experimental.engines import MockEngine +>>> from libtmux.experimental.ops import CapturePane, HasSession, SplitWindow +>>> from libtmux.experimental.ops import PaneId, SessionId, WindowId, run +>>> engine = MockEngine(capture_lines=("canned:first", "canned:second")) +>>> created = run(SplitWindow(target=WindowId("@1")), engine) +>>> captured = run(CapturePane(target=PaneId("%1")), engine) +>>> existence = run(HasSession(target=SessionId("$404")), engine) +>>> created.new_pane_id, captured.lines, existence.exists +('%1', ('canned:first', 'canned:second'), True) +``` + +`%1` is fabricated, the capture lines are canned, and `True` is simulated. None +proves that the pane, output, or `$404` session exists on a server. + +## Async variation + +{class}`~libtmux.experimental.engines.mock.AsyncMockEngine` applies the same +simulation behind {func}`~libtmux.experimental.ops.arun`. Use it when the code +under test is async, not to make concurrency, cancellation, or connection +lifecycle claims. + +```python +>>> import asyncio +>>> from libtmux.experimental.engines import AsyncMockEngine +>>> from libtmux.experimental.ops import CapturePane, PaneId, arun +>>> async def capture_offline(): +... engine = AsyncMockEngine(capture_lines=("canned:async",)) +... return await arun(CapturePane(target=PaneId("%404")), engine) +>>> captured = asyncio.run(capture_offline()).raise_for_status() +>>> captured.lines +('canned:async',) +``` + +The returned line is canned. The async engine does not inspect `%404`, start +tmux, or prove that a pane emitted output. + +## What still needs a live engine + +Use a subprocess or control-mode engine for target lookup, permissions, shell +effects, tmux version behavior, byte-level output, transport cleanup, and +failure text from the installed tmux. Mock engines cannot report a tmux version; +pass `version` explicitly when an offline test needs a specific rendering gate. diff --git a/docs/experimental/tutorials/results-and-failures.md b/docs/experimental/tutorials/results-and-failures.md new file mode 100644 index 000000000..a60231064 --- /dev/null +++ b/docs/experimental/tutorials/results-and-failures.md @@ -0,0 +1,138 @@ +# Handle results and failures + +{func}`~libtmux.experimental.ops.run` returns the operation's declared +{class}`~libtmux.experimental.ops.results.Result` subtype. It does not collapse +successful payloads, command rejections, expected absence, render-time version +errors, and undispatched work into one exception path. + +## Read a typed success + +List operations retain raw output and expose typed projections over parsed +rows. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import ListSessions, result_to_dict, run +>>> assert session.session_id is not None +>>> result = run( +... ListSessions(), +... SubprocessEngine.for_server(server), +... ).raise_for_status() +>>> type(result).__name__, result.status +('ListSessionsResult', 'complete') +>>> any(item.session_id == session.session_id for item in result.sessions) +True +>>> payload = result_to_dict(result) +>>> payload["status"], len(payload["rows"]) == len(result.sessions) +('complete', True) +``` + +## Keep a tmux rejection as data + +A rejected tmux command produces a failed result. Inspect it directly, or opt +into {exc}`~libtmux.experimental.ops.exc.TmuxCommandError` with +{meth}`~libtmux.experimental.ops.results.Result.raise_for_status`. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import RenameWindow, TmuxCommandError, run +>>> from libtmux.experimental.ops._types import WindowId +>>> failed = run( +... RenameWindow(target=WindowId("@999999999"), name="unreachable"), +... SubprocessEngine.for_server(server), +... ) +>>> failed.status, failed.returncode != 0, bool(failed.stderr) +('failed', True, True) +>>> try: +... failed.raise_for_status() +... except TmuxCommandError as error: +... details = ( +... error.returncode == failed.returncode, +... error.cmd == failed.argv, +... ) +>>> details +(True, True) +``` + +## Treat expected absence as an answer + +{class}`~libtmux.experimental.ops.HasSession` maps tmux's missing-session exit +to a complete {class}`~libtmux.experimental.ops.results.HasSessionResult`. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import HasSession, run +>>> from libtmux.experimental.ops._types import SessionId +>>> result = run( +... HasSession(target=SessionId("$999999999")), +... SubprocessEngine.for_server(server), +... ) +>>> result.status, result.exists, result.returncode != 0 +('complete', False, True) +>>> result.raise_for_status() is result +True +``` + +## Reject an unsupported operation before dispatch + +Whole-operation version gates raise +{exc}`~libtmux.experimental.ops.exc.VersionUnsupported` while rendering. No +engine is called. + +```python +>>> from libtmux.experimental.ops import NewPane, VersionUnsupported +>>> from libtmux.experimental.ops._types import PaneId +>>> try: +... NewPane(target=PaneId("%1")).render(version="3.6") +... except VersionUnsupported as error: +... requirement = (error.kind, error.need, error.have) +>>> requirement +('new_pane', '3.7', '3.6') +``` + +## Reject a successful create without a captured ID + +Eager and async object navigation requires the identifier promised by a create +operation. If an engine reports success but omits that capture, +{exc}`~libtmux.experimental.ops.exc.MissingCreateIdError` preserves the complete +result for diagnosis instead of constructing an object with an empty target. + +```python +>>> from libtmux.experimental.ops import MissingCreateIdError, NewSession +>>> missing = NewSession().build_result(returncode=0) +>>> try: +... raise MissingCreateIdError(missing) +... except MissingCreateIdError as error: +... details = (error.kind, error.missing, error.result is missing) +>>> details +('new_session', ('self',), True) +``` + +## Distinguish failed and skipped plan steps + +A folding planner dispatches adjacent chainable operations as one tmux command +group. If the first command fails, tmux does not run the remainder; the result +attribution marks that remainder `skipped`. + +```python +>>> from libtmux.experimental.engines import SubprocessEngine +>>> from libtmux.experimental.ops import FoldingPlanner, LazyPlan, RenameWindow +>>> from libtmux.experimental.ops._types import WindowId +>>> assert window.window_id is not None +>>> plan = LazyPlan() +>>> _ = plan.add( +... RenameWindow(target=WindowId("@999999999"), name="unreachable") +... ) +>>> _ = plan.add( +... RenameWindow(target=WindowId(window.window_id), name="not-applied") +... ) +>>> outcome = plan.execute( +... SubprocessEngine.for_server(server), +... planner=FoldingPlanner(), +... ) +>>> [result.status for result in outcome.results] +['failed', 'skipped'] +``` + +With the default sequential planner, each step is a separate dispatch, so one +failed result does not by itself imply that the next operation was skipped. diff --git a/docs/index.md b/docs/index.md index 683c17ebc..21b60a7cd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -125,6 +125,7 @@ api/index api/testing/index internals/index project/index +experimental/index history migration glossary diff --git a/docs/justfile b/docs/justfile index 670ab4c44..f26e1f93a 100644 --- a/docs/justfile +++ b/docs/justfile @@ -143,8 +143,7 @@ linkcheck: # Run doctests embedded in documentation [group: 'validate'] doctest: - {{ sphinxbuild }} -b doctest {{ allsphinxopts }} {{ builddir }}/doctest - @echo "Testing of doctests in the sources finished, look at the results in {{ builddir }}/doctest/output.txt." + uv run py.test --reruns 0 . ../tests/docs # Check build from scratch [group: 'validate'] diff --git a/docs/redirects.txt b/docs/redirects.txt index 8859000bc..9ca479ad8 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -2,7 +2,7 @@ "properties.md" "reference/properties.md" "servers.md" "reference/servers.md" "windows.md" "reference/windows.md" -"traversal.md" "topics/traversal.md" +"traversal.md" "topics/querying/hierarchy.md" "sessions.md" "reference/sessions.md" # "api.md" → api/index.md: not needed with dirhtml (same output path) # "pytest-plugin.md" → api/pytest-plugin/index.md: conflicts with pytest-plugin/index.md redirect in dirhtml @@ -39,6 +39,8 @@ "internals/query_list.md" "internals/api/libtmux._internal.query_list.md" "internals/sparse_array.md" "internals/api/libtmux._internal.sparse_array.md" "about.md" "topics/architecture.md" +"topics/traversal.md" "topics/querying/hierarchy.md" +"topics/filtering.md" "topics/querying/query-list.md" # Testing section restructure (2026-03) # Note: anchor traffic (e.g. /api/pytest-plugin/#recommended-fixtures) redirects page-level only — # individual anchors cannot be redirected (inherent rediraffe limitation) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba9641108..0738dd4d8 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -12,18 +12,19 @@ makes that coordination ordinary Python — you start commands with pile of {meth}`send_keys() ` calls into automation you can trust: output monitoring, timeouts, retries, and multi-pane orchestration. -Most scripts only need a couple of these patterns. Output monitoring and the -context manager patterns cover the common case — send a command, wait for a -marker, clean up after yourself — so start there. The later sections (state +Most scripts only need a couple of these patterns. Command completion and the +context manager patterns cover the common case — send a command, wait for it to +finish, clean up after yourself — so start there. The later sections (state machines, task queues) are for the rarer cases where a single pane drives a longer sequence of steps; reach for them when you actually need them. -These patterns lean on polling: you call {meth}`~libtmux.Pane.capture_pane` in a -loop and `sleep` between reads. That is simpler than wiring up an event-driven -system, and it costs you latency — each poll is a tmux round-trip, and a `sleep` -between polls is dead time you pay whether the command finished or not. For most -automation that trade is worth it. When milliseconds matter, look instead at tmux -hooks or an external event-driven framework. +When you control the command and do not need a timeout, synchronize through +{meth}`~libtmux.Server.wait_for` instead of polling the pane. When you need a +bounded wait, a retry, or output you do not control, the later patterns call +{meth}`~libtmux.Pane.capture_pane` in a loop and `sleep` between reads. Those polls +match complete output lines that cannot also occur in the shell's echoed input. +Polling costs latency — each read is a tmux round-trip, and a `sleep` between polls +is dead time you pay whether the output arrived or not. Open two terminals: @@ -75,34 +76,42 @@ going in the pane. The pane object stays your handle on that running work. ### Checking process status -Because {meth}`send_keys() ` doesn't wait, you find out -whether a command is still running the same way a person would: by reading -what's on screen. Capture the pane and look for a marker your command prints -when it reaches a known state. +Because {meth}`send_keys() ` doesn't wait, have the command +report its state in output lines you can distinguish from the shell's echoed input. +Capture the pane and read the most recent state after each explicit signal. ```python ->>> import time - >>> status_window = session.new_window(window_name='status-check', attach=False) >>> status_pane = status_window.active_pane ->>> def is_process_running(pane, marker='RUNNING'): -... """Check if a marker indicates process is still running.""" -... output = pane.capture_pane() -... return marker in '\\n'.join(output) - ->>> # Start and mark a process ->>> status_pane.send_keys('echo "RUNNING"; sleep 0.3; echo "DONE"') ->>> time.sleep(0.1) +>>> def latest_status(pane): +... """Return the most recent status line.""" +... statuses = [ +... line.removeprefix('status:') +... for line in pane.capture_pane() +... if line.startswith('status:') +... ] +... return statuses[-1] if statuses else None + +>>> running_channel = 'libtmux-status-running' +>>> checked_channel = 'libtmux-status-checked' +>>> done_channel = 'libtmux-status-done' +>>> status_pane.send_keys( +... "printf 'status:%s\\n' RUNNING; " +... f"tmux wait-for -S {running_channel}; " +... f"tmux wait-for {checked_channel}; " +... "sleep 0.3; printf 'status:%s\\n' DONE; " +... f"tmux wait-for -S {done_channel}" +... ) ->>> # Check while running ->>> 'RUNNING' in '\\n'.join(status_pane.capture_pane()) -True +>>> server.wait_for(running_channel) +>>> latest_status(status_pane) +'RUNNING' ->>> # Wait for completion ->>> time.sleep(0.5) ->>> 'DONE' in '\\n'.join(status_pane.capture_pane()) -True +>>> server.wait_for(checked_channel, set_flag=True) +>>> server.wait_for(done_channel) +>>> latest_status(status_pane) +'DONE' >>> # Clean up >>> status_window.kill() @@ -110,32 +119,32 @@ True ## Output monitoring -### Waiting for specific output +### Waiting for command completion -The workhorse of terminal automation is "run something, then block until a string -shows up." You wrap {meth}`~libtmux.Pane.capture_pane` in a loop with a timeout, so a -command that never finishes can't hang your script forever. The `poll_interval` is -the latency/work trade in one knob: poll faster to react sooner, slower to spare tmux -the round-trips. +When you control the command, have it signal a tmux channel after it finishes and +block on that channel with {meth}`~libtmux.Server.wait_for`. tmux remembers a signal +sent before the waiter starts, so this has no lost-wakeup race. It also avoids +confusing the shell's echoed command with the command's output. -```python ->>> import time +{meth}`~libtmux.Server.wait_for` has no timeout. Make sure every expected exit path +reaches `tmux wait-for -S` so a failed command cannot leave your script blocked. +Channels are server-wide, so give each in-flight command a distinct channel name. +> **Note:** If you drive tmux through the libtmux MCP server, prefer the +> event-backed `wait_for_output` tool: it folds live `%output` and returns when +> the pane settles without requiring a raw tmux synchronization channel. + +```python >>> monitor_window = session.new_window(window_name='monitor', attach=False) >>> monitor_pane = monitor_window.active_pane ->>> def wait_for_output(pane, text, timeout=5.0, poll_interval=0.1): -... """Wait for specific text to appear in pane output.""" -... start = time.time() -... while time.time() - start < timeout: -... output = '\\n'.join(pane.capture_pane()) -... if text in output: -... return True -... time.sleep(poll_interval) -... return False - ->>> monitor_pane.send_keys('sleep 0.2; echo "READY"') ->>> wait_for_output(monitor_pane, 'READY', timeout=2.0) +>>> completion_channel = 'libtmux-automation-ready' +>>> monitor_pane.send_keys( +... "sleep 0.2; printf 'command %s\\n' complete; " +... f"tmux wait-for -S {completion_channel}" +... ) +>>> server.wait_for(completion_channel) +>>> 'command complete' in monitor_pane.capture_pane() True >>> # Clean up @@ -144,75 +153,95 @@ True ### Detecting errors in output -Waiting for success is only half the job — you also want to notice failure. The same -capture-and-scan approach works for spotting error patterns, so you can bail out -early instead of timing out on a command that already crashed. +Waiting for success is only half the job — you also want to notice failure, so you +can bail out early instead of timing out on a command that already crashed. -```python ->>> import time +Scan the command's **output**, never the whole pane. The shell echoes what you sent +into the same buffer, so a pane-wide substring search reports a crash for +`mytool --log-level=ERROR` before the tool has printed anything at all — it is +matching your own command line. That is the same trap the polls above avoid, in the +one place it is easiest to walk into. + +So have the command tag the lines it owns, and scan only those. The tag has to be one +that cannot begin a line of the echoed command — and a long command *wraps*, so "it +is not at the start of my command" is not good enough. Let the shell assemble it: +below, `printf` builds `out:` from a format and a separate argument, so the literal +never appears in what you typed, wrapped or not. +```python >>> error_window = session.new_window(window_name='error-check', attach=False) >>> error_pane = error_window.active_pane ->>> def check_for_errors(pane, patterns=None): -... """Check pane output for error patterns.""" +>>> def command_output(pane, tag='out:'): +... """The lines the command tagged as its own.""" +... return [ +... line.removeprefix(tag) +... for line in pane.capture_pane() +... if line.startswith(tag) +... ] + +>>> def check_for_errors(lines, patterns=None): +... """Return the first error pattern found in *lines*, or None.""" ... if patterns is None: ... patterns = ['Error:', 'error:', 'ERROR', 'FAILED', 'Exception'] -... output = '\\n'.join(pane.capture_pane()) -... for pattern in patterns: -... if pattern in output: -... return pattern +... for line in lines: +... for pattern in patterns: +... if pattern in line: +... return pattern ... return None ->>> # Test with successful output ->>> error_pane.send_keys('echo "Success!"') ->>> time.sleep(0.1) ->>> check_for_errors(error_pane) is None +A command that succeeds, even though its own command line says `ERROR`: + +>>> ch = 'libtmux-errors' +>>> error_pane.send_keys( +... "true --log-level=ERROR; " +... "printf 'out%s%s\\n' : ok; " +... f"tmux wait-for -S {ch}" +... ) +>>> server.wait_for(ch) +>>> command_output(error_pane) +['ok'] +>>> check_for_errors(command_output(error_pane)) is None True +And one that genuinely fails: + +>>> error_pane.send_keys( +... "printf 'out%sError: no disk\\n' :; " +... f"tmux wait-for -S {ch}" +... ) +>>> server.wait_for(ch) +>>> check_for_errors(command_output(error_pane)) +'Error:' + >>> # Clean up >>> error_window.kill() ``` -### Capturing output between markers +### Capturing output after completion -Sometimes you don't want the whole scrollback — you want just the lines a command -produced. Bracket the interesting output with a marker you control, then return -everything that follows it. This is how you pull a command's result out of a shared -pane without dragging along the prompt and prior history. +Sometimes you don't want to know only that a command finished — you want the lines +it produced. Wait for the completion signal before you capture the pane, then select +the result lines your command owns. Here the output values are separate shell +arguments, so neither complete result line appears in the echoed command. ```python ->>> import time - >>> capture_window = session.new_window(window_name='capture', attach=False) >>> capture_pane = capture_window.active_pane ->>> def capture_after_marker(pane, marker, timeout=5.0): -... """Capture output after a marker appears.""" -... start_time = time.time() -... while time.time() - start_time < timeout: -... lines = pane.capture_pane() -... output = '\\n'.join(lines) -... if marker in output: -... # Return all lines after the marker -... found = False -... result = [] -... for line in lines: -... if marker in line: -... found = True -... continue -... if found: -... result.append(line) -... return result -... time.sleep(0.1) -... return None - ->>> # Test marker capture ->>> capture_pane.send_keys('echo "MARKER"; echo "captured data"') ->>> time.sleep(0.3) ->>> result = capture_after_marker(capture_pane, 'MARKER', timeout=2.0) ->>> any('captured' in line for line in (result or [])) -True +>>> capture_channel = 'libtmux-automation-capture' +>>> capture_pane.send_keys( +... "printf 'result:%s\\n' alpha beta; " +... f"tmux wait-for -S {capture_channel}" +... ) +>>> server.wait_for(capture_channel) +>>> result = [ +... line.removeprefix('result:') +... for line in capture_pane.capture_pane() +... if line.startswith('result:') +... ] +>>> result +['alpha', 'beta'] >>> # Clean up >>> capture_window.kill() @@ -229,7 +258,6 @@ To run work in parallel, give each task its own pane. You split the window with concurrently; you gather their results afterward by capturing every pane. ```python ->>> import time >>> from libtmux.constants import PaneDirection >>> parallel_window = session.new_window(window_name='parallel', attach=False) @@ -242,19 +270,26 @@ Window(@... ...) >>> # Start tasks in parallel >>> tasks = [ -... (pane1, 'echo "Task 1"; sleep 0.2; echo "DONE1"'), -... (pane2, 'echo "Task 2"; sleep 0.1; echo "DONE2"'), -... (pane3, 'echo "Task 3"; sleep 0.3; echo "DONE3"'), +... (pane1, '0.2', '1', 'libtmux-parallel-1'), +... (pane2, '0.1', '2', 'libtmux-parallel-2'), +... (pane3, '0.3', '3', 'libtmux-parallel-3'), ... ] ->>> for pane, cmd in tasks: -... pane.send_keys(cmd) +>>> for pane, delay, number, channel in tasks: +... pane.send_keys( +... f"sleep {delay}; printf 'Task %s\\n' {number}; " +... f"tmux wait-for -S {channel}" +... ) >>> # Wait for all tasks ->>> time.sleep(0.5) +>>> for _, _, _, channel in tasks: +... server.wait_for(channel) >>> # Verify all completed ->>> all('DONE' in '\\n'.join(p.capture_pane()) for p, _ in tasks) +>>> all( +... f'Task {number}' in pane.capture_pane() +... for pane, _, number, _ in tasks +... ) True >>> # Clean up @@ -265,8 +300,8 @@ True A fixed `sleep` only works when you know how long the slowest task takes. When tasks finish at different times, watch them all at once and drop each pane from the -watch-list as its marker appears — you return as soon as the last one completes, -instead of always waiting for a worst-case timeout. +watch-list as its complete output line appears. The command below assembles that +line at runtime, so the shell's echoed input cannot complete the wait. ```python >>> import time @@ -282,18 +317,18 @@ Window(@... ...) >>> def wait_all_complete(panes, marker='COMPLETE', timeout=10.0): ... """Wait for all panes to show completion marker.""" -... start = time.time() +... start = time.monotonic() ... remaining = set(range(len(panes))) -... while remaining and time.time() - start < timeout: +... while remaining and time.monotonic() - start < timeout: ... for i in list(remaining): -... if marker in '\\n'.join(panes[i].capture_pane()): +... if marker in panes[i].capture_pane(): ... remaining.remove(i) ... time.sleep(0.1) ... return len(remaining) == 0 >>> # Start tasks with different durations >>> for i, pane in enumerate(panes): -... pane.send_keys(f'sleep 0.{i+1}; echo "COMPLETE"') +... pane.send_keys(f"sleep 0.{i+1}; printf 'COMP%s\\n' LETE") >>> # Wait for all >>> wait_all_complete(panes, 'COMPLETE', timeout=2.0) @@ -335,13 +370,15 @@ window opens for the body of the block and is gone afterward, so a short-lived j never outlives its purpose. ```python ->>> import time - >>> with session.new_window(window_name='subtask') as sub_window: ... pane = sub_window.active_pane -... pane.send_keys('echo "Subtask running"') -... time.sleep(0.1) -... 'Subtask' in '\\n'.join(pane.capture_pane()) +... subtask_channel = 'libtmux-subtask-complete' +... pane.send_keys( +... "printf 'Subtask %s\\n' running; " +... f"tmux wait-for -S {subtask_channel}" +... ) +... server.wait_for(subtask_channel) +... 'Subtask running' in pane.capture_pane() True >>> # Window cleaned up automatically @@ -353,12 +390,14 @@ True ### Command with timeout -Any command you wait on can hang, so give every wait an upper bound. Pair the command -with a completion marker and poll until either the marker shows up or the clock runs -out — and when it runs out, raise, so a stuck command surfaces as an error you can -catch instead of a script that quietly stalls. +Any command you wait on can hang, so give every polling wait an upper bound. Append +a completion line assembled from separate shell arguments, then poll for that exact +line until it appears or the monotonic clock runs out. When it runs out, raise, so a +stuck command surfaces as an error you can catch instead of a script that quietly +stalls. ```python +>>> import shlex >>> import time >>> timeout_window = session.new_window(window_name='timeout-demo', attach=False) @@ -370,18 +409,31 @@ catch instead of a script that quietly stalls. >>> def run_with_timeout(pane, command, marker='__DONE__', timeout=5.0): ... """Run command and wait for completion with timeout.""" -... pane.send_keys(f'{command}; echo {marker}') -... start = time.time() -... while time.time() - start < timeout: -... output = '\\n'.join(pane.capture_pane()) -... if marker in output: -... return output +... if len(marker) < 2: +... raise ValueError('marker must contain at least two characters') +... split_at = len(marker) // 2 +... marker_command = ( +... "printf '%s%s\\n' " +... f"{shlex.quote(marker[:split_at])} " +... f"{shlex.quote(marker[split_at:])}" +... ) +... pane.send_keys(f'{command}; {marker_command}') +... start = time.monotonic() +... while time.monotonic() - start < timeout: +... lines = pane.capture_pane() +... if marker in lines: +... return '\n'.join(lines) ... time.sleep(0.1) ... raise CommandTimeout(f'Command timed out after {timeout}s') +>>> run_with_timeout(timeout_pane, 'true', marker='x') +Traceback (most recent call last): +... +ValueError: marker must contain at least two characters + >>> # Test successful command ->>> result = run_with_timeout(timeout_pane, 'echo "fast"', timeout=2.0) ->>> 'fast' in result +>>> result = run_with_timeout(timeout_pane, "printf 'fa%s\\n' st", timeout=2.0) +>>> 'fast' in result.splitlines() True >>> # Clean up @@ -390,30 +442,57 @@ True ### Retry pattern -For flaky work that succeeds on a later attempt, retry until a success marker -appears. Be honest about the cost: each retry runs the command again and waits the -full `delay`, so a slow `delay` times `max_retries` is the worst case you're signing -up for. Tune both for how expensive the command is and how patient you can be. +For flaky work that succeeds on a later attempt, wait for each attempt to finish and +then look for its complete success line. A finished attempt without that line can be +retried. A timed-out attempt stops the helper: the foreground process may still own +the pane, so sending another command could feed input to the wrong process. Be +honest about the cost: `timeout_per_attempt` times `max_retries` is the worst case +you're signing up for. ```python +>>> import shlex >>> import time >>> retry_window = session.new_window(window_name='retry-demo', attach=False) >>> retry_pane = retry_window.active_pane ->>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5): +>>> def retry_until_success( +... pane, +... command, +... success_marker, +... max_retries=3, +... timeout_per_attempt=2.0, +... poll_interval=0.1, +... ): ... """Retry command until success marker appears.""" ... for attempt in range(max_retries): -... pane.send_keys(command) -... time.sleep(delay) -... output = '\\n'.join(pane.capture_pane()) -... if success_marker in output: -... return True, attempt + 1 +... completion_marker = f'__ATTEMPT_{attempt}_DONE__' +... split_at = len(completion_marker) // 2 +... pane.send_keys( +... f"{command}; printf '%s%s\\n' " +... f"{shlex.quote(completion_marker[:split_at])} " +... f"{shlex.quote(completion_marker[split_at:])}" +... ) +... start = time.monotonic() +... while time.monotonic() - start < timeout_per_attempt: +... lines = pane.capture_pane() +... if completion_marker in lines: +... if success_marker in lines: +... return True, attempt + 1 +... break +... time.sleep(poll_interval) +... else: +... return False, attempt + 1 ... return False, max_retries >>> # Test retry >>> success, attempts = retry_until_success( -... retry_pane, 'echo "OK"', 'OK', max_retries=3, delay=0.2 +... retry_pane, +... "printf '%s%s\\n' O K", +... 'OK', +... max_retries=3, +... timeout_per_attempt=2.0, +... poll_interval=0.05, ... ) >>> success True @@ -433,10 +512,13 @@ genuinely a pipeline of steps, not a single call. ### Task queue processor A task queue runs a list of commands in order, waiting for each to finish before -starting the next. You tag every task with an indexed marker so you know exactly -which step you're waiting on, and you collect a pass/fail result per task. +starting the next. You tag every task with an indexed marker and collect a +completion/timeout result for each attempted task. On timeout the queue stops, +because the foreground process may still own the pane. Completion does not imply a +zero exit status; capture that separately when your workflow needs it. ```python +>>> import shlex >>> import time >>> queue_window = session.new_window(window_name='queue', attach=False) @@ -446,22 +528,28 @@ which step you're waiting on, and you collect a pass/fail result per task. ... """Process a queue of tasks sequentially.""" ... results = [] ... for i, task in enumerate(tasks): -... pane.send_keys(f'{task}; echo "{completion_marker}_{i}"') +... marker = f'{completion_marker}_{i}' +... split_at = len(marker) // 2 +... pane.send_keys( +... f"{task}; printf '%s%s\\n' " +... f"{shlex.quote(marker[:split_at])} " +... f"{shlex.quote(marker[split_at:])}" +... ) ... # Wait for this task to complete -... start = time.time() -... while time.time() - start < 5.0: -... output = '\\n'.join(pane.capture_pane()) -... if f'{completion_marker}_{i}' in output: +... start = time.monotonic() +... while time.monotonic() - start < 5.0: +... if marker in pane.capture_pane(): ... results.append((i, True)) ... break ... time.sleep(0.1) ... else: ... results.append((i, False)) +... break ... return results >>> tasks = ['echo "Step 1"', 'echo "Step 2"', 'echo "Step 3"'] >>> results = process_task_queue(queue_pane, tasks) ->>> all(success for _, success in results) +>>> all(completed for _, completed in results) True >>> # Clean up @@ -490,10 +578,9 @@ and the history tells you how far you got before it stopped. ... state_name, command, next_marker = states[current_state] ... pane.send_keys(command) ... -... start = time.time() -... while time.time() - start < timeout_per_state: -... output = '\\n'.join(pane.capture_pane()) -... if next_marker in output: +... start = time.monotonic() +... while time.monotonic() - start < timeout_per_state: +... if next_marker in pane.capture_pane(): ... history.append(state_name) ... current_state += 1 ... break @@ -504,9 +591,9 @@ and the history tells you how far you got before it stopped. ... return history, True >>> states = [ -... ('init', 'echo "INIT_DONE"', 'INIT_DONE'), -... ('process', 'echo "PROCESS_DONE"', 'PROCESS_DONE'), -... ('cleanup', 'echo "CLEANUP_DONE"', 'CLEANUP_DONE'), +... ('init', "printf 'INIT_%s\\n' DONE", 'INIT_DONE'), +... ('process', "printf 'PROCESS_%s\\n' DONE", 'PROCESS_DONE'), +... ('cleanup', "printf 'CLEANUP_%s\\n' DONE", 'CLEANUP_DONE'), ... ] >>> history, success = run_state_machine(state_pane, states) @@ -521,23 +608,24 @@ True ## Best practices -### 1. Always use markers for completion detection +### 1. Use explicit completion signals -Timing is a guess; a marker is a fact. Instead of sleeping long enough and hoping a -command finished, have it print an explicit marker and poll for that. Your automation -then reacts to what actually happened rather than to a clock. +Timing is a guess; a completion signal is a fact. When you control the command and +can block without a timeout, signal a distinct tmux channel and wait on it. For a +bounded poll, match a complete output line that the echoed command cannot contain. +Both patterns react to work the shell performed rather than text it merely echoed. ```python >>> bp_window = session.new_window(window_name='best-practice', attach=False) >>> bp_pane = bp_window.active_pane ->>> # Good: Use completion marker ->>> bp_pane.send_keys('long_command; echo "__DONE__"') - ->>> # Then poll for marker ->>> import time ->>> time.sleep(0.2) ->>> '__DONE__' in '\\n'.join(bp_pane.capture_pane()) +>>> bp_channel = 'libtmux-best-practice-complete' +>>> bp_pane.send_keys( +... "printf 'work %s\\n' complete; " +... f"tmux wait-for -S {bp_channel}" +... ) +>>> server.wait_for(bp_channel) +>>> 'work complete' in bp_pane.capture_pane() True >>> bp_window.kill() diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md deleted file mode 100644 index 02cbbf4b2..000000000 --- a/docs/topics/filtering.md +++ /dev/null @@ -1,536 +0,0 @@ -(querylist-filtering)= - -# Filtering collections - -Every collection libtmux hands you — -{attr}`server.sessions `, -{attr}`session.windows `, and -{attr}`window.panes ` — is a -{class}`~libtmux._internal.query_list.QueryList`, a list that knows how to filter -itself. You narrow one by calling -{meth}`~libtmux._internal.query_list.QueryList.filter` with keyword arguments, -optionally suffixed with a lookup like `__contains`, `__startswith`, or -`__regex`, and you get back another -{class}`~libtmux._internal.query_list.QueryList` you can iterate or chain -further. It's Django-style filtering applied to sessions, windows, and panes. - -Most readers never look beyond `.filter()`. It's the common path, it works out -of the box on every collection, and the lookup suffixes and chaining cover -almost every query you'll write. The tmux-native `.search_*()` methods at the -end of this page are an optional escape hatch for large servers — you can skip -them until you measure a reason to care. - -## Basic filtering - -Every collection is already a {class}`~libtmux._internal.query_list.QueryList`, -so you can inspect one before you narrow it. Here's the full set of sessions on -your server: - -```python ->>> server.sessions # doctest: +ELLIPSIS -[Session($... ...)] -``` - -### Exact match - -When you pass a bare keyword like `session_name=...`, the default lookup is -`exact` — so these two calls mean the same thing: - -```python ->>> # These are equivalent ->>> server.sessions.filter(session_name=session.session_name) # doctest: +ELLIPSIS -[Session($... ...)] ->>> server.sessions.filter(session_name__exact=session.session_name) # doctest: +ELLIPSIS -[Session($... ...)] -``` - -### Contains and startswith - -Add a suffix to the keyword to match part of a value instead of the whole -thing: - -```python ->>> # Create windows for this example ->>> w1 = session.new_window(window_name="api-server") ->>> w2 = session.new_window(window_name="api-worker") ->>> w3 = session.new_window(window_name="web-frontend") - ->>> # Windows containing 'api' ->>> api_windows = session.windows.filter(window_name__contains='api') ->>> len(api_windows) >= 2 -True - ->>> # Windows starting with 'web' ->>> web_windows = session.windows.filter(window_name__startswith='web') ->>> len(web_windows) >= 1 -True - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -## Available lookups - -Each suffix you append after the `__` selects one of these lookups. The -`i`-prefixed variants ignore case: - -| Lookup | Description | -|--------|-------------| -| `exact` | Exact match (default) | -| `iexact` | Case-insensitive exact match | -| `contains` | Substring match | -| `icontains` | Case-insensitive substring | -| `startswith` | Prefix match | -| `istartswith` | Case-insensitive prefix | -| `endswith` | Suffix match | -| `iendswith` | Case-insensitive suffix | -| `in` | Value in list | -| `nin` | Value not in list | -| `regex` | Regular expression match | -| `iregex` | Case-insensitive regex | - -## Getting a single item - -When you expect exactly one match and want the object itself rather than a -list, reach for {meth}`~libtmux._internal.query_list.QueryList.get`: - -```python ->>> window = session.windows.get(window_id=session.active_window.window_id) ->>> window # doctest: +ELLIPSIS -Window(@... ..., Session($... ...)) -``` - -`get()` insists on exactly one result. If the query matches the wrong number of -objects, it raises: - -- {exc}`~libtmux.exc.ObjectDoesNotExist` - no matching object found -- {exc}`~libtmux.exc.MultipleObjectsReturned` - more than one object matches - -Both are {exc}`~libtmux.exc.LibTmuxException`s, so one `except` clause catches -either, and both say what they went looking for: - -```python ->>> from libtmux import exc ->>> try: -... session.windows.get(window_name="nonexistent") -... except exc.LibTmuxException as e: -... print(e) -No objects found: window_name='nonexistent' -``` - -Pass a `default` to get a fallback value back instead of an exception: - -```python ->>> session.windows.get(window_name="nonexistent", default=None) is None -True -``` - -A `default` stands in for an object that is *absent*, so it does not apply when -a query is merely ambiguous — {exc}`~libtmux.exc.MultipleObjectsReturned` is -raised whether or not you passed one. Handing back one of several equally valid -matches is how you end up driving the wrong pane. The next section is about the -one case where an ambiguous match is routine rather than a mistake. - -(winlinks)= - -## When one window is in two sessions - -A server-wide collection — {attr}`server.windows ` and -{attr}`server.panes ` — does not enumerate windows. It -enumerates {term}`winlinks `: the `(session, index, window)` edges tmux -actually stores. Nearly always there is exactly one edge per window and you never -notice the difference. - -Sharing a window adds edges. `link-window` does it explicitly, and a grouped -session (`tmux new-session -t existing`, the mechanism behind [tmuxp](https://tmuxp.git-pull.com/)'s session -groups) does it for every window at once. The window is then genuinely reachable -from each session that links it, and a server-wide listing reports it once per -edge: - -```python ->>> home = server.new_session(session_name="home") ->>> shared = home.new_window(window_name="shared", attach=False) ->>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) - ->>> len(server.windows.filter(window_id=shared.window_id)) -2 -``` - -Two rows for one `window_id` is not a miscount — it is the shape of the data. -The window is in both sessions, and a listing that collapsed the rows would be -throwing away the very fact you need. - -The consequence is that a *point lookup* against a server-wide collection can be -ambiguous, and says so rather than guessing: - -```python ->>> from libtmux import exc ->>> home = server.new_session(session_name="home") ->>> shared = home.new_window(window_name="shared", attach=False) ->>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) - ->>> try: -... server.windows.get(window_id=shared.window_id) -... except exc.MultipleObjectsReturned as e: -... print(e) # doctest: +ELLIPSIS -Multiple objects returned (2): window_id='@...' -``` - -### Which sessions hold it? - -{attr}`Window.linked_sessions ` answers directly, -listing each holding session once however many indexes it links the window at: - -```python ->>> home = server.new_session(session_name="home") ->>> shared = home.new_window(window_name="shared", attach=False) ->>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) - ->>> sorted(s.session_name for s in shared.linked_sessions) -['guest', 'home'] -``` - -### Just fetch the object - -When you have an id and want the object, don't scan a listing for it — name it. -{meth}`Pane.from_pane_id ` and -{meth}`Window.from_window_id ` hand the id to tmux -with a `-t` target, and tmux always resolves it to exactly one object — the same -one it would act on if you typed the command yourself. They cannot be ambiguous, -so they are the right tool for a lookup by id: - -```python ->>> from libtmux.window import Window ->>> home = server.new_session(session_name="home") ->>> shared = home.new_window(window_name="shared", attach=False) ->>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) - ->>> Window.from_window_id(server, shared.window_id).window_id == shared.window_id -True -``` - -Reserve the server-wide collections for what they are good at — sweeping the -whole server — and reach for them with {meth}`~libtmux._internal.query_list.QueryList.filter`, -which is happy to return two rows, rather than `get()`, which is not. - -## Chaining filters - -You can stack conditions two ways, and both narrow with AND. Pass several -keywords to a single `.filter()` call, or chain `.filter()` calls one after -another: - -```python ->>> # Create windows for this example ->>> w1 = session.new_window(window_name="feature-login") ->>> w2 = session.new_window(window_name="feature-signup") ->>> w3 = session.new_window(window_name="bugfix-typo") - ->>> # Multiple conditions in one filter (AND) ->>> session.windows.filter( -... window_name__startswith='feature', -... window_name__endswith='signup' -... ) # doctest: +ELLIPSIS -[Window(@... ...:feature-signup, Session($... ...))] - ->>> # Chained filters (also AND) ->>> session.windows.filter( -... window_name__contains='feature' -... ).filter( -... window_name__contains='login' -... ) # doctest: +ELLIPSIS -[Window(@... ...:feature-login, Session($... ...))] - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -## Case-insensitive filtering - -Reach for the `i`-prefixed variants when the casing of a name shouldn't matter: - -```python ->>> # Create windows with mixed case ->>> w1 = session.new_window(window_name="MyApp-Server") ->>> w2 = session.new_window(window_name="myapp-worker") - ->>> # Case-insensitive contains ->>> myapp_windows = session.windows.filter(window_name__icontains='MYAPP') ->>> len(myapp_windows) >= 2 -True - ->>> # Case-insensitive startswith ->>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS -[Window(@... ...:MyApp-Server, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() -``` - -## Regex filtering - -When a prefix or substring isn't expressive enough, the regex lookups match -against a full pattern: - -```python ->>> # Create windows with version-like names ->>> w1 = session.new_window(window_name="app-v1-0") ->>> w2 = session.new_window(window_name="app-v2-0") ->>> w3 = session.new_window(window_name="app-beta") - ->>> # Match version pattern ->>> versioned = session.windows.filter(window_name__regex=r'v\d+-\d+$') ->>> len(versioned) >= 2 -True - ->>> # Case-insensitive regex ->>> session.windows.filter(window_name__iregex=r'BETA') # doctest: +ELLIPSIS -[Window(@... ...:app-beta, Session($... ...))] - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -## Filtering by list membership - -When you already have a set of names in hand, `in` keeps the matches and `nin` -(not in) drops them: - -```python ->>> # Create test windows ->>> w1 = session.new_window(window_name="dev") ->>> w2 = session.new_window(window_name="staging") ->>> w3 = session.new_window(window_name="prod") - ->>> # Filter windows in a list of names ->>> target_envs = ["dev", "prod"] ->>> session.windows.filter(window_name__in=target_envs) # doctest: +ELLIPSIS -[Window(@... ...:dev, Session($... ...)), Window(@... ...:prod, Session($... ...))] - ->>> # Filter windows NOT in a list ->>> non_prod = session.windows.filter(window_name__nin=["prod"]) ->>> any(w.window_name == "prod" for w in non_prod) -False - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -## Filtering across the hierarchy - -You aren't limited to one window's panes. Every level of the hierarchy returns -a {class}`~libtmux._internal.query_list.QueryList`, and the server-wide -collections — {attr}`server.panes `, -{attr}`server.windows `, and -{attr}`server.sessions ` — flatten everything beneath -them into a single list. That lets you query the whole server at once, which is -handy when you want a pane by some attribute and don't care which session or -window it lives in: - -```python ->>> # All panes across all windows in the server ->>> server.panes # doctest: +ELLIPSIS -[Pane(%... Window(@... ..., Session($... ...)))] - ->>> # Filter panes by their window's name ->>> pane = session.active_pane ->>> pane # doctest: +ELLIPSIS -Pane(%... Window(@... ..., Session($... ...))) -``` - -## Real-world examples - -A couple of patterns you'll reach for in practice. - -### Find all editor windows - -Match several editor names at once with a single regex lookup: - -```python ->>> # Create sample windows ->>> w1 = session.new_window(window_name="vim-main") ->>> w2 = session.new_window(window_name="nvim-config") ->>> w3 = session.new_window(window_name="shell") - ->>> # Find vim/nvim windows ->>> editors = session.windows.filter(window_name__iregex=r'n?vim') ->>> len(editors) >= 2 -True - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -### Find windows by naming convention - -If you name windows by convention, a prefix match pulls the whole group, and -`.get()` plucks one out by name: - -```python ->>> # Create windows following a naming convention ->>> w1 = session.new_window(window_name="project-frontend") ->>> w2 = session.new_window(window_name="project-backend") ->>> w3 = session.new_window(window_name="logs") - ->>> # Find all project windows ->>> project_windows = session.windows.filter(window_name__startswith='project-') ->>> len(project_windows) >= 2 -True - ->>> # Get specific project window ->>> backend = session.windows.get(window_name='project-backend') ->>> backend.window_name -'project-backend' - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -(native-filtering)= - -## Filtering before object creation - -Everything above runs in Python, *after* tmux has already returned every row. -That's fine for the handful of sessions and windows most servers carry. But on -a large server — hundreds or thousands of panes, where you want only a few — -you pay to build objects you immediately discard. - -The `search_*()` methods push the filtering down to tmux itself: tmux applies a -format expression and hands back only the matching rows, so libtmux builds -objects for the matches alone. Every level of the hierarchy ships one: - -| Caller | Method | Underlying tmux | -|--------|--------|-----------------| -| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_sessions` | `tmux list-sessions -f ` | -| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_windows` | `tmux list-windows -a -f ` | -| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_panes` | `tmux list-panes -a -f ` | -| {class}`~libtmux.Session` | {meth}`~libtmux.Session.search_windows` | `tmux list-windows -t $sess -f ` | -| {class}`~libtmux.Session` | {meth}`~libtmux.Session.search_panes` | `tmux list-panes -s -t $sess -f ` | -| {class}`~libtmux.Window` | {meth}`~libtmux.Window.search_panes` | `tmux list-panes -t @win -f ` | - -The {meth}`~libtmux.Server.list_buffers` method also accepts a `filter=` -kwarg with the same semantics. - -There is no `search_clients()` method; filter clients via the -{attr}`~libtmux.Server.clients` accessor and Python-side -{meth}`~libtmux._internal.query_list.QueryList.filter`. Filtering -clients in Python is usually enough because a server's client -count is bounded by attached terminals, not by session/window/pane -fan-out. - -### Python-side vs. tmux-native - -| | `.filter()` | `.search_*()` | -|-|-------------|---------------| -| Where | Python (after fetch) | tmux server (before fetch) | -| Filter language | libtmux's lookup operators (`__contains`, `__regex`, etc.) | tmux's [FORMATS](https://man.openbsd.org/tmux.1#FORMATS) grammar | -| Round trips | one (full list, then filter in memory) | one (tmux returns only matches) | -| Best for | rich Python checks, set membership, post-fetch composition | exact/glob matches over many rows | -| Stability | every libtmux version supports it | requires tmux ≥ 3.2 | - -Both are valid; pick based on data volume and the filter language you want. - -### Filter syntax - -tmux's filter language is the same one used in `-F` templates. Three -shapes cover most use cases: - -```python ->>> # Match by glob ->>> s_alpha = server.new_session(session_name='alpha-1') ->>> s_beta = server.new_session(session_name='beta-1') ->>> alphas = server.search_sessions(filter='#{m:alpha-*,#{session_name}}') ->>> [s.session_name for s in alphas] -['alpha-1'] - ->>> # Match by equality ->>> exact = server.search_sessions( -... filter='#{==:#{session_name},alpha-1}' -... ) ->>> [s.session_name for s in exact] -['alpha-1'] - ->>> # Clean up ->>> s_alpha.kill() ->>> s_beta.kill() -``` - -`#{e:...}` evaluates an arithmetic expression; `#{?cond,a,b}` is the -conditional form. See `man tmux` for the full grammar. - -### The silent zero-match trap - -A malformed filter expression is the single biggest footgun. tmux expands an -unclosed `#{...}` or an unknown format token to an empty string, -which the filter engine evaluates as "false" — every row is filtered -out and **no stderr is emitted**. A bad filter is indistinguishable -from a filter that genuinely matched nothing. - -If `search_*()` returns empty unexpectedly: - -1. Replace the filter with `#{m:*,#{session_name}}` (or the - equivalent for windows/panes). If that returns rows, the issue is - filter syntax, not data. -2. Expand the expression standalone via - {meth}`~libtmux.Server.display_message` to see what tmux actually - produced: - - ```python - >>> result = server.display_message( - ... '#{m:alpha-*,alpha-1}', get_text=True - ... ) - >>> result[0] - '1' - ``` - - A non-`1`, non-empty result tells you the expression is parsing as - text, not as a boolean. - -3. Cross-check the token name against the FORMATS section of - `tmux(1)` and against the version gate (see {ref}`format-tokens`). - -### When to prefer which - -Use `search_*()` when: - -- you have hundreds or thousands of windows/panes and only want a few, -- your filter is a glob (`m:`) or equality check (`==:`), -- you're already in tmux-format thinking (writing `#{...}` for a - status-line template, for example). - -Use `.filter()` when: - -- your filter needs Python types you can't express in tmux format - (set membership, complex regex, computed values from outside tmux), -- you're chaining multiple filters and prefer composing in Python, -- you want predictable, version-independent semantics. - -## API reference - -See {class}`~libtmux._internal.query_list.QueryList` for the complete -QueryList API, and each `search_*()` method for the tmux-native filter -contract. diff --git a/docs/topics/index.md b/docs/topics/index.md index c955e5857..d35da8616 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -11,11 +11,11 @@ Explore libtmux's core functionalities and underlying principles at a high level Module hierarchy, data flow, and internal identifiers. ::: -:::{grid-item-card} Traversal -:link: traversal +:::{grid-item-card} Querying +:link: querying/index :link-type: doc -Navigate the {class}`~libtmux.Server`, {class}`~libtmux.Session`, -{class}`~libtmux.Window`, {class}`~libtmux.Pane` hierarchy. +Choose hierarchy traversal, Python filtering, tmux formats, raw rows, or +snapshot plans. ::: :::{grid-item-card} Locating Yourself @@ -25,12 +25,6 @@ Code running inside a pane asking which pane, window, session, and server it is in. ::: -:::{grid-item-card} Filtering -:link: filtering -:link-type: doc -Query and filter collections by attributes. -::: - :::{grid-item-card} Pane Interaction :link: pane_interaction :link-type: doc @@ -88,9 +82,8 @@ architecture configuration design-decisions public-vs-internal -traversal +querying/index self_location -filtering pane_interaction floating_panes workspace_setup diff --git a/docs/topics/querying/hierarchy.md b/docs/topics/querying/hierarchy.md new file mode 100644 index 000000000..978fa0980 --- /dev/null +++ b/docs/topics/querying/hierarchy.md @@ -0,0 +1,109 @@ +(traversal)= + +# Traverse the tmux hierarchy + +The object hierarchy mirrors tmux: a {class}`~libtmux.Server` contains +{class}`~libtmux.Session` objects, sessions contain +{class}`~libtmux.Window` objects, and windows contain +{class}`~libtmux.Pane` objects. Every object retains enough identity to move +back toward its parents. + +## When to use it + +Use traversal when you already have one object and need a directly related +object. It is the shortest path for common work such as finding a session's +active pane or moving from a pane back to its server. + +Child accessors such as {attr}`~libtmux.Session.windows` and +{attr}`~libtmux.Window.panes` query tmux each time. Bind the returned collection +when several operations should share one view. + +## Tutorial + +### Happy path + +Create a window, take its active pane, then move down and back up the hierarchy: + +```python +>>> query_window = session.new_window(window_name="query-hierarchy") +>>> query_pane = query_window.active_pane +>>> query_pane is not None +True +>>> query_pane.session.session_id == session.session_id +True +>>> any(item.window_id == query_window.window_id for item in session.windows) +True +``` + +### Sad path + +A Python object retains its identifier after another actor removes the tmux +object. Refreshing that stale point object reports disappearance explicitly: + +```python +>>> from libtmux import exc +>>> stale_window = session.new_window(window_name="query-stale") +>>> stale_window.kill() +>>> try: +... stale_window.refresh() +... except exc.TmuxObjectDoesNotExist: +... print("window is stale") +window is stale +``` + +If you only need the current children, read the parent collection again. If you +need to validate a retained point object, call its `refresh()` method and handle +{exc}`~libtmux.exc.TmuxObjectDoesNotExist`. + +## API reference + +The styled API entries below expose the hierarchy accessors and point-object +refresh behavior: + +```{eval-rst} +.. autoattribute:: libtmux.Server.sessions + :no-index: + +.. autoattribute:: libtmux.Server.windows + :no-index: + +.. autoattribute:: libtmux.Server.panes + :no-index: + +.. autoattribute:: libtmux.Session.windows + :no-index: + +.. autoattribute:: libtmux.Session.active_window + :no-index: + +.. autoattribute:: libtmux.Session.active_pane + :no-index: + +.. autoattribute:: libtmux.Window.panes + :no-index: + +.. autoattribute:: libtmux.Window.active_pane + :no-index: + +.. autoattribute:: libtmux.Window.session + :no-index: + +.. autoattribute:: libtmux.Pane.window + :no-index: + +.. autoattribute:: libtmux.Pane.session + :no-index: + +.. automethod:: libtmux.Window.refresh + :no-index: + +.. automethod:: libtmux.Pane.refresh + :no-index: +``` + +## Related topics + +- [QueryList filtering](query-list.md) narrows a fetched child collection. +- [Locating yourself](../self_location.md) starts traversal from the current + tmux process environment. +- [Architecture](../architecture.md) explains object identity and targets. diff --git a/docs/topics/querying/index.md b/docs/topics/querying/index.md new file mode 100644 index 000000000..41945335d --- /dev/null +++ b/docs/topics/querying/index.md @@ -0,0 +1,33 @@ +(querying)= + +# Querying tmux + +libtmux offers five query styles because callers need different trade-offs: +convenient object traversal, expressive Python filtering, server-side tmux +formats, raw fresh rows, or immutable snapshot plans. + +## Choose a query style + +| Style | Best fit | Freshness | Failure signal | +| --- | --- | --- | --- | +| [Hierarchy traversal](hierarchy.md) | move between related libtmux objects | child accessors query tmux on access | stale point objects fail when refreshed | +| [QueryList filtering](query-list.md) | filter an object collection in Python | collection is a fetched snapshot | `get()` distinguishes absent from ambiguous | +| [tmux format queries](tmux-formats.md) | discard rows inside tmux before Python object creation | one server-side query | malformed filters can silently match nothing | +| [Neo raw-row queries](neo.md) | consume fresh format dictionaries without ORM objects | one explicit list command | missing point targets and transport errors differ | +| [Snapshot queries](snapshot.md) | compose pure pane reads and bulk commands | caller controls the snapshot | empty queries return empty values or plans | + +Start with [hierarchy traversal](hierarchy.md). Use +[QueryList](query-list.md) when a collection needs narrowing. Move filtering +into [tmux formats](tmux-formats.md) only when row volume or tmux-native +expressions justify it. The [Neo](neo.md) and [snapshot](snapshot.md) layers are +lower-level and experimental, respectively. + +```{toctree} +:hidden: + +hierarchy +query-list +tmux-formats +neo +snapshot +``` diff --git a/docs/topics/querying/neo.md b/docs/topics/querying/neo.md new file mode 100644 index 000000000..38230a032 --- /dev/null +++ b/docs/topics/querying/neo.md @@ -0,0 +1,78 @@ +# Query raw rows with Neo + +{func}`~libtmux.neo.fetch_objs` and {func}`~libtmux.neo.fetch_obj` expose +fresh tmux format rows as dictionaries. They are the data boundary underneath +the object model and avoid constructing Session, Window, or Pane objects. + +## When to use it + +Use Neo when a consumer needs raw fields, custom object construction, or an +explicit distinction between a missing target and a dead server. Prefer the +object hierarchy when its relationships and methods are useful. + +`fetch_objs()` runs one list command and returns every parsed row. +`fetch_obj()` requires an identity field and value and applies tmux's winlink +selection rules when a linked window produces several rows. + +## Tutorial + +### Happy path + +Fetch a collection, then fetch one pane by its permanent identifier: + +```python +>>> from libtmux.neo import fetch_obj, fetch_objs +>>> rows = fetch_objs(server=server, list_cmd="list-sessions") +>>> any(row["session_id"] == session.session_id for row in rows) +True +>>> pane_row = fetch_obj( +... server=pane.server, +... obj_key="pane_id", +... obj_id=pane.pane_id, +... list_cmd="list-panes", +... list_extra_args=("-t", pane.pane_id), +... ) +>>> pane_row["pane_id"] == pane.pane_id +True +``` + +### Sad path + +A reachable server with no such target raises a specific point-lookup error: + +```python +>>> from libtmux import exc +>>> from libtmux.neo import fetch_obj +>>> try: +... fetch_obj( +... server=server, +... obj_key="pane_id", +... obj_id="%99999", +... list_cmd="list-panes", +... list_extra_args=("-t", "%99999"), +... ) +... except exc.TmuxObjectDoesNotExist: +... print("pane does not exist") +pane does not exist +``` + +Other command failures remain {exc}`~libtmux.exc.LibTmuxException`. This keeps +"target is gone" distinct from "tmux is unreachable." + +## API reference + +```{eval-rst} +.. autofunction:: libtmux.neo.fetch_objs + :no-index: + +.. autofunction:: libtmux.neo.fetch_obj + :no-index: +``` + +## Related topics + +- [Hierarchy traversal](hierarchy.md) wraps raw rows as relational objects. +- [tmux format queries](tmux-formats.md) shares the server-side `filter=` + behavior. +- [Public versus internal APIs](../public-vs-internal.md) explains stability + expectations. diff --git a/docs/topics/querying/query-list.md b/docs/topics/querying/query-list.md new file mode 100644 index 000000000..ac2b7bbdc --- /dev/null +++ b/docs/topics/querying/query-list.md @@ -0,0 +1,82 @@ +(querylist-filtering)= + +# Filter collections with QueryList + +Collection accessors return +{class}`~libtmux._internal.query_list.QueryList`, a list subtype with +Django-style attribute lookups. Filtering happens in Python after tmux has +returned the rows and libtmux has built the objects. + +## When to use it + +Use {meth}`~libtmux._internal.query_list.QueryList.filter` for small, already +fetched collections and expressive Python lookups. Use +{meth}`~libtmux._internal.query_list.QueryList.get` only when zero or multiple +matches are both errors. + +Common suffixes are `exact`, `iexact`, `contains`, `icontains`, `startswith`, +`istartswith`, `endswith`, `iendswith`, `in`, `nin`, `regex`, and `iregex`. +Multiple keyword arguments and chained filters combine with AND. + +## Tutorial + +### Happy path + +Filter windows by a case-insensitive prefix, then use `get()` for an exact +point lookup: + +```python +>>> _ = session.new_window(window_name="Query-API") +>>> _ = session.new_window(window_name="query-worker") +>>> matches = session.windows.filter(window_name__istartswith="query-") +>>> sorted(item.window_name for item in matches) +['Query-API', 'query-worker'] +>>> matches.get(window_name="query-worker").window_name +'query-worker' +``` + +### Sad path + +`get()` refuses to choose arbitrarily when more than one object matches: + +```python +>>> from libtmux import exc +>>> _ = session.new_window(window_name="query-duplicate-one") +>>> _ = session.new_window(window_name="query-duplicate-two") +>>> try: +... session.windows.get(window_name__startswith="query-duplicate") +... except exc.MultipleObjectsReturned: +... print("query is ambiguous") +query is ambiguous +``` + +An absent match raises {exc}`~libtmux.exc.ObjectDoesNotExist`; pass +`default=None` only when absence is an expected value. A default never hides an +ambiguous match. + +## API reference + +```{eval-rst} +.. automethod:: libtmux._internal.query_list.QueryList.filter + :no-index: + +.. automethod:: libtmux._internal.query_list.QueryList.get + :no-index: +``` + +## Related topics + +- [Hierarchy traversal](hierarchy.md) explains where collections come from. +- [tmux format queries](tmux-formats.md) filters rows before object creation. +- {ref}`winlinks` explains why a server-wide window query can contain + multiple rows for one window. + +(winlinks)= + +### Shared windows and ambiguity + +Server-wide window and pane collections enumerate tmux winlinks: the edges from +sessions to windows. A window linked into two sessions therefore appears twice. +Use `filter()` when both relationships are meaningful; use +{meth}`~libtmux.Window.from_window_id` when a permanent window identifier should +resolve to one object. diff --git a/docs/topics/querying/snapshot.md b/docs/topics/querying/snapshot.md new file mode 100644 index 000000000..73b317d88 --- /dev/null +++ b/docs/topics/querying/snapshot.md @@ -0,0 +1,87 @@ +# Query pane snapshots and build commands + +{func}`~libtmux.experimental.query.panes` starts an immutable +{class}`~libtmux.experimental.query.PaneQuery`. A query resolves against either +an engine or a supplied sequence of +{class}`~libtmux.experimental.models.snapshots.PaneSnapshot` values. It can +project reads or create a {class}`~libtmux.experimental.query.CommandPlan`. + +## When to use it + +Use snapshot queries when deterministic read composition matters, when one +captured view should drive several decisions, or when a bulk pane command +should be planned before dispatch. This API is experimental. + +Calling `filter()`, `order_by()`, `limit()`, `map()`, or `commands()` is pure. +`all()` and `first()` resolve a source; `CommandPlan.run()` additionally +dispatches recorded operations. + +## Tutorial + +### Happy path + +Resolve a pure query, then build one typed command per matching pane: + +```python +>>> from libtmux.experimental.models.snapshots import PaneSnapshot +>>> from libtmux.experimental.query import panes +>>> rows = [ +... PaneSnapshot.from_format( +... {"pane_id": "%1", "pane_active": "1", "pane_current_command": "vim"} +... ), +... PaneSnapshot.from_format( +... {"pane_id": "%2", "pane_active": "0", "pane_current_command": "zsh"} +... ), +... ] +>>> active = panes().filter(active=True) +>>> active.map(lambda item: item.pane_id).all(rows) +('%1',) +>>> commands = active.commands(lambda ref: ref.cmd.send_keys("clear")) +>>> [operation.kind for operation in commands.to_plan(rows).operations] +['send_keys'] +``` + +### Sad path + +An empty snapshot query is an ordinary value: `first()` returns `None`, and a +derived command plan contains no operations: + +```python +>>> from libtmux.experimental.models.snapshots import PaneSnapshot +>>> from libtmux.experimental.query import panes +>>> rows = [ +... PaneSnapshot.from_format( +... {"pane_id": "%1", "pane_active": "1", "pane_current_command": "zsh"} +... ) +... ] +>>> missing = panes().filter(current_command="vim") +>>> missing.first(rows) is None +True +>>> command_plan = missing.commands(lambda ref: ref.cmd.send_keys("clear")) +>>> len(command_plan.to_plan(rows).operations) +0 +``` + +An empty query does not indicate a transport failure. When the source is an +engine, inspect the engine result path separately if failure provenance matters. + +## API reference + +```{eval-rst} +.. autofunction:: libtmux.experimental.query.panes + +.. autoclass:: libtmux.experimental.query.PaneQuery + :members: filter, order_by, limit, all, first, map, commands + +.. autoclass:: libtmux.experimental.query.CommandPlan + :members: to_plan, run +``` + +## Related topics + +- [Experimental operations](../../experimental/operations/index.md) documents + the commands produced by a command plan. +- [Experimental plans](../../experimental/plans.md) explains forward + references and planner selection. +- [QueryList filtering](query-list.md) supplies the lookup language used by + `PaneQuery.filter()`. diff --git a/docs/topics/querying/tmux-formats.md b/docs/topics/querying/tmux-formats.md new file mode 100644 index 000000000..ce5238322 --- /dev/null +++ b/docs/topics/querying/tmux-formats.md @@ -0,0 +1,84 @@ +(native-filtering)= + +# Query with tmux formats + +The `search_*()` methods pass a tmux format expression to a `list-* -f` +command. tmux discards non-matching rows before libtmux creates Python objects. + +## When to use it + +Use tmux-native filtering when a server has enough windows or panes that +discarding rows before object creation matters, or when the query already fits +tmux's format language. Prefer [QueryList](query-list.md) for Python regexes, +set membership, and composable post-fetch logic. + +tmux format filters require tmux 3.2 or newer. The main entry points are: + +| Scope | Methods | +| --- | --- | +| Server | {meth}`~libtmux.Server.search_sessions`, {meth}`~libtmux.Server.search_windows`, {meth}`~libtmux.Server.search_panes` | +| Session | {meth}`~libtmux.Session.search_windows`, {meth}`~libtmux.Session.search_panes` | +| Window | {meth}`~libtmux.Window.search_panes` | + +## Tutorial + +### Happy path + +Match a session name with tmux's `m:` glob operator: + +```python +>>> _ = server.new_session(session_name="format-alpha") +>>> _ = server.new_session(session_name="format-beta") +>>> matches = server.search_sessions( +... filter="#{m:format-a*,#{session_name}}" +... ) +>>> [item.session_name for item in matches] +['format-alpha'] +``` + +### Sad path + +tmux expands an unknown token to an empty string. In a filter position, that is +false, so a malformed query can look exactly like a valid zero-match query: + +```python +>>> _ = server.new_session(session_name="format-visible") +>>> server.search_sessions(filter="#{query_token_does_not_exist}") +[] +>>> bool(server.search_sessions(filter="#{m:*,#{session_name}}")) +True +``` + +When an unexpected query is empty, first run a known-all filter, then expand +the suspect expression with {meth}`~libtmux.Server.display_message`. tmux does +not emit stderr for this class of format mistake. + +## API reference + +```{eval-rst} +.. automethod:: libtmux.Server.search_sessions + :no-index: + +.. automethod:: libtmux.Server.search_windows + :no-index: + +.. automethod:: libtmux.Server.search_panes + :no-index: + +.. automethod:: libtmux.Session.search_windows + :no-index: + +.. automethod:: libtmux.Session.search_panes + :no-index: + +.. automethod:: libtmux.Window.search_panes + :no-index: +``` + +## Related topics + +- [QueryList filtering](query-list.md) compares Python-side lookup semantics. +- [Format-token fields](../format-tokens.md) lists typed tokens and version + gates. +- The tmux `FORMATS` grammar is documented in the + [tmux manual](https://man.openbsd.org/tmux.1#FORMATS). diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md deleted file mode 100644 index 600fa55f3..000000000 --- a/docs/topics/traversal.md +++ /dev/null @@ -1,338 +0,0 @@ -(traversal)= - -# Traversal - -When you navigate a tmux server with libtmux, you move through a hierarchy of -related objects: a {class}`~libtmux.Server` holds {class}`~libtmux.Session` -objects, each session holds {class}`~libtmux.Window` objects, and each window -holds {class}`~libtmux.Pane` objects. Every object knows both its parents and -its children, so you can traverse in either direction — reach for -{attr}`session.windows ` to list the windows under a -session, or {attr}`pane.session ` to jump -from a pane back up to the session that contains it. - -Most of the time you call a handful of properties like -{attr}`session.windows ` and -{attr}`pane.session ` and never look further. This works -out of the box, with no setup. The filtering and relationship checks later on -the page are there for the rarer cases where you need to find a specific object -by name or pattern, or confirm how two objects relate. - -Under the hood this all rides on libtmux's object abstraction of {term}`target`s -(the `-t` argument) and the permanent internal IDs tmux assigns to each object, -but you rarely have to think about that layer to move around. - -Open two terminals: - -Terminal one: start tmux in a separate terminal: - -```console -$ tmux -``` - -Terminal two, `python` or `ptpython` if you have it: - -```console -$ python -``` - -## Setup - -First, create a test session: - -```python ->>> session = server.new_session() # Create a test session using existing server -``` - -## Server level - -The {class}`~libtmux.Server` sits at the top of the hierarchy. Start by viewing -its representation: - -```python ->>> server # doctest: +ELLIPSIS -Server(socket_name=...) -``` - -Get all sessions in the server: - -```python ->>> server.sessions # doctest: +ELLIPSIS -[Session($... ...)] -``` - -Get all windows across all sessions: - -```python ->>> server.windows # doctest: +ELLIPSIS -[Window(@... ..., Session($... ...))] -``` - -Get all panes across all windows: - -```python ->>> server.panes # doctest: +ELLIPSIS -[Pane(%... Window(@... ..., Session($... ...)))] -``` - -Each of these properties queries tmux fresh every time you access it, so the -result always reflects the server's current state. That freshness costs a tmux -round-trip per access — worth it for correctness, but if you iterate over the -same collection repeatedly, bind it to a variable once instead of re-reading the -property inside a loop. - -## Session level - -A {class}`~libtmux.Session` groups windows. Get the first one: - -```python ->>> session = server.sessions[0] ->>> session # doctest: +ELLIPSIS -Session($... ...) -``` - -Get windows in a session: - -```python ->>> session.windows # doctest: +ELLIPSIS -[Window(@... ..., Session($... ...))] -``` - -Get active window and pane: - -```python ->>> session.active_window # doctest: +ELLIPSIS -Window(@... ..., Session($... ...)) - ->>> session.active_pane # doctest: +ELLIPSIS -Pane(%... Window(@... ..., Session($... ...))) -``` - -## Window level - -A {class}`~libtmux.Window` groups panes. Get one and inspect its properties: - -```python ->>> window = session.windows[0] ->>> window.window_index # doctest: +ELLIPSIS -'...' -``` - -Traverse upward to the window's parent session: - -```python ->>> window.session # doctest: +ELLIPSIS -Session($... ...) ->>> window.session.session_id == session.session_id -True -``` - -Get panes in a window: - -```python ->>> window.panes # doctest: +ELLIPSIS -[Pane(%... Window(@... ..., Session($... ...)))] -``` - -Get active pane: - -```python ->>> window.active_pane # doctest: +ELLIPSIS -Pane(%... Window(@... ..., Session($... ...))) -``` - -## Pane level - -A {class}`~libtmux.Pane` is the leaf of the hierarchy. From a pane you can walk -all the way back up to its window, session, and server: - -```python ->>> pane = window.panes[0] ->>> pane.window.window_id == window.window_id -True ->>> pane.session.session_id == session.session_id -True ->>> pane.server is server -True -``` - -## Locating yourself - -Everything above starts from a handle you already hold. Sometimes you hold -nothing, because your code is *running inside* a pane — and tmux has already told -it where it is. {meth}`Pane.from_env() `, and its siblings -on {class}`~libtmux.Server`, {class}`~libtmux.Session` and -{class}`~libtmux.Window`, read that back, so you can pick up the hierarchy from -wherever you happen to be running. - -See {ref}`self-location` for the whole story, including the window that -*contains* you versus the one in front of you, and windows that live in more than -one session at once. - -## Filtering and finding objects - -Sometimes a property like {attr}`session.windows ` -hands you more objects than you -want, and you need the one — or the few — matching a name, an index, or a -pattern. Every libtmux collection lets you narrow it down: call -{meth}`~libtmux._internal.query_list.QueryList.filter` to keep the objects that -match a condition, or {meth}`~libtmux._internal.query_list.QueryList.get` to -pull out a single object (it raises if it finds zero or more than one). You match -on the same attributes the objects already expose — `window_name`, -`window_index`, `pane_id`, and so on. - -This is the opt-in part of the page. If the plain properties above already get -you to the object you want, you can stop here. For comprehensive coverage of all -lookup operators, see {ref}`querylist-filtering`. For tmux-native filters that -return only matching rows on large servers, see {ref}`native-filtering`. - -### Basic filtering - -Find windows by exact attribute match: - -```python ->>> session.windows.filter(window_index=window.window_index) # doctest: +ELLIPSIS -[Window(@... ..., Session($... ...))] -``` - -Get a specific pane by ID: - -```python ->>> window.panes.get(pane_id=pane.pane_id) # doctest: +ELLIPSIS -Pane(%... Window(@... ..., Session($... ...))) -``` - -### Partial matching - -Use lookup suffixes like `__contains`, `__startswith`, `__endswith`: - -```python ->>> # Create windows to demonstrate filtering ->>> w1 = session.new_window(window_name="app-frontend") ->>> w2 = session.new_window(window_name="app-backend") ->>> w3 = session.new_window(window_name="logs") - ->>> # Find windows starting with 'app-' ->>> session.windows.filter(window_name__startswith='app-') # doctest: +ELLIPSIS -[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] - ->>> # Find windows containing 'end' ->>> session.windows.filter(window_name__contains='end') # doctest: +ELLIPSIS -[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -### Case-insensitive matching - -Prefix any lookup with `i` for case-insensitive matching: - -```python ->>> # Create windows with mixed case ->>> w1 = session.new_window(window_name="MyApp") ->>> w2 = session.new_window(window_name="myapp-worker") - ->>> # Case-insensitive search ->>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS -[Window(@... ...:MyApp, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() -``` - -### Regex filtering - -For complex patterns, use `__regex` or `__iregex`: - -```python ->>> # Create versioned windows ->>> w1 = session.new_window(window_name="release-v1-0") ->>> w2 = session.new_window(window_name="release-v2-0") ->>> w3 = session.new_window(window_name="dev") - ->>> # Match version pattern ->>> session.windows.filter(window_name__regex=r'v\d+-\d+') # doctest: +ELLIPSIS -[Window(@... ...:release-v1-0, Session($... ...)), Window(@... ...:release-v2-0, Session($... ...))] - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -### Chaining filters - -Multiple conditions can be combined: - -```python ->>> # Create windows for chaining example ->>> w1 = session.new_window(window_name="api-prod") ->>> w2 = session.new_window(window_name="api-staging") ->>> w3 = session.new_window(window_name="web-prod") - ->>> # Multiple conditions in one call (AND) ->>> session.windows.filter( -... window_name__startswith='api', -... window_name__endswith='prod' -... ) # doctest: +ELLIPSIS -[Window(@... ...:api-prod, Session($... ...))] - ->>> # Chained calls (also AND) ->>> session.windows.filter( -... window_name__contains='api' -... ).filter( -... window_name__contains='staging' -... ) # doctest: +ELLIPSIS -[Window(@... ...:api-staging, Session($... ...))] - ->>> # Clean up ->>> w1.kill() ->>> w2.kill() ->>> w3.kill() -``` - -### Get with default - -Avoid exceptions when an object might not exist: - -```python ->>> # Returns None instead of raising ObjectDoesNotExist ->>> session.windows.get(window_name="nonexistent", default=None) is None -True -``` - -## Checking relationships - -Two questions come up often: does an object belong to a collection (membership), -and do two handles point at the same tmux entity (identity)? Python's `in` -operator answers the first — whether an object is part of a collection: - -```python ->>> window in session.windows -True ->>> pane in window.panes -True ->>> session in server.sessions -True -``` - -Comparing IDs answers the second — here, whether the window you hold is the -session's active window: - -```python ->>> window.window_id == session.active_window.window_id -True -``` - -And whether the pane you hold is the window's active pane: - -```python ->>> pane.pane_id == window.active_pane.pane_id -True -``` - -[target]: http://man.openbsd.org/OpenBSD-5.9/man1/tmux.1#COMMANDS diff --git a/fastmcp.json b/fastmcp.json new file mode 100644 index 000000000..31bbfab36 --- /dev/null +++ b/fastmcp.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "type": "filesystem", + "path": "src/libtmux/experimental/mcp/__init__.py", + "entrypoint": "default_async_server" + }, + "deployment": { + "transport": "stdio" + } +} diff --git a/justfile b/justfile index 05557181a..b8e59fba1 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,11 @@ watch-test: build-docs: just -f docs/justfile html +# Run executable documentation examples and documentation tests +[group: 'docs'] +test-docs: + just -f docs/justfile doctest + # Watch files and rebuild docs on change [group: 'docs'] watch-docs: diff --git a/pyproject.toml b/pyproject.toml index 16f8ebc3a..892defd13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ include = [ { path = "docs", format = "sdist" }, { path = "conftest.py", format = "sdist" }, ] +dependencies = [ + "PyYAML>=6.0", +] [project.urls] "Bug Tracker" = "https://github.com/tmux-python/libtmux/issues" @@ -55,6 +58,8 @@ dev = [ "gp-sphinx==0.0.1a36", "sphinx-autodoc-api-style==0.0.1a36", "sphinx-autodoc-pytest-fixtures==0.0.1a36", + "sphinx-ux-autodoc-layout==0.0.1a36", + "sphinx-ux-badges==0.0.1a36", "sphinx-autobuild", "types-docutils", # Testing @@ -65,6 +70,12 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + # MCP adapter — the optional `mcp` extra, included here so the gate + # type-checks (mypy) and exercises the fastmcp adapter + its tests + # instead of silently skipping them. + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", # Coverage "codecov", "coverage", @@ -72,12 +83,15 @@ dev = [ # Lint "ruff>=0.16.0", "mypy", + "ty", ] docs = [ "gp-sphinx==0.0.1a36", "sphinx-autodoc-api-style==0.0.1a36", "sphinx-autodoc-pytest-fixtures==0.0.1a36", + "sphinx-ux-autodoc-layout==0.0.1a36", + "sphinx-ux-badges==0.0.1a36", "sphinx-autobuild", ] testing = [ @@ -87,6 +101,10 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + # MCP adapter (optional `mcp` extra) so the adapter tests run here too + "fastmcp", + # Scripts (scripts/mcp_swap.py dev tool) + "tomlkit", ] coverage =[ "codecov", @@ -102,6 +120,16 @@ lint = [ [project.entry-points.pytest11] libtmux = "libtmux.pytest_plugin" +[project.scripts] +# Experimental typed-ops MCP server (stdio). Requires the `mcp` extra; +# `main` prints an install hint and exits non-zero when fastmcp is absent. +libtmux-engine-mcp = "libtmux.experimental.mcp:main" + +[project.optional-dependencies] +mcp = [ + "fastmcp>=3.4.2", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -140,6 +168,74 @@ files = [ ] +[tool.ty.environment] +python-version = "3.10" + +[tool.ty.src] +include = ["src", "tests"] + +[tool.ty.rules] +# Private _pytest APIs (e.g. RaisesContext) are not publicly exported; +# both mypy and ty flag them. ty categorizes these as unresolved-import +# rather than attr-defined. +# https://github.com/astral-sh/ty/issues/1276 +unresolved-import = "ignore" +# ty resolves cmd() as a union type including a (Any, Any, /) -> tmux_cmd +# variant from CmdProtocol, causing false positives on *args methods. +# ty does not yet fully support argument unpacking (*args/**kwargs). +# https://github.com/astral-sh/ty/issues/404 +too-many-positional-arguments = "ignore" +# Same root cause as too-many-positional-arguments: ty cannot verify +# required params are present when calling with **kwargs unpacking. +# https://github.com/astral-sh/ty/issues/785 +missing-argument = "ignore" +# ty falls back to object.__init__ for union return types and +# dataclass-transform decorators, flagging all kwargs as unknown. +# https://github.com/astral-sh/ty/issues/2369 +unknown-argument = "ignore" +# Tests use monkeypatch.setattr(libtmux.common, ...) without explicit +# submodule import. The submodule is always available via __init__.py +# re-exports but ty cannot detect implicit registration. +# https://github.com/astral-sh/ty/issues/133 +possibly-missing-submodule = "ignore" +# Vendored version.py uses tuple comparison with mixed-type elements +# (int, str, InfinityType) for PEP 440 version ordering. ty cannot +# resolve element-wise comparison operators across union-typed tuples. +# https://github.com/astral-sh/ty/issues/1202 +unsupported-operator = "ignore" +# ty cannot verify argument types through **kwargs unpacking and +# narrows LiteralString to str differently than mypy. 20 false +# positives, mostly from **call_kwargs and **filter_expr patterns. +# https://github.com/astral-sh/ty/issues/785 +invalid-argument-type = "ignore" +# ty doesn't narrow through dict value iteration (item.split() in +# options.py) or union access patterns (Pane | None). 5 false +# positives where mypy correctly narrows the type. +unresolved-attribute = "ignore" +# ty can't see through isinstance narrowing for TypeVars (subscript +# assignment on _V in options.py) and IO[str] | None unions +# (control_mode.py, already suppressed for mypy). 4 false positives. +invalid-assignment = "ignore" +# Pane, Session, Window inherit from Obj which defines defaulted fields; +# subclasses add required server: Server. Python dataclass inheritance +# handles this at runtime but ty's analysis doesn't account for it. +dataclass-field-order = "ignore" +# re.search(rhs, data) in query_list.py where isinstance narrows both +# to str | bytes, but ty can't match the overload (str, str) | (bytes, +# Buffer) against (str | bytes, str | bytes). 2 false positives. +no-matching-overload = "ignore" +# options.py returns dict subscript typed as object where str | int | +# None expected; test_session.py returns MockTmuxCmd where tmux_cmd +# expected (already suppressed for mypy). 2 false positives. +invalid-return-type = "ignore" +# query_list.py b[key] where b is typed as object from a narrowing +# path ty can't follow (same context as unresolved-attribute). +not-subscriptable = "ignore" +# query_list.py filter_(k) where filter_ is a union including +# T@QueryList & Top[(...) -> object] — ty's intersection type +# resolution produces an uncallable Top type. +call-top-callable = "ignore" + [tool.coverage.run] branch = true parallel = true @@ -256,6 +352,31 @@ convention = "numpy" # use `Server.raise_if_dead`. "BLE001", ] +"src/libtmux/experimental/engines/async_control_mode.py" = [ + # The supervisor and reader tasks re-raise `CancelledError`, then record any + # other failure so waiters see a `ControlModeError` rather than a dead task. + "BLE001", +] +"src/libtmux/experimental/mcp/events.py" = [ + # The event drainer stores its failure for the next poll; crashing the + # background task would lose the stream with nothing to report. + "BLE001", +] +"src/libtmux/experimental/mcp/middleware.py" = [ + # Turning any tool exception into an `is_error` result is this middleware's + # entire job. + "BLE001", +] +"src/libtmux/experimental/mcp/schema.py" = [ + # pydantic rejects some dataclasses and `get_type_hints` fails on unresolved + # annotations; both fall back to the stdlib introspector. + "BLE001", +] +"tests/experimental/mcp/test_safety_gate.py" = [ + # The tier assertions accept either a raise or an error result, so the call + # is caught broadly to classify which happened. + "BLE001", +] [tool.pytest.ini_options] addopts = [ @@ -264,7 +385,10 @@ addopts = [ "--showlocals", "--doctest-docutils-modules", "-p no:doctest", - "--reruns=2" + "--reruns=2", + # Built HTML lives under docs/_build and `docs` is a testpath; never + # collect generated artifacts (their relative directives fail to parse). + "--ignore=docs/_build", ] doctest_optionflags = [ "ELLIPSIS", diff --git a/scripts/bench-results/RESULTS.md b/scripts/bench-results/RESULTS.md new file mode 100644 index 000000000..dac99942d --- /dev/null +++ b/scripts/bench-results/RESULTS.md @@ -0,0 +1,147 @@ +# libtmux engine build-benchmark — results + +Produced by `scripts/bench_engines.py` (a hermetic PEP 723 grid runner) plus a +one-off hyperfine end-to-end run. All builds are isolated: per-run sockets under +a throwaway dir, `TMUX` unset, servers killed on exit — the default tmux server +is never contacted. Run the default grid: + +```console +$ uv run scripts/bench_engines.py run +``` + +Compare selected engines with shell-readiness waits: + +```console +$ uv run scripts/bench_engines.py run --engines classic,control_mode,pipelined --wait +``` + +Profile one larger control-mode build: + +```console +$ uv run scripts/bench_engines.py profile --engine control_mode --shape 8x4 +``` + +Raw data: `grid.json` (no-wait grid), `wait.json` (wait comparison). + +## Engine grid — in-process build, median ms (xN vs classic), 20 runs + +Shape = `windows x panes-per-window`. Structural builds (no shell-readiness wait). + +| engine | 1x1 | 1x4 | 3x3 | 5x4 | 8x4 | +|---|--:|--:|--:|--:|--:| +| classic (Server/Session/Window/Pane) | 22.0 | 169.4 | 452.5 | 1442.2 | 3497.2 | +| builder / subprocess | 23.0 | 42.8 | 86.9 | 246.7 | 428.8 (8x) | +| builder / imsg | 20.6 | 31.1 | 62.6 | 153.4 | 262.0 (13x) | +| builder / control_mode | 2.5 | 9.4 | 26.5 | 103.3 | 166.7 (21x) | +| **pipelined (prototype)** | **1.4** | **7.9** | **20.2** | **65.3** | **115.7 (30x)** | +| mock (offline, in-memory) | 0.1 | 0.1 | 0.3 | 1.3 | 1.5 | + +Full percentiles at 8x4 (ms): + +| engine | min | avg | median | p90 | p95 | p99 | max | +|---|--:|--:|--:|--:|--:|--:|--:| +| classic | 2192 | 3404 | 3497 | 3931 | 4077 | 4432 | 4432 | +| subprocess | 358 | 426 | 429 | 479 | 481 | 487 | 487 | +| imsg | 222 | 283 | 262 | 342 | 421 | 455 | 455 | +| control_mode | 118 | 180 | 167 | 215 | 216 | 398 | 398 | +| pipelined | 97 | 123 | 116 | 156 | 165 | 194 | 194 | +| mock | 1 | 2 | 2 | 2 | 2 | 2 | 2 | + +Reads: + +- **control_mode** (one persistent `tmux -C`, no per-op fork) is the fastest + shipped engine: 21x classic at 32 panes. +- **imsg** (AF_UNIX one-shot per call) sits between subprocess and control_mode; + its per-call handshake makes tiny builds no faster than classic. +- **pipelined** (prototype: batch independent creates into ~3 `run_batch` + round-trips instead of ~34) is fastest overall, ~1.4x over control_mode. Not + the 11x the round-trip count implies, because the build is **tmux-server-bound** + (one shell fork per pane), not round-trip-bound. `mock` (offline, 1.5 ms) + is the Python floor: the plan/compile layer is negligible; the time is tmux. + +## With vs without shell-readiness wait + +`wait.json`, 10 runs. "wait" polls each pane until its shell has drawn a prompt. + +| shape | engine | nowait median | wait median | wait penalty | speedup vs classic | +|---|---|--:|--:|--:|--:| +| 1x4 | classic | 217.6 | 1134.5 | 5.2x | 1.0x | +| 1x4 | control_mode | 9.4 | 865.2 | 92x | 23.1x -> 1.3x | +| 1x4 | pipelined | 9.0 | 1004.3 | 112x | 24.3x -> 1.1x | +| 5x4 | classic | 1365.6 | 3252.7 | 2.4x | 1.0x | +| 5x4 | control_mode | 73.1 | 2194.5 | 30x | 18.7x -> 1.5x | +| 5x4 | pipelined | 66.6 | 2123.3 | 32x | 20.5x -> 1.5x | + +**The engine win only exists when nobody waits for shells.** Shell startup +(~0.8-2.1 s) dominates a fast build (30-112x penalty) but barely moves the slow +classic path (2-5x), so the ~20x engine advantage collapses to ~1.5x once both +sides wait. Compare engines with matching readiness policies; mixing a waiting +classic path with a no-wait builder overstates the speedup. + +## Profile — where a control_mode build spends time (32 panes x5) + +~200 ms/build; **68% is `select.epoll.poll`** in `control_mode._read_blocks` — +one round-trip per created id (each new window/pane id read back before the next +op targets it). Round-trip-latency bound, not CPU/Python bound. + +## Whole-process wall time (hyperfine, SubprocessEngine, end-to-end) + +For reference, whole-script wall time (Python start + import + build + teardown) +on `SubprocessEngine`, 50 runs: classic simple 210 ms / large 6764 ms; builder +simple 453 ms / large 1692 ms. End-to-end on the *slowest* engine understates the +builder (startup dwarfs a 3 ms build) — the in-process grid above is the clean +signal. Prefer `control_mode` and in-process timing to measure build cost. + +## Build-cost factorial — `matrix` + +The `matrix` subcommand isolates which of four independent choices drives build +cost, sweeping them as a factorial: **async** {sync, async} × **transport** +{subprocess, control_mode} × **planner** {imperative, plan-seq, plan-fold} × +**workspace** {hand `LazyPlan`, declarative `Workspace`}. Because a declarative +workspace compiles to a lazy plan, the valid expression layers are five — +`imperative`, `plan-seq`, `plan-fold`, `ws-seq`, `ws-fold` — crossed with 2 +transports × 2 modes = 20 cells, against a `classic` reference. Reproduce: + +```console +$ uv run scripts/bench_engines.py matrix --shapes 1x4,3x3,5x4 +``` + +`mock` is absent from the table by design: it is the offline correctness +**oracle**, not a results row. `matrix --check` (default on) and the standalone +`contract` subcommand assert every layer × mode renders identical tmux argv to +mock, so the benchmark doubles as an ops-language contract test: + +```console +$ uv run scripts/bench_engines.py contract +``` + +Illustrative 1×4 snapshot (small run; absolute ms are machine-dependent — the +ratios are the portable signal): + +| layer × transport × mode | median ms | vs classic | +|---|--:|--:| +| classic (reference) | 168.7 | 1.0x | +| ws-fold · control_mode · sync | 7.8 | 21.5x | +| plan-fold · control_mode · sync | 6.3 | 26.8x | +| imperative · control_mode · async | 7.0 | 24.0x | +| ws-fold · subprocess · sync | 30.7 | 5.5x | + +Reads: transport dominates (control_mode ~4-5× subprocess); planner choice +(imperative → plan-seq → plan-fold) and the declarative-workspace compile move +build cost only marginally — the plan/compile layer is cheap, the time is tmux. +Single-build async ≈ sync (a linear build serializes either way). + +## Concurrency — `concurrency` + +Async's real lever is not single-build latency but overlap. `concurrency` +builds K independent sessions sync-serial vs `asyncio.gather` over one +connection: + +```console +$ uv run scripts/bench_engines.py concurrency --transport control_mode --k 4 +``` + +Over one `control_mode` connection, async-gather runs ~2.4× the sync-serial +wall time at K=4 — the persistent connection multiplexes the K builds' +round-trips instead of paying them end-to-end. This is the win the single-build +matrix structurally cannot show. diff --git a/scripts/bench-results/grid.json b/scripts/bench-results/grid.json new file mode 100644 index 000000000..cfb79abbf --- /dev/null +++ b/scripts/bench-results/grid.json @@ -0,0 +1,1082 @@ +[ + { + "engine": "classic", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.435490999370813, + 21.44604700151831, + 19.487711018882692, + 22.916321991942823, + 26.080587063916028, + 20.377128035761416, + 19.85792105551809, + 21.783681004308164, + 27.05869299825281, + 22.205271059647202, + 20.67994710523635, + 26.499966043047607, + 24.94822407606989, + 30.270048999227583, + 29.39265000168234, + 21.638029953464866, + 20.480802981182933, + 20.635419990867376, + 26.2573619838804, + 19.851638935506344 + ], + "n_ms": 20.0, + "min_ms": 19.487711018882692, + "avg_ms": 23.365147114964202, + "median_ms": 21.994476031977683, + "p90_ms": 27.05869299825281, + "p95_ms": 29.39265000168234, + "p99_ms": 30.270048999227583, + "max_ms": 30.270048999227583 + }, + { + "engine": "subprocess", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 22.950448095798492, + 23.71105900965631, + 27.512941975146532, + 24.13841593079269, + 22.9648700915277, + 23.848011973313987, + 25.90626000892371, + 20.328919985331595, + 21.03408903349191, + 21.60188800189644, + 26.37235203292221, + 22.638716967776418, + 20.477519021369517, + 19.63279501069337, + 28.77276297658682, + 25.713130016811192, + 20.554474089294672, + 20.25220892392099, + 28.945287107490003, + 22.18223095405847 + ], + "n_ms": 20.0, + "min_ms": 19.63279501069337, + "avg_ms": 23.47691906034015, + "median_ms": 22.957659093663096, + "p90_ms": 27.512941975146532, + "p95_ms": 28.77276297658682, + "p99_ms": 28.945287107490003, + "max_ms": 28.945287107490003 + }, + { + "engine": "control_mode", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 2.3771480191498995, + 2.6499099330976605, + 2.8321900172159076, + 2.5614179903641343, + 2.9272950487211347, + 2.5065389927476645, + 2.832969999872148, + 2.916119061410427, + 2.130561973899603, + 2.0278169540688396, + 2.620737999677658, + 2.21067201346159, + 1.859560958109796, + 2.2114990279078484, + 2.182059921324253, + 2.0884659606963396, + 2.447605016641319, + 4.296073107980192, + 4.0978980250656605, + 3.2866770634427667 + ], + "n_ms": 20.0, + "min_ms": 1.859560958109796, + "avg_ms": 2.653160854242742, + "median_ms": 2.5339784915558994, + "p90_ms": 3.2866770634427667, + "p95_ms": 4.0978980250656605, + "p99_ms": 4.296073107980192, + "max_ms": 4.296073107980192 + }, + { + "engine": "imsg", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 25.242659961804748, + 23.58605689369142, + 21.349252085201442, + 22.916006040759385, + 18.253559013828635, + 18.885195022448897, + 35.018087015487254, + 37.03230095561594, + 36.96872899308801, + 36.74224903807044, + 18.353665014728904, + 18.583990982733667, + 17.208026023581624, + 24.591958965174854, + 19.304446992464364, + 18.249089014716446, + 18.050470971502364, + 22.29922788683325, + 19.827933982014656, + 17.557888058945537 + ], + "n_ms": 20.0, + "min_ms": 17.208026023581624, + "avg_ms": 23.50103964563459, + "median_ms": 20.58859303360805, + "p90_ms": 36.74224903807044, + "p95_ms": 36.96872899308801, + "p99_ms": 37.03230095561594, + "max_ms": 37.03230095561594 + }, + { + "engine": "mock", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 0.05781592335551977, + 0.05536503158509731, + 0.08927390445023775, + 0.06635801400989294, + 0.05585001781582832, + 0.05495199002325535, + 0.053521012887358665, + 0.05605991464108229, + 0.05437701474875212, + 0.052780029363930225, + 0.05164195317775011, + 0.0517780426889658, + 0.0520970206707716, + 0.05274603608995676, + 0.05097396206110716, + 0.05048594903200865, + 0.05105405580252409, + 0.051804003305733204, + 0.05062599666416645, + 0.0513358972966671 + ], + "n_ms": 20.0, + "min_ms": 0.05048594903200865, + "avg_ms": 0.05554478848353028, + "median_ms": 0.05276303272694349, + "p90_ms": 0.05781592335551977, + "p95_ms": 0.06635801400989294, + "p99_ms": 0.08927390445023775, + "max_ms": 0.08927390445023775 + }, + { + "engine": "pipelined", + "shape": "1x1", + "panes": 1, + "wait": false, + "samples_ms": [ + 1.272339024581015, + 1.423096051439643, + 2.0246210042387247, + 2.4055520771071315, + 2.593372017145157, + 1.708880066871643, + 1.5237980987876654, + 1.258800970390439, + 1.4068959280848503, + 1.528986031189561, + 1.2518159346655011, + 1.1928770691156387, + 1.2443959712982178, + 1.61149597261101, + 1.3652080669999123, + 1.1852029711008072, + 1.2318679364398122, + 1.9605309935286641, + 2.02260899823159, + 1.3686279999092221 + ], + "n_ms": 20.0, + "min_ms": 1.1852029711008072, + "avg_ms": 1.5790486591868103, + "median_ms": 1.4149959897622466, + "p90_ms": 2.0246210042387247, + "p95_ms": 2.4055520771071315, + "p99_ms": 2.593372017145157, + "max_ms": 2.593372017145157 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 153.5736940568313, + 152.53363398369402, + 176.28063401207328, + 162.175236037001, + 175.2850729972124, + 169.6498990058899, + 160.512474947609, + 175.7287960499525, + 156.43915405962616, + 160.1339690387249, + 207.24134298507124, + 189.16924006771296, + 194.17230098042637, + 170.98306701518595, + 161.9495979975909, + 175.3947560209781, + 170.09779904037714, + 154.71308294218034, + 169.2105890251696, + 141.2136929575354 + ], + "n_ms": 20.0, + "min_ms": 141.2136929575354, + "avg_ms": 168.82290166104212, + "median_ms": 169.43024401552975, + "p90_ms": 189.16924006771296, + "p95_ms": 194.17230098042637, + "p99_ms": 207.24134298507124, + "max_ms": 207.24134298507124 + }, + { + "engine": "subprocess", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 38.20429800543934, + 34.88947299774736, + 56.08579399995506, + 35.82027601078153, + 36.9337199954316, + 35.59582296293229, + 46.69300094246864, + 39.19104894157499, + 37.6851549372077, + 35.862258984707296, + 30.545516056008637, + 41.76524991635233, + 50.84225791506469, + 50.91948399785906, + 55.70168199483305, + 49.489257973618805, + 43.84331707842648, + 59.19129599351436, + 52.224029088392854, + 54.89515699446201 + ], + "n_ms": 20.0, + "min_ms": 30.545516056008637, + "avg_ms": 44.318904739338905, + "median_ms": 42.804283497389406, + "p90_ms": 55.70168199483305, + "p95_ms": 56.08579399995506, + "p99_ms": 59.19129599351436, + "max_ms": 59.19129599351436 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 7.563800900243223, + 8.978422963991761, + 8.618877036496997, + 9.57214506343007, + 10.934944031760097, + 12.400830979458988, + 11.549205984920263, + 7.952383020892739, + 9.504236048087478, + 8.437798009254038, + 7.7332829823717475, + 9.446645970456302, + 9.59436094854027, + 8.322115987539291, + 9.378890972584486, + 8.756348979659379, + 9.824263979680836, + 7.038456969894469, + 10.727863991633058, + 11.08790806028992 + ], + "n_ms": 20.0, + "min_ms": 7.038456969894469, + "avg_ms": 9.37113914405927, + "median_ms": 9.412768471520394, + "p90_ms": 11.08790806028992, + "p95_ms": 11.549205984920263, + "p99_ms": 12.400830979458988, + "max_ms": 12.400830979458988 + }, + { + "engine": "imsg", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 36.68499703053385, + 34.726232988759875, + 24.83677805867046, + 26.792447897605598, + 36.66620596777648, + 28.368790983222425, + 27.89685397874564, + 37.136508035473526, + 34.588526003062725, + 31.007860088720918, + 26.524171931669116, + 33.32362789660692, + 44.18608907144517, + 39.438307052478194, + 28.73879298567772, + 27.788623003289104, + 30.046261032111943, + 31.244902056641877, + 33.68811297696084, + 28.78861501812935 + ], + "n_ms": 20.0, + "min_ms": 24.83677805867046, + "avg_ms": 32.123635202879086, + "median_ms": 31.126381072681397, + "p90_ms": 37.136508035473526, + "p95_ms": 39.438307052478194, + "p99_ms": 44.18608907144517, + "max_ms": 44.18608907144517 + }, + { + "engine": "mock", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 0.13607402797788382, + 0.23710704408586025, + 0.14775199815630913, + 0.13310997746884823, + 0.12774800416082144, + 0.12735999189317226, + 0.1251481007784605, + 0.12269604485481977, + 0.12107205111533403, + 0.11823198292404413, + 0.27573294937610626, + 0.2059279941022396, + 0.1430619740858674, + 0.1845939550548792, + 0.13147201389074326, + 0.13140100054442883, + 0.125426915474236, + 0.1242499565705657, + 0.12552295811474323, + 0.12560293544083834 + ], + "n_ms": 20.0, + "min_ms": 0.11823198292404413, + "avg_ms": 0.14846459380351007, + "median_ms": 0.12957450235262513, + "p90_ms": 0.2059279941022396, + "p95_ms": 0.23710704408586025, + "p99_ms": 0.27573294937610626, + "max_ms": 0.27573294937610626 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.6258920123800635, + 9.559032041579485, + 9.58340906072408, + 5.963396979495883, + 8.836780907586217, + 10.417644982226193, + 7.979689980857074, + 8.669061004184186, + 6.5402110340073705, + 7.1102980291470885, + 6.61726703401655, + 7.768513984046876, + 8.26644105836749, + 6.927274982444942, + 6.618922925554216, + 6.4139129826799035, + 7.574769086204469, + 9.094446897506714, + 8.622737019322813, + 9.986574063077569 + ], + "n_ms": 20.0, + "min_ms": 5.963396979495883, + "avg_ms": 7.958813803270459, + "median_ms": 7.874101982451975, + "p90_ms": 9.58340906072408, + "p95_ms": 9.986574063077569, + "p99_ms": 10.417644982226193, + "max_ms": 10.417644982226193 + }, + { + "engine": "classic", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 463.1088289897889, + 403.2544719520956, + 459.22533597331494, + 505.975084961392, + 438.8479490298778, + 482.3058929760009, + 413.75007992610335, + 413.2016849471256, + 444.53056796919554, + 483.2421139581129, + 468.3551120106131, + 470.5149090150371, + 416.62799497134984, + 454.917594906874, + 409.0973420534283, + 427.1308289607987, + 460.38287493865937, + 450.0137569848448, + 479.48227205779403, + 442.03562603797764 + ], + "n_ms": 20.0, + "min_ms": 403.2544719520956, + "avg_ms": 449.3000161310192, + "median_ms": 452.4656759458594, + "p90_ms": 482.3058929760009, + "p95_ms": 483.2421139581129, + "p99_ms": 505.975084961392, + "max_ms": 505.975084961392 + }, + { + "engine": "subprocess", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 96.40262997709215, + 107.8126790234819, + 102.82182600349188, + 95.2509690541774, + 93.2349389186129, + 86.750422953628, + 89.36347393319011, + 89.38358607701957, + 113.29700902570039, + 82.9845640109852, + 90.94950300641358, + 83.90699897427112, + 81.64807397406548, + 87.03590603545308, + 73.54520098306239, + 82.44245196692646, + 72.46743002906442, + 82.35985110513866, + 79.32134799193591, + 83.34585605189204 + ], + "n_ms": 20.0, + "min_ms": 72.46743002906442, + "avg_ms": 88.71623595478013, + "median_ms": 86.89316449454054, + "p90_ms": 102.82182600349188, + "p95_ms": 107.8126790234819, + "p99_ms": 113.29700902570039, + "max_ms": 113.29700902570039 + }, + { + "engine": "control_mode", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 30.5233730468899, + 24.231599061749876, + 25.235411943867803, + 27.411659015342593, + 24.05042899772525, + 25.479506002739072, + 28.722655959427357, + 22.07455795723945, + 22.580575081519783, + 29.86845897976309, + 27.496899012476206, + 36.62381402682513, + 37.449890980497, + 26.981694041751325, + 24.941370938904583, + 25.788024999201298, + 30.847082030959427, + 26.0819629766047, + 23.499268922023475, + 27.957514976151288 + ], + "n_ms": 20.0, + "min_ms": 22.07455795723945, + "avg_ms": 27.39228744758293, + "median_ms": 26.531828509178013, + "p90_ms": 30.847082030959427, + "p95_ms": 36.62381402682513, + "p99_ms": 37.449890980497, + "max_ms": 37.449890980497 + }, + { + "engine": "imsg", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 73.45083705149591, + 70.16680098604411, + 78.33038899116218, + 74.66395699884742, + 56.962854927405715, + 57.84656805917621, + 64.33053908403963, + 54.64023910462856, + 58.12385701574385, + 62.07921903114766, + 55.78872701153159, + 59.88113197963685, + 63.08747094590217, + 69.321816903539, + 56.98866699822247, + 71.06748304795474, + 59.83901908621192, + 61.00640504155308, + 77.51034398097545, + 64.7579530486837 + ], + "n_ms": 20.0, + "min_ms": 54.64023910462856, + "avg_ms": 64.49221396469511, + "median_ms": 62.583344988524914, + "p90_ms": 74.66395699884742, + "p95_ms": 77.51034398097545, + "p99_ms": 78.33038899116218, + "max_ms": 78.33038899116218 + }, + { + "engine": "mock", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 0.2903119893744588, + 0.28088700491935015, + 0.3294319612905383, + 0.2774670720100403, + 0.28050492983311415, + 0.29172003269195557, + 0.2925050212070346, + 0.4807590739801526, + 0.30473608057945967, + 0.33838499803096056, + 0.2801839727908373, + 0.26983011048287153, + 0.31153589952737093, + 0.30686904210597277, + 0.26840297505259514, + 0.2645669737830758, + 0.3714329795911908, + 0.28299796395003796, + 0.2705819206312299, + 0.28041808400303125 + ], + "n_ms": 20.0, + "min_ms": 0.2645669737830758, + "avg_ms": 0.3036764042917639, + "median_ms": 0.2866549766622484, + "p90_ms": 0.33838499803096056, + "p95_ms": 0.3714329795911908, + "p99_ms": 0.4807590739801526, + "max_ms": 0.4807590739801526 + }, + { + "engine": "pipelined", + "shape": "3x3", + "panes": 9, + "wait": false, + "samples_ms": [ + 16.53240597806871, + 15.442625037394464, + 19.34879703912884, + 22.86289702169597, + 18.47324299160391, + 18.982085050083697, + 24.889598018489778, + 19.762809039093554, + 17.981484066694975, + 19.241684931330383, + 20.058405003510416, + 20.32635302748531, + 19.626682973466814, + 21.44766692072153, + 25.036148028448224, + 22.131431964226067, + 27.264841948635876, + 27.298758970573545, + 25.995625066570938, + 28.35541300009936 + ], + "n_ms": 20.0, + "min_ms": 15.442625037394464, + "avg_ms": 21.552947803866118, + "median_ms": 20.192379015497863, + "p90_ms": 27.264841948635876, + "p95_ms": 27.298758970573545, + "p99_ms": 28.35541300009936, + "max_ms": 28.35541300009936 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1281.397273996845, + 1210.668591898866, + 1242.258501937613, + 1174.9852310167626, + 1330.7410050183535, + 1521.1127869552001, + 1366.0107220057398, + 1602.0200490020216, + 1404.2648519389331, + 1466.6325360303745, + 1482.5694229220971, + 1441.553378943354, + 1442.840927047655, + 1607.132775010541, + 1580.0651350291446, + 1381.9000310031697, + 1454.8676230479032, + 1629.573607002385, + 1485.2986589539796, + 1375.63347897958 + ], + "n_ms": 20.0, + "min_ms": 1174.9852310167626, + "avg_ms": 1424.076329387026, + "median_ms": 1442.1971529955044, + "p90_ms": 1602.0200490020216, + "p95_ms": 1607.132775010541, + "p99_ms": 1629.573607002385, + "max_ms": 1629.573607002385 + }, + { + "engine": "subprocess", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 230.99103895947337, + 199.28296492435038, + 270.25563502684236, + 317.19566497486085, + 455.45686106197536, + 497.62218398973346, + 347.6630869554356, + 265.15684905461967, + 224.24249001778662, + 214.0263100154698, + 215.80458094831556, + 252.42189702112228, + 245.46299397479743, + 257.7294419752434, + 194.6850820677355, + 246.80601397994906, + 246.52875203173608, + 262.4232630478218, + 235.30278506223112, + 216.59297600854188 + ], + "n_ms": 20.0, + "min_ms": 194.6850820677355, + "avg_ms": 269.7825435549021, + "median_ms": 246.66738300584257, + "p90_ms": 347.6630869554356, + "p95_ms": 455.45686106197536, + "p99_ms": 497.62218398973346, + "max_ms": 497.62218398973346 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.78025595284998, + 79.53090802766383, + 236.67864105664194, + 233.00717200618237, + 328.0731838895008, + 131.67984399478883, + 164.9903169600293, + 336.2570349127054, + 328.47389997914433, + 113.03125496488065, + 83.44777394086123, + 72.73705000989139, + 70.41866099461913, + 179.36235608067364, + 102.63979400042444, + 71.07233500573784, + 73.72917397879064, + 70.85887296125293, + 104.05752004589885, + 94.78877391666174 + ], + "n_ms": 20.0, + "min_ms": 70.41866099461913, + "avg_ms": 147.53074113395996, + "median_ms": 103.34865702316165, + "p90_ms": 328.0731838895008, + "p95_ms": 328.47389997914433, + "p99_ms": 336.2570349127054, + "max_ms": 336.2570349127054 + }, + { + "engine": "imsg", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 134.2119559412822, + 210.86893905885518, + 148.33857398480177, + 138.0466379923746, + 154.90469709038734, + 130.4093300132081, + 151.83229907415807, + 218.34416908677667, + 124.5981550309807, + 128.86274000629783, + 159.59694795310497, + 145.80014697276056, + 135.36079099867493, + 260.35256509203464, + 163.294667028822, + 218.46147894393653, + 177.09315801039338, + 157.00659702997655, + 137.62785703875124, + 177.16083896812052 + ], + "n_ms": 20.0, + "min_ms": 124.5981550309807, + "avg_ms": 163.6086272657849, + "median_ms": 153.3684980822727, + "p90_ms": 218.34416908677667, + "p95_ms": 218.46147894393653, + "p99_ms": 260.35256509203464, + "max_ms": 260.35256509203464 + }, + { + "engine": "mock", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1.1191670782864094, + 0.9929570369422436, + 0.8589229546487331, + 0.7576720090582967, + 1.3178159715607762, + 1.1864739935845137, + 1.3520210050046444, + 1.2667239643633366, + 1.0587309952825308, + 1.7452990869060159, + 1.4916330110281706, + 3.988807089626789, + 2.0200000144541264, + 1.7959449905902147, + 1.871323911473155, + 1.5445369062945247, + 1.4150440692901611, + 1.2863259762525558, + 0.6520430324599147, + 0.6350380135700107 + ], + "n_ms": 20.0, + "min_ms": 0.6350380135700107, + "avg_ms": 1.4178240555338562, + "median_ms": 1.302070973906666, + "p90_ms": 1.871323911473155, + "p95_ms": 2.0200000144541264, + "p99_ms": 3.988807089626789, + "max_ms": 3.988807089626789 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 113.15120500512421, + 54.02535106986761, + 64.92527201771736, + 78.20391294080764, + 57.41404299624264, + 70.83705102559179, + 92.94041607063264, + 56.410389952361584, + 69.41384205128998, + 65.76590496115386, + 108.70656406041235, + 64.65335004031658, + 75.56975504849106, + 68.74816899653524, + 75.74488001409918, + 60.71585509926081, + 57.00136907398701, + 61.06731400359422, + 58.447972987778485, + 59.52235695440322 + ], + "n_ms": 20.0, + "min_ms": 54.02535106986761, + "avg_ms": 70.66324871848337, + "median_ms": 65.34558848943561, + "p90_ms": 92.94041607063264, + "p95_ms": 108.70656406041235, + "p99_ms": 113.15120500512421, + "max_ms": 113.15120500512421 + }, + { + "engine": "classic", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 3666.3628419628367, + 3372.8903520386666, + 3848.983636009507, + 3508.1395419547334, + 3486.258820979856, + 3584.1793949948624, + 3485.946947010234, + 3771.2144720135257, + 3931.1889680102468, + 4432.214422966354, + 4076.763738063164, + 2764.4857480190694, + 3215.3420510003343, + 3295.630355947651, + 3329.8535760259256, + 3519.156881957315, + 3853.867839090526, + 2536.271504010074, + 2208.0091719981283, + 2192.4075819551945 + ], + "n_ms": 20.0, + "min_ms": 2192.4075819551945, + "avg_ms": 3403.95839230041, + "median_ms": 3497.1991814672947, + "p90_ms": 3931.1889680102468, + "p95_ms": 4076.763738063164, + "p99_ms": 4432.214422966354, + "max_ms": 4432.214422966354 + }, + { + "engine": "subprocess", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 392.0094169443473, + 399.5577469468117, + 461.8747670901939, + 463.75522401649505, + 403.79654499702156, + 478.78038801718503, + 486.85317998752, + 401.86715801246464, + 385.10203699115664, + 421.61177797243, + 463.4238800499588, + 357.96659102197737, + 435.90391403995454, + 470.3146460233256, + 480.929414043203, + 438.19006194826216, + 395.19416098482907, + 373.0431740405038, + 367.8618990816176, + 445.61960094142705 + ], + "n_ms": 20.0, + "min_ms": 357.96659102197737, + "avg_ms": 426.18277915753424, + "median_ms": 428.75784600619227, + "p90_ms": 478.78038801718503, + "p95_ms": 480.929414043203, + "p99_ms": 486.85317998752, + "max_ms": 486.85317998752 + }, + { + "engine": "control_mode", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 152.8084089513868, + 117.58937302511185, + 195.70027699228376, + 138.3822859497741, + 397.9049799963832, + 215.567508013919, + 138.99186393246055, + 188.69415298104286, + 189.87572100013494, + 173.7240820657462, + 158.34677510429174, + 215.0000180117786, + 194.2786539439112, + 176.19880801066756, + 153.5388040356338, + 159.58266996312886, + 143.19772401358932, + 153.82824302650988, + 178.53682104032487, + 147.99589000176638 + ], + "n_ms": 20.0, + "min_ms": 117.58937302511185, + "avg_ms": 179.48715300299227, + "median_ms": 166.65337601443753, + "p90_ms": 215.0000180117786, + "p95_ms": 215.567508013919, + "p99_ms": 397.9049799963832, + "max_ms": 397.9049799963832 + }, + { + "engine": "imsg", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 454.6383459819481, + 259.375739027746, + 249.91479504387826, + 420.9431590279564, + 222.0811820589006, + 264.5984790287912, + 231.42871004529297, + 333.20740202907473, + 254.01110399980098, + 341.4921569637954, + 272.3997130524367, + 239.1474029282108, + 242.0606450177729, + 231.84302891604602, + 285.94926302321255, + 250.6428079213947, + 266.55483595095575, + 276.24492603354156, + 326.5154130058363, + 226.46799904759973 + ], + "n_ms": 20.0, + "min_ms": 222.0811820589006, + "avg_ms": 282.4758554052096, + "median_ms": 261.9871090282686, + "p90_ms": 341.4921569637954, + "p95_ms": 420.9431590279564, + "p99_ms": 454.6383459819481, + "max_ms": 454.6383459819481 + }, + { + "engine": "mock", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 1.0853420244529843, + 0.9852750226855278, + 0.8930320618674159, + 1.6033079009503126, + 1.4309020480141044, + 1.3377750292420387, + 2.1038519917055964, + 1.9198539666831493, + 1.9366199849173427, + 2.0881620002910495, + 1.9528960110619664, + 2.314181998372078, + 1.6242529964074492, + 1.7207990167662501, + 1.67950801551342, + 1.1982190189883113, + 1.0953579330816865, + 1.4674999983981252, + 1.091616926714778, + 1.1784050147980452 + ], + "n_ms": 20.0, + "min_ms": 0.8930320618674159, + "avg_ms": 1.5353429480455816, + "median_ms": 1.535403949674219, + "p90_ms": 2.0881620002910495, + "p95_ms": 2.1038519917055964, + "p99_ms": 2.314181998372078, + "max_ms": 2.314181998372078 + }, + { + "engine": "pipelined", + "shape": "8x4", + "panes": 32, + "wait": false, + "samples_ms": [ + 100.61924997717142, + 96.57052101101726, + 99.77939596865326, + 117.95211094431579, + 113.54388005565852, + 98.9359940867871, + 124.54905500635505, + 101.82711097877473, + 156.30723407957703, + 125.18065399490297, + 110.4188309982419, + 133.29723698552698, + 140.21150907501578, + 165.18471902236342, + 121.83465505950153, + 193.51797795388848, + 152.09435508586466, + 102.82438900321722, + 101.34623397607356, + 110.86194100789726 + ], + "n_ms": 20.0, + "min_ms": 96.57052101101726, + "avg_ms": 123.3428527135402, + "median_ms": 115.74799549998716, + "p90_ms": 156.30723407957703, + "p95_ms": 165.18471902236342, + "p99_ms": 193.51797795388848, + "max_ms": 193.51797795388848 + } +] \ No newline at end of file diff --git a/scripts/bench-results/wait.json b/scripts/bench-results/wait.json new file mode 100644 index 000000000..0b138f7c4 --- /dev/null +++ b/scripts/bench-results/wait.json @@ -0,0 +1,314 @@ +[ + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 166.7344990419224, + 212.69031206611544, + 256.8014459684491, + 199.50575497932732, + 261.5824770182371, + 222.48539805877954, + 203.23852205183357, + 242.7966770483181, + 239.08080800902098, + 198.84139497298747 + ], + "n_ms": 10.0, + "min_ms": 166.7344990419224, + "avg_ms": 220.3757289214991, + "median_ms": 217.5878550624475, + "p90_ms": 256.8014459684491, + "p95_ms": 261.5824770182371, + "p99_ms": 261.5824770182371, + "max_ms": 261.5824770182371 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 8.247727062553167, + 10.702441912144423, + 14.183179941028357, + 10.467821964994073, + 8.07721505407244, + 9.324315935373306, + 8.775111986324191, + 9.275623015128076, + 9.485395043157041, + 13.174964929930866 + ], + "n_ms": 10.0, + "min_ms": 8.07721505407244, + "avg_ms": 10.171379684470594, + "median_ms": 9.404855489265174, + "p90_ms": 13.174964929930866, + "p95_ms": 14.183179941028357, + "p99_ms": 14.183179941028357, + "max_ms": 14.183179941028357 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": false, + "samples_ms": [ + 6.229061051271856, + 6.618206040002406, + 8.960460894741118, + 11.124748038128018, + 7.881589001044631, + 11.010617017745972, + 8.948027971200645, + 7.985224016010761, + 10.071107069961727, + 15.785112977027893 + ], + "n_ms": 10.0, + "min_ms": 6.229061051271856, + "avg_ms": 9.461415407713503, + "median_ms": 8.954244432970881, + "p90_ms": 11.124748038128018, + "p95_ms": 15.785112977027893, + "p99_ms": 15.785112977027893, + "max_ms": 15.785112977027893 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 1330.9594680322334, + 1398.6252510221675, + 1403.3046170370653, + 1252.3579199332744, + 1248.6419779015705, + 1235.2563559543341, + 1639.7459730505943, + 1629.7535400371999, + 1400.728255044669, + 1332.566024037078 + ], + "n_ms": 10.0, + "min_ms": 1235.2563559543341, + "avg_ms": 1387.1939382050186, + "median_ms": 1365.5956375296228, + "p90_ms": 1629.7535400371999, + "p95_ms": 1639.7459730505943, + "p99_ms": 1639.7459730505943, + "max_ms": 1639.7459730505943 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 68.88843094930053, + 78.3604410244152, + 78.01781396847218, + 72.06234894692898, + 72.52011599484831, + 73.72508093249053, + 64.87809692043811, + 69.0728749614209, + 97.45409805327654, + 90.78932600095868 + ], + "n_ms": 10.0, + "min_ms": 64.87809692043811, + "avg_ms": 76.576862775255, + "median_ms": 73.12259846366942, + "p90_ms": 90.78932600095868, + "p95_ms": 97.45409805327654, + "p99_ms": 97.45409805327654, + "max_ms": 97.45409805327654 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": false, + "samples_ms": [ + 75.46699501108378, + 58.07583301793784, + 62.140439986251295, + 74.83123405836523, + 57.96935607213527, + 70.67135605029762, + 62.06154392566532, + 72.16213690117002, + 65.47302007675171, + 67.81659903936088 + ], + "n_ms": 10.0, + "min_ms": 57.96935607213527, + "avg_ms": 66.6668514139019, + "median_ms": 66.6448095580563, + "p90_ms": 74.83123405836523, + "p95_ms": 75.46699501108378, + "p99_ms": 75.46699501108378, + "max_ms": 75.46699501108378 + }, + { + "engine": "classic", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 2271.074099931866, + 1628.9673490682617, + 1180.6232570670545, + 1546.2298300117254, + 1129.7054740134627, + 1003.3381689572707, + 1103.009557002224, + 1139.2105700215325, + 1072.3191909492016, + 910.3259799303487 + ], + "n_ms": 10.0, + "min_ms": 910.3259799303487, + "avg_ms": 1298.4803476952948, + "median_ms": 1134.4580220174976, + "p90_ms": 1628.9673490682617, + "p95_ms": 2271.074099931866, + "p99_ms": 2271.074099931866, + "max_ms": 2271.074099931866 + }, + { + "engine": "control_mode", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 771.0779230110347, + 826.4453769661486, + 858.2343220477924, + 749.7300069080666, + 872.2627089591697, + 856.7365389317274, + 970.8761879010126, + 1058.184701949358, + 969.4039679598063, + 1015.9691049484536 + ], + "n_ms": 10.0, + "min_ms": 749.7300069080666, + "avg_ms": 894.892083958257, + "median_ms": 865.248515503481, + "p90_ms": 1015.9691049484536, + "p95_ms": 1058.184701949358, + "p99_ms": 1058.184701949358, + "max_ms": 1058.184701949358 + }, + { + "engine": "pipelined", + "shape": "1x4", + "panes": 4, + "wait": true, + "samples_ms": [ + 924.671886023134, + 957.4840500717983, + 970.9887669887394, + 717.6021320046857, + 1027.1775299916044, + 1078.7920550210401, + 1047.6982130203396, + 1131.4134739805013, + 1078.2063950318843, + 981.4246169989929 + ], + "n_ms": 10.0, + "min_ms": 717.6021320046857, + "avg_ms": 991.545911913272, + "median_ms": 1004.3010734952986, + "p90_ms": 1078.7920550210401, + "p95_ms": 1131.4134739805013, + "p99_ms": 1131.4134739805013, + "max_ms": 1131.4134739805013 + }, + { + "engine": "classic", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2688.125988934189, + 3421.498993993737, + 2721.125219017267, + 3680.0719080492854, + 3053.872239892371, + 3266.9221319956705, + 3363.687581033446, + 3229.252888006158, + 3476.997076999396, + 3238.5111950570717 + ], + "n_ms": 10.0, + "min_ms": 2688.125988934189, + "avg_ms": 3214.006522297859, + "median_ms": 3252.716663526371, + "p90_ms": 3476.997076999396, + "p95_ms": 3680.0719080492854, + "p99_ms": 3680.0719080492854, + "max_ms": 3680.0719080492854 + }, + { + "engine": "control_mode", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2327.0793380215764, + 2169.6944349678233, + 2290.0202609598637, + 2193.4810240054503, + 2250.2999720163643, + 2082.6522540301085, + 2308.945218101144, + 2176.221609930508, + 2195.5926649970934, + 2136.908065993339 + ], + "n_ms": 10.0, + "min_ms": 2082.6522540301085, + "avg_ms": 2213.089484302327, + "median_ms": 2194.536844501272, + "p90_ms": 2308.945218101144, + "p95_ms": 2327.0793380215764, + "p99_ms": 2327.0793380215764, + "max_ms": 2327.0793380215764 + }, + { + "engine": "pipelined", + "shape": "5x4", + "panes": 20, + "wait": true, + "samples_ms": [ + 2107.4311519041657, + 2110.2887169690803, + 2249.8882319778204, + 2227.454266976565, + 2197.3950610263273, + 2126.2204200029373, + 2120.3758959891275, + 2134.5664139371365, + 2097.358933999203, + 2115.1353849563748 + ], + "n_ms": 10.0, + "min_ms": 2097.358933999203, + "avg_ms": 2148.611447773874, + "median_ms": 2123.2981579960324, + "p90_ms": 2227.454266976565, + "p95_ms": 2249.8882319778204, + "p99_ms": 2249.8882319778204, + "max_ms": 2249.8882319778204 + } +] \ No newline at end of file diff --git a/scripts/bench_engines.py b/scripts/bench_engines.py new file mode 100755 index 000000000..b2d56292c --- /dev/null +++ b/scripts/bench_engines.py @@ -0,0 +1,1167 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["rich>=13", "typer>=0.12", "libtmux"] +# +# [tool.uv.sources] +# libtmux = { path = "..", editable = true } +# /// + +# ``typer`` is a PEP 723 inline dependency, resolved only inside ``uv run``'s +# ephemeral venv; the repo's mypy environment can't import it, which also makes +# its command decorators look untyped. Suppress just those two environment +# artifacts -- every other check (including the ImsgForServer narrowing below) +# stays strict. +# mypy: disable-error-code="import-not-found, untyped-decorator" +"""Hermetic libtmux engine build-benchmark grid. + +Measures how long the experimental workspace builder (and the classic API, and a +hand-rolled pipelined prototype) take to build tmux session structures, sweeping +scenarios x engines x wait-modes. Reports min/avg/median/max/p90/p95/p99. + +Hermetic & sandboxed: every server runs on its OWN socket under a throwaway +mkdtemp dir; ``TMUX`` is unset at import so the ambient session is never touched; +an ``atexit`` hook kills every spawned server (and any orphan on our sockets) and +removes the dir. The default server is never contacted. + +Engines (``--engines``): + classic classic libtmux Server/Session/Window/Pane API (subprocess) + subprocess builder on SubprocessEngine (one tmux fork per op) + control_mode builder on ControlModeEngine (one persistent ``tmux -C``) + imsg builder on ImsgEngine (AF_UNIX imsg, socket-injected) + mock builder on MockEngine (offline, in-memory: Python floor) + pipelined prototype: batch independent creates via run_batch (control_mode) + +Timing (``run`` = in-process build-only, the clean signal; ``--hyperfine`` also +runs whole-process wall time via hyperfine over the ``cell`` subcommand). + +The ``matrix`` subcommand isolates *why* a build costs what it does, sweeping a +four-axis factorial -- async {sync, async} x transport {subprocess, +control_mode} x planner {imperative, plan-seq, plan-fold} x workspace +{hand plan, declarative} -- as five expression layers x 2 transports x 2 modes, +against a ``classic`` reference. ``mock`` is not benchmarked here: it is the +offline correctness oracle, and ``matrix --check`` / ``contract`` assert every +layer x mode renders identical tmux argv to it, so the grid doubles as an +ops-language contract test. ``concurrency`` measures async's real lever -- K +independent builds sync-serial vs ``asyncio.gather`` over one connection. + +Run: uv run bench_engines.py run + uv run bench_engines.py run --engines control_mode,pipelined --wait + uv run bench_engines.py profile --engine control_mode --shape 8x4 + uv run bench_engines.py cell control_mode 8x4 # one build (for hyperfine) + uv run bench_engines.py matrix --shapes 1x4,3x3,5x4 + uv run bench_engines.py concurrency --transport control_mode --k 4 + uv run bench_engines.py contract # parity only, for CI +""" + +from __future__ import annotations + +import asyncio +import atexit +import contextlib +import cProfile +import dataclasses +import io +import itertools +import json +import math +import os +import pathlib +import pstats +import shutil +import statistics +import subprocess +import tempfile +import time +import typing as t +import uuid + +# Never inherit the ambient tmux session -- do this BEFORE importing libtmux. +os.environ.pop("TMUX", None) +os.environ.pop("TMUX_PANE", None) + +import rich.console +import rich.table +import typer + +from libtmux.experimental.engines import ( + AsyncControlModeEngine, + AsyncMockEngine, + AsyncSubprocessEngine, + ControlModeEngine, + ImsgEngine, + MockEngine, + SubprocessEngine, +) +from libtmux.experimental.engines.base import CommandRequest +from libtmux.experimental.ops import ( + FoldingPlanner, + LazyPlan, + NewSession, + NewWindow, + Planner, + RenameWindow, + SequentialPlanner, + SplitWindow, + arun as op_arun, + run as op_run, +) +from libtmux.experimental.ops._types import PaneId, SessionId, SlotRef, WindowId +from libtmux.experimental.workspace import Pane, Window, Workspace +from libtmux.server import Server + +console = rich.console.Console() +# Wide console for the factorial tables: their per-cell labels (layer · transport +# · mode) overflow an 80-col pipe, so render them at a fixed width so each row +# stays on one line under redirection. +wide_console = rich.console.Console(width=132) +R = CommandRequest.from_args +_ctr = itertools.count() +STAT_LABELS = ("n", "min", "avg", "median", "p90", "p95", "p99", "max") + +# --------------------------------------------------------------------------- # +# Hermetic isolation # +# --------------------------------------------------------------------------- # +_SOCK_DIR = pathlib.Path( + tempfile.mkdtemp(prefix="ltbench-") +) # short: /tmp/ltbench-XXXX +_SERVERS: list[Server] = [] +#: A session every bench server keeps for its whole life, so killing a cell's +#: session never drops the server to zero and trips tmux's exit-empty teardown. +_KEEPALIVE = "keepalive" + + +def new_server() -> Server: + """Return a fresh isolated server on a unique socket under the scratch dir. + + The server is pinned alive by a keepalive session. Every cell kills its + session between builds, which would otherwise drop the server to zero + sessions; under tmux's ``exit-empty`` default the server then starts + exiting, and the next build's ``new-session`` can reach the still-bound + socket mid-shutdown and fail with "server exited unexpectedly". The race is + load-dependent, so it surfaced as an intermittent create failure rather than + an obvious teardown bug. Control mode never hit it -- its ``tmux -C`` + phantom session already pinned the server -- which is exactly why only the + subprocess cells were affected. + """ + srv = Server(socket_path=str(_SOCK_DIR / f"{uuid.uuid4().hex[:8]}.sock")) + _SERVERS.append(srv) + # The keepalive has to come first: `start-server` alone leaves a server with + # zero sessions, which exits immediately under the default, so there is no + # server left to set the option on. Creating a session that is never killed + # is what actually holds the floor above zero. + srv.cmd("new-session", "-d", "-s", _KEEPALIVE) + srv.cmd("set-option", "-s", "exit-empty", "off") + return srv + + +def _cleanup() -> None: + for srv in _SERVERS: + with contextlib.suppress(Exception): + srv.kill() + # Backstop: SIGKILL any tmux server still bound to a socket in our dir. + with contextlib.suppress(Exception): + out = subprocess.run( + ["pgrep", "-f", f"tmux .*-S{_SOCK_DIR}/"], + capture_output=True, + text=True, + check=False, + ).stdout.split() + for pid in out: + with contextlib.suppress(Exception): + os.kill(int(pid), 9) + with contextlib.suppress(Exception): + shutil.rmtree(_SOCK_DIR, ignore_errors=True) + + +def _reap_stale_scratch() -> None: + """Remove scratch dirs left behind by runs that died before their cleanup. + + :func:`_cleanup` only knows *this* process's socket dir, so a run killed + before its ``atexit`` hook leaves its dir -- and any tmux still bound to + it -- behind for good. Those survivors keep consuming CPU and file + descriptors, and machine load is precisely what makes the server-teardown + race fire, so an unreaped leak feeds the very failure it came from. + + A dir with a live tmux is left alone: it may belong to a concurrent run, and + stealing another run's servers would be worse than leaking. That means hung + clients are only reclaimed once their server is gone, which is the + conservative trade. + """ + reaped = 0 + for path in pathlib.Path(tempfile.gettempdir()).glob("ltbench-*"): + if path == _SOCK_DIR or not path.is_dir(): + continue + with contextlib.suppress(Exception): + alive = subprocess.run( + ["pgrep", "-f", f"tmux .*-S{path}/"], + capture_output=True, + text=True, + check=False, + ).stdout.split() + if alive: + continue + shutil.rmtree(path, ignore_errors=True) + reaped += 1 + if reaped: + console.print(f"[dim]reaped {reaped} stale bench scratch dir(s)[/dim]") + + +atexit.register(_cleanup) +_reap_stale_scratch() + + +def uniq() -> str: + """Return a process-unique session name (never collides across builds).""" + return f"b{next(_ctr)}" + + +# --------------------------------------------------------------------------- # +# Scenario spec + build implementations # +# --------------------------------------------------------------------------- # +def parse_shape(s: str) -> tuple[int, int]: + """'8x4' -> (8 windows, 4 panes-per-window).""" + w, _, p = s.lower().partition("x") + return int(w), int(p) + + +def spec(name: str, wins: int, panes: int) -> Workspace: + """Build the declarative Workspace IR for *wins* windows x *panes* panes.""" + return Workspace( + name=name, + on_exists="replace", + windows=[ + Window(name=f"w{w}", panes=[Pane() for _ in range(panes)]) + for w in range(wins) + ], + ) + + +def build_classic(server: Server, name: str, wins: int, panes: int) -> None: + """Build the structure with the classic Server/Session/Window/Pane API.""" + session = server.new_session(session_name=name, window_name="w0") + for _ in range(panes - 1): + session.active_window.split() + for wi in range(1, wins): + window = session.new_window(window_name=f"w{wi}") + for _ in range(panes - 1): + window.split() + + +def build_pipelined(engine: t.Any, name: str, wins: int, panes: int) -> None: + """Prototype: batch INDEPENDENT creates into few run_batch round-trips. + + new-session (1) + all new-windows in one run_batch (1) + all splits in one + run_batch (1) = 3 round-trips for any shape, vs ~1-per-op for the builder. + The control-mode run_batch pipelines (write all, read all reply blocks). + """ + engine.run(R("new-session", "-d", "-s", name, "-n", "w0")) + if wins > 1: + engine.run_batch( + [R("new-window", "-t", name, "-n", f"w{i}") for i in range(1, wins)] + ) + splits = [ + R("split-window", "-t", f"{name}:w{i}") + for i in range(wins) + for _ in range(panes - 1) + ] + if splits: + engine.run_batch(splits) + + +class ImsgForServer: + """Bind ImsgEngine to a specific server by injecting ``-S`` per call. + + ImsgEngine has no ``for_server`` -- it parses ``-L``/``-S`` from the command + args -- so this wrapper prepends the isolated socket flag to every request. + """ + + def __init__(self, server: Server | None) -> None: + assert server is not None + self._e = ImsgEngine() + self._flag = ( + f"-S{server.socket_path}" + if server.socket_path + else f"-L{server.socket_name}" + ) + + def run(self, req: CommandRequest) -> t.Any: + """Run one request with the socket flag injected.""" + return self._e.run(R(self._flag, *req.args)) + + def run_batch(self, reqs: t.Sequence[CommandRequest]) -> t.Any: + """Run a batch of requests, each with the socket flag injected.""" + return self._e.run_batch([R(self._flag, *r.args) for r in reqs]) + + def tmux_version(self) -> t.Any: + """Report the underlying imsg engine's tmux version.""" + return self._e.tmux_version() + + +@dataclasses.dataclass(frozen=True) +class Impl: + """One benchmarked implementation: how to make its engine and build.""" + + name: str + kind: str # classic | builder | pipelined | offline + make_engine: t.Callable[[Server | None], t.Any] | None = None + needs_preboot: bool = False + preflight: bool = True + + +IMPLS: dict[str, Impl] = { + "classic": Impl("classic", "classic"), + "subprocess": Impl( + "subprocess", "builder", lambda s: SubprocessEngine.for_server(s) + ), + "control_mode": Impl( + "control_mode", "builder", lambda s: ControlModeEngine.for_server(s) + ), + "imsg": Impl("imsg", "builder", lambda s: ImsgForServer(s), needs_preboot=True), + "mock": Impl("mock", "offline", lambda s: MockEngine(), preflight=False), + "pipelined": Impl( + "pipelined", "pipelined", lambda s: ControlModeEngine.for_server(s) + ), +} + + +def do_build( + impl: Impl, server: Server | None, engine: t.Any, name: str, w: int, p: int +) -> None: + """Dispatch one build of *w* x *p* to the right implementation path.""" + if impl.kind == "classic": + build_classic(server, name, w, p) # type: ignore[arg-type] + elif impl.kind == "pipelined": + build_pipelined(engine, name, w, p) + else: # builder / offline + spec(name, w, p).build(engine, preflight=impl.preflight) + + +def wait_ready( + server: Server, name: str, timeout: float = 2.0, interval: float = 0.015 +) -> None: + """Poll each pane until its shell has drawn something (a prompt) or timeout. + + Models the classic ``_wait_for_pane_ready`` cost -- the shell-readiness wait + that inflates 'realistic' build times. Engine-agnostic (reads via subprocess). + """ + ids = [ + x + for x in server.cmd("list-panes", "-s", "-t", name, "-F", "#{pane_id}").stdout + if x + ] + pending = set(ids) + deadline = time.monotonic() + timeout + while pending and time.monotonic() < deadline: + for pid in list(pending): + cap = server.cmd("capture-pane", "-p", "-t", pid).stdout + if any(line.strip() for line in cap): + pending.discard(pid) + if pending: + time.sleep(interval) + + +def run_cell( + impl: Impl, wins: int, panes: int, wait: bool, runs: int, warmup: int +) -> list[float]: + """Return per-build wall times (ms), in-process, with session cleanup.""" + if impl.kind == "offline": + engine = impl.make_engine(None) # type: ignore[misc] + for _ in range(warmup): + spec(uniq(), wins, panes).build(engine, preflight=False) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + spec(name, wins, panes).build(engine, preflight=False) + samples.append((time.perf_counter() - t0) * 1000) + return samples + + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + engine = impl.make_engine(server) if impl.make_engine else None + try: + for _ in range(warmup): + name = uniq() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + server.cmd("kill-session", "-t", name) + samples = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + do_build(impl, server, engine, name, wins, panes) + if wait: + wait_ready(server, name) + samples.append((time.perf_counter() - t0) * 1000) + server.cmd("kill-session", "-t", name) # untimed cleanup -> no accumulation + return samples + finally: + with contextlib.suppress(Exception): + server.kill() + + +# --------------------------------------------------------------------------- # +# Stats (nearest-rank percentiles, like agentgrep's benchmark) # +# --------------------------------------------------------------------------- # +def percentile(sorted_vals: list[float], pct: float) -> float: + """Nearest-rank percentile of a pre-sorted sequence.""" + if not sorted_vals: + return float("nan") + rank = max(1, math.ceil(pct / 100.0 * len(sorted_vals))) + return sorted_vals[min(rank, len(sorted_vals)) - 1] + + +def summarize(samples: list[float]) -> dict[str, float]: + """Return min/avg/median/p90/p95/p99/max (and n) for *samples*.""" + s = sorted(samples) + return { + "n": float(len(s)), + "min": s[0], + "avg": statistics.fmean(s), + "median": statistics.median(s), + "p90": percentile(s, 90), + "p95": percentile(s, 95), + "p99": percentile(s, 99), + "max": s[-1], + } + + +# --------------------------------------------------------------------------- # +# Factorial matrix -- axes as registries, the grid via itertools.product # +# --------------------------------------------------------------------------- # +# Three orthogonal axes, each its own small registry. The matrix is *generated* +# by product() over them, never enumerated by hand -- adding an axis value (a new +# transport, a new expression layer) is one registry entry, and every cell flows +# through one generic timing core. +# +# MODES sync | async -- which run-strategy drives a build +# TRANSPORTS subprocess | control_mode -- each supplies a sync + async engine +# LAYERS imperative | plan-seq | plan-fold | ws-seq | ws-fold +# +# The five LAYERS are the valid *expression* layers: a declarative Workspace +# compiles to a LazyPlan, so ws-* and plan-* share one op spine, and folding is a +# planner swap. `mock` is NOT a layer -- it is the offline correctness oracle for +# the parity contract (`check_parity`), never a results row. +# +# Sync/async is unified without contaminating either path with the other's +# machinery: the plan/ws layers differ only by method name (`execute`/`aexecute`, +# `build`/`abuild`), and the imperative layer is a single sans-I/O generator of +# ops -- a sync pump drives it with `run`, an async pump with `arun`. That is the +# same colored-leaf split the library's own ``LazyPlan._drive`` uses. + +MODES: tuple[str, ...] = ("sync", "async") + + +def _imperative_ops( + name: str, wins: int, panes: int +) -> t.Generator[t.Any, t.Any, None]: + """Yield the WxP build as typed ops, reading each created id off its result. + + A sans-I/O generator: it ``yield``s an operation and is ``.send()`` the typed + result, from which it reads the concrete id to target the next op. Written + ONCE; :func:`_pump_sync` drives it with :func:`op_run`, :func:`_pump_async` + with :func:`op_arun`. The emitted op sequence mirrors the workspace compiler + exactly, so it renders argv-identical to every other layer under mock. + """ + result = yield NewSession(session_name=name, capture_panes=True) + session_id, window0_id, pane0_id = ( + result.new_id, + result.first_window_id, + result.first_pane_id, + ) + yield RenameWindow(target=WindowId(window0_id), name="w0") + prev = pane0_id + for _ in range(panes - 1): + result = yield SplitWindow(target=PaneId(prev)) + prev = result.new_pane_id + for wi in range(1, wins): + result = yield NewWindow( + target=SessionId(session_id), name=f"w{wi}", capture_pane=True + ) + prev = result.first_pane_id + for _ in range(panes - 1): + result = yield SplitWindow(target=PaneId(prev)) + prev = result.new_pane_id + + +def _pump_sync(gen: t.Generator[t.Any, t.Any, None], engine: t.Any) -> None: + """Drive an op-generator synchronously (``run`` one op, send its result back).""" + try: + op = next(gen) + while True: + op = gen.send(op_run(op, engine)) + except StopIteration: + pass + + +async def _pump_async(gen: t.Generator[t.Any, t.Any, None], engine: t.Any) -> None: + """Async twin of :func:`_pump_sync` (``await arun`` per op).""" + try: + op = next(gen) + while True: + op = gen.send(await op_arun(op, engine)) + except StopIteration: + pass + + +def _hand_plan(name: str, wins: int, panes: int) -> LazyPlan: + """Hand-author the WxP build as a ``LazyPlan`` with forward SlotRef targets. + + Mirrors :func:`_imperative_ops` (and the workspace compiler) op-for-op, but + records refs instead of resolving ids eagerly, so a planner can fold or + sequence the dispatch. + """ + plan = LazyPlan() + session = plan.add(NewSession(session_name=name, capture_panes=True)) + plan.add(RenameWindow(target=session.window, name="w0")) + prev: SlotRef = session.pane + for _ in range(panes - 1): + prev = plan.add(SplitWindow(target=prev)) + for wi in range(1, wins): + window = plan.add(NewWindow(target=session, name=f"w{wi}", capture_pane=True)) + prev = window.pane + for _ in range(panes - 1): + prev = plan.add(SplitWindow(target=prev)) + return plan + + +@dataclasses.dataclass(frozen=True) +class Layer: + """One expression layer: how a build is *authored* (not how it is dispatched). + + ``kind`` selects the execution shape (``imperative`` drives the generator; + ``plan`` executes a hand ``LazyPlan``; ``ws`` builds the declarative + :class:`~libtmux.experimental.workspace.Workspace` IR). ``planner`` is the + dispatch policy for the plan/ws kinds (``None`` for imperative). + """ + + name: str + kind: str # imperative | plan | ws + planner: Planner | None = None + + +LAYERS: dict[str, Layer] = { + "imperative": Layer("imperative", "imperative"), + "plan-seq": Layer("plan-seq", "plan", SequentialPlanner()), + "plan-fold": Layer("plan-fold", "plan", FoldingPlanner()), + "ws-seq": Layer("ws-seq", "ws", SequentialPlanner()), + "ws-fold": Layer("ws-fold", "ws", FoldingPlanner()), +} + + +def build_sync(layer: Layer, engine: t.Any, name: str, wins: int, panes: int) -> None: + """Execute one WxP build for *layer* over a synchronous *engine*.""" + if layer.kind == "imperative": + _pump_sync(_imperative_ops(name, wins, panes), engine) + elif layer.kind == "plan": + _hand_plan(name, wins, panes).execute(engine, planner=layer.planner) + else: # ws + spec(name, wins, panes).build(engine, preflight=False, planner=layer.planner) + + +async def build_async( + layer: Layer, engine: t.Any, name: str, wins: int, panes: int +) -> None: + """Execute one WxP build for *layer* over an asynchronous *engine*.""" + if layer.kind == "imperative": + await _pump_async(_imperative_ops(name, wins, panes), engine) + elif layer.kind == "plan": + await _hand_plan(name, wins, panes).aexecute(engine, planner=layer.planner) + else: # ws + await spec(name, wins, panes).abuild( + engine, preflight=False, planner=layer.planner + ) + + +@dataclasses.dataclass(frozen=True) +class Transport: + """One transport axis value: a sync engine factory and an async one.""" + + name: str + make_sync: t.Callable[[Server], t.Any] + make_async: t.Callable[[Server], t.Any] + + +TRANSPORTS: dict[str, Transport] = { + "subprocess": Transport( + "subprocess", + lambda s: SubprocessEngine.for_server(s), + lambda s: AsyncSubprocessEngine.for_server(s), + ), + "control_mode": Transport( + "control_mode", + lambda s: ControlModeEngine.for_server(s), + lambda s: AsyncControlModeEngine.for_server(s), + ), +} + + +@contextlib.asynccontextmanager +async def _open_async(transport: Transport, server: Server) -> t.AsyncIterator[t.Any]: + """Yield an async engine, honoring an async context manager when it is one. + + ``AsyncControlModeEngine`` holds a persistent connection (opened on enter, + closed on exit); ``AsyncSubprocessEngine`` is per-call and needs no teardown. + """ + engine = transport.make_async(server) + if hasattr(engine, "__aenter__"): + async with engine as opened: + yield opened + else: + yield engine + + +def _sync_cell( + transport: Transport, + layer: Layer, + server: Server, + wins: int, + panes: int, + runs: int, + warmup: int, +) -> list[float]: + """Time *runs* synchronous builds of one cell (untimed kill-session cleanup).""" + engine = transport.make_sync(server) + for _ in range(warmup): + name = uniq() + build_sync(layer, engine, name, wins, panes) + server.cmd("kill-session", "-t", name) + samples: list[float] = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + build_sync(layer, engine, name, wins, panes) + samples.append((time.perf_counter() - t0) * 1000) + server.cmd("kill-session", "-t", name) + return samples + + +async def _akill_session(server: Server, name: str) -> None: + """Kill *name* without blocking the event loop. + + ``server.cmd`` shells out synchronously. Called straight from a coroutine it + stalls the loop mid-cell, and a blocking subprocess issued from inside a + running loop measurably raises the rate of failed ``new-session`` calls, so + the cleanup is offloaded to a thread. + """ + await asyncio.to_thread(server.cmd, "kill-session", "-t", name) + + +async def _async_cell( + transport: Transport, + layer: Layer, + server: Server, + wins: int, + panes: int, + runs: int, + warmup: int, +) -> list[float]: + """Async twin of :func:`_sync_cell`, over one persistent async connection.""" + async with _open_async(transport, server) as engine: + for _ in range(warmup): + name = uniq() + await build_async(layer, engine, name, wins, panes) + await _akill_session(server, name) + samples: list[float] = [] + for _ in range(runs): + name = uniq() + t0 = time.perf_counter() + await build_async(layer, engine, name, wins, panes) + samples.append((time.perf_counter() - t0) * 1000) + await _akill_session(server, name) + return samples + + +def matrix_cell( + transport: Transport, + mode: str, + layer: Layer, + wins: int, + panes: int, + runs: int, + warmup: int, +) -> list[float]: + """Time one generated cell (transport x mode x layer) on a fresh server.""" + server = new_server() + try: + if mode == "sync": + return _sync_cell(transport, layer, server, wins, panes, runs, warmup) + return asyncio.run( + _async_cell(transport, layer, server, wins, panes, runs, warmup) + ) + finally: + with contextlib.suppress(Exception): + server.kill() + + +# --------------------------------------------------------------------------- # +# Mock-parity contract: every layer x mode renders the SAME argv as mock # +# --------------------------------------------------------------------------- # +class _Recorder: + """Wrap a sync engine, recording each dispatched argv (the parity oracle tap).""" + + def __init__(self, inner: t.Any) -> None: + self.inner = inner + self.argv: list[tuple[str, ...]] = [] + + def run(self, request: CommandRequest) -> t.Any: + """Record and forward one request.""" + self.argv.append(tuple(request.args)) + return self.inner.run(request) + + def run_batch(self, requests: t.Sequence[CommandRequest]) -> t.Any: + """Record and forward a batch of requests.""" + self.argv.extend(tuple(r.args) for r in requests) + return self.inner.run_batch(requests) + + +class _AsyncRecorder: + """Async twin of :class:`_Recorder`.""" + + def __init__(self, inner: t.Any) -> None: + self.inner = inner + self.argv: list[tuple[str, ...]] = [] + + async def run(self, request: CommandRequest) -> t.Any: + """Record and forward one request.""" + self.argv.append(tuple(request.args)) + return await self.inner.run(request) + + async def run_batch(self, requests: t.Sequence[CommandRequest]) -> t.Any: + """Record and forward a batch of requests.""" + self.argv.extend(tuple(r.args) for r in requests) + return await self.inner.run_batch(requests) + + +def _record_sync( + layer: Layer, name: str, wins: int, panes: int +) -> list[tuple[str, ...]]: + """Render *layer* through the deterministic MockEngine, capturing its argv.""" + recorder = _Recorder(MockEngine()) + build_sync(layer, recorder, name, wins, panes) + return recorder.argv + + +async def _record_async( + layer: Layer, name: str, wins: int, panes: int +) -> list[tuple[str, ...]]: + """Async twin of :func:`_record_sync` (AsyncMockEngine).""" + recorder = _AsyncRecorder(AsyncMockEngine()) + await build_async(layer, recorder, name, wins, panes) + return recorder.argv + + +def check_parity( + wins: int, panes: int +) -> tuple[list[tuple[str, ...]], list[tuple[str, bool]]]: + """Assert every layer x mode renders the mock oracle's argv for WxP. + + Mock is deterministic, so all five expression layers -- driven through it via + both the sync and async paths -- must emit the identical op argv sequence. + The oracle is the declarative ws-seq rendering (one argv per op); returns the + oracle plus a ``(label, agrees)`` row per layer x mode. + """ + name = "parity" + oracle = _record_sync(LAYERS["ws-seq"], name, wins, panes) + rows: list[tuple[str, bool]] = [] + for layer_name, layer in LAYERS.items(): + rows.append( + (f"{layer_name}/sync", _record_sync(layer, name, wins, panes) == oracle) + ) + rows.append( + ( + f"{layer_name}/async", + asyncio.run(_record_async(layer, name, wins, panes)) == oracle, + ) + ) + return oracle, rows + + +# --------------------------------------------------------------------------- # +# CLI # +# --------------------------------------------------------------------------- # +app = typer.Typer(add_completion=False, help=__doc__) + + +@app.command() +def run( + shapes: str = typer.Option("1x1,1x4,3x3,5x4,8x4", help="comma WxP shapes"), + engines: str = typer.Option( + "classic,subprocess,control_mode,imsg,mock,pipelined", + help="comma engine names", + ), + wait: bool = typer.Option(False, help="ALSO measure with shell-readiness wait"), + runs: int = typer.Option(20, help="timed builds per cell"), + warmup: int = typer.Option(3, help="warmup builds per cell"), + json_out: str = typer.Option("", help="write full JSON results here"), +) -> None: + """In-process build-only benchmark grid (the clean signal).""" + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + engine_list = [e for e in engines.split(",") if e in IMPLS] + wait_modes = [False, True] if wait else [False] + results: list[dict[str, t.Any]] = [] + + for wm in wait_modes: + for wins, panes in shape_list: + table = rich.table.Table( + title=f"[bold]{wins} win x {panes} pane ({wins * panes} panes)" + f"{' [wait]' if wm else ''} -- in-process build ms[/bold]" + ) + table.add_column("engine", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("vs classic", justify="right", style="green") + base_median = None + for name in engine_list: + impl = IMPLS[name] + if impl.kind == "offline" and wm: + continue # no real panes to wait on + samples = run_cell(impl, wins, panes, wm, runs, warmup) + st = summarize(samples) + if name == "classic": + base_median = st["median"] + speed = ( + f"{base_median / st['median']:.1f}x" + if base_median and st["median"] + else "-" + ) + table.add_row( + name, + f"{int(st['n'])}", + *[f"{st[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + results.append( + { + "engine": name, + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "wait": wm, + "samples_ms": samples, + **{f"{k}_ms": st[k] for k in STAT_LABELS}, + } + ) + console.print(table) + console.print() + + if json_out: + pathlib.Path(json_out).write_text(json.dumps(results, indent=2)) + console.print(f"[dim]wrote {json_out}[/dim]") + + +@app.command() +def cell(engine: str, shape: str, wait: bool = typer.Option(False)) -> None: + """Build ONE workspace of *shape* with *engine* (isolated). For hyperfine.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + if impl.kind == "offline": + spec(uniq(), wins, panes).build(impl.make_engine(None), preflight=False) # type: ignore[misc] + return + server = new_server() + if impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if wait: + wait_ready(server, name) + finally: + with contextlib.suppress(Exception): + server.kill() + + +@app.command() +def profile( + engine: str = typer.Option("control_mode"), + shape: str = typer.Option("8x4"), + builds: int = typer.Option(5), + top: int = typer.Option(18), +) -> None: + """Profile *builds* builds of *shape* with *engine*; print slowest by cumtime.""" + impl = IMPLS[engine] + wins, panes = parse_shape(shape) + server = None if impl.kind == "offline" else new_server() + if server is not None and impl.needs_preboot: + server.cmd("start-server") + eng = impl.make_engine(server) if impl.make_engine else None + try: + warm = uniq() + do_build(impl, server, eng, warm, wins, panes) # warmup + if server is not None: + server.cmd("kill-session", "-t", warm) + pr = cProfile.Profile() + pr.enable() + for _ in range(builds): + name = uniq() + do_build(impl, server, eng, name, wins, panes) + if server is not None: + server.cmd("kill-session", "-t", name) + pr.disable() + buf = io.StringIO() + pstats.Stats(pr, stream=buf).sort_stats("cumulative").print_stats(top) + console.print(f"[bold]profile: {engine} {shape} x{builds}[/bold]") + console.print(buf.getvalue()) + finally: + if server is not None: + with contextlib.suppress(Exception): + server.kill() + + +@app.command() +def matrix( + shapes: str = typer.Option("1x4", help="comma WxP shapes"), + layers: str = typer.Option(",".join(LAYERS), help="comma expression layers"), + transports: str = typer.Option(",".join(TRANSPORTS), help="comma transports"), + modes: str = typer.Option("sync,async", help="comma modes: sync,async"), + runs: int = typer.Option(10, help="timed builds per cell"), + warmup: int = typer.Option(2, help="warmup builds per cell"), + check: bool = typer.Option(True, help="run the mock-parity contract first"), + json_out: str = typer.Option("", help="write full JSON results here"), +) -> None: + """Factorial matrix: expression-layer x transport x mode, one table per shape. + + Rows are *generated* by ``product`` over the axis registries (never + hand-enumerated); ``classic`` is the reference row and ``mock`` never appears + (it is the parity oracle, run first when ``--check``). + """ + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + layer_list = [name for name in layers.split(",") if name in LAYERS] + transport_list = [name for name in transports.split(",") if name in TRANSPORTS] + mode_list = [name for name in modes.split(",") if name in MODES] + results: list[dict[str, t.Any]] = [] + + for wins, panes in shape_list: + if check: + _oracle, parity_rows = check_parity(wins, panes) + failed = [label for label, agrees in parity_rows if not agrees] + if failed: + console.print( + f"[bold red]mock-parity FAILED[/bold red] for {wins}x{panes}: " + f"{', '.join(failed)}" + ) + raise typer.Exit(1) + console.print( + f"[green]mock-parity OK[/green] -- {len(parity_rows)} layer x mode " + f"agree with mock argv for {wins}x{panes}" + ) + + classic_samples = run_cell(IMPLS["classic"], wins, panes, False, runs, warmup) + base_median = summarize(classic_samples)["median"] + + table = rich.table.Table( + title=f"[bold]{wins} win x {panes} pane ({wins * panes} panes)" + f" -- in-process build ms (async x transport x layer)[/bold]" + ) + table.add_column("cell", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("vs classic", justify="right", style="green") + + classic_stat = summarize(classic_samples) + table.add_row( + "classic (reference)", + f"{int(classic_stat['n'])}", + *[f"{classic_stat[k]:.1f}" for k in STAT_LABELS[1:]], + "1.0x", + ) + results.append( + { + "cell": "classic", + "layer": "classic", + "transport": "classic", + "mode": "sync", + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "samples_ms": classic_samples, + **{f"{k}_ms": classic_stat[k] for k in STAT_LABELS}, + } + ) + + for layer_name, transport_name, mode in itertools.product( + layer_list, transport_list, mode_list + ): + samples = matrix_cell( + TRANSPORTS[transport_name], + mode, + LAYERS[layer_name], + wins, + panes, + runs, + warmup, + ) + st = summarize(samples) + speed = ( + f"{base_median / st['median']:.1f}x" + if base_median and st["median"] + else "-" + ) + label = f"{layer_name} · {transport_name} · {mode}" + table.add_row( + label, + f"{int(st['n'])}", + *[f"{st[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + results.append( + { + "cell": label, + "layer": layer_name, + "transport": transport_name, + "mode": mode, + "shape": f"{wins}x{panes}", + "panes": wins * panes, + "samples_ms": samples, + **{f"{k}_ms": st[k] for k in STAT_LABELS}, + } + ) + + wide_console.print(table) + wide_console.print() + + if json_out: + pathlib.Path(json_out).write_text(json.dumps(results, indent=2)) + console.print(f"[dim]wrote {json_out}[/dim]") + + +def _serial_build( + transport: Transport, layer: Layer, server: Server, k: int, wins: int, panes: int +) -> float: + """Build *k* independent sessions serially over a sync engine; return ms.""" + engine = transport.make_sync(server) + names = [uniq() for _ in range(k)] + t0 = time.perf_counter() + for name in names: + build_sync(layer, engine, name, wins, panes) + elapsed = (time.perf_counter() - t0) * 1000 + for name in names: + server.cmd("kill-session", "-t", name) + return elapsed + + +async def _gather_build( + transport: Transport, layer: Layer, server: Server, k: int, wins: int, panes: int +) -> float: + """Build *k* independent sessions via ``asyncio.gather`` over one connection.""" + async with _open_async(transport, server) as engine: + names = [uniq() for _ in range(k)] + t0 = time.perf_counter() + await asyncio.gather( + *(build_async(layer, engine, name, wins, panes) for name in names) + ) + elapsed = (time.perf_counter() - t0) * 1000 + for name in names: + await _akill_session(server, name) + return elapsed + + +@app.command() +def contract( + shapes: str = typer.Option("1x1,1x4,2x2,3x3,5x4", help="comma WxP shapes"), +) -> None: + """Run the mock-parity contract standalone; exit non-zero on divergence. + + The benchmark's correctness oracle without the timing -- for CI, which can + assert the ops language stays consistent without paying for live builds. + Every expression layer, sync and async, must render the mock oracle's argv. + A negative control confirms the equality gate is not vacuous (the oracle is + non-empty and order-sensitive, so a dropped op would be caught). + """ + shape_list = [parse_shape(s) for s in shapes.split(",") if s] + failures: list[str] = [] + for wins, panes in shape_list: + oracle, rows = check_parity(wins, panes) + # Negative control: a non-empty oracle whose prefix differs from itself + # proves the `== oracle` check below can actually catch a dropped op. + if not oracle or oracle[:-1] == oracle: + failures.append(f"{wins}x{panes}: negative control vacuous (empty oracle)") + failures.extend( + f"{wins}x{panes}: {label} diverges from the mock oracle" + for label, agrees in rows + if not agrees + ) + if failures: + for problem in failures: + console.print(f"[red]FAIL[/red] {problem}") + raise typer.Exit(1) + console.print( + f"[green]contract OK[/green] -- every layer x {{sync,async}} renders the " + f"mock oracle's argv across {len(shape_list)} shape(s); negative control passed" + ) + + +@app.command() +def concurrency( + shape: str = typer.Option("1x4", help="WxP shape of each session"), + transport: str = typer.Option("control_mode", help="subprocess | control_mode"), + layer: str = typer.Option("ws-fold", help="expression layer"), + k: int = typer.Option(4, help="independent sessions to build"), + runs: int = typer.Option(5, help="timed repeats"), + warmup: int = typer.Option(1, help="warmup repeats"), +) -> None: + """Build K independent sessions: sync-serial vs async-gather wall time. + + Async should actually win here: one async connection pipelines K builds' + round-trips instead of blocking on each in turn. + """ + wins, panes = parse_shape(shape) + tp = TRANSPORTS[transport] + lay = LAYERS[layer] + + def timed_serial() -> float: + server = new_server() + try: + return _serial_build(tp, lay, server, k, wins, panes) + finally: + with contextlib.suppress(Exception): + server.kill() + + def timed_gather() -> float: + server = new_server() + try: + return asyncio.run(_gather_build(tp, lay, server, k, wins, panes)) + finally: + with contextlib.suppress(Exception): + server.kill() + + for _ in range(warmup): + timed_serial() + timed_gather() + serial = [timed_serial() for _ in range(runs)] + gather = [timed_gather() for _ in range(runs)] + + serial_stat = summarize(serial) + gather_stat = summarize(gather) + table = rich.table.Table( + title=f"[bold]K={k} x ({wins}x{panes}) sessions -- {transport} / {layer}" + f" -- wall ms[/bold]" + ) + table.add_column("strategy", style="cyan") + for label in STAT_LABELS: + table.add_column(label, justify="right") + table.add_column("speedup", justify="right", style="green") + table.add_row( + "sync-serial", + f"{int(serial_stat['n'])}", + *[f"{serial_stat[k]:.1f}" for k in STAT_LABELS[1:]], + "1.0x", + ) + speed = ( + f"{serial_stat['median'] / gather_stat['median']:.2f}x" + if gather_stat["median"] + else "-" + ) + table.add_row( + "async-gather", + f"{int(gather_stat['n'])}", + *[f"{gather_stat[k]:.1f}" for k in STAT_LABELS[1:]], + speed, + ) + wide_console.print(table) + + +if __name__ == "__main__": + app() diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py new file mode 100755 index 000000000..6f141b490 --- /dev/null +++ b/scripts/mcp_swap.py @@ -0,0 +1,1610 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["tomlkit>=0.13"] +# /// +"""Swap MCP server configs across Claude / Codex / Cursor / Gemini / Grok / agy. + +Use when you want every installed agent CLI to run a local checkout of an +MCP server (editable) instead of a pinned release. ``use-local`` rewrites +each CLI's config to invoke the checkout via ``uv --directory run +``; ``revert`` restores from the timestamped backup the swap wrote. +Swapping a layer that is already swapped keeps that first backup rather +than taking a new one, so ``revert`` always lands on the pre-swap config. + +Defaults are derived from the current repo's ``pyproject.toml``: + +- entry command = first key of ``[project.scripts]`` +- server name = that entry with a trailing ``-mcp`` stripped + (``libtmux-engine-mcp`` -> ``libtmux-engine``), falling back to + ``project.name`` when the entry has no ``-mcp`` suffix. Deriving the + slug from the entry (not ``project.name``) keeps this repo's server + key distinct from a sibling package whose ``project.name`` differs + from its console-script name. + +Examples +-------- +```console +$ uv run scripts/mcp_swap.py detect +$ uv run scripts/mcp_swap.py status +$ uv run scripts/mcp_swap.py use-local --dry-run +$ uv run scripts/mcp_swap.py use-local +$ uv run scripts/mcp_swap.py revert +``` + +Scope +----- +This script is best-effort and intentionally narrow: + +- **Global configs only.** Writes to ``~/.cursor/mcp.json``, + ``~/.claude.json``, ``~/.codex/config.toml``, + ``~/.gemini/settings.json``, ``~/.grok/config.toml`` (TOML + ``mcp_servers``, same shape as Codex), and + ``~/.gemini/config/mcp_config.json`` (agy / Antigravity CLI, JSON + ``mcpServers`` — the shared-config file the CLI reads, sibling to the + ``config.json`` it loads at startup). Workspace / project-local configs + (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, + per-project ``projects..mcpServers`` entries inside + ``~/.claude.json`` *are* recognised for Claude only) are NOT + walked — workspace files for Cursor/Gemini are silently ignored. + When workspace precedence matters, run the CLI's own + ``cursor mcp add ...`` / ``gemini mcp add ...`` directly. + +- **Claude scope.** ``use-local`` and ``revert`` accept + ``--scope {user,project}``. The default ``project`` writes the + per-project entry under ``projects[].mcpServers`` — + only the current repo's directory sees the swap, matching + pre-flag behaviour. ``--scope user`` writes Claude's top-level + ``mcpServers`` fallback so every project that has no per-project + override picks up the swap; useful when QA-ing a branch across + many directories. Codex, Cursor, Gemini, Grok, and agy have no per-project + layer in their config files; the flag is silently coerced to + ``user`` for them. Both Claude scopes can coexist with + independent backups; full ``revert`` unwinds in LIFO order. +- **Simple binary detection.** Probing is ``shutil.which()`` + plus ``.exists()``. Custom install locations + (Homebrew, npm prefixes, ``~/.npm-global/bin``, + ``~/.claude/local/claude``, ``~/.gemini/local/gemini``) are picked + up only if the binary is on ``PATH``. FastMCP's installer probes + these locations directly; this script does not. +- **Single config shape per CLI.** No fallback paths, no merge of + multiple sources. If your setup deviates from the defaults above, + use the CLI's native ``mcp`` subcommand instead. +- **Serialized mutations.** Concurrent ``use-local`` and ``revert`` + invocations share an advisory transaction lock, so config and recovery-state + updates cannot overwrite one another. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import difflib +import fcntl +import functools +import json +import os +import pathlib +import shutil +import sys +import tempfile +import time +import typing as t + +import tomlkit +import tomlkit.items + +CLIName = t.Literal["claude", "codex", "cursor", "gemini", "grok", "agy"] +ALL_CLIS: tuple[CLIName, ...] = ("claude", "codex", "cursor", "gemini", "grok", "agy") + +#: Claude config scope: ``"user"`` targets the user/system-level top-level +#: ``mcpServers`` fallback that applies to every project without its own +#: override; ``"project"`` targets the project-level per-project +#: ``projects..mcpServers`` node. Non-Claude CLIs have no +#: per-project scope in their config files, so for those CLIs the scope +#: is always normalised to ``"user"`` regardless of what was passed. +Scope = t.Literal["user", "project"] +ALL_SCOPES: tuple[Scope, ...] = ("user", "project") + + +def _normalize_scope(cli: CLIName, scope: Scope | None) -> Scope: + """Coerce ``scope`` to the value that actually applies to ``cli``. + + Non-Claude CLIs have no per-project config layer — every write to + them is necessarily user-level — so the flag is silently coerced to + ``"user"`` for those. For Claude, ``None`` defaults to ``"project"`` + to preserve pre-flag behaviour where the script always wrote the + per-project entry. + """ + if cli != "claude": + return "user" + return scope if scope is not None else "project" + + +def _state_key(cli: CLIName, scope: Scope) -> str: + """Compose the ``cli:scope`` key used inside the state file.""" + return f"{cli}:{scope}" + + +def _parse_state_key(key: str) -> tuple[CLIName, Scope] | None: + """Decode a ``cli:scope`` state key, returning ``None`` for malformed input. + + The script declares no compatibility contract for its state file — + schema is internal — so this only accepts the canonical + ``f"{cli}:{scope}"`` form. Hand-edited or unrecognised keys return + ``None`` so ``load_state`` can drop them without crashing. + """ + if ":" not in key: + return None + cli_str, _, scope_str = key.partition(":") + if cli_str in ALL_CLIS and scope_str in ALL_SCOPES: + return cli_str, scope_str + return None + + +def _parse_state_entry(v: dict[str, t.Any]) -> SwapEntry | None: + """Build a :class:`SwapEntry` from a raw state-file dict, or ``None``. + + Validates at the trust boundary so a hand-edited ``state.json`` can't + crash later code paths — particularly :func:`cmd_revert`'s LIFO sort, + which compares ``SwapEntry.seq_no`` and would raise ``TypeError`` on a + mixed ``int``/``str`` ordering. ``seq_no`` is coerced via ``int()``; + any ``KeyError`` (missing required field), ``ValueError`` (non-numeric + string), or ``TypeError`` (wrong shape, extra keys for the dataclass) + drops the entry silently. Same drop-on-malformed posture as + :func:`_parse_state_key`. + + Mirrors CPython's ``Lib/sched.py`` discipline: validate at the + counter's *origin* (``enterabs`` for sched, ``load_state`` here), not + at sort time. State-file schema is internal — no compatibility + contract — so silent drop is the right failure mode. + """ + try: + v = {**v, "seq_no": int(v["seq_no"])} + return SwapEntry(**v) + except (KeyError, TypeError, ValueError): + return None + + +def _xdg_state_home() -> pathlib.Path: + """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. + + Defaults to ``~/.local/state`` when the env var is unset or empty. + State is the right XDG bucket here (vs. cache / config / data): the + file is machine-written, must persist across runs so ``revert`` can + locate the right backup, but is not safely deletable like cache nor + user-edited like config. + """ + env = os.environ.get("XDG_STATE_HOME") + if env: + return pathlib.Path(env) + return pathlib.Path.home() / ".local" / "state" + + +# ``-dev`` suffix in the namespace makes it loud that this is dev-only +# tooling state, distinct from the runtime ``libtmux`` package and from +# any sibling ``libtmux-mcp-dev`` swap state. +STATE_DIR = _xdg_state_home() / "libtmux-engine-mcp-dev" / "swap" +STATE_FILE = STATE_DIR / "state.json" +STATE_LOCK_NAME = "transaction.lock" + +BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass(frozen=True) +class CLIInfo: + """Static descriptor for a CLI's config file and discovery heuristics.""" + + name: CLIName + binary: str + config_path: pathlib.Path + fmt: t.Literal["json", "toml"] + + +CLIS: dict[CLIName, CLIInfo] = { + "claude": CLIInfo( + name="claude", + binary="claude", + config_path=pathlib.Path.home() / ".claude.json", + fmt="json", + ), + "codex": CLIInfo( + name="codex", + binary="codex", + config_path=pathlib.Path.home() / ".codex" / "config.toml", + fmt="toml", + ), + "cursor": CLIInfo( + name="cursor", + binary="cursor-agent", + config_path=pathlib.Path.home() / ".cursor" / "mcp.json", + fmt="json", + ), + "gemini": CLIInfo( + name="gemini", + binary="gemini", + config_path=pathlib.Path.home() / ".gemini" / "settings.json", + fmt="json", + ), + "grok": CLIInfo( + name="grok", + binary="grok", + config_path=pathlib.Path.home() / ".grok" / "config.toml", + fmt="toml", + ), + # Antigravity (the ``agy`` CLI). Its MCP config is the standard JSON + # ``mcpServers`` shape (same as Cursor / Gemini). The CLI reads + # ``~/.gemini/config/mcp_config.json`` — its shared-config dir, + # sibling to the ``config.json`` it loads at startup. The file may + # start empty until a server is added; ``load_config`` tolerates a + # 0-byte JSON file as ``{}``. + "agy": CLIInfo( + name="agy", + binary="agy", + config_path=(pathlib.Path.home() / ".gemini" / "config" / "mcp_config.json"), + fmt="json", + ), +} + + +@dataclasses.dataclass +class McpServerSpec: + """The portable shape shared across CLI configs.""" + + command: str + args: list[str] = dataclasses.field(default_factory=list) + env: dict[str, str] = dataclasses.field(default_factory=dict) + + def to_json_dict(self, *, include_stdio_type: bool = False) -> dict[str, t.Any]: + """Serialize to the JSON shape (Claude-extended when ``include_stdio_type``).""" + # Claude's format always includes ``type`` and ``env`` (even when empty); + # Cursor/Gemini omit both. include_stdio_type selects Claude shape. + if include_stdio_type: + return { + "type": "stdio", + "command": self.command, + "args": list(self.args), + "env": dict(self.env), + } + out: dict[str, t.Any] = {"command": self.command, "args": list(self.args)} + if self.env: + out["env"] = dict(self.env) + return out + + def is_local_uv_directory(self) -> bool: + """Return True for a ``uv --directory run `` shape.""" + return ( + self.command == "uv" and "--directory" in self.args and "run" in self.args + ) + + def local_repo_path(self) -> pathlib.Path | None: + """Extract the ``--directory`` argument, if any.""" + try: + i = self.args.index("--directory") + except ValueError: + return None + if i + 1 >= len(self.args): + return None + return pathlib.Path(self.args[i + 1]) + + +@dataclasses.dataclass +class SwapEntry: + """One CLI's bookkeeping for a swap, written to the state file.""" + + config_path: str + backup_path: str + server: str + action: t.Literal["replaced", "added"] + #: ``YYYYMMDDHHMMSS`` registration timestamp, human-readable for + #: anyone inspecting ``state.json`` directly. Sort order is enforced + #: separately via :attr:`seq_no` so this field stays purely + #: descriptive. + swapped_at: str + #: Monotonic registration counter — the primary LIFO sort key for + #: ``cmd_revert``. ``cmd_use_local`` computes the next value as + #: ``max(existing seq_nos, default=-1) + 1`` so it strictly + #: increases per swap regardless of wall-clock collisions or dict + #: iteration order. Same explicit-counter pattern CPython's + #: ``Lib/sched.py`` uses to break ties on ``Event(time, priority, + #: sequence, …)``. + seq_no: int + + +# --------------------------------------------------------------------------- +# Config IO — per format +# --------------------------------------------------------------------------- + + +def load_config(info: CLIInfo) -> t.Any: + """Parse a CLI's config file (JSON or TOML) into an editable structure. + + Empty JSON files are treated as empty objects so first-run MCP configs can + be seeded with their initial server entry. + """ + raw = info.config_path.read_bytes() + if info.fmt == "json": + text = raw.decode().strip() + return json.loads(text) if text else {} + return tomlkit.parse(raw.decode()) + + +def dump_config_bytes(info: CLIInfo, config: t.Any) -> bytes: + """Serialize an edited config back to bytes in its original format.""" + if info.fmt == "json": + return (json.dumps(config, indent=2) + "\n").encode() + return tomlkit.dumps(config).encode() + + +def atomic_write(path: pathlib.Path, data: bytes) -> None: + """Write bytes to ``path`` via tempfile + ``os.replace`` to avoid partial writes.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + tmp = pathlib.Path(tmp_name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + tmp.replace(path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +def write_new_backup(base: pathlib.Path, data: bytes) -> pathlib.Path: + """Write a backup without overwriting an existing path. + + The timestamp in ``base`` has one-second precision, so claim each + candidate with exclusive creation and add a numeric suffix on collision. + + Parameters + ---------- + base : pathlib.Path + Preferred backup path. + data : bytes + Config bytes to preserve. + + Returns + ------- + pathlib.Path + Exclusively created backup path. + + Examples + -------- + >>> with tempfile.TemporaryDirectory() as tmp_dir: + ... base = pathlib.Path(tmp_dir) / "config.bak" + ... first = write_new_backup(base, b"first") + ... second = write_new_backup(base, b"second") + ... (first.name, second.name, first.read_bytes(), second.read_bytes()) + ('config.bak', 'config.bak-1', b'first', b'second') + """ + base.parent.mkdir(parents=True, exist_ok=True) + candidate = base + attempt = 0 + while True: + try: + fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + attempt += 1 + candidate = base.with_name(f"{base.name}-{attempt}") + continue + with os.fdopen(fd, "wb") as fh: + fh.write(data) + return candidate + + +# --------------------------------------------------------------------------- +# Per-CLI get / set / delete (the only CLI-specific logic) +# --------------------------------------------------------------------------- + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[True], +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_project_node( + config: dict[str, t.Any], + repo: pathlib.Path, + *, + create: t.Literal[False], +) -> dict[str, t.Any] | None: ... + + +def _claude_project_node( + config: dict[str, t.Any], repo: pathlib.Path, *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the ``projects.`` node Claude keys per-project. + + With ``create=True``, the node is unconditionally created if missing + and the return type is statically narrowed to ``dict[str, t.Any]``; + callers can drop runtime ``assert node is not None`` defensiveness. + With ``create=False``, the absence of the node is a real return value + and the type stays ``dict[str, t.Any] | None``. + + Raises ``RuntimeError`` if Claude's config layout is not the + expected ``projects..mcpServers`` mapping shape — the layout + is undocumented Claude Code internal state, so a clear error before + the atomic write beats a silent partial mutation that the backup + defense would be asked to recover from. + """ + key = str(repo.resolve()) + projects_node = config.get("projects") + if projects_node is not None and not isinstance(projects_node, dict): + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects' to be a mapping but got " + f"{type(projects_node).__name__}" + ) + raise RuntimeError(msg) + projects = ( + config.setdefault("projects", {}) if create else config.get("projects", {}) + ) + raw_node = projects.get(key) + node: dict[str, t.Any] | None = None + if isinstance(raw_node, dict): + node = raw_node + elif raw_node is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'projects[{key!r}]' to be a mapping but got " + f"{type(raw_node).__name__}" + ) + raise RuntimeError(msg) + if node is None and create: + node = {"allowedTools": [], "mcpContextUris": [], "mcpServers": {}, "env": {}} + projects[key] = node + return node + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[True] +) -> dict[str, t.Any]: ... + + +@t.overload +def _claude_user_servers( + config: dict[str, t.Any], *, create: t.Literal[False] +) -> dict[str, t.Any] | None: ... + + +def _claude_user_servers( + config: dict[str, t.Any], *, create: bool +) -> dict[str, t.Any] | None: + """Return (or create) the top-level ``mcpServers`` dict — Claude user scope. + + Mirrors :func:`_claude_project_node` for the user-scope path so the + shape guard is centralised once and reused across read / write / + delete instead of duplicated at each call site (or worse, missing + on read and delete the way the inline write-side guard left them). + Same reasoning applies as for the project-scope helper: Claude's + config shape is undocumented internal state, so a clear + ``RuntimeError`` before the atomic write beats an opaque + ``AttributeError`` from ``.setdefault()`` on a non-dict. + + With ``create=True`` the dict is initialised when missing and the + return type narrows to ``dict[str, t.Any]``. With ``create=False`` + a missing key returns ``None``. + """ + raw = config.get("mcpServers") + existing: dict[str, t.Any] | None = None + if isinstance(raw, dict): + existing = raw + elif raw is not None: + msg = ( + "Claude config layout appears to have changed; expected " + f"'mcpServers' to be a mapping but got " + f"{type(raw).__name__}" + ) + raise RuntimeError(msg) + if existing is None and create: + existing = {} + config["mcpServers"] = existing + return existing + + +def get_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> McpServerSpec | None: + """Fetch the MCP server entry for ``name`` from a CLI's config, if present. + + ``scope`` only affects Claude (see :data:`Scope` for the layered shape + of ``~/.claude.json``); for Codex / Cursor / Gemini the parameter is + accepted-but-ignored because their config has no per-project layer. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + entry = servers.get(name) if servers else None + else: + node = _claude_project_node(config, repo, create=False) + if not node: + return None + entry = node.get("mcpServers", {}).get(name) + elif cli in ("cursor", "gemini", "agy"): + entry = config.get("mcpServers", {}).get(name) + else: # cli in ("codex", "grok") — TOML "mcp_servers" table + entry = config.get("mcp_servers", {}).get(name) + if entry is None: + return None + return _spec_from_entry(entry, fmt=CLIS[cli].fmt) + + +def set_server( + cli: CLIName, + config: t.Any, + name: str, + spec: McpServerSpec, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> t.Literal["replaced", "added"]: + """Write ``spec`` under ``name`` in a CLI's config, returning replaced/added. + + ``scope == "user"`` for Claude writes the top-level ``mcpServers`` + fallback used by every project that has no per-project override; + ``"project"`` (the default, preserving pre-flag behaviour) writes + under ``projects[abs(repo)].mcpServers``. The parameter is silently + ignored for non-Claude CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=True) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + node = _claude_project_node(config, repo, create=True) + servers = node.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict(include_stdio_type=True) + return "replaced" if had else "added" + if cli in ("cursor", "gemini", "agy"): + servers = config.setdefault("mcpServers", {}) + had = name in servers + servers[name] = spec.to_json_dict() + return "replaced" if had else "added" + if cli in ("codex", "grok"): + # tomlkit: top-level tables are accessed via dict protocol too. + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + mcp_servers = tomlkit.table() + config["mcp_servers"] = mcp_servers + had = name in mcp_servers + table = tomlkit.table() + table["command"] = spec.command + table["args"] = list(spec.args) + if spec.env: + env_tbl = tomlkit.table() + for k, v in spec.env.items(): + env_tbl[k] = v + table["env"] = env_tbl + mcp_servers[name] = table + return "replaced" if had else "added" + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def delete_server( + cli: CLIName, + config: t.Any, + name: str, + repo: pathlib.Path, + *, + scope: Scope = "project", +) -> bool: + """Remove the entry for ``name`` from a CLI's config; return whether it existed. + + See :func:`set_server` for the meaning of ``scope`` — the parameter + is honoured for Claude and ignored for the other CLIs. + """ + if cli == "claude": + if scope == "user": + servers = _claude_user_servers(config, create=False) + if servers is not None and name in servers: + del servers[name] + return True + return False + node = _claude_project_node(config, repo, create=False) + if not node: + return False + servers = node.get("mcpServers", {}) + return servers.pop(name, None) is not None + if cli in ("cursor", "gemini", "agy"): + return config.get("mcpServers", {}).pop(name, None) is not None + if cli in ("codex", "grok"): + mcp_servers = config.get("mcp_servers") + if mcp_servers is None: + return False + if name in mcp_servers: + del mcp_servers[name] + return True + return False + msg = f"unreachable: unknown CLI {cli!r}" + raise AssertionError(msg) + + +def _spec_from_entry(entry: t.Any, *, fmt: t.Literal["json", "toml"]) -> McpServerSpec: + """Convert a raw config entry (dict or tomlkit Table) into an McpServerSpec.""" + # tomlkit items quack like dicts/lists; coerce to plain Python for our spec. + if fmt == "toml": + entry = ( + tomlkit.items.Table.unwrap(entry) + if isinstance(entry, tomlkit.items.Table) + else dict(entry) + ) + if not isinstance(entry, dict): + msg = f"expected server entry to be a mapping, got {type(entry).__name__}" + raise TypeError(msg) + command = str(entry.get("command", "")) + raw_args = entry.get("args", []) + args = [str(a) for a in raw_args] if raw_args else [] + raw_env = entry.get("env") or {} + env = {str(k): str(v) for k, v in dict(raw_env).items()} + return McpServerSpec(command=command, args=args, env=env) + + +# --------------------------------------------------------------------------- +# Repo metadata +# --------------------------------------------------------------------------- + + +def resolve_repo_meta(repo: pathlib.Path) -> tuple[str, str]: + """Derive (server_name, entry_command) from the repo's pyproject.toml. + + The server name is the registration slug used as the config-file key + (``mcpServers.`` in JSON, ``[mcp_servers.]`` in TOML). + Default: the first ``[project.scripts]`` entry with a trailing + ``-mcp`` stripped (``libtmux-engine-mcp`` → ``libtmux-engine``), + falling back to ``project.name`` when the entry has no ``-mcp`` + suffix. Deriving the slug from the entry rather than ``project.name`` + keeps this repo's server key (``libtmux-engine``) distinct from a + sibling package whose ``project.name`` is ``libtmux`` — both can be + registered side by side. Pass ``--server `` to override. + """ + pyproject = repo / "pyproject.toml" + doc = tomlkit.parse(pyproject.read_text()) + project = doc.get("project") + if project is None: + msg = f"{pyproject} has no [project] table" + raise RuntimeError(msg) + scripts = project.get("scripts") or {} + if not scripts: + msg = f"{pyproject} has no [project.scripts] — cannot derive entry" + raise RuntimeError(msg) + entry = next(iter(scripts)) + server = entry[: -len("-mcp")] if entry.endswith("-mcp") else str(project["name"]) + return server, entry + + +def build_local_spec(repo: pathlib.Path, entry: str) -> McpServerSpec: + """Build the ``uv --directory run `` spec used by ``use-local``.""" + return McpServerSpec( + command="uv", + args=["--directory", str(repo.resolve()), "run", entry], + ) + + +# --------------------------------------------------------------------------- +# State file +# --------------------------------------------------------------------------- + + +def load_state() -> dict[tuple[CLIName, Scope], SwapEntry]: + """Read the swap-state file, returning an empty mapping when absent. + + The state file's schema is internal — no compatibility contract — + so this loader requires its canonical top-level shape. Invalid JSON + and invalid containers raise ``RuntimeError`` so mutating commands + cannot overwrite recovery metadata they failed to understand. + Malformed individual keys and entries are dropped. + """ + if not STATE_FILE.exists(): + return {} + try: + raw = json.loads(STATE_FILE.read_text()) + except (UnicodeError, json.JSONDecodeError) as exc: + msg = f"recovery state is unreadable: {exc}" + raise RuntimeError(msg) from exc + if not isinstance(raw, dict): + msg = ( + "recovery state is unreadable: expected a mapping, got " + f"{type(raw).__name__}" + ) + raise TypeError(msg) + entries = raw.get("entries", {}) + if not isinstance(entries, dict): + msg = ( + "recovery state is unreadable: expected 'entries' to be a mapping, " + f"got {type(entries).__name__}" + ) + raise TypeError(msg) + out: dict[tuple[CLIName, Scope], SwapEntry] = {} + for k, v in entries.items(): + parsed = _parse_state_key(k) + if parsed is None: + continue + entry = _parse_state_entry(v) + if entry is None: + continue + out[parsed] = entry + return out + + +def save_state(entries: dict[tuple[CLIName, Scope], SwapEntry]) -> None: + """Write the swap-state file atomically.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "entries": { + _state_key(cli, scope): dataclasses.asdict(v) + for (cli, scope), v in entries.items() + }, + } + atomic_write(STATE_FILE, (json.dumps(payload, indent=2) + "\n").encode("utf-8")) + + +def clear_state(keys: t.Iterable[tuple[CLIName, Scope]]) -> None: + """Remove the given ``(cli, scope)`` keys; delete the file if empty.""" + current = load_state() + for key in keys: + current.pop(key, None) + if current: + save_state(current) + elif STATE_FILE.exists(): + STATE_FILE.unlink() + + +def _serialized_transaction( + command: t.Callable[[argparse.Namespace], int], +) -> t.Callable[[argparse.Namespace], int]: + """Serialize one config-and-recovery-state transaction across processes.""" + + @functools.wraps(command) + def locked(args: argparse.Namespace) -> int: + STATE_DIR.mkdir(parents=True, exist_ok=True) + with (STATE_DIR / STATE_LOCK_NAME).open("a+b") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + return command(args) + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + return locked + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Presence: + """Detection outcome for a CLI: binary on PATH and config file present.""" + + cli: CLIName + binary_found: bool + config_found: bool + + @property + def present(self) -> bool: + """Return True only when both the binary and the config file were found.""" + return self.binary_found and self.config_found + + +def detect_clis() -> list[Presence]: + """Probe all supported CLIs and return their detection results.""" + return [ + Presence( + cli=info.name, + binary_found=shutil.which(info.binary) is not None, + config_found=info.config_path.exists(), + ) + for info in CLIS.values() + ] + + +def present_clis() -> list[CLIName]: + """Return the list of CLIs that have both a binary and a config present.""" + return [p.cli for p in detect_clis() if p.present] + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +def cmd_detect(args: argparse.Namespace) -> int: + """Print detection results for every supported CLI.""" + for p in detect_clis(): + flag = "yes" if p.present else " no" + extra = [] + if not p.binary_found: + extra.append("binary missing") + if not p.config_found: + extra.append(f"config missing: {CLIS[p.cli].config_path}") + suffix = f" ({', '.join(extra)})" if extra else "" + print(f" [{flag}] {p.cli:<7}{suffix}") + return 0 + + +def cmd_status(args: argparse.Namespace) -> int: + """Print the current MCP server entry per detected CLI. + + For Claude, prints separate lines for the user-level fallback + (``[claude:user]``) and the per-project override + (``[claude:project]``) when both exist; if only one exists, only + that line shows. ``args.scope`` (when set) restricts Claude output + to the matching layer only. Other CLIs print a single line as + ``[]`` since their config has no scope concept and ignore + ``args.scope``. + """ + repo = pathlib.Path(args.repo).resolve() + server = args.server or resolve_repo_meta(repo)[0] + scope_filter: Scope | None = args.scope + had_error = 0 + for cli in args.cli or present_clis(): + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{cli}] (no config at {info.config_path})") + continue + # Wrap the read + shape-guarded queries in try/except RuntimeError + # so a malformed Claude config surfaces as a clean per-CLI error + # instead of aborting status output for the rest of the CLIs. + try: + config = load_config(info) + if cli == "claude": + # Lazy reads: skip the get_server call entirely for the + # filtered-out scope so a malformed projects node doesn't + # raise when the user only asked about user scope. + user_spec = ( + get_server(cli, config, server, repo, scope="user") + if scope_filter in (None, "user") + else None + ) + project_spec = ( + get_server(cli, config, server, repo, scope="project") + if scope_filter in (None, "project") + else None + ) + shown = False + if user_spec is not None: + tag = _describe_spec(user_spec, repo) + print( + f"[claude:user] {server} = {user_spec.command} " + f"{' '.join(user_spec.args)} ({tag})" + ) + shown = True + if project_spec is not None: + tag = _describe_spec(project_spec, repo) + print( + f"[claude:project] {server} = {project_spec.command} " + f"{' '.join(project_spec.args)} ({tag})" + ) + shown = True + if not shown: + label = f"claude:{scope_filter}" if scope_filter else "claude" + print(f"[{label}] no entry for {server!r}") + else: + spec = get_server(cli, config, server, repo) + if spec is None: + print(f"[{cli}] no entry for {server!r}") + continue + tag = _describe_spec(spec, repo) + print( + f"[{cli}] {server} = {spec.command} {' '.join(spec.args)} ({tag})" + ) + except (AttributeError, OSError, RuntimeError, TypeError, ValueError) as exc: + print(f"[{cli}] {exc}", file=sys.stderr) + had_error = 1 + continue + return had_error + + +def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: + """Return a short label classifying a spec (local/pypi-pin/other).""" + if spec.is_local_uv_directory(): + local = spec.local_repo_path() + if local and local.resolve() == repo.resolve(): + return "local: this repo" + return f"local: {local}" + if spec.command == "uvx": + pinned = next((a for a in spec.args if "==" in a or "@" in a), None) + return f"pypi pin: {pinned}" if pinned else "pypi (unpinned)" + return "other" + + +@_serialized_transaction +def cmd_use_local(args: argparse.Namespace) -> int: + """Rewrite each target CLI's config to run the repo's checkout via ``uv``. + + The optional ``--scope`` flag selects Claude's user-level fallback + vs. per-project override; see :data:`Scope`. The flag is silently + coerced to ``"user"`` for non-Claude CLIs by :func:`_normalize_scope`. + """ + repo = pathlib.Path(args.repo).resolve() + server, default_entry = resolve_repo_meta(repo) + server = args.server or server + entry = args.entry or default_entry + spec = build_local_spec(repo, entry) + extra_env = dict(args.env or []) + + hint = _naming_hint(repo, server) + if hint: + print(hint, file=sys.stderr) + + targets = args.cli or present_clis() + if not targets: + print("no CLIs detected — nothing to do", file=sys.stderr) + return 1 + + ts = time.strftime("%Y%m%d%H%M%S") + try: + state = load_state() + except (OSError, RuntimeError, TypeError) as exc: + print(f"recovery state error: {exc}; no config changed", file=sys.stderr) + return 1 + had_error = 0 + for cli in targets: + scope = _normalize_scope(cli, args.scope) + label = f"{cli}:{scope}" if cli == "claude" else cli + info = CLIS[cli] + if not info.config_path.exists(): + print(f"[{label}] skip — config not found at {info.config_path}") + continue + # Treat read, parse, and shape errors as per-CLI failures so one + # malformed target cannot strand earlier successful targets. + try: + original_bytes = info.config_path.read_bytes() + config = load_config(info) + current = get_server(cli, config, server, repo, scope=scope) + state_key = (cli, scope) + prior = state.get(state_key) + prior_backup = ( + pathlib.Path(prior.backup_path) if prior is not None else None + ) + if prior_backup is not None and not prior_backup.exists(): + print( + f"[{label}] recorded pre-swap backup is missing " + f"({prior_backup}); refusing to replace recovery state", + file=sys.stderr, + ) + had_error = 1 + continue + if ( + current + and current.command == spec.command + and current.args == spec.args + and all(current.env.get(k) == v for k, v in extra_env.items()) + ): + print(f"[{label}] already local (this repo) — no change") + continue + # Preserve the existing entry's env on replacement. ``build_local_spec`` + # writes an empty env, so without this merge a swap would silently drop + # client-side settings (LIBTMUX_SAFETY, LIBTMUX_SOCKET, custom dev + # knobs). Symmetric with ``_spec_from_entry`` which round-trips env on + # the read side. + base_env = dict(current.env) if current else {} + base_env.update(extra_env) + cli_spec = ( + dataclasses.replace(spec, env=base_env) + if (current or extra_env) + else spec + ) + action = set_server(cli, config, server, cli_spec, repo, scope=scope) + new_bytes = dump_config_bytes(info, config) + except (AttributeError, OSError, RuntimeError, TypeError, ValueError) as exc: + print(f"[{label}] {exc}", file=sys.stderr) + had_error = 1 + continue + + if args.dry_run: + print(f"--- {info.config_path} (current)") + print(f"+++ {info.config_path} (proposed)") + diff = difflib.unified_diff( + original_bytes.decode(errors="replace").splitlines(keepends=True), + new_bytes.decode(errors="replace").splitlines(keepends=True), + lineterm="", + ) + sys.stdout.writelines(diff) + continue + + # A repeated swap sees this script's earlier output, not the + # user's pristine config. Keep the first backup and its ordering + # metadata so revert still unwinds the layers it actually captured. + if prior is not None: + newer = sorted( + ( + (other_key, other_entry) + for other_key, other_entry in state.items() + if other_key != state_key + and other_entry.config_path == prior.config_path + and other_entry.seq_no > prior.seq_no + ), + key=lambda item: item[1].seq_no, + reverse=True, + ) + if newer: + newer_labels = ", ".join( + f"{new_cli}:{new_scope}" if new_cli == "claude" else new_cli + for (new_cli, new_scope), _entry in newer + ) + print( + f"[{label}] cannot update beneath newer whole-file layer(s) " + f"{newer_labels}; revert those layers first", + file=sys.stderr, + ) + had_error = 1 + continue + reused_prior_backup = False + if prior_backup is not None: + backup_path = prior_backup + reused_prior_backup = True + backup_note = f"pre-swap backup kept: {backup_path}" + else: + backup_suffix = f"{BACKUP_SUFFIX_PREFIX}{ts}" + if cli == "claude": + backup_suffix += f"-{scope}" + try: + backup_path = write_new_backup( + info.config_path.with_suffix( + info.config_path.suffix + backup_suffix + ), + original_bytes, + ) + except OSError as exc: + print(f"[{label}] backup failed ({exc})", file=sys.stderr) + had_error = 1 + continue + backup_note = f"backup: {backup_path}" + + if prior is not None and reused_prior_backup: + seq_no = prior.seq_no + swapped_at = prior.swapped_at + else: + seq_no = max((e.seq_no for e in state.values()), default=-1) + 1 + swapped_at = ts + recovery_entry = SwapEntry( + config_path=str(info.config_path), + backup_path=str(backup_path), + server=server, + action=action, + swapped_at=swapped_at, + seq_no=seq_no, + ) + state[state_key] = recovery_entry + try: + save_state(state) + except (OSError, TypeError, ValueError) as exc: + if prior is None: + state.pop(state_key) + else: + state[state_key] = prior + cleanup_note = "" + if not reused_prior_backup: + try: + backup_path.unlink(missing_ok=True) + except OSError as cleanup_exc: + cleanup_note = f"; backup cleanup failed ({cleanup_exc})" + print( + f"[{label}] recovery state write failed ({exc}){cleanup_note}", + file=sys.stderr, + ) + had_error = 1 + continue + + try: + atomic_write(info.config_path, new_bytes) + _revalidate(info) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + try: + atomic_write(info.config_path, original_bytes) + load_config(info) + except (OSError, RuntimeError, TypeError, ValueError) as rollback_exc: + print( + f"[{label}] write failed ({exc}); config rollback failed " + f"({rollback_exc}); recovery state and backup kept at " + f"{backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + + if prior is None: + state.pop(state_key) + else: + state[state_key] = prior + try: + if state: + save_state(state) + else: + STATE_FILE.unlink(missing_ok=True) + except (OSError, TypeError, ValueError) as rollback_exc: + state[state_key] = recovery_entry + print( + f"[{label}] write failed ({exc}); recovery state rollback " + f"failed ({rollback_exc}); backup kept at {backup_path}", + file=sys.stderr, + ) + had_error = 1 + continue + + cleanup_note = "" + if not reused_prior_backup: + try: + backup_path.unlink(missing_ok=True) + except OSError as cleanup_exc: + cleanup_note = f"; backup cleanup failed ({cleanup_exc})" + print( + f"[{label}] write failed ({exc}); config and state rolled back" + f"{cleanup_note}", + file=sys.stderr, + ) + had_error = 1 + continue + + print(f"[{label}] {action}; {backup_note}") + + return had_error + + +def _revalidate(info: CLIInfo) -> None: + """Re-parse the file after writing; raise on failure.""" + load_config(info) + + +@_serialized_transaction +def cmd_revert(args: argparse.Namespace) -> int: + """Restore each target CLI's config from the backup recorded in the state file. + + Without ``--scope``, every recorded entry for the targeted CLIs is + reverted (so a Claude install that has both user-scope and + project-scope swaps gets both restored). With ``--scope``, only + the matching scope is reverted; the parameter is silently coerced + to ``"user"`` for non-Claude CLIs. + """ + try: + state = load_state() + except (OSError, RuntimeError, TypeError) as exc: + print(f"recovery state error: {exc}; no config changed", file=sys.stderr) + return 1 + # Without --cli, revert every CLI that has any recorded swap. + targets = list(args.cli) if args.cli else list({cli for cli, _scope in state}) + if not targets: + print("no recorded swaps — nothing to revert", file=sys.stderr) + return 1 + + had_error = 0 + for cli in targets: + if args.scope is not None: + wanted_scopes: tuple[Scope, ...] = (_normalize_scope(cli, args.scope),) + else: + wanted_scopes = ALL_SCOPES + cli_keys = [ + (sc_cli, sc_scope) + for (sc_cli, sc_scope) in state + if sc_cli == cli and sc_scope in wanted_scopes + ] + if not cli_keys: + label = f"{cli}:{args.scope}" if args.scope and cli == "claude" else cli + print(f"[{label}] no state entry — skip") + continue + blocked = False + if args.scope is not None: + for key in cli_keys: + entry = state[key] + newer = sorted( + ( + (other_key, other_entry) + for other_key, other_entry in state.items() + if other_key != key + and other_entry.config_path == entry.config_path + and other_entry.seq_no > entry.seq_no + ), + key=lambda item: item[1].seq_no, + reverse=True, + ) + if not newer: + continue + sc_cli, sc_scope = key + label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli + newer_labels = ", ".join( + f"{new_cli}:{new_scope}" if new_cli == "claude" else new_cli + for (new_cli, new_scope), _entry in newer + ) + print( + f"[{label}] cannot revert before newer whole-file layer(s) " + f"{newer_labels}; revert those layers first", + file=sys.stderr, + ) + had_error = 1 + blocked = True + if blocked: + continue + # Unwind in reverse-registration order (LIFO) — sort by the + # explicit ``SwapEntry.seq_no`` counter so order is independent + # of JSON parse order, dict iteration, and wall-clock + # collisions. ``seq_no`` is coerced to ``int`` at load time by + # ``_parse_state_entry``; entries with a non-coercible value + # are dropped before they reach this sort, so the comparison + # is always int vs int. When two scopes back the same physical + # file (Claude user + project), the later swap's backup + # contains the earlier swap's modifications, so each backup + # must restore its own layer before the prior one is restored. + # Same explicit counter pattern CPython's ``Lib/sched.py`` uses + # to break ties on ``Event(time, priority, sequence, …)``. + cli_keys.sort(key=lambda k: state[k].seq_no, reverse=True) + for key in cli_keys: + sc_cli, sc_scope = key + entry = state[key] + label = f"{sc_cli}:{sc_scope}" if sc_cli == "claude" else sc_cli + backup = pathlib.Path(entry.backup_path) + dest = pathlib.Path(entry.config_path) + if not backup.exists(): + print(f"[{label}] backup missing: {backup}", file=sys.stderr) + had_error = 1 + break + if args.dry_run: + print(f"[{label}] would restore {dest} from {backup}") + continue + try: + atomic_write(dest, backup.read_bytes()) + except OSError as exc: + print(f"[{label}] restore failed ({exc})", file=sys.stderr) + had_error = 1 + break + + # Checkpoint each completed layer before consuming its backup. + # A later LIFO restore may fail, so deferring state cleanup until + # the whole command finishes would leave a dead state entry that + # points at an already-deleted backup. + try: + clear_state([key]) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + print( + f"[{label}] restored config but state checkpoint failed " + f"({exc}); backup kept at {backup}", + file=sys.stderr, + ) + had_error = 1 + break + state.pop(key) + + try: + backup.unlink() + except OSError as exc: + print( + f"[{label}] restored from {backup}; backup cleanup failed ({exc})", + file=sys.stderr, + ) + had_error = 1 + continue + print(f"[{label}] restored from {backup}") + + return had_error + + +# --------------------------------------------------------------------------- +# doctor — read-only diagnostics +# --------------------------------------------------------------------------- + +#: Env vars that, when set, override a CLI's stored subscription/login auth +#: with an API key — a frequent cause of "why is it billing / refusing?" +#: surprises when driving the CLI against a local server. Doctor only reports +#: presence; it never reads the value. +AUTH_ENV_VARS: dict[str, CLIName] = { + "ANTHROPIC_API_KEY": "claude", + "OPENAI_API_KEY": "codex", + "GEMINI_API_KEY": "gemini", + "GOOGLE_API_KEY": "gemini", + "XAI_API_KEY": "grok", + "GROK_API_KEY": "grok", +} + + +def _env_pair(raw: str) -> tuple[str, str]: + """Parse a ``KEY=VALUE`` ``--env`` argument, or raise for argparse.""" + key, sep, value = raw.partition("=") + if not sep or not key: + msg = f"--env expects KEY=VALUE, got {raw!r}" + raise argparse.ArgumentTypeError(msg) + return key, value + + +def _config_present_clis() -> list[CLIName]: + """CLIs whose config file exists — enough to *read* entries (no binary needed). + + Distinct from :func:`present_clis`, which also requires the binary on + ``PATH``. Doctor and the naming hint only inspect config files, so a CLI + whose binary is absent but whose config is present still has readable + entries worth surfacing. + """ + return [cli for cli in ALL_CLIS if CLIS[cli].config_path.exists()] + + +def _all_server_specs( + cli: CLIName, config: t.Any, repo: pathlib.Path +) -> dict[str, McpServerSpec]: + """Enumerate every MCP server entry visible in a CLI's config. + + Spans the scopes a CLI actually keys servers under: Claude's top-level + user ``mcpServers`` plus this repo's per-project node, and the single + ``mcpServers`` / ``mcp_servers`` table for the others. Used to detect the + server-name footgun — the repo registered under a name other than the + derived default — which a same-name-only lookup misses. + """ + out: dict[str, McpServerSpec] = {} + + def _add(raw: t.Any) -> None: + if not isinstance(raw, dict): + return + for name, entry in raw.items(): + if not isinstance(entry, dict): + continue + out[str(name)] = _spec_from_entry(entry, fmt=CLIS[cli].fmt) + + if cli == "claude": + _add(_claude_user_servers(config, create=False)) + node = _claude_project_node(config, repo, create=False) + if node: + _add(node.get("mcpServers")) + elif cli in ("cursor", "gemini", "agy"): + _add(config.get("mcpServers")) + else: # codex, grok + _add(config.get("mcp_servers")) + return out + + +def _repo_pointing_names(cli: CLIName, config: t.Any, repo: pathlib.Path) -> list[str]: + """Server names in this CLI's config whose local checkout is ``repo``.""" + return sorted( + name + for name, spec in _all_server_specs(cli, config, repo).items() + if spec.is_local_uv_directory() and spec.local_repo_path() == repo + ) + + +def _naming_hint(repo: pathlib.Path, server: str) -> str | None: + """Suggest ``--server `` when the repo is registered under another name. + + The derived default (the console-script entry minus ``-mcp``) often + doesn't match the slug the CLIs were actually registered under (e.g. + ``tmux`` vs the derived ``libtmux-engine``), so a bare run silently + operates on a non-existent entry. Returns a one-line hint naming the real + slug, or ``None`` when the derived name is already the registered one (or + nothing points here). + """ + names: set[str] = set() + server_points = False + for cli in _config_present_clis(): + try: + config = load_config(CLIS[cli]) + pointing = _repo_pointing_names(cli, config, repo) + except (AttributeError, OSError, RuntimeError, TypeError, ValueError): + continue + for name in pointing: + if name == server: + server_points = True + else: + names.add(name) + if server_points or not names: + return None + pick = min(names) + return ( + f"note: nothing is registered under server {server!r}, but this repo is " + f"registered as {sorted(names)} — pass --server {pick} to target it" + ) + + +def _orphaned_backups(config_path: pathlib.Path) -> list[pathlib.Path]: + """All ``mcp-swap`` backups sitting next to ``config_path`` (any timestamp).""" + pattern = config_path.name + BACKUP_SUFFIX_PREFIX + "*" + return sorted(config_path.parent.glob(pattern)) + + +def cmd_doctor(args: argparse.Namespace) -> int: + """Report the effective MCP-swap environment without changing anything. + + Read-only. Surfaces the footguns that swap/status don't: the repo + registered under an unexpected server name, un-reverted swaps and orphaned + backups accumulating on disk, a state entry whose backup has gone missing + (so revert would fail), and auth-overriding env vars. It deliberately does + NOT model each CLI's config-merge behaviour — that is CLI-version-specific + and lives in documentation, not here. + """ + repo = pathlib.Path(args.repo).resolve() + server = args.server or resolve_repo_meta(repo)[0] + print("mcp-swap doctor") + print(f" repo: {repo}") + print(f" server: {server} (derived default; override with --server)") + + print(" entries by CLI:") + had_error = 0 + all_repo_names: set[str] = set() + for cli in _config_present_clis(): + try: + config = load_config(CLIS[cli]) + specs = _all_server_specs(cli, config, repo) + pointing = _repo_pointing_names(cli, config, repo) + except (AttributeError, OSError, RuntimeError, TypeError, ValueError) as exc: + print(f" [{cli}] config unreadable: {exc}") + had_error = 1 + continue + spec = specs.get(server) + if spec is not None: + print(f" [{cli}] {server} = {_describe_spec(spec, repo)}") + all_repo_names.update(pointing) + for name in pointing: + if name != server: + print(f" [{cli}] {name} = local: this repo (other name)") + if not all_repo_names: + print(" (no CLI currently points at this repo)") + + if all_repo_names and server not in all_repo_names: + pick = min(all_repo_names) + print( + f" ! server name mismatch: this repo is registered as " + f"{sorted(all_repo_names)}, not {server!r} — use --server {pick}" + ) + + try: + state = load_state() + except (OSError, RuntimeError, TypeError) as exc: + print(f" ! recovery state unreadable: {exc}") + state = {} + had_error = 1 + if state: + print(" outstanding swaps (un-reverted):") + for (cli, scope), entry in sorted(state.items(), key=lambda kv: kv[1].seq_no): + flag = ( + "" + if pathlib.Path(entry.backup_path).exists() + else " ! BACKUP MISSING — revert would fail for this entry" + ) + print(f" {cli}:{scope} swapped_at={entry.swapped_at}{flag}") + + referenced = {e.backup_path for e in state.values()} + orphans = [ + b + for info in CLIS.values() + for b in _orphaned_backups(info.config_path) + if str(b) not in referenced + ] + if orphans: + total = sum(b.stat().st_size for b in orphans if b.exists()) + print( + f" orphaned backups: {len(orphans)} file(s), {total} bytes not tracked " + "by state — inspect before deleting: an untracked backup can be the " + "only surviving pre-swap copy of a config" + ) + + auth_hits = [ + (var, cli) for var, cli in AUTH_ENV_VARS.items() if os.environ.get(var) + ] + if auth_hits: + print(" auth-overriding env vars set:") + for var, cli in auth_hits: + print( + f" ! {var} overrides {cli}'s stored login — prefix with " + f"`env -u {var}` to use the subscription/OAuth auth instead" + ) + return had_error + + +# --------------------------------------------------------------------------- +# argparse glue +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + """Construct the ``argparse`` parser for ``mcp_swap``.""" + p = argparse.ArgumentParser(prog="mcp_swap", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser( + "detect", help="list installed CLIs and their config presence" + ).set_defaults(func=cmd_detect) + + ps = sub.add_parser("status", help="show the current MCP server entry per CLI") + ps.add_argument("--repo", default=".", help="repo root (default: .)") + ps.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + ps.add_argument( + "--cli", action="append", choices=ALL_CLIS, help="limit to one or more CLIs" + ) + ps.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit Claude output to one scope: 'user' shows only the " + "top-level mcpServers fallback, 'project' shows only the " + "projects..mcpServers entry. Without this flag, both " + "Claude scopes print when both have an entry. No-op for " + "non-Claude CLIs (their config has no per-project layer)." + ), + ) + ps.set_defaults(func=cmd_status) + + pu = sub.add_parser("use-local", help="rewrite configs to run this checkout") + pu.add_argument("--repo", default=".", help="repo root (default: .)") + pu.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + pu.add_argument( + "--entry", help="uv run entry command (default: [project.scripts] first key)" + ) + pu.add_argument( + "--env", + action="append", + type=_env_pair, + metavar="KEY=VALUE", + help=( + "Extra env var to write into the server entry (repeatable). " + "Layered on top of any preserved existing env; explicit --env wins. " + "Use to inject e.g. LIBTMUX_SOCKET without a manual post-edit." + ), + ) + pu.add_argument("--cli", action="append", choices=ALL_CLIS) + pu.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Claude config scope: 'user' rewrites the top-level mcpServers " + "fallback (every project without an override picks it up), " + "'project' rewrites projects..mcpServers under this repo. " + "Default 'project'. Silently coerced to 'user' for non-Claude CLIs." + ), + ) + pu.add_argument("--dry-run", action="store_true") + pu.set_defaults(func=cmd_use_local) + + pr = sub.add_parser("revert", help="restore each CLI's config from its swap backup") + pr.add_argument("--cli", action="append", choices=ALL_CLIS) + pr.add_argument( + "--scope", + choices=ALL_SCOPES, + default=None, + help=( + "Limit revert to one Claude scope. Without this flag, every " + "recorded scope for the targeted CLIs is reverted." + ), + ) + pr.add_argument("--dry-run", action="store_true") + pr.set_defaults(func=cmd_revert) + + pd = sub.add_parser( + "doctor", help="report the effective MCP-swap environment (read-only)" + ) + pd.add_argument("--repo", default=".", help="repo root (default: .)") + pd.add_argument( + "--server", help="MCP server name (default: derived from pyproject.toml)" + ) + pd.set_defaults(func=cmd_doctor) + + return p + + +def main(argv: list[str] | None = None) -> int: + """Entry point — dispatches to the selected subcommand.""" + args = build_parser().parse_args(argv) + return t.cast("int", args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/libtmux/experimental/__init__.py b/src/libtmux/experimental/__init__.py new file mode 100644 index 000000000..e4f2a842e --- /dev/null +++ b/src/libtmux/experimental/__init__.py @@ -0,0 +1,13 @@ +"""Experimental libtmux APIs. + +This package hosts work that is **not** covered by the project's versioning +policy. Anything under :mod:`libtmux.experimental` may change shape or be +removed between any two releases without notice. + +The package centers on inert, typed tmux operation values and interchangeable +execution engines. Operations render commands, carry result types, and +serialize without a live tmux server; engines execute them and return the same +typed result shapes. +""" + +from __future__ import annotations diff --git a/src/libtmux/experimental/_wait_pane.py b/src/libtmux/experimental/_wait_pane.py new file mode 100644 index 000000000..8e6465002 --- /dev/null +++ b/src/libtmux/experimental/_wait_pane.py @@ -0,0 +1,99 @@ +"""Wait for a freshly created pane's shell to draw its prompt. + +tmux returns a pane id the instant it forks the pane, long before the shell in it +has printed a prompt -- so a ``send-keys`` fired immediately can be swallowed. The +fix both builders use is the same: poll ``#{cursor_x},#{cursor_y}`` until the +cursor leaves the origin, then proceed. + +This is a *host* step: it must run between tmux dispatches, never inside a fold, +so both drivers replay it from their plan's ``on_step`` hook (the workspace runner +through a compiled :class:`~.workspace.compiler.HostStep`, the fluent builder +through its recorded host action). Only the poll itself is shared here. +""" + +from __future__ import annotations + +import asyncio +import time +import typing as t + +from libtmux.experimental.ops import DisplayMessage, arun, run +from libtmux.experimental.ops.plan import _resolve + +if t.TYPE_CHECKING: + from libtmux.experimental.engines.base import AsyncTmuxEngine, TmuxEngine + from libtmux.experimental.ops._types import Target + from libtmux.experimental.ops.operation import Operation + +#: Pane-readiness poll budget: ~2s at a 50ms cadence (matches tmuxp's timeout). +WAIT_PANE_POLLS = 40 +WAIT_PANE_INTERVAL = 0.05 +CURSOR_FMT = "#{cursor_x},#{cursor_y}" + + +def pane_ready(cursor: str) -> bool: + """Whether the pane's cursor has left the origin (its shell prompt drew). + + Parameters + ---------- + cursor : str + A ``#{cursor_x},#{cursor_y}`` reading, or ``""`` when unreadable. + + Returns + ------- + bool + + Examples + -------- + >>> pane_ready("2,1") + True + >>> pane_ready("0,0") + False + >>> pane_ready("") + False + """ + return bool(cursor) and cursor != "0,0" + + +def _cursor_probe( + pane: Target, + bindings: dict[int | tuple[int, str], str], +) -> Operation[t.Any]: + """Build the cursor read for *pane*, resolving a forward ref against bindings.""" + return _resolve(DisplayMessage(target=pane, message=CURSOR_FMT), bindings) + + +def wait_pane( + engine: TmuxEngine, + pane: Target, + bindings: dict[int | tuple[int, str], str], + version: str | None = None, +) -> bool: + """Poll *pane* until its prompt draws; return whether it did within the budget. + + Returns ``False`` on exhaustion rather than raising: a pane whose shell never + moves the cursor (a full-screen program, a bare ``cat``) is not an error, and + the build proceeds exactly as it did before the wait was requested. + """ + op = _cursor_probe(pane, bindings) + for _ in range(WAIT_PANE_POLLS): + if pane_ready(run(op, engine, version=version).text): + return True + time.sleep(WAIT_PANE_INTERVAL) + return False + + +async def await_pane( + engine: AsyncTmuxEngine, + pane: Target, + bindings: dict[int | tuple[int, str], str], + version: str | None = None, +) -> bool: + """Async sibling of :func:`wait_pane` (same budget, same exhaustion contract).""" + op = _cursor_probe(pane, bindings) + for _ in range(WAIT_PANE_POLLS): + result = await arun(op, engine, version=version) + if pane_ready(result.text): + return True + await asyncio.sleep(WAIT_PANE_INTERVAL) + return False diff --git a/src/libtmux/experimental/engines/__init__.py b/src/libtmux/experimental/engines/__init__.py new file mode 100644 index 000000000..6b9d6b7c9 --- /dev/null +++ b/src/libtmux/experimental/engines/__init__.py @@ -0,0 +1,64 @@ +"""Execution engines for :mod:`libtmux.experimental.ops`. + +An *engine* executes a rendered tmux command and returns a structured result. +Engines are interchangeable behind the :class:`~.base.TmuxEngine` / +:class:`~.base.AsyncTmuxEngine` protocols, so the same typed operation can run +through a subprocess (classic), an in-memory simulator (mock), a persistent +``tmux -C`` control connection, an async transport, or tmux's native binary +peer protocol -- and return the *same* typed result. +""" + +from __future__ import annotations + +from libtmux.experimental.engines.async_control_mode import ( + AsyncControlModeEngine, + ControlNotification, +) +from libtmux.experimental.engines.asyncio import AsyncSubprocessEngine +from libtmux.experimental.engines.base import ( + AsyncTmuxEngine, + CommandRequest, + CommandResult, + EngineKind, + EngineSpec, + SupportsTmuxVersion, + TmuxEngine, +) +from libtmux.experimental.engines.connection import ServerConnection +from libtmux.experimental.engines.control_mode import ( + ControlModeEngine, + ControlModeError, + ControlModeParser, +) +from libtmux.experimental.engines.imsg import ImsgEngine +from libtmux.experimental.engines.mock import AsyncMockEngine, MockEngine +from libtmux.experimental.engines.registry import ( + available_engines, + create_engine, + register_engine, +) +from libtmux.experimental.engines.subprocess import SubprocessEngine + +__all__ = ( + "AsyncControlModeEngine", + "AsyncMockEngine", + "AsyncSubprocessEngine", + "AsyncTmuxEngine", + "CommandRequest", + "CommandResult", + "ControlModeEngine", + "ControlModeError", + "ControlModeParser", + "ControlNotification", + "EngineKind", + "EngineSpec", + "ImsgEngine", + "MockEngine", + "ServerConnection", + "SubprocessEngine", + "SupportsTmuxVersion", + "TmuxEngine", + "available_engines", + "create_engine", + "register_engine", +) diff --git a/src/libtmux/experimental/engines/async_control_mode.py b/src/libtmux/experimental/engines/async_control_mode.py new file mode 100644 index 000000000..e09cb47bf --- /dev/null +++ b/src/libtmux/experimental/engines/async_control_mode.py @@ -0,0 +1,1052 @@ +"""An asynchronous control-mode (``tmux -C``) engine with an event stream. + +A real async control engine -- not an ``asyncio.to_thread`` wrapper around the +sync one. It holds a persistent ``tmux -C`` connection, reads it from a single +background task, correlates each command to an :class:`asyncio.Future`, and +exposes tmux's asynchronous notifications (``%output``, ``%window-add``, ...) as +an ``async for`` event stream. + +Design, informed by prior libtmux/mux control-mode work: + +- The I/O-free :class:`~.control_mode.ControlModeParser` is reused verbatim; only + the I/O layer differs from the sync engine (``await stdout.read`` instead of + ``selectors``). +- Command correlation is a FIFO of futures resolved in block-arrival order. A + block that arrives with *no* pending command is **unsolicited** (a hook- + triggered command, or the startup ACK) and is skipped, so correlation never + desyncs. The startup ACK is consumed synchronously in :meth:`_spawn` before + the reader runs, closing the startup race. +- A supervisor owns the process lifecycle. :meth:`start` launches it once; it + attaches to an exact existing session without updating its environment, + replays the desired subscriptions, and runs the reader inline (one reader at + a time). When the reader returns on EOF, the supervisor resets + connection-scoped state -- a fresh parser, failed pending commands, cleared + attach -- bumps the connection generation, and reconnects with a deterministic + jittered backoff, so a tmux restart or socket blip self-heals instead of + freezing the engine. An intentional :meth:`aclose` flags ``_closing`` first so + the close is not mistaken for a crash and retried. +- If no safe session exists yet, command batches use the native async + subprocess engine. A command such as ``new-session`` can bootstrap the server + without a throwaway control session; later batches can open the persistent + client. +- A reader failure or EOF marks the engine *dead* and fails every pending + command, rather than hanging; the supervisor then reconnects. +- Notifications go to a bounded queue; on overflow the oldest is dropped and + counted (backpressure), mirroring control mode's own ``%pause`` philosophy. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import logging +import typing as t +from dataclasses import dataclass, field + +from libtmux import exc +from libtmux.experimental.engines.asyncio import AsyncSubprocessEngine +from libtmux.experimental.engines.base import CommandRequest, render_control_line +from libtmux.experimental.engines.connection import ServerConnection +from libtmux.experimental.engines.control_mode import ( + BlockSequenceMonitor, + ControlModeError, + ControlModeParser, + _merge_blocks, + command_count, +) + +if t.TYPE_CHECKING: + import types + from collections.abc import AsyncIterator, Sequence + + from typing_extensions import Self + + from libtmux.experimental.engines.base import CommandResult + from libtmux.experimental.engines.control_mode import ControlModeBlock + +logger = logging.getLogger(__name__) + +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 +_STOP_TIMEOUT = 2.0 +# A connection must survive at least this long to count as healthy and reset the +# reconnect backoff; a shorter-lived one is treated as a failed attempt so a +# persistently flapping proc escalates instead of fork-storming. +_HEALTHY_CONNECTION_SECONDS = 1.0 + +_STREAM_END = object() # broadcast to subscriber queues to end their async for + + +@dataclass(frozen=True) +class ControlNotification: + """An asynchronous tmux control-mode notification. + + Attributes + ---------- + kind : str + Notification name without the leading ``%``. + args : tuple[str, ...] + Whitespace-separated notification arguments. + raw : str + Decoded control-mode line before tokenization. + + Examples + -------- + >>> ControlNotification.parse(b"%window-add @3") + ControlNotification(kind='window-add', args=('@3',), raw='%window-add @3') + >>> ControlNotification.parse(b"%output %1 hello world").kind + 'output' + """ + + kind: str + args: tuple[str, ...] + raw: str + + @classmethod + def parse(cls, line: bytes) -> ControlNotification: + """Parse a raw ``%``-notification line.""" + text = line.decode(errors="replace") + body = text.removeprefix("%") + parts = body.split(" ") + kind = parts[0] if parts else "" + return cls(kind=kind, args=tuple(parts[1:]), raw=text) + + +@dataclass(slots=True) +class _PendingCommand: + """One command awaiting its control-mode response blocks. + + Attributes + ---------- + future : asyncio.Future[CommandResult] + Future completed when the expected blocks arrive. + argv : tuple[str, ...] + Rendered tmux command tokens. + expected : int + Number of response blocks required for completion. + blocks : list[ControlModeBlock] + Response blocks collected so far. + """ + + future: asyncio.Future[CommandResult] + argv: tuple[str, ...] + expected: int + blocks: list[ControlModeBlock] = field(default_factory=list) + + +def _offer( + queue: asyncio.Queue[ControlNotification], + notification: ControlNotification, +) -> int: + """Put *notification* on *queue*, dropping the oldest on overflow. + + Returns ``1`` when a notification was dropped, else ``0`` (so a broadcast can + tally drops without a ``try``/``except`` in its hot loop). + """ + try: + queue.put_nowait(notification) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(notification) + return 1 + return 0 + + +def _force_put(queue: asyncio.Queue[t.Any], item: t.Any) -> None: + """Put *item* on *queue*, evicting the oldest entry first when it is full. + + Like :func:`_offer` but drop-count-free: used to land the stream-end + sentinel even on a queue already at ``maxsize``, so a slow consumer that hit + backpressure still gets closed instead of hanging on ``queue.get()``. Pulled + out of the broadcast loop so the ``try``/``except`` stays out of it. + """ + try: + queue.put_nowait(item) + except asyncio.QueueFull: + with contextlib.suppress(asyncio.QueueEmpty): + queue.get_nowait() # evict oldest; tolerable at death + with contextlib.suppress(asyncio.QueueFull): + queue.put_nowait(item) + + +def _swallow_future(future: asyncio.Future[t.Any]) -> None: + """Retrieve an independently owned future so it isn't flagged unretrieved. + + Subscription replays have no awaiter, and every caller may cancel its wait + on a shared start attempt. Calling :meth:`asyncio.Future.exception` marks + either result retrieved without changing what a later ``result()`` observes. + """ + if future.cancelled(): + return + with contextlib.suppress(Exception): + future.exception() + + +class AsyncControlModeEngine: + """Execute tmux commands over one persistent async ``tmux -C`` connection. + + Parameters + ---------- + tmux_bin : str or None + The tmux binary; resolved from ``$PATH`` when ``None``. + server_args : Sequence[str] + Connection flags inserted before ``-C``. + timeout : float + Seconds to await a command's result before failing it. + event_queue_size : int + Bounded size of the notification queue (backpressure). + + Notes + ----- + The connection opens on async context entry when a safe session exists, or + on the first later command that finds one. Use the engine as an async context + manager, or call :meth:`aclose`, to tear it down. Commands use async + subprocess execution until an existing session has an effective + ``destroy-unattached`` value of ``off``. + """ + + def __init__( + self, + tmux_bin: str | None = None, + *, + server_args: Sequence[str] = (), + timeout: float = _DEFAULT_TIMEOUT, + event_queue_size: int = 4096, + ) -> None: + self._conn = ServerConnection.of(tmux_bin, server_args) + self.timeout = timeout + self._parser = ControlModeParser() + self._sequence = BlockSequenceMonitor() + self._pending: collections.deque[_PendingCommand] = collections.deque() + self._event_queue_size = event_queue_size + self._subscribers: set[asyncio.Queue[t.Any]] = set() + self._dropped_notifications = 0 + self._proc: asyncio.subprocess.Process | None = None + self._start_lock = asyncio.Lock() + self._write_lock = asyncio.Lock() + self._started = False + self._dead: BaseException | None = None + self._bootstrap = AsyncSubprocessEngine( + tmux_bin=self._conn.tmux_bin, + server_args=self._conn.args, + ) + self._bootstrap_tasks: set[asyncio.Task[list[CommandResult]]] = set() + # Desired (declarative) state, replayed on every (re)connect. + self._desired_subscriptions: list[str] = [] + self._desired_attach: list[str] = [] + self._attached_session: str | None = None + # Supervisor / reconnect bookkeeping. + self._generation = 0 + self._closing = False + self._supervisor_task: asyncio.Task[None] | None = None + self._start_attempt: asyncio.Future[None] | None = None + self._next_attach_target: str | None = None + + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before ``-C``.""" + return self._conn.args + + def tmux_version(self) -> str | None: + """Report the connected server's tmux version (``tmux -V``), memoized. + + Implements + :class:`~libtmux.experimental.engines.base.SupportsTmuxVersion` so + version-gated operations render correctly over control mode; in-memory + engines omit it and resolution assumes latest. + """ + return self._conn.tmux_version() + + def add_subscription(self, spec: str) -> None: + """Record a desired ``refresh-client -B`` subscription (idempotent). + + The spec is stored in :attr:`_desired_subscriptions` and replayed on + every (re)connect by the supervisor, so a subscription survives a tmux + restart or socket blip. Adding the same spec twice is a no-op. + + Parameters + ---------- + spec : str + A ``refresh-client -B`` subscription spec, e.g. + ``"agentstate:%*:#{@agent_state}"``. + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine.add_subscription("agentstate:%*:#{@agent_state}") + >>> engine._desired_subscriptions + ['agentstate:%*:#{@agent_state}'] + """ + if spec not in self._desired_subscriptions: + self._desired_subscriptions.append(spec) + + def set_attach_targets(self, ids: list[str]) -> None: + """Record the sessions the engine should (re)attach to on reconnect. + + Stores a *copy* of *ids* in :attr:`_desired_attach`. The supervisor + replays these on every (re)connect via :meth:`_replay_attach`, so the + engine stays attached across a tmux restart or socket blip (a control + client attaches to one session at a time, so the last target wins). + + Parameters + ---------- + ids : list[str] + Session ids to attach to (e.g. ``["$0", "$1"]``). + + Examples + -------- + >>> engine = AsyncControlModeEngine() + >>> engine.set_attach_targets(["$0", "$1"]) + >>> engine._desired_attach + ['$0', '$1'] + """ + self._desired_attach = list(ids) + + async def start(self) -> None: + """Launch the supervisor (once) and wait for its first connection. + + The supervisor owns the ``tmux -C`` process lifecycle: it spawns the + proc, consumes the startup ACK, replays desired subscriptions, runs the + reader, and reconnects with backoff when the reader returns. This method + is idempotent (the ``_start_lock`` + ``_started`` guard) and never + launches a second supervisor; all callers block on the same immutable + attempt. Direct startup requires an existing session whose effective + ``destroy-unattached`` option is ``off``; :meth:`run_batch` uses + subprocess execution while no such target exists. + """ + async with self._start_lock: + attempt = self._begin_start_locked() + await self._wait_start_attempt(attempt) + + def _begin_start_locked(self) -> asyncio.Future[None]: + """Return the current immutable start attempt, creating it if needed.""" + attempt: asyncio.Future[None] | None + if not self._started: + self._closing = False + attempt = asyncio.get_running_loop().create_future() + attempt.add_done_callback(_swallow_future) + self._start_attempt = attempt + self._supervisor_task = asyncio.create_task( + self._supervisor(attempt), + name="libtmux-async-control-supervisor", + ) + self._started = True + else: + attempt = self._start_attempt + if attempt is None: + msg = "control-mode start attempt is unavailable" + raise ControlModeError(msg) + return attempt + + @staticmethod + async def _wait_start_attempt(attempt: asyncio.Future[None]) -> None: + """Wait without propagating caller cancellation into *attempt*. + + ``asyncio.shield`` creates an intermediate future whose exception can be + reported as unretrieved when its only waiter is cancelled. Waiting on + the shared future through ``asyncio.wait`` preserves its immutable result + without creating that diagnostic-only wrapper. + """ + await asyncio.wait((attempt,)) + attempt.result() + + async def _spawn(self) -> None: + """Spawn a fresh ``tmux -C`` process and consume its startup ACK. + + Extracted from :meth:`start` so the supervisor can re-run it on every + reconnect. Sets :attr:`_proc`, then clears :attr:`_dead` only *after* the + startup ACK is consumed (so a command racing the reconnect still hits the + dead-guard). The caller is responsible for resetting the parser *before* + this runs, so the new process's startup bytes are parsed by a fresh parser. + """ + # A reader that returned via an exception (not a clean EOF) leaves the + # prior tmux -C alive; terminate it before overwriting _proc so a + # reconnect never orphans a control client. A clean-EOF proc has already + # exited, so this is a no-op there. + old = self._proc + if old is not None: + await self._stop_process(old) + target = self._next_attach_target + self._next_attach_target = None + if target is None: + target = await self._find_attach_target() + if target is None: + msg = ( + "control mode requires an existing session whose effective " + "destroy-unattached option is off" + ) + raise ControlModeError(msg) + cmd = self._conn.argv( + "-C", + "attach-session", + "-E", + "-t", + target, + ) + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + self._proc = proc + # Keep the death-sentinel set while the startup ACK is consumed. The + # first-start future is already resolved during reconnects, so a racing + # run_batch must hit the dead guard instead of writing a reply that + # _consume_startup would drain and discard. + try: + await self._consume_startup() + except BaseException: + await self._stop_process(proc) + self._proc = None + raise + self._dead = None + + async def _consume_startup(self) -> None: + """Read and discard tmux's startup ACK block before commands flow. + + Doing this synchronously (before the reader task launches and before any + command future is queued) means the startup block can never be matched + to a real command. + """ + proc = self._proc + if proc is None or proc.stdout is None: + return + loop = asyncio.get_running_loop() + deadline = loop.time() + _STARTUP_TIMEOUT + while True: + remaining = deadline - loop.time() + if remaining <= 0: + msg = "tmux control-mode startup timed out" + raise ControlModeError(msg) + try: + chunk = await asyncio.wait_for( + proc.stdout.read(_READ_CHUNK), + timeout=remaining, + ) + except asyncio.TimeoutError: + msg = "tmux control-mode startup timed out" + raise ControlModeError(msg) from None + if not chunk: + msg = "tmux -C closed stdout during startup" + raise ControlModeError(msg) + self._parser.feed(chunk) + self._parser.notifications() # discard any startup notifications + discarded = self._parser.blocks() + if discarded: + if discarded[-1].is_error: + detail = "; ".join( + line.decode(errors="replace") for line in discarded[-1].body + ) + msg = "tmux control-mode attach failed" + raise ControlModeError(f"{msg}: {detail}" if detail else msg) + solicited = [block for block in discarded if block.flags == 1] + if solicited: + # Anything but the ACK here is a command reply thrown away on + # the reconnect path -- the documented swallow risk. + logger.warning( + "control-mode startup consumed %s solicited block(s); " + "%s command(s) were pending", + len(solicited), + len(self._pending), + extra={ + "tmux_stdout": [ + f"#{block.number}: " + + b" | ".join(block.body).decode(errors="replace") + for block in solicited + ], + "tmux_stdout_len": len(solicited), + }, + ) + return + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one command through control mode or bootstrap subprocess.""" + return (await self.run_batch([request]))[0] + + async def run_batch( + self, requests: Sequence[CommandRequest] + ) -> list[CommandResult]: + """Pipeline a batch, bootstrapping through subprocess when necessary.""" + if not requests: + return [] + bootstrap: asyncio.Task[list[CommandResult]] | None = None + attempt: asyncio.Future[None] | None = None + async with self._start_lock: + if self._bootstrap_tasks: + bootstrap = self._begin_bootstrap_locked(requests) + elif self._started and self._dead is not None: + target = await self._find_attach_target() + if target is None: + await self._close_locked() + bootstrap = self._begin_bootstrap_locked(requests) + if bootstrap is None and not self._started: + target = await self._find_attach_target() + if target is None: + bootstrap = self._begin_bootstrap_locked(requests) + else: + self._next_attach_target = target + if bootstrap is None: + attempt = self._begin_start_locked() + if bootstrap is not None: + return await bootstrap + if attempt is None: + msg = "control-mode dispatch attempt is unavailable" + raise ControlModeError(msg) + await self._wait_start_attempt(attempt) + if self._dead is not None: + msg = "control-mode engine is dead" + raise ControlModeError(msg) from self._dead + + loop = asyncio.get_running_loop() + rendered = [tuple(req.args) for req in requests] + futures: list[asyncio.Future[CommandResult]] = [] + async with self._write_lock: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + appended: list[_PendingCommand] = [] + for argv in rendered: + future: asyncio.Future[CommandResult] = loop.create_future() + pending = _PendingCommand(future, argv, command_count(argv)) + self._pending.append(pending) + appended.append(pending) + futures.append(future) + payload = b"".join( + (render_control_line(argv) + "\n").encode() for argv in rendered + ) + try: + proc.stdin.write(payload) + await proc.stdin.drain() + except asyncio.CancelledError: + # ``write`` already accepted the payload. Keep its FIFO entries + # so the reader can drain any replies, but make their futures + # terminal so close/reconnect cannot publish unobserved errors. + for queued in appended: + if not queued.future.done(): + queued.future.cancel() + raise + except (BrokenPipeError, OSError) as error: + # Remove the futures we just queued so a write failure cannot + # leave orphans that desync FIFO correlation for the next batch. + cm_error = ControlModeError(f"tmux control-mode write failed: {error}") + for queued in appended: + with contextlib.suppress(ValueError): + self._pending.remove(queued) + if not queued.future.done(): + queued.future.cancel() + raise cm_error from error + + try: + return await asyncio.wait_for( + asyncio.gather(*futures), + timeout=self.timeout, + ) + except asyncio.TimeoutError as error: + # The futures stay queued (now cancelled); the reader drains their + # blocks on arrival, keeping FIFO correlation aligned. + msg = f"tmux control-mode timed out after {self.timeout}s" + raise ControlModeError(msg) from error + + def _begin_bootstrap_locked( + self, + requests: Sequence[CommandRequest], + ) -> asyncio.Task[list[CommandResult]]: + """Start one tracked subprocess batch while lifecycle state is locked.""" + self._closing = False + task = asyncio.create_task( + self._bootstrap.run_batch(requests), + name="libtmux-async-control-bootstrap", + ) + self._bootstrap_tasks.add(task) + task.add_done_callback(self._bootstrap_tasks.discard) + return task + + async def subscribe(self) -> AsyncIterator[ControlNotification]: + """Yield asynchronous tmux notifications as they arrive. + + Each subscriber gets its own queue, so concurrent subscribers (the event + push tool, the pull ring, the output monitor) each see *every* + notification rather than competing for one shared stream. The iterator + runs until the engine is closed or the caller stops iterating; its queue + is unregistered on exit. When the engine dies, ``_STREAM_END`` is + broadcast to every subscriber queue so the ``async for`` ends cleanly + instead of hanging on ``queue.get()``. + + A subscribe() *after* :meth:`aclose` (which set :attr:`_closing`, + broadcast the stream-end sentinel, and cleared :attr:`_subscribers`) + would register a fresh queue no broadcast will ever touch, hanging the + consumer forever. So a permanently-closing engine yields nothing and + ends at once. A reader death also closes the current stream; callers can + subscribe again after the supervisor reconnects. + """ + if self._closing: + return + queue: asyncio.Queue[t.Any] = asyncio.Queue( + maxsize=self._event_queue_size, + ) + self._subscribers.add(queue) + try: + while True: + item = await queue.get() + if item is _STREAM_END: + return + yield item + finally: + self._subscribers.discard(queue) + + @property + def dropped_notifications(self) -> int: + """How many notifications were dropped due to a full event queue.""" + return self._dropped_notifications + + async def aclose(self) -> None: + """Tear down: flag closing, cancel the supervisor, fail pending, kill proc. + + Setting :attr:`_closing` *first* distinguishes an intentional close from a + crash, so cancelling the supervisor (and the reader it owns inline) ends + the loop instead of triggering a reconnect. The start lock covers the + complete transition so a new supervisor cannot appear midway through + close. + """ + cleanup = asyncio.create_task( + self._aclose_impl(), + name="libtmux-async-control-close", + ) + cancelled = await self._wait_for_close_cleanup(cleanup) + cleanup.result() + if cancelled: + raise asyncio.CancelledError + + @staticmethod + async def _wait_for_close_cleanup(cleanup: asyncio.Task[None]) -> bool: + """Wait through every caller cancellation; report whether one occurred.""" + try: + await asyncio.wait((cleanup,)) + except asyncio.CancelledError: + # Recursion gives each accepted ``cancel()`` its own handler without + # propagating cancellation into the independently owned cleanup task. + await AsyncControlModeEngine._wait_for_close_cleanup(cleanup) + return True + return False + + async def _aclose_impl(self) -> None: + """Serialize close and cancel every tracked subprocess bootstrap.""" + async with self._start_lock: + self._closing = True + bootstrap = tuple(self._bootstrap_tasks) + for task in bootstrap: + task.cancel() + if bootstrap: + await asyncio.gather(*bootstrap, return_exceptions=True) + await self._close_locked() + + async def _close_locked(self) -> None: + """Close while the caller holds :attr:`_start_lock`.""" + self._closing = True + self._started = False + supervisor = self._supervisor_task + attempt = self._start_attempt + self._next_attach_target = None + if supervisor is not None: + supervisor.cancel() + await asyncio.wait((supervisor,)) + if not supervisor.cancelled(): + supervisor.result() + if self._supervisor_task is supervisor: + self._supervisor_task = None + if attempt is not None and not attempt.done(): + attempt.set_exception(ControlModeError("control-mode engine closed")) + if self._start_attempt is attempt: + self._start_attempt = None + self._broadcast_stream_end() + self._fail_pending(ControlModeError("control-mode engine closed")) + proc = self._proc + if proc is not None: + await self._stop_process(proc) + if self._proc is proc: + self._proc = None + + async def __aenter__(self) -> Self: + """Start when a safe session exists; otherwise remain lazy to bootstrap.""" + attempt: asyncio.Future[None] | None = None + async with self._start_lock: + if self._started: + attempt = self._begin_start_locked() + else: + target = await self._find_attach_target() + if target is not None: + self._next_attach_target = target + attempt = self._begin_start_locked() + if attempt is not None: + await self._wait_start_attempt(attempt) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the engine on context exit.""" + await self.aclose() + + async def _supervisor( + self, + first_attempt: asyncio.Future[None] | None = None, + ) -> None: + """Own the proc lifecycle: connect, replay desired state, read, reconnect. + + One supervisor runs at a time (launched once by :meth:`start`). Each + iteration resets connection-scoped state *before* the new process's bytes + flow -- a fresh :class:`~.control_mode.ControlModeParser`, failed pending + commands, cleared attach -- then spawns ``tmux -C``, bumps + :attr:`_generation`, replays subscriptions, and runs the reader inline so + there is never more than one reader. When the reader returns on EOF (and + the engine is not :attr:`_closing`), it backs off with deterministic + jitter and reconnects. An intentional :meth:`aclose` cancels this task, + which propagates into the inline reader. + """ + attempt = 0 + connected_once = False + try: + while not self._closing: + # Reset connection-scoped state BEFORE the new proc's bytes flow. + # Reconnect is the only place permitted to reset the parser and + # fail pending, keeping FIFO correlation aligned across the gap. + self._parser = ControlModeParser() + self._sequence.reset() + self._fail_pending(ControlModeError("control-mode reconnecting")) + self._reset_attach() + try: + await self._spawn() + except asyncio.CancelledError: + raise + except BaseException as error: + if not connected_once: + # First connect failed (e.g. missing binary): surface it + # to start() and stop -- a permanent error should not spin. + await self._finish_failed_start(first_attempt, error) + return + # A transient spawn failure mid-life: back off and retry. + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + continue + # The spawn succeeded and its startup ACK was consumed. Do NOT + # reset the backoff yet: a proc that connects then immediately + # dies (reader EOF within the grace) is not a healthy session, and + # resetting here would pin every reconnect at _backoff(0) and + # fork-storm tmux. The reset is gated on connection lifetime below. + self._generation += 1 + connected_once = True + await self._replay_subscriptions() + await self._replay_attach() + if first_attempt is not None and not first_attempt.done(): + first_attempt.set_result(None) + # The reader runs inline (one reader at a time). On EOF it returns + # and we reconnect; on cancellation (aclose) it propagates out. + loop = asyncio.get_running_loop() + connected_at = loop.time() + await self._reader() + if self._closing: + return + # Only a connection that survived a meaningful interval resets the + # backoff; a connect-then-immediately-die counts as a failed + # attempt, so a persistently flapping proc escalates instead of + # spinning at _backoff(0). + if loop.time() - connected_at >= _HEALTHY_CONNECTION_SECONDS: + attempt = 0 + await asyncio.sleep(self._backoff(attempt)) + attempt += 1 + except asyncio.CancelledError: + raise + except BaseException as error: + failure = ControlModeError(f"control-mode supervisor failed: {error}") + self._mark_dead(failure) + proc = self._proc + if proc is not None: + try: + await self._stop_process(proc) + except Exception as cleanup_error: + failure = ControlModeError( + f"control-mode supervisor failed: {error}; " + f"process cleanup failed: {cleanup_error}" + ) + else: + if self._proc is proc: + self._proc = None + await self._finish_failed_start(first_attempt, failure) + finally: + if first_attempt is not None and not first_attempt.done(): + first_attempt.set_exception( + ControlModeError("control-mode engine closed before connecting"), + ) + + async def _finish_failed_start( + self, + attempt: asyncio.Future[None] | None, + error: BaseException, + ) -> None: + """Publish one failed attempt and make a later call eligible to retry.""" + if attempt is None: + return + async with self._start_lock: + if self._start_attempt is attempt: + self._started = False + self._start_attempt = None + if self._supervisor_task is asyncio.current_task(): + self._supervisor_task = None + if not attempt.done(): + attempt.set_exception(error) + + async def _find_attach_target(self) -> str | None: + """Return an exact session id whose effective detach policy is off.""" + sessions = await self._bootstrap.run( + CommandRequest.from_args("list-sessions", "-F", "#{session_id}"), + ) + if sessions.returncode != 0: + return None + for session_id in sessions.stdout: + option = await self._bootstrap.run( + CommandRequest.from_args( + "show-options", + "-Av", + "-t", + session_id, + "destroy-unattached", + ), + ) + if option.returncode == 0 and option.stdout == ("off",): + return session_id + return None + + @staticmethod + async def _stop_process(proc: asyncio.subprocess.Process) -> None: + """Terminate and reap *proc*, escalating after a bounded wait.""" + if proc.returncode is not None: + return + with contextlib.suppress(ProcessLookupError): + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=_STOP_TIMEOUT) + except asyncio.TimeoutError: + with contextlib.suppress(ProcessLookupError): + proc.kill() + try: + await asyncio.wait_for(proc.wait(), timeout=_STOP_TIMEOUT) + except asyncio.TimeoutError: + msg = "tmux control process did not exit after kill" + raise ControlModeError(msg) from None + + async def _replay_subscriptions(self) -> None: + """Re-issue every desired subscription to the freshly connected proc. + + Each spec is sent as ``refresh-client -B `` with a queued pending + command, so the reader correlates its result block in FIFO order (the + replay commands sit at the front of the deque, ahead of any user command, + because :meth:`start` has not yet returned). The futures are + fire-and-forget: their outcome is swallowed rather than awaited, since the + reader has not started yet. Writing here re-enters neither :meth:`start` + nor :meth:`run_batch`, so the supervisor cannot recurse into itself. + """ + if not self._desired_subscriptions: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for spec in self._desired_subscriptions: + argv = ("refresh-client", "-B", spec) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + + async def _replay_attach(self) -> None: + """Re-attach to every desired session on the freshly connected proc. + + Mirrors :meth:`_replay_subscriptions`. The process initially attaches to + an existing session; this replay switches it to each requested target + with ``-E`` so the last target wins without applying + ``update-environment``. Each command is written directly to stdin with a + swallowed pending future, so it re-enters neither :meth:`start` nor + :meth:`run_batch`. The fire-and-forget replay does not cache + :attr:`_attached_session`; the events layer sets that only after a + confirmed attach. Does nothing when :attr:`_desired_attach` is empty. + """ + if not self._desired_attach: + return + proc = self._proc + if proc is None or proc.stdin is None: + return + loop = asyncio.get_running_loop() + async with self._write_lock: + payload_parts: list[bytes] = [] + for target in self._desired_attach: + argv = ("attach-session", "-E", "-t", target) + future: asyncio.Future[CommandResult] = loop.create_future() + future.add_done_callback(_swallow_future) + self._pending.append(_PendingCommand(future, argv, command_count(argv))) + payload_parts.append((render_control_line(argv) + "\n").encode()) + try: + proc.stdin.write(b"".join(payload_parts)) + await proc.stdin.drain() + except (BrokenPipeError, OSError): + # The proc died before replay landed; the reader will EOF and the + # supervisor reconnects, failing these pending commands then. + return + # The attach is fire-and-forget (swallowed future): its returncode is + # not awaited, so _attached_session is NOT cached optimistically here. + # The events layer caches it only on a confirmed attach and re-attaches + # on a miss, so a session that vanished during the disconnect surfaces a + # real error instead of a silently-empty capture. + + def _reset_attach(self) -> None: + """Clear the sticky attach so reconnect re-attaches from scratch. + + The events layer caches a confirmed requested target in + :attr:`_attached_session`. A fresh process starts on the engine's safe + bootstrap session, not necessarily that requested target, so the cache + is cleared on every reconnect. + """ + self._attached_session = None + + @staticmethod + def _backoff(attempt: int) -> float: + """Deterministic jittered exponential backoff (seconds) for *attempt*. + + Capped exponential growth plus a small jitter derived solely from + *attempt* -- never :mod:`random` or wall-clock time -- so reconnect + timing stays finite and reproducible under test. + """ + base = min(0.1 * (2.0 ** min(attempt, 6)), 5.0) + jitter = 0.01 * float(attempt % 7) + return base + jitter + + async def _reader(self) -> None: + """Background task: read tmux output, resolve futures, publish events.""" + proc = self._proc + if proc is None or proc.stdout is None: + return + stdout = proc.stdout + try: + while True: + chunk = await stdout.read(_READ_CHUNK) + if not chunk: + self._mark_dead(ControlModeError("tmux -C closed stdout")) + return + self._parser.feed(chunk) + for block in self._parser.blocks(): + self._dispatch_block(block) + for line in self._parser.notifications(): + self._publish(line) + except asyncio.CancelledError: + raise + except Exception as error: + self._mark_dead(ControlModeError(f"control-mode reader failed: {error}")) + + def _dispatch_block(self, block: ControlModeBlock) -> None: + """Accumulate a solicited block; resolve the command once it has them all. + + A ``;``-folded command emits one block per sub-command; unsolicited blocks + (hook-triggered commands, the startup ACK) carry flags 0 and are skipped, + so FIFO correlation never desyncs. + """ + if block.flags != 1: + return # unsolicited (hook-triggered command or startup ACK): skip + if not self._pending: + # A solicited reply with no command waiting: its command's future was + # already resolved, cancelled, or failed. FIFO is now one block off. + logger.warning( + "control-mode dropped solicited block #%s with no pending command", + block.number, + extra={ + "tmux_stdout": [ + line.decode(errors="replace") for line in block.body + ], + "tmux_stdout_len": len(block.body), + }, + ) + return + pending = self._pending[0] + self._sequence.check(block, pending.argv) + pending.blocks.append(block) + if len(pending.blocks) < pending.expected: + return + self._pending.popleft() + if not pending.future.done(): + pending.future.set_result(_merge_blocks(pending.blocks, pending.argv)) + + def _publish(self, line: bytes) -> None: + """Broadcast a notification to every subscriber (drop-oldest per queue). + + Runs synchronously from the single reader task, so the subscriber set is + never mutated mid-iteration. + """ + notification = ControlNotification.parse(line) + for queue in self._subscribers: + self._dropped_notifications += _offer(queue, notification) + + def _broadcast_stream_end(self) -> None: + """Push the stream-end sentinel to every subscriber, then clear them. + + Uses :func:`_force_put` so the sentinel lands even on a queue already at + ``maxsize`` (a slow consumer that hit backpressure); otherwise the + sentinel would be lost and the consumer would hang forever on + ``queue.get()`` -- the exact bug this guards against. + """ + for queue in list(self._subscribers): + _force_put(queue, _STREAM_END) + self._subscribers.clear() + + def _mark_dead(self, error: BaseException) -> None: + """Record the engine as dead and fail all pending commands.""" + if self._dead is None: + self._dead = error + self._fail_pending(error) + self._broadcast_stream_end() + + def _fail_pending(self, error: BaseException) -> None: + """Fail every queued command future with *error*.""" + while self._pending: + pending = self._pending.popleft() + if not pending.future.done(): + pending.future.set_exception(error) + + @classmethod + def for_server(cls, server: t.Any, **kwargs: t.Any) -> AsyncControlModeEngine: + """Build an async control-mode engine bound to a live server's socket.""" + conn = ServerConnection.from_server(server) + return cls( + tmux_bin=conn.tmux_bin, + server_args=conn.args, + **kwargs, + ) diff --git a/src/libtmux/experimental/engines/asyncio.py b/src/libtmux/experimental/engines/asyncio.py new file mode 100644 index 000000000..00944008e --- /dev/null +++ b/src/libtmux/experimental/engines/asyncio.py @@ -0,0 +1,129 @@ +"""A real asynchronous subprocess engine. + +Built on :func:`asyncio.create_subprocess_exec` -- genuine async process I/O, +not a thread wrapper around the sync engine. On cancellation it terminates the +child process before propagating :class:`asyncio.CancelledError`, so a cancelled +``arun`` leaks no tmux process. It mirrors the classic engine's output handling +(``backslashreplace`` decoding, trailing-blank stripping) so it returns the +*same* typed result the classic engine does. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import CommandResult, encode_direct_argv +from libtmux.experimental.engines.connection import ServerConnection + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from libtmux.experimental.engines.base import CommandRequest + + +class AsyncSubprocessEngine: + """Execute tmux commands via :func:`asyncio.create_subprocess_exec`. + + Parameters + ---------- + tmux_bin : str or pathlib.Path or None + The tmux binary; resolved from ``$PATH`` when ``None``. + server_args : Sequence[str] + Connection flags inserted before the command. + + Examples + -------- + >>> import asyncio + >>> from libtmux.experimental.ops import SendKeys, arun + >>> from libtmux.experimental.ops._types import PaneId + >>> engine = AsyncSubprocessEngine() + >>> hasattr(engine, "run") and hasattr(engine, "run_batch") + True + """ + + def __init__( + self, + tmux_bin: str | pathlib.Path | None = None, + *, + server_args: Sequence[str] = (), + ) -> None: + self._conn = ServerConnection.of(tmux_bin, server_args) + + @property + def connection(self) -> ServerConnection: + """The tmux binary + connection flags this engine dispatches through.""" + return self._conn + + @property + def tmux_bin(self) -> str | None: + """The explicitly configured tmux binary, if any.""" + return self._conn.tmux_bin + + @property + def server_args(self) -> tuple[str, ...]: + """Connection flags placed before every tmux subcommand.""" + return self._conn.args + + def tmux_version(self) -> str | None: + """Report this engine's tmux version (``tmux -V``), memoized. + + Returns ``None`` when the binary is missing or its version cannot be + parsed, so version resolution degrades to "assume latest". + """ + return self._conn.tmux_version() + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command asynchronously and return its result.""" + argv = encode_direct_argv(request.args) + cmd = self._conn.argv(*argv, tmux_bin=request.tmux_bin) + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + try: + stdout_bytes, stderr_bytes = await process.communicate() + except asyncio.CancelledError: + # The child may have already exited (terminate races the reap); + # suppress so the cancellation propagates, not ProcessLookupError. + with contextlib.suppress(ProcessLookupError): + process.terminate() + await process.wait() + raise + + stdout = stdout_bytes.decode(errors="backslashreplace") + stderr = stderr_bytes.decode(errors="backslashreplace") + + stdout_lines = stdout.split("\n") + while stdout_lines and stdout_lines[-1] == "": + stdout_lines.pop() + stderr_lines = [line for line in stderr.split("\n") if line] + + return CommandResult( + cmd=tuple(cmd), + stdout=tuple(stdout_lines), + stderr=tuple(stderr_lines), + returncode=process.returncode if process.returncode is not None else -1, + ) + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests sequentially (preserving tmux command ordering).""" + return [await self.run(req) for req in requests] + + @classmethod + def for_server(cls, server: t.Any) -> AsyncSubprocessEngine: + """Build an async engine bound to a live :class:`libtmux.Server`'s socket.""" + conn = ServerConnection.from_server(server) + return cls(tmux_bin=conn.tmux_bin, server_args=conn.args) diff --git a/src/libtmux/experimental/engines/base.py b/src/libtmux/experimental/engines/base.py new file mode 100644 index 000000000..efd157d72 --- /dev/null +++ b/src/libtmux/experimental/engines/base.py @@ -0,0 +1,448 @@ +"""Core engine abstractions: requests, results, and the engine protocols. + +A :class:`CommandRequest` is a rendered tmux argv plus an optional binary path; a +:class:`CommandResult` is the structured outcome. :class:`TmuxEngine` and +:class:`AsyncTmuxEngine` are :class:`typing.Protocol` types, so any object with +the right methods is an engine -- including a live :class:`libtmux.Server` for +the classic case -- without inheriting a base class. +""" + +from __future__ import annotations + +import enum +import re +import shlex +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + from typing_extensions import Self + +#: tmux escapes a byte in ``%output`` as a backslash plus three octal digits. +_CONTROL_OCTAL = re.compile(rb"\\([0-7]{3})") + +# tmux parses these options with getopt before its command argv reaches +# cmd_parse_from_arguments. Only values in the latter have structural +# trailing-semicolon semantics. +_GLOBAL_OPTIONS_WITH_VALUE = frozenset({"c", "f", "L", "S", "T"}) +_GLOBAL_OPTIONS_WITHOUT_VALUE = frozenset( + {"2", "8", "C", "D", "d", "h", "l", "N", "q", "u", "U", "v", "V"}, +) + + +class CommandSeparator(str): + """A planner-authored command boundary, distinct from a literal ``";"``. + + Examples + -------- + >>> CommandSeparator(";") + ';' + >>> CommandSeparator("kill-server") + Traceback (most recent call last): + ... + ValueError: a command separator must be exactly ';' + """ + + def __new__(cls, value: str) -> Self: + """Construct the one legal structural token.""" + if value != ";": + msg = "a command separator must be exactly ';'" + raise ValueError(msg) + return super().__new__(cls, value) + + +def is_command_separator(token: str) -> bool: + """Return whether *token* is an intentional tmux command boundary. + + Examples + -------- + >>> is_command_separator(CommandSeparator(";")) + True + >>> is_command_separator(";") + False + """ + return type(token) is CommandSeparator and token == ";" + + +class DirectArgv(t.NamedTuple): + """The client-global and command portions of direct tmux argv. + + Attributes + ---------- + global_args : tuple[str, ...] + Leading options consumed by tmux's client-level ``getopt`` parser. + command_argv : tuple[str, ...] + The subcommand and arguments passed to ``cmd_parse_from_arguments``. + """ + + global_args: tuple[str, ...] + command_argv: tuple[str, ...] + + +def _global_option_consumes_next(token: str) -> bool | None: + """Return a global option's separate-value arity, or ``None`` if unknown. + + Examples + -------- + >>> _global_option_consumes_next("-L") + True + >>> _global_option_consumes_next("-Lwork") + False + >>> _global_option_consumes_next("list-sessions") is None + True + """ + if not token.startswith("-") or token in {"-", "--"}: + return None + cluster = token[1:] + if not cluster: + return None + for index, option in enumerate(cluster): + if option in _GLOBAL_OPTIONS_WITH_VALUE: + return index == len(cluster) - 1 + if option not in _GLOBAL_OPTIONS_WITHOUT_VALUE: + return None + return False + + +def split_direct_argv(argv: Sequence[str]) -> DirectArgv: + """Split raw tmux argv at the client-global/command parser boundary. + + The split follows tmux's leading short-option ``getopt`` grammar, including + attached values and ``--``. Global values remain byte-for-byte data because + tmux removes them before parsing command separators. + + Examples + -------- + >>> split_direct_argv(("-L", "socket;", "display-message", "text;")) + DirectArgv(global_args=('-L', 'socket;'), command_argv=('display-message', 'text;')) + >>> split_direct_argv(("-Lsocket;", "--", "display-message")) + DirectArgv(global_args=('-Lsocket;', '--'), command_argv=('display-message',)) + """ + args = tuple(argv) + if any("\0" in token for token in args): + msg = "tmux command arguments cannot contain NUL" + raise ValueError(msg) + + index = 0 + while index < len(args): + token = args[index] + if token == "--": + index += 1 + break + consumes_next = _global_option_consumes_next(token) + if consumes_next is None: + break + index += 2 if consumes_next and index + 1 < len(args) else 1 + return DirectArgv(global_args=args[:index], command_argv=args[index:]) + + +def _encode_command_argv(argv: Sequence[str]) -> tuple[str, ...]: + r"""Escape literal separators in argv already known to be command-scoped. + + Examples + -------- + >>> _encode_command_argv(("display-message", "literal;")) + ('display-message', 'literal\\;') + """ + encoded: list[str] = [] + for token in argv: + if not is_command_separator(token) and token.endswith(";"): + token = f"{token[:-1]}\\;" + encoded.append(str(token)) + return tuple(encoded) + + +def encode_direct_argv(argv: Sequence[str]) -> tuple[str, ...]: + r"""Encode literal arguments for tmux's direct argv parser. + + Tmux first removes client-global options, then routes only the remaining + command argv through ``cmd_parse_from_arguments``, where a final ``;`` is + structural. Prefixing that final byte with one backslash preserves it as + data. Global option values remain unchanged, and a + :class:`CommandSeparator` remains structural. + + Examples + -------- + >>> encode_direct_argv(("send-keys", "text;")) + ('send-keys', 'text\\;') + >>> encode_direct_argv(("-L", "socket;", "send-keys", "text;")) + ('-L', 'socket;', 'send-keys', 'text\\;') + >>> encode_direct_argv(("a", CommandSeparator(";"), "b")) + ('a', ';', 'b') + """ + direct = split_direct_argv(argv) + return (*direct.global_args, *_encode_command_argv(direct.command_argv)) + + +def _quote_control_token(token: str) -> str: + r"""Quote one literal token for tmux's line-oriented control parser.""" + if "\0" in token: + msg = "tmux command arguments cannot contain NUL" + raise ValueError(msg) + if "\n" in token or "\r" in token: + return "".join(f"\\{byte:03o}" for byte in token.encode()) + return shlex.quote(token) + + +def render_control_line(argv: Sequence[str]) -> str: + r"""Render a tmux argv as a control-mode (``tmux -C``) command line. + + Literal tokens are quoted for the control parser. Tokens containing a line + delimiter are UTF-8 octal encoded so one request remains one physical line. + Only a :class:`CommandSeparator` is left bare. + + Examples + -------- + >>> render_control_line(("rename-window", "-t", "@1", "a b")) + "rename-window -t @1 'a b'" + >>> render_control_line( + ... ("rename-window", "a", CommandSeparator(";"), "kill-window", "@2") + ... ) + 'rename-window a ; kill-window @2' + >>> "\n" not in render_control_line(("display-message", "first\nsecond")) + True + """ + return " ".join( + str(token) if is_command_separator(token) else _quote_control_token(token) + for token in argv + ) + + +def unescape_control_output(payload: str) -> bytes: + r"""Decode a control-mode ``%output`` payload back to the bytes the pane wrote. + + tmux does not forward pane output verbatim: in a ``%output`` notification it + writes every non-printable byte -- and the backslash itself -- as a backslash + followed by three octal digits. A reader that scans for raw bytes must undo + this first, or it can never match: an ``ESC`` (``0x1b``) arrives on the wire + as the four *characters* ``\``, ``0``, ``3``, ``3``. + + Bytes tmux left alone pass through untouched, so feeding this an already-raw + payload is harmless. + + Examples + -------- + Printable output is returned as-is: + + >>> unescape_control_output("hello world") + b'hello world' + + An escape sequence tmux octal-escaped comes back as real bytes: + + >>> unescape_control_output(r"\033]3008;state=idle\033\134") + b'\x1b]3008;state=idle\x1b\\' + + Multi-byte UTF-8 survives the round trip: + + >>> unescape_control_output(r"caf\303\251").decode() + 'café' + """ + raw = payload.encode("utf-8", "surrogateescape") + return _CONTROL_OCTAL.sub(lambda m: bytes((int(m.group(1), 8),)), raw) + + +@dataclass(frozen=True) +class CommandRequest: + """A rendered tmux command, ready for an engine to execute. + + Attributes + ---------- + args : tuple[str, ...] + The tmux argv *after* the binary (e.g. ``("split-window", "-t", "%1")``). + tmux_bin : str or None + Override the tmux binary for this request; ``None`` lets the engine + decide. + + Examples + -------- + >>> CommandRequest.from_args("split-window", "-t", "%1") + CommandRequest(args=('split-window', '-t', '%1'), tmux_bin=None) + >>> CommandRequest.from_args("kill-window", "-t", 2).args + ('kill-window', '-t', '2') + """ + + args: tuple[str, ...] + tmux_bin: str | None = None + + def __post_init__(self) -> None: + r"""Reject arguments that cannot survive tmux's C-string transports. + + Examples + -------- + >>> CommandRequest(args=("display-message", "a\0b")) + Traceback (most recent call last): + ... + ValueError: tmux command arguments cannot contain NUL + """ + if any( + type(arg) is CommandSeparator and not is_command_separator(arg) + for arg in self.args + ): + msg = "a command separator must be exactly ';'" + raise ValueError(msg) + normalized = tuple( + arg if is_command_separator(arg) else str.__str__(arg) for arg in self.args + ) + if any("\0" in arg for arg in normalized): + msg = "tmux command arguments cannot contain NUL" + raise ValueError(msg) + object.__setattr__(self, "args", normalized) + + @classmethod + def from_args( + cls, + *args: t.Any, + tmux_bin: str | pathlib.Path | None = None, + ) -> CommandRequest: + """Build a request from arbitrary tokens, stringifying each.""" + return cls( + args=tuple(arg if isinstance(arg, str) else str(arg) for arg in args), + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + ) + + +@dataclass(frozen=True) +class CommandResult: + """The structured outcome of executing a :class:`CommandRequest`. + + A tmux-side failure (``%error`` / nonzero exit) is *data* here -- it sets + ``returncode`` and ``stderr`` rather than raising. Only engine-broken + conditions (missing binary, lost connection, protocol desync) raise. + + Attributes + ---------- + cmd : tuple[str, ...] + The full argv that ran (including the tmux binary). + stdout : tuple[str, ...] + Captured standard-output lines. + stderr : tuple[str, ...] + Captured standard-error lines. + returncode : int + tmux exit code (``-1`` when unknown). + """ + + cmd: tuple[str, ...] + stdout: tuple[str, ...] = () + stderr: tuple[str, ...] = () + returncode: int = 0 + + +class EngineKind(str, enum.Enum): + """Named engine families.""" + + SUBPROCESS = "subprocess" + MOCK = "mock" + CONTROL_MODE = "control_mode" + IMSG = "imsg" + + +@dataclass(frozen=True) +class EngineSpec: + """A typed, serializable selector for an engine family. + + Attributes + ---------- + kind : EngineKind + Selected engine family. + protocol_version : int or None + Native imsg protocol version, valid only for the imsg engine. + + Examples + -------- + >>> EngineSpec.subprocess().kind + + >>> EngineSpec.imsg(protocol_version=8).protocol_version + 8 + >>> EngineSpec.subprocess(protocol_version=8) + Traceback (most recent call last): + ... + ValueError: protocol_version is only valid for the imsg engine + """ + + kind: EngineKind + protocol_version: int | None = None + + def __post_init__(self) -> None: + """Normalize and validate the spec.""" + kind = EngineKind(self.kind) + if kind is not EngineKind.IMSG and self.protocol_version is not None: + msg = "protocol_version is only valid for the imsg engine" + raise ValueError(msg) + object.__setattr__(self, "kind", kind) + + @classmethod + def subprocess(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build a subprocess (classic) engine spec.""" + return cls(kind=EngineKind.SUBPROCESS, protocol_version=protocol_version) + + @classmethod + def mock(cls) -> EngineSpec: + """Build a mock (in-memory) engine spec.""" + return cls(kind=EngineKind.MOCK) + + @classmethod + def control_mode(cls) -> EngineSpec: + """Build a control-mode engine spec.""" + return cls(kind=EngineKind.CONTROL_MODE) + + @classmethod + def imsg(cls, *, protocol_version: int | None = None) -> EngineSpec: + """Build an imsg (native binary) engine spec.""" + return cls(kind=EngineKind.IMSG, protocol_version=protocol_version) + + +@t.runtime_checkable +class TmuxEngine(t.Protocol): + """A synchronous executor of tmux commands.""" + + def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request. + + Persistent-connection engines (control mode) override this to pipeline; + stateless engines implement it as a loop over :meth:`run`. + """ + ... + + +@t.runtime_checkable +class AsyncTmuxEngine(t.Protocol): + """An asynchronous executor of tmux commands.""" + + async def run(self, request: CommandRequest) -> CommandResult: + """Execute one tmux command and return its structured result.""" + ... + + async def run_batch( + self, + requests: Sequence[CommandRequest], + ) -> list[CommandResult]: + """Execute requests in order, returning one result per request.""" + ... + + +@t.runtime_checkable +class SupportsTmuxVersion(t.Protocol): + """An engine that can report the tmux version it targets. + + Optional engine capability. The executors + (:func:`~libtmux.experimental.ops.execute.run` / ``arun`` and the + :class:`~libtmux.experimental.ops.plan.LazyPlan` drivers) call + :meth:`tmux_version` to resolve the version for version-gated rendering when + the caller passes none. Engines that cannot know their version -- in-memory + or fake engines -- simply do not implement it, and resolution falls back to + "assume latest". + """ + + def tmux_version(self) -> str | None: + """Return the engine's tmux version string, or ``None`` if unknown.""" + ... diff --git a/src/libtmux/experimental/engines/connection.py b/src/libtmux/experimental/engines/connection.py new file mode 100644 index 000000000..d3308113e --- /dev/null +++ b/src/libtmux/experimental/engines/connection.py @@ -0,0 +1,218 @@ +"""The connection an engine talks to: which tmux binary, which tmux server. + +Every real engine -- subprocess, asyncio, control mode, async control mode, imsg +-- needs the same two things before it can dispatch anything: a tmux *binary* to +exec, and the *connection flags* (``-L``/``-S``/``-f``/``-2``/``-8``) that point +at one particular tmux server. :class:`ServerConnection` is that pair, as one +frozen value object, and it is the *only* place either is computed. + +Engines **hold** a connection (``self._conn``) rather than re-deriving one. +:meth:`ServerConnection.resolve_bin` is the single door to a tmux binary path: +it memoizes :func:`shutil.which` and raises +:exc:`~libtmux.exc.TmuxCommandNotFound` when tmux is absent, so an engine cannot +accidentally ship an unguarded, unmemoized ``shutil.which("tmux")`` of its own. +:meth:`ServerConnection.tmux_version` is the matching memoized ``tmux -V`` probe. +""" + +from __future__ import annotations + +import shutil +import typing as t +from dataclasses import dataclass, field + +from libtmux import exc +from libtmux.common import get_version + +if t.TYPE_CHECKING: + import pathlib + from collections.abc import Sequence + + +class _BinaryResolver: + """Memoized tmux-binary resolution and ``tmux -V`` probe. + + Owned by a :class:`ServerConnection`; never constructed by engines. Holding + the (mutable) cache here keeps :class:`ServerConnection` itself a frozen, + comparable value. + """ + + __slots__ = ("_declared", "_resolved", "_version", "_version_probed") + + def __init__(self, tmux_bin: str | None = None) -> None: + self._declared = tmux_bin + self._resolved: str | None = None + self._version: str | None = None + self._version_probed = False + + def resolve(self) -> str: + """Return the tmux binary path, memoized for this connection. + + An explicit binary wins. Otherwise :func:`shutil.which` walks ``$PATH`` + once -- it costs ~50µs and sits on the hot path of every command -- and + the answer is cached. A *failure* is not cached, so a tmux installed + after the miss is picked up. + """ + if self._declared is not None: + return self._declared + if self._resolved is None: + resolved = shutil.which("tmux") + if resolved is None: + raise exc.TmuxCommandNotFound + self._resolved = resolved + return self._resolved + + def version(self) -> str | None: + """Return the tmux version string, memoized; ``None`` when unknowable. + + ``None`` (missing binary, unparseable output) lets version resolution + degrade to "assume latest" rather than exploding. + """ + if not self._version_probed: + self._version_probed = True + try: + self._version = str(get_version(self.resolve())) + except exc.LibTmuxException: + self._version = None + return self._version + + +@dataclass(frozen=True) +class ServerConnection: + """Which tmux binary, and which tmux server, an engine talks to. + + Attributes + ---------- + tmux_bin : str or None + An explicit tmux binary. ``None`` means "resolve from ``$PATH``", which + :meth:`resolve_bin` does once and memoizes. + args : tuple[str, ...] + Connection flags placed before the tmux subcommand (e.g. ``("-Lwork",)``). + _resolver : _BinaryResolver + Private memoized resolver for the binary path and tmux version. + + Examples + -------- + The default connection targets the ambient tmux server: + + >>> ServerConnection() + ServerConnection(tmux_bin=None, args=()) + + :meth:`from_server` reads the flags off a live :class:`libtmux.Server`, + which is what every engine's ``for_server`` classmethod is built on: + + >>> conn = ServerConnection.from_server(server) + >>> conn.args[0].startswith(("-L", "-S")) + True + >>> conn.tmux_version() == conn.tmux_version() # memoized + True + + It duck-types, so a plain object with the same attributes works too: + + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_name="work", colors=256) + ... ) + ServerConnection(tmux_bin=None, args=('-Lwork', '-2')) + + :meth:`argv` prepends the binary and the flags to a rendered command: + + >>> ServerConnection.of(tmux_bin="tmux", args=("-Lwork",)).argv( + ... "kill-window", "-t", "@1" + ... ) + ['tmux', '-Lwork', 'kill-window', '-t', '@1'] + """ + + tmux_bin: str | None = None + args: tuple[str, ...] = () + _resolver: _BinaryResolver = field( + init=False, + repr=False, + compare=False, + hash=False, + ) + + def __post_init__(self) -> None: + """Normalize *args* and build the connection's binary resolver.""" + object.__setattr__(self, "args", tuple(self.args)) + object.__setattr__(self, "_resolver", _BinaryResolver(self.tmux_bin)) + + @classmethod + def of( + cls, + tmux_bin: str | pathlib.Path | None = None, + args: Sequence[str] = (), + ) -> ServerConnection: + """Build a connection, stringifying a :class:`pathlib.Path` binary. + + Examples + -------- + >>> import pathlib + >>> ServerConnection.of(pathlib.Path("/usr/bin/tmux")).tmux_bin + '/usr/bin/tmux' + >>> ServerConnection.of(args=["-L", "test"]).args + ('-L', 'test') + """ + return cls( + tmux_bin=str(tmux_bin) if tmux_bin is not None else None, + args=tuple(args), + ) + + @classmethod + def from_server(cls, server: t.Any) -> ServerConnection: + """Build the connection a live :class:`libtmux.Server` talks over. + + Mirrors :meth:`libtmux.Server.cmd`'s connection-flag construction, so an + engine built from it reaches the same tmux server as the object API. + + Examples + -------- + >>> import types + >>> ServerConnection.from_server( + ... types.SimpleNamespace(socket_path="/tmp/s", config_file="/tmp/c") + ... ) + ServerConnection(tmux_bin=None, args=('-S/tmp/s', '-f/tmp/c')) + """ + args: list[str] = [] + if getattr(server, "socket_name", None): + args.append(f"-L{server.socket_name}") + if getattr(server, "socket_path", None): + args.append(f"-S{server.socket_path}") + if getattr(server, "config_file", None): + args.append(f"-f{server.config_file}") + colors = getattr(server, "colors", None) + if colors == 256: + args.append("-2") + elif colors == 88: + args.append("-8") + return cls.of(tmux_bin=getattr(server, "tmux_bin", None), args=args) + + def resolve_bin(self) -> str: + """Return the tmux binary path (memoized). + + Raises :exc:`~libtmux.exc.TmuxCommandNotFound` when tmux is not on + ``$PATH`` and none was declared -- the guard every engine gets for free. + + Examples + -------- + >>> ServerConnection.of(tmux_bin="/usr/bin/tmux").resolve_bin() + '/usr/bin/tmux' + """ + return self._resolver.resolve() + + def tmux_version(self) -> str | None: + """Return this connection's tmux version (memoized), or ``None``. + + Examples + -------- + >>> ServerConnection().tmux_version() is not None + True + """ + return self._resolver.version() + + def argv(self, *args: str, tmux_bin: str | None = None) -> list[str]: + """Render a full command line: binary, connection flags, then *args*. + + *tmux_bin* overrides this connection's binary for one command (a + :class:`~libtmux.experimental.engines.base.CommandRequest` may carry one). + """ + return [tmux_bin or self.resolve_bin(), *self.args, *args] diff --git a/src/libtmux/experimental/engines/control_mode.py b/src/libtmux/experimental/engines/control_mode.py new file mode 100644 index 000000000..ad740ad80 --- /dev/null +++ b/src/libtmux/experimental/engines/control_mode.py @@ -0,0 +1,807 @@ +"""A persistent control-mode (``tmux -C``) engine. + +Holds one long-lived ``tmux -C`` connection and pipelines command lines over it, +parsing each command's ``%begin``/``%end``/``%error`` block back into a +:class:`~.base.CommandResult`. Because it returns the same typed result the +subprocess engine does, an operation run through control mode is +indistinguishable -- at the result level -- from one run through a fork-per-call +subprocess. + +Control mode attaches to an existing safe session instead of creating a private +one. Until a session has an effective ``destroy-unattached`` value of ``off``, +requests run through the subprocess engine; a later batch can then open the +persistent client. + +The parser (:class:`ControlModeParser`) is I/O-free: it consumes bytes and emits +parsed blocks, so it is unit-testable without spawning tmux. ``run_batch`` writes +all command lines at once and collects one block per command, which is the +control engine's advantage over per-call subprocess startup. +""" + +from __future__ import annotations + +import collections +import contextlib +import dataclasses +import logging +import os +import selectors +import subprocess +import threading +import time +import typing as t + +from libtmux import exc +from libtmux.experimental.engines.base import ( + CommandRequest, + CommandResult, + is_command_separator, + render_control_line, +) +from libtmux.experimental.engines.connection import ServerConnection +from libtmux.experimental.engines.subprocess import SubprocessEngine + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from typing_extensions import Self + +logger = logging.getLogger(__name__) + +_BEGIN_PREFIX = b"%begin " +_END_PREFIX = b"%end " +_ERROR_PREFIX = b"%error " +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_STARTUP_TIMEOUT = 5.0 +_GRACEFUL_EXIT_TIMEOUT = 0.5 +_TERMINATE_TIMEOUT = 1.0 +_GUARD_MIN_PARTS = 3 + + +class ControlModeError(exc.LibTmuxException): + """The control-mode engine failed (connection, protocol, or timeout).""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ControlModeBlock: + """One ``%begin``/``%end`` or ``%error`` control-mode command block. + + Attributes + ---------- + number : int + Command block sequence number. + flags : int + Flags from the opening control-mode guard. + is_error : bool + Whether tmux closed the block with ``%error``. + body : tuple[bytes, ...] + Raw output lines between the opening and closing guards. + """ + + number: int + flags: int + is_error: bool + body: tuple[bytes, ...] + + +@dataclasses.dataclass(slots=True) +class _PendingBlock: + """Mutable command block while the parser collects its body. + + Attributes + ---------- + number : int + Command block sequence number. + flags : int + Flags from the opening control-mode guard. + body : list[bytes] + Raw output lines collected before the closing guard. + """ + + number: int + flags: int + body: list[bytes] + + +class ControlModeParser: + r"""I/O-free parser for the command-block subset of control mode. + + Examples + -------- + >>> parser = ControlModeParser() + >>> parser.feed(b"%begin 1 1 1\nhello\n%end 1 1 1\n") + >>> [block.body for block in parser.blocks()] + [(b'hello',)] + >>> parser.feed(b"%begin 2 2 1\nboom\n%error 2 2 1\n") + >>> block = parser.blocks()[0] + >>> block.is_error, block.body + (True, (b'boom',)) + """ + + __slots__ = ("_blocks", "_buffer", "_notifications", "_pending") + + def __init__(self) -> None: + self._buffer = bytearray() + self._blocks: list[ControlModeBlock] = [] + self._notifications: list[bytes] = [] + self._pending: _PendingBlock | None = None + + def feed(self, data: bytes) -> None: + """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 notifications(self) -> list[bytes]: + """Drain raw ``%``-notification lines seen outside command blocks. + + Control mode wraps *command output* in ``%begin``/``%end`` blocks but + emits asynchronous notifications (``%output``, ``%window-add``, ...) as + bare lines. The sync engine ignores these; the async engine routes them + to its event stream. + """ + notifications, self._notifications = self._notifications, [] + return notifications + + def _handle_line(self, line: bytes) -> None: + if self._pending is not None: + if _matches_pending_close(line, self._pending.number): + self._close_block(line) + return + self._pending.body.append(line) + return + if line.startswith(_BEGIN_PREFIX): + self._open_block(line) + elif line.startswith(b"%"): + self._notifications.append(line) + + def _open_block(self, line: bytes) -> None: + number, flags = _parse_guard(line, _BEGIN_PREFIX) + if number is None: + # A %begin whose guard will not parse is dropped: its body lines then + # fall through as notifications and a LATER command's block fills the + # count, silently mis-attributing output. Never observed in practice + # (tmux always writes "%begin